From 199dcf7c52a702719693a134bfadf020667462ce Mon Sep 17 00:00:00 2001 From: Rafael Matias Date: Tue, 28 Apr 2026 22:39:33 +0200 Subject: [PATCH 1/4] feat: add warmup_test_payload phase between setup and test Adds an optional warmup phase that takes each engine_newPayload* call from the test step, replaces stateRoot with a fork-specific placeholder, and recomputes blockHash so the EL accepts the header and starts execution. The expected outcome is a state-root rejection, but the work the client performs first warms its caches before the real test runs. Configured via runner.client.config.warmup_test_payload.{enabled, fork} with the standard global/instance override pattern. Currently only fork=osaka is supported. Block-hash recomputation is delegated to go-ethereum's engine.ExecutableDataToBlockNoHash. Warmup results write under /warmup.* and surface in result.json and per-suite stats without folding warmup gas/time into test totals. The resolved value lands in the run's config.json and the UI's run detail and compare views. --- docs/configuration.md | 27 +++ go.mod | 35 +++- go.sum | 85 ++++++-- pkg/config/config.go | 50 +++++ pkg/config/config_test.go | 115 +++++++++++ pkg/executor/executor.go | 99 +++++++++- pkg/executor/results.go | 5 + pkg/executor/source.go | 1 + pkg/executor/suite_stats.go | 14 ++ pkg/runner/lifecycle.go | 7 + pkg/runner/runner.go | 1 + pkg/runner/strategy_checkpoint.go | 1 + pkg/runner/strategy_container.go | 1 + pkg/warmup/warmup.go | 182 ++++++++++++++++++ pkg/warmup/warmup_test.go | 153 +++++++++++++++ ui/src/api/types.ts | 6 + ui/src/components/compare/ConfigDiff.tsx | 11 ++ .../run-detail/RunConfiguration.tsx | 27 +++ 18 files changed, 794 insertions(+), 26 deletions(-) create mode 100644 pkg/warmup/warmup.go create mode 100644 pkg/warmup/warmup_test.go diff --git a/docs/configuration.md b/docs/configuration.md index d20a04ac0..47c2dbb53 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -656,6 +656,7 @@ runner: | `post_test_rpc_calls` | []object | - | Arbitrary RPC calls to execute after each test step (see [Post-Test RPC Calls](#post-test-rpc-calls)) | | `post_test_sleep_duration` | string | - | Sleep duration after each test, e.g. `200ms`, `1s` (see below) | | `bootstrap_fcu` | bool/object | - | Send an `engine_forkchoiceUpdatedV3` after RPC is ready to confirm the client is fully synced (see [Bootstrap FCU](#bootstrap-fcu)) | +| `warmup_test_payload` | object | - | Insert a warmup phase between setup and test that sends modified `engine_newPayload*` calls (stateRoot replaced, blockHash recomputed) to warm caches (see [Warmup Test Payload](#warmup-test-payload)) | | `genesis` | map | - | Genesis file URLs keyed by client type | ##### Drop Memory Caches @@ -917,6 +918,30 @@ When using the `container-recreate` rollback strategy, the bootstrap FCU is sent - When starting from pre-populated data directories where the client needs time to validate state before processing Engine API requests - When you observe test failures due to the client returning errors or SYNCING responses on the first Engine API calls +##### Warmup Test Payload + +The `warmup_test_payload` option inserts a warmup phase between the setup and test steps. For each `engine_newPayload*` call in the test step, the runner creates a copy with the `stateRoot` replaced by a fork-specific placeholder and the `blockHash` recomputed to match. These warmup payloads are sent to the client before the real test runs. The client is expected to reject them with a state-root mismatch — the value is in the work the client performs (cache fills, codepath warming) before that rejection. + +```yaml +runner: + client: + config: + warmup_test_payload: + enabled: true + fork: osaka +``` + +| Option | Type | Required | Default | Description | +|--------|------|----------|---------|-------------| +| `enabled` | bool | Yes | `false` | Enable the warmup phase | +| `fork` | string | Yes | - | Fork used to compute the warmup `blockHash`. Only `osaka` is currently supported | + +Warmup steps reuse the test step's lines as the source. Non-`engine_newPayload*` lines pass through unchanged. Warmup results are written to `/warmup.{response,result-details.json,result-aggregated.json}` alongside the existing setup/test/cleanup outputs. + +**When to use:** +- When you want to measure the client's "hot" performance and the existing setup phase doesn't fully populate caches +- When investigating cold-start regressions by comparing warmup-on vs warmup-off runs + #### Data Directories The `runner.client.datadirs` section configures pre-populated data directories per client type. When configured, the init container is skipped and data is mounted directly. @@ -949,6 +974,7 @@ runner: | `overlayfs` | Linux overlayfs for near-instant setup | Root access | | `fuse-overlayfs` | FUSE-based overlayfs | `fuse-overlayfs` package; `user_allow_other` in `/etc/fuse.conf` if Docker runs as root. **Warning:** ~3x slower than native overlayfs | | `zfs` | ZFS snapshots and clones for copy-on-write setup | Source directory on ZFS filesystem; root access or ZFS delegations configured | +| `direct` | Bind-mount `source_dir` as-is, no copy/snapshot/clone. Container writes persist after the run. **Not suitable for normal benchmarking** — intended for inspection or resume workflows (e.g. pointing at a ZFS clone left behind by `--debug.stop-after-prerun`) | None | ###### ZFS Setup @@ -1024,6 +1050,7 @@ runner: | `post_test_rpc_calls` | []object | No | From `runner.client.config` | Instance-specific post-test RPC calls (replaces global) | | `post_test_sleep_duration` | string | No | From `runner.client.config` | Instance-specific post-test sleep duration | | `bootstrap_fcu` | bool/object | No | From `runner.client.config` | Instance-specific bootstrap FCU setting | +| `warmup_test_payload` | object | No | From `runner.client.config` | Instance-specific warmup test payload setting (replaces global) | ## Resource Limits diff --git a/go.mod b/go.mod index b61424202..a4170724d 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,7 @@ require ( github.com/containers/podman/v5 v5.8.0 github.com/docker/docker v28.5.1+incompatible github.com/docker/go-units v0.5.0 + github.com/ethereum/go-ethereum v1.17.2 github.com/glebarez/sqlite v1.11.0 github.com/go-chi/chi/v5 v5.2.5 github.com/go-chi/cors v1.2.2 @@ -36,6 +37,8 @@ require ( github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect github.com/BurntSushi/toml v1.5.0 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6 // indirect + github.com/StackExchange/wmi v1.2.1 // indirect github.com/VividCortex/ewma v1.2.0 // indirect github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 // indirect @@ -47,9 +50,11 @@ require ( github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17 // indirect github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.17 // indirect github.com/aws/smithy-go v1.24.0 // indirect + github.com/bits-and-blooms/bitset v1.20.0 // indirect github.com/blang/semver/v4 v4.0.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/chzyer/readline v1.5.1 // indirect + github.com/consensys/gnark-crypto v0.18.1 // indirect github.com/containerd/errdefs v1.0.0 // indirect github.com/containerd/errdefs/pkg v0.3.0 // indirect github.com/containerd/log v0.1.0 // indirect @@ -60,9 +65,12 @@ require ( github.com/containers/ocicrypt v1.2.1 // indirect github.com/containers/psgo v1.9.1-0.20250826150930-4ae76f200c86 // indirect github.com/coreos/go-systemd/v22 v22.6.0 // indirect + github.com/crate-crypto/go-eth-kzg v1.5.0 // indirect github.com/cyberphone/json-canonicalization v0.0.0-20241213102144-19d51d7fe467 // indirect github.com/cyphar/filepath-securejoin v0.5.2 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/deckarep/golang-set/v2 v2.6.0 // indirect + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 // indirect github.com/disiqueira/gotree/v3 v3.0.2 // indirect github.com/distribution/reference v0.6.0 // indirect github.com/docker/distribution v2.8.3+incompatible // indirect @@ -70,7 +78,10 @@ require ( github.com/docker/go-connections v0.6.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/ebitengine/purego v0.9.1 // indirect + github.com/emicklei/dot v1.6.2 // indirect + github.com/ethereum/c-kzg-4844/v2 v2.1.6 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/ferranbt/fastssz v0.1.4 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/glebarez/go-sqlite v1.21.2 // indirect github.com/go-jose/go-jose/v4 v4.1.3 // indirect @@ -79,14 +90,18 @@ require ( github.com/go-ole/go-ole v1.3.0 // indirect github.com/go-viper/mapstructure/v2 v2.2.1 // indirect github.com/godbus/dbus/v5 v5.1.1-0.20241109141217-c266b19b28e9 // indirect + github.com/gofrs/flock v0.12.1 // indirect github.com/golang/protobuf v1.5.4 // indirect + github.com/golang/snappy v1.0.0 // indirect github.com/google/go-containerregistry v0.20.6 // indirect github.com/google/go-intervals v0.0.2 // indirect github.com/google/uuid v1.6.0 // indirect github.com/gorilla/mux v1.8.1 // indirect github.com/gorilla/schema v1.4.1 // indirect + github.com/gorilla/websocket v1.4.2 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect + github.com/holiman/uint256 v1.3.2 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect @@ -98,15 +113,17 @@ require ( github.com/json-iterator/go v1.1.12 // indirect github.com/kevinburke/ssh_config v1.4.0 // indirect github.com/klauspost/compress v1.18.0 // indirect + github.com/klauspost/cpuid/v2 v2.2.3 // indirect github.com/klauspost/pgzip v1.2.6 // indirect github.com/kr/fs v0.1.0 // indirect github.com/letsencrypt/boulder v0.0.0-20240620165639-de9c06129bec // indirect github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683 // indirect github.com/manifoldco/promptui v0.9.0 // indirect - github.com/mattn/go-isatty v0.0.17 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect github.com/mattn/go-sqlite3 v1.14.32 // indirect github.com/miekg/pkcs11 v1.1.1 // indirect + github.com/minio/sha256-simd v1.0.0 // indirect github.com/mistifyio/go-zfs/v3 v3.1.0 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect github.com/moby/sys/capability v0.4.0 // indirect @@ -134,6 +151,7 @@ require ( github.com/rivo/uniseg v0.4.7 // indirect github.com/sagikazarmark/locafero v0.7.0 // indirect github.com/secure-systems-lab/go-securesystemslib v0.9.1 // indirect + github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible // indirect github.com/sigstore/fulcio v1.7.1 // indirect github.com/sigstore/protobuf-specs v0.4.1 // indirect github.com/sigstore/sigstore v1.9.5 // indirect @@ -145,6 +163,7 @@ require ( github.com/spf13/pflag v1.0.10 // indirect github.com/stefanberger/go-pkcs11uri v0.0.0-20230803200340-78284954bff6 // indirect github.com/subosito/gotenv v1.6.0 // indirect + github.com/supranational/blst v0.3.16 // indirect github.com/sylabs/sif/v2 v2.22.0 // indirect github.com/tchap/go-patricia/v2 v2.3.3 // indirect github.com/titanous/rocacheck v0.0.0-20171023193734-afe73141d399 // indirect @@ -156,10 +175,9 @@ require ( github.com/yusufpapurcu/wmi v1.2.4 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 // indirect - go.opentelemetry.io/otel v1.39.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.39.0 // indirect - go.opentelemetry.io/otel/metric v1.39.0 // indirect - go.opentelemetry.io/otel/trace v1.39.0 // indirect + go.opentelemetry.io/otel v1.40.0 // indirect + go.opentelemetry.io/otel/metric v1.40.0 // indirect + go.opentelemetry.io/otel/trace v1.40.0 // indirect go.podman.io/image/v5 v5.39.1 // indirect go.podman.io/storage v1.62.0 // indirect go.uber.org/multierr v1.11.0 // indirect @@ -168,11 +186,12 @@ require ( golang.org/x/sys v0.41.0 // indirect golang.org/x/term v0.40.0 // indirect golang.org/x/text v0.34.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20251222181119-0a764e51fe1b // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect google.golang.org/grpc v1.77.0 // indirect - google.golang.org/protobuf v1.36.10 // indirect + google.golang.org/protobuf v1.36.11 // indirect gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect modernc.org/libc v1.22.5 // indirect modernc.org/mathutil v1.5.0 // indirect modernc.org/memory v1.5.0 // indirect diff --git a/go.sum b/go.sum index c31412ec3..c1447e5b7 100644 --- a/go.sum +++ b/go.sum @@ -8,6 +8,10 @@ github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1 github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6 h1:1zYrtlhrZ6/b6SAjLSfKzWtdgqK0U+HtH/VcBWh1BaU= +github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6/go.mod h1:ioLG6R+5bUSO1oeGSDxOV3FADARuMoytZCSX6MEMQkI= +github.com/StackExchange/wmi v1.2.1 h1:VIkavFPXSjcnS+O8yTq7NI32k0R5Aj+v39y29VYDOSA= +github.com/StackExchange/wmi v1.2.1/go.mod h1:rcmrprowKIVzvc+NUiLncP2uuArMWLCbu9SBzvHz7e8= github.com/VividCortex/ewma v1.2.0 h1:f58SaIzcDXrSy3kWaHNvuJgJ3Nmz59Zji6XoJR/q1ow= github.com/VividCortex/ewma v1.2.0/go.mod h1:nz4BbCtbLyFDeC9SUHbtcT5644juEuWfUAUnGx7j5l4= github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d h1:licZJFw2RwpHMqeKTCYkitsPqHNxTmd4SNR5r94FGM8= @@ -38,6 +42,8 @@ github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk= github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bits-and-blooms/bitset v1.20.0 h1:2F+rfL86jE2d/bmw7OhqUg2Sj/1rURkBn3MdfoPyRVU= +github.com/bits-and-blooms/bitset v1.20.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= @@ -55,6 +61,8 @@ github.com/chzyer/test v1.0.0 h1:p3BQDXSxOhOG0P9z6/hGnII4LGiEPOYBhs8asl/fC04= github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8= github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= +github.com/consensys/gnark-crypto v0.18.1 h1:RyLV6UhPRoYYzaFnPQA4qK3DyuDgkTgskDdoGqFt3fI= +github.com/consensys/gnark-crypto v0.18.1/go.mod h1:L3mXGFTe1ZN+RSJ+CLjUt9x7PNdx8ubaYfDROyp2Z8c= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= @@ -78,6 +86,8 @@ github.com/containers/psgo v1.9.1-0.20250826150930-4ae76f200c86/go.mod h1:52GX23 github.com/coreos/go-systemd/v22 v22.6.0 h1:aGVa/v8B7hpb0TKl0MWoAavPDmHvobFe5R5zn0bCJWo= github.com/coreos/go-systemd/v22 v22.6.0/go.mod h1:iG+pp635Fo7ZmV/j14KUcmEyWF+0X7Lua8rrTWzYgWU= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/crate-crypto/go-eth-kzg v1.5.0 h1:FYRiJMJG2iv+2Dy3fi14SVGjcPteZ5HAAUe4YWlJygc= +github.com/crate-crypto/go-eth-kzg v1.5.0/go.mod h1:J9/u5sWfznSObptgfa92Jq8rTswn6ahQWEuiLHOjCUI= github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY= github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= github.com/cyberphone/json-canonicalization v0.0.0-20241213102144-19d51d7fe467 h1:uX1JmpONuD549D73r6cgnxyUu18Zb7yHAy5AYU0Pm4Q= @@ -88,6 +98,12 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/deckarep/golang-set/v2 v2.6.0 h1:XfcQbWM1LlMB8BsJ8N9vW5ehnnPVIw0je80NsVHagjM= +github.com/deckarep/golang-set/v2 v2.6.0/go.mod h1:VAky9rY/yGXJOLEDv3OMci+7wtDpOF4IN+y82NBOac4= +github.com/decred/dcrd/crypto/blake256 v1.0.0 h1:/8DMNYp9SGi5f0w7uCm6d6M4OU2rGFK09Y2A4Xv7EE0= +github.com/decred/dcrd/crypto/blake256 v1.0.0/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn47hh8kt6rqSlvmrXFAc= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 h1:YLtO71vCjJRCBcrPMtQ9nqBsqpA1m5sE92cU+pd5Mcc= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1/go.mod h1:hyedUtir6IdtD/7lIxGeCxkaw7y45JueMRL4DIyJDKs= github.com/disiqueira/gotree/v3 v3.0.2 h1:ik5iuLQQoufZBNPY518dXhiO5056hyNBIK9lWhkNRq8= github.com/disiqueira/gotree/v3 v3.0.2/go.mod h1:ZuyjE4+mUQZlbpkI24AmruZKhg3VHEgPLDY8Qk+uUu8= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= @@ -110,8 +126,16 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A= github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/emicklei/dot v1.6.2 h1:08GN+DD79cy/tzN6uLCT84+2Wk9u+wvqP+Hkx/dIR8A= +github.com/emicklei/dot v1.6.2/go.mod h1:DeV7GvQtIw4h2u73RKBkkFdvVAz0D9fzeJrgPW6gy/s= +github.com/ethereum/c-kzg-4844/v2 v2.1.6 h1:xQymkKCT5E2Jiaoqf3v4wsNgjZLY0lRSkZn27fRjSls= +github.com/ethereum/c-kzg-4844/v2 v2.1.6/go.mod h1:8HMkUZ5JRv4hpw/XUrYWSQNAUzhHMg2UDb/U+5m+XNw= +github.com/ethereum/go-ethereum v1.17.2 h1:ag6geu0kn8Hv5FLKTpH+Hm2DHD+iuFtuqKxEuwUsDOI= +github.com/ethereum/go-ethereum v1.17.2/go.mod h1:KHcRXfGOUfUmKg51IhQ0IowiqZ6PqZf08CMtk0g5K1o= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/ferranbt/fastssz v0.1.4 h1:OCDB+dYDEQDvAgtAGnTSidK1Pe2tW3nFV40XyMkTeDY= +github.com/ferranbt/fastssz v0.1.4/go.mod h1:Ea3+oeoRGGLGm5shYAeDgu6PGUlcvQhE2fILyD9+tGg= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw= @@ -132,6 +156,7 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-ole/go-ole v1.2.5/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= @@ -143,8 +168,12 @@ github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIx github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/godbus/dbus/v5 v5.1.1-0.20241109141217-c266b19b28e9 h1:Kzr9J0S0V2PRxiX6B6xw1kWjzsIyjLO2Ibi4fNTaYBM= github.com/godbus/dbus/v5 v5.1.1-0.20241109141217-c266b19b28e9/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= +github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E= +github.com/gofrs/flock v0.12.1/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs= +github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= @@ -162,6 +191,8 @@ github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= github.com/gorilla/schema v1.4.1 h1:jUg5hUjCSDZpNGLuXQOgIWGdlgrIdYvgQ0wZtdK1M3E= github.com/gorilla/schema v1.4.1/go.mod h1:Dg5SSm5PV60mhF2NFaTV1xuYYj8tV8NOPRo4FggUMnM= +github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= +github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 h1:NmZ1PKzSTQbuGHw9DGPFomqkkLWMC+vZCkfs+FHv1Vg= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3/go.mod h1:zQrxl1YP88HQlA6i9c63DSVPFklWpGX4OWAc9bFuaH4= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= @@ -169,6 +200,8 @@ github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/holiman/uint256 v1.3.2 h1:a9EgMPSC1AAaj1SZL5zIQD3WbwTuHrMGOerLjGmM/TA= +github.com/holiman/uint256 v1.3.2/go.mod h1:EOMSn4q6Nyt9P6efbI3bueV4e1b3dGlUCXeiRV4ng7E= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= @@ -193,6 +226,9 @@ github.com/kevinburke/ssh_config v1.4.0 h1:6xxtP5bZ2E4NF5tuQulISpTO2z8XbtH8cg1PW github.com/kevinburke/ssh_config v1.4.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/klauspost/cpuid/v2 v2.0.4/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.2.3 h1:sxCkb+qR91z4vsqw4vGGZlDgPz3G7gjaLyK3V8y70BU= +github.com/klauspost/cpuid/v2 v2.2.3/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY= github.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU= github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs= github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8= @@ -201,20 +237,26 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/leanovate/gopter v0.2.11 h1:vRjThO1EKPb/1NsDXuDrzldR28RLkBflWYcU9CvzWu4= +github.com/leanovate/gopter v0.2.11/go.mod h1:aK3tzZP/C+p1m3SPRE4SYZFGP7jjkuSI4f7Xvpt0S9c= github.com/letsencrypt/boulder v0.0.0-20240620165639-de9c06129bec h1:2tTW6cDth2TSgRbAhD7yjZzTQmcN25sDRPEeinR51yQ= github.com/letsencrypt/boulder v0.0.0-20240620165639-de9c06129bec/go.mod h1:TmwEoGCwIti7BCeJ9hescZgRtatxRE+A72pCoPfmcfk= github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683 h1:7UMa6KCCMjZEMDtTVdcGu0B1GmmC7QJKiCCjyTAWQy0= github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683/go.mod h1:ilwx/Dta8jXAgpFYFvSWEMwxmbWXyiUHkd5FwyKhb5k= github.com/manifoldco/promptui v0.9.0 h1:3V4HzJk1TtXW1MTZMP7mdlwbBpIinw3HztaIlYthEiA= github.com/manifoldco/promptui v0.9.0/go.mod h1:ka04sppxSGFAtxX0qhlYQjISsg9mR4GWtQEhdbn6Pgg= -github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng= -github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs= github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/miekg/pkcs11 v1.1.1 h1:Ugu9pdy6vAYku5DEpVWVFPYnzV+bxB+iRdbuFSu7TvU= github.com/miekg/pkcs11 v1.1.1/go.mod h1:XsNlhZGX73bx86s2hdc/FuaLm2CPZJemRLMA+WTFxgs= +github.com/minio/sha256-simd v1.0.0 h1:v1ta+49hkWZyvaKwrQB8elexRqm6Y0aMLjCNsrYxo6g= +github.com/minio/sha256-simd v1.0.0/go.mod h1:OuYzVNI5vcoYIAmbIvHPl3N3jUzVedXbKy5RFepssQM= github.com/mistifyio/go-zfs/v3 v3.1.0 h1:FZaylcg0hjUp27i23VcJJQiuBeAZjrC8lPqCGM1CopY= github.com/mistifyio/go-zfs/v3 v3.1.0/go.mod h1:CzVgeB0RvF2EGzQnytKVvVSDwmKJXxkOTUGbNrTja/k= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= @@ -285,6 +327,8 @@ github.com/prometheus/common v0.63.0 h1:YR/EIY1o3mEFP/kZCD7iDMnLPlGyuU2Gb3HIcXnA github.com/prometheus/common v0.63.0/go.mod h1:VVFF/fBIoToEnWRVkYoXEkq3R3paCoxG9PXP74SnV18= github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= +github.com/prysmaticlabs/gohashtree v0.0.4-beta h1:H/EbCuXPeTV3lpKeXGPpEV9gsUpkqOOVnWapUyeWro4= +github.com/prysmaticlabs/gohashtree v0.0.4-beta/go.mod h1:BFdtALS+Ffhg3lGQIHv9HDWuHS8cTvHZzrHWxwOtGOs= github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= @@ -304,6 +348,8 @@ github.com/secure-systems-lab/go-securesystemslib v0.9.1 h1:nZZaNz4DiERIQguNy0cL github.com/secure-systems-lab/go-securesystemslib v0.9.1/go.mod h1:np53YzT0zXGMv6x4iEWc9Z59uR+x+ndLwCLqPYpLXVU= github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= +github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible h1:Bn1aCHHRnjv4Bl16T8rcaFjYSrGrIZvpiGO6P3Q4GpU= +github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA= github.com/shirou/gopsutil/v4 v4.25.12 h1:e7PvW/0RmJ8p8vPGJH4jvNkOyLmbkXgXW4m6ZPic6CY= github.com/shirou/gopsutil/v4 v4.25.12/go.mod h1:EivAfP5x2EhLp2ovdpKSozecVXn1TmuG7SMzs/Wh4PU= github.com/sigstore/fulcio v1.7.1 h1:RcoW20Nz49IGeZyu3y9QYhyyV3ZKQ85T+FXPKkvE+aQ= @@ -343,6 +389,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/supranational/blst v0.3.16 h1:bTDadT+3fK497EvLdWRQEjiGnUtzJ7jjIUMF0jqwYhE= +github.com/supranational/blst v0.3.16/go.mod h1:jZJtfjgudtNl4en1tzwPIV3KjUnQUvG3/j+w+fVonLw= github.com/sylabs/sif/v2 v2.22.0 h1:Y+xXufp4RdgZe02SR3nWEg7S6q4tPWN237WHYzkDSKA= github.com/sylabs/sif/v2 v2.22.0/go.mod h1:W1XhWTmG1KcG7j5a3KSYdMcUIFvbs240w/MMVW627hs= github.com/tchap/go-patricia/v2 v2.3.3 h1:xfNEsODumaEcCcY3gI0hYPZ/PcpVv5ju6RMAhgwZDDc= @@ -372,20 +420,20 @@ go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 h1:ssfIgGNANqpVFCndZvcuyKbl0g+UAVcbBcqGkG28H0Y= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0/go.mod h1:GQ/474YrbE4Jx8gZ4q5I4hrhUzM6UPzyrqJYV2AqPoQ= -go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= -go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= +go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms= +go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0 h1:f0cb2XPmrqn4XMy9PNliTgRKJgS5WcL/u0/WRYGz4t0= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0/go.mod h1:vnakAaFckOMiMtOIhFI2MNH4FYrZzXCYxmb1LlhoGz8= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.39.0 h1:Ckwye2FpXkYgiHX7fyVrN1uA/UYd9ounqqTuSNAv0k4= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.39.0/go.mod h1:teIFJh5pW2y+AN7riv6IBPX2DuesS3HgP39mwOspKwU= -go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= -go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= -go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= -go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= +go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g= +go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc= +go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8= +go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE= go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= -go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= -go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= +go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw= +go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA= go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A= go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4= go.podman.io/common v0.67.0 h1:6Ci5oU1ek08OAxBLkHEqSyWmjNh5zf03PRqZ04cPdwU= @@ -444,11 +492,12 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= @@ -491,19 +540,21 @@ golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= -google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls= -google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= +google.golang.org/genproto/googleapis/api v0.0.0-20251222181119-0a764e51fe1b h1:uA40e2M6fYRBf0+8uN5mLlqUtV192iiksiICIBkYJ1E= +google.golang.org/genproto/googleapis/api v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:Xa7le7qx2vmqB/SzWUBa7KdMjpdpAHlh5QCSnjessQk= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM= google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig= -google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= -google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/pkg/config/config.go b/pkg/config/config.go index 071ff990c..b2285e492 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -508,6 +508,18 @@ type BootstrapFCUConfig struct { HeadBlockHash string `yaml:"head_block_hash" mapstructure:"head_block_hash" json:"head_block_hash,omitempty"` } +// WarmupTestPayloadConfig configures the warmup phase that runs between +// setup and test steps. When enabled, the runner takes the test step's +// engine_newPayload* calls, replaces stateRoot with a fork-specific +// placeholder, and recomputes blockHash before sending the resulting +// payloads to the client. The expected outcome is a fast state-root +// rejection by the client; the value of doing this is that caches and +// codepaths get warmed up before the real test runs. +type WarmupTestPayloadConfig struct { + Enabled bool `yaml:"enabled" mapstructure:"enabled" json:"enabled"` + Fork string `yaml:"fork" mapstructure:"fork" json:"fork,omitempty"` +} + // PostTestRPCCall defines an arbitrary RPC call to execute after the test step. type PostTestRPCCall struct { Method string `yaml:"method" mapstructure:"method" json:"method"` @@ -729,6 +741,7 @@ type ClientDefaults struct { PostTestSleepDuration string `yaml:"post_test_sleep_duration,omitempty" mapstructure:"post_test_sleep_duration"` BootstrapFCU *BootstrapFCUConfig `yaml:"bootstrap_fcu,omitempty" mapstructure:"bootstrap_fcu"` CheckpointRestoreStrategyOptions *CheckpointRestoreStrategyOptions `yaml:"checkpoint_restore_strategy_options,omitempty" mapstructure:"checkpoint_restore_strategy_options"` + WarmupTestPayload *WarmupTestPayloadConfig `yaml:"warmup_test_payload,omitempty" mapstructure:"warmup_test_payload"` Metadata MetadataConfig `yaml:"metadata,omitempty" mapstructure:"metadata"` } @@ -755,6 +768,7 @@ type ClientInstance struct { PostTestSleepDuration string `yaml:"post_test_sleep_duration,omitempty" mapstructure:"post_test_sleep_duration"` BootstrapFCU *BootstrapFCUConfig `yaml:"bootstrap_fcu,omitempty" mapstructure:"bootstrap_fcu"` CheckpointRestoreStrategyOptions *CheckpointRestoreStrategyOptions `yaml:"checkpoint_restore_strategy_options,omitempty" mapstructure:"checkpoint_restore_strategy_options"` + WarmupTestPayload *WarmupTestPayloadConfig `yaml:"warmup_test_payload,omitempty" mapstructure:"warmup_test_payload"` Metadata MetadataConfig `yaml:"metadata,omitempty" mapstructure:"metadata"` } @@ -1197,6 +1211,11 @@ func (c *Config) Validate(opts ...ValidateOpts) error { return err } + // Validate warmup_test_payload settings. + if err := c.validateWarmupTestPayload(); err != nil { + return err + } + // Validate results_upload settings. if err := c.validateResultsUpload(); err != nil { return err @@ -1613,6 +1632,17 @@ func (c *Config) GetCheckpointRestoreStrategyOptions( return c.Runner.Client.Config.CheckpointRestoreStrategyOptions } +// GetWarmupTestPayload returns the warmup_test_payload config for an instance. +// Instance-level config (when non-nil) fully replaces the global default. +// Returns nil if not configured at either level. +func (c *Config) GetWarmupTestPayload(instance *ClientInstance) *WarmupTestPayloadConfig { + if instance.WarmupTestPayload != nil { + return instance.WarmupTestPayload + } + + return c.Runner.Client.Config.WarmupTestPayload +} + // GetCheckpointTmpfsThreshold returns the tmpfs_threshold for an instance. // Instance-level setting takes precedence over global default. // Returns empty string if not configured (feature disabled). @@ -2087,6 +2117,26 @@ func (c *Config) validateBootstrapFCU() error { return nil } +// validateWarmupTestPayload validates warmup_test_payload settings. +// Currently only "osaka" is a supported fork. +func (c *Config) validateWarmupTestPayload() error { + for _, instance := range c.Runner.Instances { + cfg := c.GetWarmupTestPayload(&instance) + if cfg == nil || !cfg.Enabled { + continue + } + + if cfg.Fork != "osaka" { + return fmt.Errorf( + "instance %q: warmup_test_payload.fork must be \"osaka\" (got %q)", + instance.ID, cfg.Fork, + ) + } + } + + return nil +} + // validateResultsUpload validates results_upload settings. func (c *Config) validateResultsUpload() error { if c.Runner.Benchmark.ResultsUpload == nil || c.Runner.Benchmark.ResultsUpload.S3 == nil { diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 9cb0b200f..0a375722a 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -2673,3 +2673,118 @@ func TestValidateRunTimeout(t *testing.T) { }) } } + +func TestGetWarmupTestPayload(t *testing.T) { + tests := []struct { + name string + global *WarmupTestPayloadConfig + instance *WarmupTestPayloadConfig + expected *WarmupTestPayloadConfig + }{ + { + name: "both nil returns nil", + global: nil, + instance: nil, + expected: nil, + }, + { + name: "global set, instance nil inherits", + global: &WarmupTestPayloadConfig{Enabled: true, Fork: "osaka"}, + instance: nil, + expected: &WarmupTestPayloadConfig{Enabled: true, Fork: "osaka"}, + }, + { + name: "instance overrides global", + global: &WarmupTestPayloadConfig{Enabled: true, Fork: "osaka"}, + instance: &WarmupTestPayloadConfig{Enabled: false}, + expected: &WarmupTestPayloadConfig{Enabled: false}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := &Config{ + Runner: RunnerConfig{ + Client: ClientConfig{ + Config: ClientDefaults{ + WarmupTestPayload: tt.global, + }, + }, + }, + } + instance := &ClientInstance{ + WarmupTestPayload: tt.instance, + } + result := cfg.GetWarmupTestPayload(instance) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestValidateWarmupTestPayload(t *testing.T) { + tests := []struct { + name string + global *WarmupTestPayloadConfig + instance *WarmupTestPayloadConfig + wantErr bool + errSubstr string + }{ + { + name: "disabled is always valid", + global: &WarmupTestPayloadConfig{Enabled: false, Fork: "prague"}, + wantErr: false, + }, + { + name: "enabled with osaka", + global: &WarmupTestPayloadConfig{Enabled: true, Fork: "osaka"}, + wantErr: false, + }, + { + name: "enabled with empty fork", + global: &WarmupTestPayloadConfig{Enabled: true, Fork: ""}, + wantErr: true, + errSubstr: `warmup_test_payload.fork must be "osaka"`, + }, + { + name: "enabled with unsupported fork", + global: &WarmupTestPayloadConfig{Enabled: true, Fork: "prague"}, + wantErr: true, + errSubstr: `warmup_test_payload.fork must be "osaka"`, + }, + { + name: "instance enabled with bad fork overrides valid global", + global: &WarmupTestPayloadConfig{Enabled: true, Fork: "osaka"}, + instance: &WarmupTestPayloadConfig{Enabled: true, Fork: "shanghai"}, + wantErr: true, + errSubstr: `warmup_test_payload.fork must be "osaka"`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := &Config{ + Runner: RunnerConfig{ + Client: ClientConfig{ + Config: ClientDefaults{ + WarmupTestPayload: tt.global, + }, + }, + Instances: []ClientInstance{ + { + ID: "test", + Client: "geth", + WarmupTestPayload: tt.instance, + }, + }, + }, + } + err := cfg.validateWarmupTestPayload() + if tt.wantErr { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.errSubstr) + } else { + require.NoError(t, err) + } + }) + } +} diff --git a/pkg/executor/executor.go b/pkg/executor/executor.go index 7e5453f43..229fdc3c9 100644 --- a/pkg/executor/executor.go +++ b/pkg/executor/executor.go @@ -26,6 +26,7 @@ import ( "github.com/ethpandaops/benchmarkoor/pkg/fsutil" "github.com/ethpandaops/benchmarkoor/pkg/jsonrpc" "github.com/ethpandaops/benchmarkoor/pkg/stats" + "github.com/ethpandaops/benchmarkoor/pkg/warmup" "github.com/sirupsen/logrus" ) @@ -114,6 +115,7 @@ type ExecuteOptions struct { FailFast bool // If true, return an error from runStepLines on the first failed RPC call. PreRunStepSleep time.Duration // Sleep between each RPC call within pre-run step files (0 = disabled). SkipUntilBlockNumber uint64 // Skip pre-run RPC lines until the first engine_newPayload with blockNumber > this. 0 = no skipping. + WarmupTestPayload *config.WarmupTestPayloadConfig // When enabled, run a warmup phase between setup and test that sends modified test newPayload calls (stateRoot replaced, blockHash recomputed) to warm caches. } // ExecutionResult contains the overall execution summary. @@ -570,7 +572,37 @@ func (e *executor) ExecuteTests(ctx context.Context, opts *ExecuteOptions) (*Exe } } - // Drop caches between setup and test. + // Run warmup step if enabled. Warmup payloads are derived from + // the test step's engine_newPayload* calls with stateRoot replaced + // and blockHash recomputed; this populates EL caches before the + // real test runs. The client is expected to reject the payloads + // (state-root mismatch), but only after performing the work that + // warms its caches. + if opts.WarmupTestPayload != nil && opts.WarmupTestPayload.Enabled && test.Test != nil { + log.Info("Running warmup step") + + warmupResult := NewTestResult(test.Name) + + if err := e.runWarmupStep(ctx, opts, test.Test, warmupResult); err != nil { + log.WithError(err).Error("Warmup step failed") + + // Check if the failure was due to context cancellation. + if ctx.Err() != nil { + interrupted = true + interruptReason = "context cancelled during warmup step" + + goto writeResults + } + // Continue to the test step even if warmup itself failed — + // warmup is best-effort, the real measurement is the test. + } else { + if err := WriteStepResults(opts.ResultsDir, test.Name, StepTypeWarmup, warmupResult, e.cfg.ResultsOwner); err != nil { + log.WithError(err).Warn("Failed to write warmup results") + } + } + } + + // Drop caches between setup/warmup and test. if dropBetweenSteps && test.Setup != nil && test.Test != nil { if err := e.dropMemoryCaches(dropCachesPath); err != nil { e.log.WithError(err).Warn("Failed to drop memory caches before test step") @@ -757,6 +789,71 @@ writeResults: return result, nil } +// runWarmupStep generates warmup payloads from the given test step's +// engine_newPayload* lines (stateRoot replaced, blockHash recomputed) and +// runs them via runStepLines so the client populates caches before the +// real test runs. +func (e *executor) runWarmupStep( + ctx context.Context, + opts *ExecuteOptions, + testStep *StepFile, + result *TestResult, +) error { + gen, err := warmup.NewGenerator(warmup.Fork(opts.WarmupTestPayload.Fork)) + if err != nil { + return fmt.Errorf("creating warmup generator: %w", err) + } + + lines, err := readStepLines(testStep) + if err != nil { + return fmt.Errorf("reading test step for warmup: %w", err) + } + + transformed, err := gen.TransformLines(lines) + if err != nil { + return fmt.Errorf("transforming warmup payloads: %w", err) + } + + return e.runStepLines(ctx, opts, testStep.Name, transformed, result, false, 0) +} + +// readStepLines returns the JSON-RPC lines from a step (file or provider). +func readStepLines(step *StepFile) ([]string, error) { + if step.Provider != nil { + return step.Provider.Lines(), nil + } + + file, err := os.Open(step.Path) + if err != nil { + return nil, fmt.Errorf("opening step file: %w", err) + } + + defer func() { _ = file.Close() }() + + reader := bufio.NewReader(file) + + var lines []string + + for { + line, err := reader.ReadString('\n') + if len(line) > 0 { + if trimmed := strings.TrimSpace(line); trimmed != "" { + lines = append(lines, trimmed) + } + } + + if err != nil { + if err == io.EOF { + break + } + + return nil, fmt.Errorf("reading step file: %w", err) + } + } + + return lines, nil +} + // runStepFile executes a single step file or provider. // If captureBlockLogs is true, blockHashes from engine_newPayload calls are registered for log matching. // betweenLineSleep, when > 0, sleeps for that duration between each RPC call. diff --git a/pkg/executor/results.go b/pkg/executor/results.go index caea97026..ee73c6f27 100644 --- a/pkg/executor/results.go +++ b/pkg/executor/results.go @@ -165,6 +165,7 @@ type StepResult struct { // StepsResult contains results for all steps of a test. type StepsResult struct { Setup *StepResult `json:"setup,omitempty"` + Warmup *StepResult `json:"warmup,omitempty"` Test *StepResult `json:"test,omitempty"` Cleanup *StepResult `json:"cleanup,omitempty"` } @@ -672,6 +673,8 @@ func GenerateRunResult(resultsDir string) (*RunResult, error) { switch filename { case string(StepTypeSetup): stepType = StepTypeSetup + case string(StepTypeWarmup): + stepType = StepTypeWarmup case string(StepTypeTest): stepType = StepTypeTest case string(StepTypeCleanup): @@ -714,6 +717,8 @@ func GenerateRunResult(resultsDir string) (*RunResult, error) { switch stepType { case StepTypeSetup: entry.Steps.Setup = stepResult + case StepTypeWarmup: + entry.Steps.Warmup = stepResult case StepTypeTest: entry.Steps.Test = stepResult case StepTypeCleanup: diff --git a/pkg/executor/source.go b/pkg/executor/source.go index f53bdf68c..0745ec55d 100644 --- a/pkg/executor/source.go +++ b/pkg/executor/source.go @@ -21,6 +21,7 @@ type StepType string const ( StepTypeSetup StepType = "setup" + StepTypeWarmup StepType = "warmup" StepTypeTest StepType = "test" StepTypeCleanup StepType = "cleanup" StepTypePreRun StepType = "pre_run" diff --git a/pkg/executor/suite_stats.go b/pkg/executor/suite_stats.go index 6e581e6c1..005386fa8 100644 --- a/pkg/executor/suite_stats.go +++ b/pkg/executor/suite_stats.go @@ -36,6 +36,7 @@ type RunDuration struct { // RunDurationStepsStats contains per-step gas and time data. type RunDurationStepsStats struct { Setup *RunDurationStepStats `json:"setup,omitempty"` + Warmup *RunDurationStepStats `json:"warmup,omitempty"` Test *RunDurationStepStats `json:"test,omitempty"` Cleanup *RunDurationStepStats `json:"cleanup,omitempty"` } @@ -177,6 +178,19 @@ func AccumulateRunResult(stats *SuiteStats, resultData []byte, run RunInfo) { totalGasUsedTime += agg.GasUsedTimeTotal } + // Warmup is a cache-priming phase; record its stats but do not + // fold them into the per-test totals so test gas/time stays + // comparable across runs with and without warmup enabled. + if testEntry.Steps.Warmup != nil && testEntry.Steps.Warmup.Aggregated != nil { + agg := testEntry.Steps.Warmup.Aggregated + stepsStats.Warmup = &RunDurationStepStats{ + GasUsed: agg.GasUsedTotal, + Time: agg.GasUsedTimeTotal, + RPCCallsCount: agg.TotalMsgs, + ResourceTotals: agg.ResourceTotals, + } + } + if testEntry.Steps.Test != nil && testEntry.Steps.Test.Aggregated != nil { agg := testEntry.Steps.Test.Aggregated stepsStats.Test = &RunDurationStepStats{ diff --git a/pkg/runner/lifecycle.go b/pkg/runner/lifecycle.go index 9c907a0a9..bd4f09d17 100644 --- a/pkg/runner/lifecycle.go +++ b/pkg/runner/lifecycle.go @@ -625,6 +625,12 @@ func (r *runner) runContainerLifecycle( } return nil }(), + WarmupTestPayload: func() *config.WarmupTestPayloadConfig { + if r.cfg.FullConfig != nil { + return r.cfg.FullConfig.GetWarmupTestPayload(instance) + } + return nil + }(), }, } @@ -1124,6 +1130,7 @@ func (r *runner) runContainerLifecycle( PostTestSleepDuration: r.cfg.FullConfig.GetPostTestSleepDuration(instance), PreRunStepSleep: r.cfg.PreRunStepSleep, SkipUntilBlockNumber: blockNum, + WarmupTestPayload: r.cfg.FullConfig.GetWarmupTestPayload(instance), } result, execErr = r.executor.ExecuteTests(execCtx, execOpts) diff --git a/pkg/runner/runner.go b/pkg/runner/runner.go index 0f01ca994..c5d66c514 100644 --- a/pkg/runner/runner.go +++ b/pkg/runner/runner.go @@ -188,6 +188,7 @@ type ResolvedInstance struct { PostTestSleepDuration string `json:"post_test_sleep_duration,omitempty"` BootstrapFCU *config.BootstrapFCUConfig `json:"bootstrap_fcu,omitempty"` CheckpointRestoreStrategyOptions *config.CheckpointRestoreStrategyOptions `json:"checkpoint_restore_strategy_options,omitempty"` + WarmupTestPayload *config.WarmupTestPayloadConfig `json:"warmup_test_payload,omitempty"` } // NewRunner creates a new runner instance. diff --git a/pkg/runner/strategy_checkpoint.go b/pkg/runner/strategy_checkpoint.go index e73363284..b853fb76b 100644 --- a/pkg/runner/strategy_checkpoint.go +++ b/pkg/runner/strategy_checkpoint.go @@ -512,6 +512,7 @@ func (r *runner) runTestsWithCheckpointRestore( RetryNewPayloadsSyncingConfig: r.cfg.FullConfig.GetRetryNewPayloadsSyncingState(params.Instance), PostTestRPCCalls: r.cfg.FullConfig.GetPostTestRPCCalls(params.Instance), PostTestSleepDuration: r.cfg.FullConfig.GetPostTestSleepDuration(params.Instance), + WarmupTestPayload: r.cfg.FullConfig.GetWarmupTestPayload(params.Instance), } result, execErr := r.executor.ExecuteTests(ctx, execOpts) diff --git a/pkg/runner/strategy_container.go b/pkg/runner/strategy_container.go index 77ba1c234..8dc59ad64 100644 --- a/pkg/runner/strategy_container.go +++ b/pkg/runner/strategy_container.go @@ -774,6 +774,7 @@ func (r *runner) runTestsWithContainerStrategy( RetryNewPayloadsSyncingConfig: r.cfg.FullConfig.GetRetryNewPayloadsSyncingState(params.Instance), PostTestRPCCalls: r.cfg.FullConfig.GetPostTestRPCCalls(params.Instance), PostTestSleepDuration: r.cfg.FullConfig.GetPostTestSleepDuration(params.Instance), + WarmupTestPayload: r.cfg.FullConfig.GetWarmupTestPayload(params.Instance), } result, err := r.executor.ExecuteTests(ctx, execOpts) diff --git a/pkg/warmup/warmup.go b/pkg/warmup/warmup.go new file mode 100644 index 000000000..989b30313 --- /dev/null +++ b/pkg/warmup/warmup.go @@ -0,0 +1,182 @@ +// Package warmup generates "warmup" engine_newPayload* requests by taking +// the new-payload calls from a test step, replacing the stateRoot with a +// fork-specific placeholder, and recomputing the blockHash so the EL +// client accepts the payload header and proceeds to execute it. The point +// of warmup is to populate caches before the real test runs. +package warmup + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/ethereum/go-ethereum/beacon/engine" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" +) + +// Fork identifies an Ethereum hardfork. We currently only support Osaka. +type Fork string + +const ( + // ForkOsaka is the only supported fork at the moment. Its header layout + // includes withdrawalsRoot, blobGasUsed, excessBlobGas, parentBeaconRoot, + // and requestsHash (EIP-7685). + ForkOsaka Fork = "osaka" +) + +// OsakaWarmupStateRoot is the placeholder stateRoot used in warmup payloads. +// It is intentionally non-empty so the EL deserializes a header successfully; +// the actual state-root mismatch is expected to be detected during execution, +// at which point the warmup has already populated caches. +const OsakaWarmupStateRoot = "0xe8d3a308a0d3fdaeed6c196f78aad4f9620b571da6dd5b886e7fa5eba07c83e0" + +// IsValidFork returns true if the given fork identifier is supported. +func IsValidFork(fork string) bool { + return Fork(fork) == ForkOsaka +} + +// Generator transforms engine_newPayload* JSON-RPC lines into warmup +// equivalents (modified stateRoot + recomputed blockHash). Lines whose +// method is not engine_newPayload* are returned unchanged. +type Generator struct { + fork Fork + stateRoot common.Hash +} + +// NewGenerator returns a Generator for the given fork. Currently only +// ForkOsaka is accepted. +func NewGenerator(fork Fork) (*Generator, error) { + if fork != ForkOsaka { + return nil, fmt.Errorf("unsupported fork %q (only %q is supported)", fork, ForkOsaka) + } + + return &Generator{ + fork: fork, + stateRoot: common.HexToHash(OsakaWarmupStateRoot), + }, nil +} + +// Transform rewrites a single JSON-RPC line. Non-engine_newPayload* lines +// pass through unchanged. For new-payload lines the stateRoot is replaced +// and the blockHash is recomputed before re-serializing the request. +func (g *Generator) Transform(line string) (string, error) { + if strings.TrimSpace(line) == "" { + return line, nil + } + + var raw struct { + JSONRPC string `json:"jsonrpc"` + ID json.RawMessage `json:"id"` + Method string `json:"method"` + Params []json.RawMessage `json:"params"` + } + if err := json.Unmarshal([]byte(line), &raw); err != nil { + return "", fmt.Errorf("parse jsonrpc line: %w", err) + } + + if !strings.HasPrefix(raw.Method, "engine_newPayload") { + return line, nil + } + + if len(raw.Params) < 1 { + return "", fmt.Errorf("%s: expected at least 1 param, got %d", raw.Method, len(raw.Params)) + } + + var data engine.ExecutableData + if err := json.Unmarshal(raw.Params[0], &data); err != nil { + return "", fmt.Errorf("parse executionPayload: %w", err) + } + + // engine_newPayloadV3+ carry blobVersionedHashes (params[1]), + // parentBeaconBlockRoot (params[2]) and (V4+) executionRequests (params[3]). + versionedHashes, beaconRoot, requests, err := decodeExtraParams(raw.Method, raw.Params) + if err != nil { + return "", err + } + + // Replace stateRoot and recompute blockHash. ExecutableDataToBlockNoHash + // builds a block with all derived roots (txRoot, withdrawalsRoot, + // requestsHash) without verifying the supplied blockHash matches. + data.StateRoot = g.stateRoot + + block, err := engine.ExecutableDataToBlockNoHash(data, versionedHashes, beaconRoot, requests) + if err != nil { + return "", fmt.Errorf("build block from payload: %w", err) + } + + data.BlockHash = block.Hash() + + newPayload, err := json.Marshal(&data) + if err != nil { + return "", fmt.Errorf("marshal warmup payload: %w", err) + } + + raw.Params[0] = newPayload + + out, err := json.Marshal(&raw) + if err != nil { + return "", fmt.Errorf("marshal jsonrpc line: %w", err) + } + + return string(out), nil +} + +// TransformLines applies Transform to every line in the input slice. The +// returned slice has the same length and ordering as input. +func (g *Generator) TransformLines(lines []string) ([]string, error) { + out := make([]string, len(lines)) + + for i, line := range lines { + transformed, err := g.Transform(line) + if err != nil { + return nil, fmt.Errorf("line %d: %w", i+1, err) + } + + out[i] = transformed + } + + return out, nil +} + +// decodeExtraParams pulls versionedHashes, parentBeaconBlockRoot, and +// executionRequests out of an engine_newPayload* params array. It tolerates +// older payload versions that omit later params. +func decodeExtraParams( + method string, + params []json.RawMessage, +) (versionedHashes []common.Hash, beaconRoot *common.Hash, requests [][]byte, err error) { + if len(params) >= 2 { + var hashes []common.Hash + if err := json.Unmarshal(params[1], &hashes); err != nil { + return nil, nil, nil, fmt.Errorf("%s: parse blobVersionedHashes: %w", method, err) + } + + versionedHashes = hashes + } + + if len(params) >= 3 { + var root common.Hash + if err := json.Unmarshal(params[2], &root); err != nil { + return nil, nil, nil, fmt.Errorf("%s: parse parentBeaconBlockRoot: %w", method, err) + } + + beaconRoot = &root + } + + if len(params) >= 4 { + var hexes []hexutil.Bytes + if err := json.Unmarshal(params[3], &hexes); err != nil { + return nil, nil, nil, fmt.Errorf("%s: parse executionRequests: %w", method, err) + } + + reqs := make([][]byte, len(hexes)) + for i, h := range hexes { + reqs[i] = h + } + + requests = reqs + } + + return versionedHashes, beaconRoot, requests, nil +} diff --git a/pkg/warmup/warmup_test.go b/pkg/warmup/warmup_test.go new file mode 100644 index 000000000..bf0fb06fa --- /dev/null +++ b/pkg/warmup/warmup_test.go @@ -0,0 +1,153 @@ +package warmup + +import ( + "encoding/json" + "math/big" + "testing" + + "github.com/ethereum/go-ethereum/beacon/engine" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/core/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// makeOsakaPayload returns a minimal but valid Osaka ExecutableData with no +// transactions, no withdrawals, no requests. Its BlockHash field is filled +// in to whatever the recomputed hash is, so callers that wrap it in a +// JSON-RPC envelope start from a self-consistent payload. +func makeOsakaPayload(t *testing.T, stateRoot common.Hash) (engine.ExecutableData, common.Hash, *common.Hash) { + t.Helper() + + zero := uint64(0) + beaconRoot := common.HexToHash("0x000102030405060708090a0b0c0d0e0f000102030405060708090a0b0c0d0e0f") + data := engine.ExecutableData{ + ParentHash: common.HexToHash("0x58b0689a8ff37dc82cd2840dabcd79afae585d9a261ac5658e4720a9a5ade187"), + FeeRecipient: common.HexToAddress("0x2adc25665018aa1fe0e6bc666dac8fc2697ff9ba"), + StateRoot: stateRoot, + ReceiptsRoot: common.HexToHash("0x9764a9b29133339b51f219a18d3bc2b617ce983cb602f95df5647fa4d7362828"), + LogsBloom: make([]byte, 256), + Random: common.HexToHash("0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"), + Number: 100, + GasLimit: 30_000_000, + GasUsed: 0, + Timestamp: 1_700_000_000, + ExtraData: []byte{}, + BaseFeePerGas: big.NewInt(7), + Transactions: [][]byte{}, + Withdrawals: []*types.Withdrawal{}, // empty (non-nil) → withdrawalsRoot of empty trie + BlobGasUsed: &zero, + ExcessBlobGas: &zero, + } + + // Compute the canonical blockHash for this payload (with empty requests). + block, err := engine.ExecutableDataToBlockNoHash(data, nil, &beaconRoot, [][]byte{}) + require.NoError(t, err) + + data.BlockHash = block.Hash() + + return data, block.Hash(), &beaconRoot +} + +func TestNewGenerator_RejectsUnknownFork(t *testing.T) { + _, err := NewGenerator(Fork("prague")) + assert.Error(t, err) +} + +func TestTransform_NonNewPayloadPassesThrough(t *testing.T) { + g, err := NewGenerator(ForkOsaka) + require.NoError(t, err) + + in := `{"jsonrpc":"2.0","id":1,"method":"engine_forkchoiceUpdatedV3","params":[]}` + + out, err := g.Transform(in) + require.NoError(t, err) + assert.Equal(t, in, out) +} + +func TestTransform_EmptyLinePassesThrough(t *testing.T) { + g, err := NewGenerator(ForkOsaka) + require.NoError(t, err) + + out, err := g.Transform("") + require.NoError(t, err) + assert.Equal(t, "", out) +} + +func TestTransform_NewPayloadOverridesStateRootAndRecomputesHash(t *testing.T) { + originalStateRoot := common.HexToHash("0xde94bab83ce96d440db7a3e2dc95ebbab73dc5aa88dc80b3d99ae8f0cff4e96c") + data, originalHash, beaconRoot := makeOsakaPayload(t, originalStateRoot) + + payloadJSON, err := json.Marshal(&data) + require.NoError(t, err) + + beaconRootJSON, err := json.Marshal(beaconRoot) + require.NoError(t, err) + + emptyRequests, err := json.Marshal([]hexutil.Bytes{}) + require.NoError(t, err) + + line := `{"jsonrpc":"2.0","id":1,"method":"engine_newPayloadV4","params":[` + + string(payloadJSON) + `,[],` + string(beaconRootJSON) + `,` + string(emptyRequests) + `]}` + + g, err := NewGenerator(ForkOsaka) + require.NoError(t, err) + + out, err := g.Transform(line) + require.NoError(t, err) + + // Parse the transformed line back out. + var parsed struct { + Method string `json:"method"` + Params []json.RawMessage `json:"params"` + } + require.NoError(t, json.Unmarshal([]byte(out), &parsed)) + assert.Equal(t, "engine_newPayloadV4", parsed.Method) + require.Len(t, parsed.Params, 4) + + var transformed engine.ExecutableData + require.NoError(t, json.Unmarshal(parsed.Params[0], &transformed)) + + // stateRoot replaced with the warmup placeholder. + assert.Equal(t, common.HexToHash(OsakaWarmupStateRoot), transformed.StateRoot) + + // blockHash changed. + assert.NotEqual(t, originalHash, transformed.BlockHash) + + // blockHash matches a fresh recomputation with the warmup stateRoot. + expected, err := engine.ExecutableDataToBlockNoHash(transformed, nil, beaconRoot, [][]byte{}) + require.NoError(t, err) + assert.Equal(t, expected.Hash(), transformed.BlockHash) +} + +func TestTransformLines_PreservesOrderAndCount(t *testing.T) { + originalStateRoot := common.HexToHash("0xde94bab83ce96d440db7a3e2dc95ebbab73dc5aa88dc80b3d99ae8f0cff4e96c") + data, _, beaconRoot := makeOsakaPayload(t, originalStateRoot) + + payloadJSON, err := json.Marshal(&data) + require.NoError(t, err) + + beaconRootJSON, err := json.Marshal(beaconRoot) + require.NoError(t, err) + + emptyRequests, err := json.Marshal([]hexutil.Bytes{}) + require.NoError(t, err) + + newPayload := `{"jsonrpc":"2.0","id":1,"method":"engine_newPayloadV4","params":[` + + string(payloadJSON) + `,[],` + string(beaconRootJSON) + `,` + string(emptyRequests) + `]}` + fcu := `{"jsonrpc":"2.0","id":2,"method":"engine_forkchoiceUpdatedV3","params":[]}` + + g, err := NewGenerator(ForkOsaka) + require.NoError(t, err) + + out, err := g.TransformLines([]string{newPayload, fcu}) + require.NoError(t, err) + require.Len(t, out, 2) + + // Second line passes through unchanged. + assert.Equal(t, fcu, out[1]) + + // First line has been rewritten (different from original because stateRoot/blockHash changed). + assert.NotEqual(t, newPayload, out[0]) +} diff --git a/ui/src/api/types.ts b/ui/src/api/types.ts index 4dd5bd783..7063d2cc1 100644 --- a/ui/src/api/types.ts +++ b/ui/src/api/types.ts @@ -259,6 +259,11 @@ export interface CheckpointRestoreStrategyOptions { restart_container?: boolean } +export interface WarmupTestPayloadConfig { + enabled: boolean + fork?: string +} + export interface InstanceConfig { id: string client: string @@ -284,6 +289,7 @@ export interface InstanceConfig { post_test_rpc_calls?: PostTestRPCCallConfig[] post_test_sleep_duration?: string checkpoint_restore_strategy_options?: CheckpointRestoreStrategyOptions + warmup_test_payload?: WarmupTestPayloadConfig } // result.json per run diff --git a/ui/src/components/compare/ConfigDiff.tsx b/ui/src/components/compare/ConfigDiff.tsx index 5dafda2c9..9f750c633 100644 --- a/ui/src/components/compare/ConfigDiff.tsx +++ b/ui/src/components/compare/ConfigDiff.tsx @@ -40,6 +40,7 @@ export function ConfigDiff({ runs, labelMode }: ConfigDiffProps) { inst.image !== first.image || inst.client !== first.client || inst.rollback_strategy !== first.rollback_strategy + || JSON.stringify(inst.warmup_test_payload) !== JSON.stringify(first.warmup_test_payload) || JSON.stringify(inst.command) !== JSON.stringify(first.command) || JSON.stringify(inst.environment) !== JSON.stringify(first.environment), ) || systems.some((sys) => @@ -109,6 +110,16 @@ export function ConfigDiff({ runs, labelMode }: ConfigDiffProps) { {instances.some((i) => i.rollback_strategy) && ( i.rollback_strategy ?? 'none')} /> )} + {instances.some((i) => i.warmup_test_payload) && ( + { + const w = i.warmup_test_payload + if (!w || !w.enabled) return 'disabled' + return w.fork ? `enabled (${w.fork})` : 'enabled' + })} + /> + )} {instances.some((i) => i.environment) && ( )} + {instance.warmup_test_payload?.enabled && ( +
+
+ Warmup Test Payload +
+
+
+
+ enabled: + true +
+ {instance.warmup_test_payload.fork && ( +
+ fork: + {instance.warmup_test_payload.fork} +
+ )} +
+

+ Inserts a warmup phase between setup and test that sends modified + engine_newPayload calls (stateRoot replaced, blockHash recomputed) to + warm client caches before the real test runs. +

+
+
+ )} + {instance.retry_new_payloads_syncing_state?.enabled && (
From 599747ffef3662bbf57e62e2bdc6184230d7d916 Mon Sep 17 00:00:00 2001 From: Rafael Matias Date: Thu, 30 Apr 2026 15:15:00 +0200 Subject: [PATCH 2/4] feat(warmup): add count option with per-iteration salted stateRoot Adds runner.client.config.warmup_test_payload.count to control how many times each engine_newPayload* line is sent during the warmup phase. Defaults to 1; must be >= 1 when enabled. Each iteration uses a different stateRoot deterministically derived as keccak256(salt || uint64BE(iteration)), so the client treats the calls as distinct payloads (different blockHashes) instead of de-duplicating them. The hardcoded OsakaWarmupStateRoot constant becomes the salt input (renamed OsakaWarmupSalt) rather than the literal stateRoot. Generator.Transform now returns a slice: non-newPayload lines (FCUs, etc.) come back as a single element regardless of count; newPayload lines expand to count variants with distinct stateRoots and recomputed blockHashes. Resolved instance config.json, run-detail UI card, compare diff row, and docs all show the count value. --- docs/configuration.md | 4 +- pkg/config/config.go | 27 ++- pkg/config/config_test.go | 35 ++++ pkg/executor/executor.go | 5 +- pkg/warmup/warmup.go | 153 +++++++++++----- pkg/warmup/warmup_test.go | 166 ++++++++++++++---- ui/src/api/types.ts | 1 + ui/src/components/compare/ConfigDiff.tsx | 5 +- .../run-detail/RunConfiguration.tsx | 10 +- 9 files changed, 320 insertions(+), 86 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 9657df33c..7f04fcc63 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -949,7 +949,7 @@ When using the `container-recreate` rollback strategy, the bootstrap FCU is sent ##### Warmup Test Payload -The `warmup_test_payload` option inserts a warmup phase between the setup and test steps. For each `engine_newPayload*` call in the test step, the runner creates a copy with the `stateRoot` replaced by a fork-specific placeholder and the `blockHash` recomputed to match. These warmup payloads are sent to the client before the real test runs. The client is expected to reject them with a state-root mismatch — the value is in the work the client performs (cache fills, codepath warming) before that rejection. +The `warmup_test_payload` option inserts a warmup phase between the setup and test steps. For each `engine_newPayload*` call in the test step, the runner creates one or more copies with the `stateRoot` replaced by a fork-specific placeholder and the `blockHash` recomputed to match. These warmup payloads are sent to the client before the real test runs. The client is expected to reject them with a state-root mismatch — the value is in the work the client performs (cache fills, codepath warming) before that rejection. ```yaml runner: @@ -958,12 +958,14 @@ runner: warmup_test_payload: enabled: true fork: osaka + count: 3 ``` | Option | Type | Required | Default | Description | |--------|------|----------|---------|-------------| | `enabled` | bool | Yes | `false` | Enable the warmup phase | | `fork` | string | Yes | - | Fork used to compute the warmup `blockHash`. Only `osaka` is currently supported | +| `count` | int | No | `1` | How many times each `engine_newPayload*` line is sent. Each iteration uses a different deterministic stateRoot (derived as `keccak256(salt ‖ uint64BE(i))`), so the client treats the calls as distinct payloads. Must be `>= 1` when enabled. Non-newPayload lines are sent once regardless | Warmup steps reuse the test step's lines as the source. Non-`engine_newPayload*` lines pass through unchanged. Warmup results are written to `/warmup.{response,result-details.json,result-aggregated.json}` alongside the existing setup/test/cleanup outputs. diff --git a/pkg/config/config.go b/pkg/config/config.go index f02e91611..04b92157f 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -526,9 +526,26 @@ type BootstrapFCUConfig struct { // payloads to the client. The expected outcome is a fast state-root // rejection by the client; the value of doing this is that caches and // codepaths get warmed up before the real test runs. +// +// Count controls how many times each engine_newPayload* line is sent. Each +// iteration uses a different (deterministically derived) stateRoot so the +// client treats the calls as distinct payloads. Default is 1; must be >= 1 +// when enabled. Non-newPayload lines (e.g. forkchoiceUpdated) are sent once +// regardless of Count. type WarmupTestPayloadConfig struct { Enabled bool `yaml:"enabled" mapstructure:"enabled" json:"enabled"` Fork string `yaml:"fork" mapstructure:"fork" json:"fork,omitempty"` + Count int `yaml:"count,omitempty" mapstructure:"count" json:"count,omitempty"` +} + +// EffectiveCount returns the number of warmup iterations per newPayload +// line. Treats unset/zero as 1. +func (c *WarmupTestPayloadConfig) EffectiveCount() int { + if c == nil || c.Count <= 0 { + return 1 + } + + return c.Count } // PostTestRPCCall defines an arbitrary RPC call to execute after the test step. @@ -2174,7 +2191,8 @@ func (c *Config) validateBootstrapFCU() error { } // validateWarmupTestPayload validates warmup_test_payload settings. -// Currently only "osaka" is a supported fork. +// Currently only "osaka" is a supported fork. Count must be >= 1 when set; +// an unset (zero) Count is treated as the default 1. func (c *Config) validateWarmupTestPayload() error { for _, instance := range c.Runner.Instances { cfg := c.GetWarmupTestPayload(&instance) @@ -2188,6 +2206,13 @@ func (c *Config) validateWarmupTestPayload() error { instance.ID, cfg.Fork, ) } + + if cfg.Count < 0 { + return fmt.Errorf( + "instance %q: warmup_test_payload.count must be >= 1 when enabled (got %d)", + instance.ID, cfg.Count, + ) + } } return nil diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 3cf495210..40d8f978b 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -2758,6 +2758,22 @@ func TestValidateWarmupTestPayload(t *testing.T) { wantErr: true, errSubstr: `warmup_test_payload.fork must be "osaka"`, }, + { + name: "enabled with explicit count is valid", + global: &WarmupTestPayloadConfig{Enabled: true, Fork: "osaka", Count: 3}, + wantErr: false, + }, + { + name: "enabled with zero count defaults to 1 (valid)", + global: &WarmupTestPayloadConfig{Enabled: true, Fork: "osaka", Count: 0}, + wantErr: false, + }, + { + name: "enabled with negative count is invalid", + global: &WarmupTestPayloadConfig{Enabled: true, Fork: "osaka", Count: -1}, + wantErr: true, + errSubstr: "warmup_test_payload.count must be >= 1", + }, } for _, tt := range tests { @@ -2789,6 +2805,25 @@ func TestValidateWarmupTestPayload(t *testing.T) { } } +func TestWarmupTestPayloadConfig_EffectiveCount(t *testing.T) { + tests := []struct { + name string + cfg *WarmupTestPayloadConfig + expected int + }{ + {name: "nil returns 1", cfg: nil, expected: 1}, + {name: "zero returns 1", cfg: &WarmupTestPayloadConfig{Count: 0}, expected: 1}, + {name: "negative returns 1", cfg: &WarmupTestPayloadConfig{Count: -5}, expected: 1}, + {name: "positive returns value", cfg: &WarmupTestPayloadConfig{Count: 3}, expected: 3}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expected, tt.cfg.EffectiveCount()) + }) + } +} + func TestGetRetryNewPayloadsFailedState(t *testing.T) { tests := []struct { name string diff --git a/pkg/executor/executor.go b/pkg/executor/executor.go index 454fea509..d4a5fef4c 100644 --- a/pkg/executor/executor.go +++ b/pkg/executor/executor.go @@ -800,7 +800,10 @@ func (e *executor) runWarmupStep( testStep *StepFile, result *TestResult, ) error { - gen, err := warmup.NewGenerator(warmup.Fork(opts.WarmupTestPayload.Fork)) + gen, err := warmup.NewGenerator( + warmup.Fork(opts.WarmupTestPayload.Fork), + opts.WarmupTestPayload.EffectiveCount(), + ) if err != nil { return fmt.Errorf("creating warmup generator: %w", err) } diff --git a/pkg/warmup/warmup.go b/pkg/warmup/warmup.go index 989b30313..96739a02c 100644 --- a/pkg/warmup/warmup.go +++ b/pkg/warmup/warmup.go @@ -1,11 +1,13 @@ // Package warmup generates "warmup" engine_newPayload* requests by taking // the new-payload calls from a test step, replacing the stateRoot with a -// fork-specific placeholder, and recomputing the blockHash so the EL -// client accepts the payload header and proceeds to execute it. The point -// of warmup is to populate caches before the real test runs. +// fork-specific placeholder derived from a salt and an iteration index, and +// recomputing the blockHash so the EL client accepts the payload header +// and proceeds to execute it. The point of warmup is to populate caches +// before the real test runs. package warmup import ( + "encoding/binary" "encoding/json" "fmt" "strings" @@ -13,6 +15,7 @@ import ( "github.com/ethereum/go-ethereum/beacon/engine" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/crypto" ) // Fork identifies an Ethereum hardfork. We currently only support Osaka. @@ -25,11 +28,10 @@ const ( ForkOsaka Fork = "osaka" ) -// OsakaWarmupStateRoot is the placeholder stateRoot used in warmup payloads. -// It is intentionally non-empty so the EL deserializes a header successfully; -// the actual state-root mismatch is expected to be detected during execution, -// at which point the warmup has already populated caches. -const OsakaWarmupStateRoot = "0xe8d3a308a0d3fdaeed6c196f78aad4f9620b571da6dd5b886e7fa5eba07c83e0" +// OsakaWarmupSalt is the 32-byte salt mixed with the iteration index to +// derive the warmup stateRoot. Picked as an arbitrary non-zero constant so +// the resulting roots are non-empty and deterministic across runs. +const OsakaWarmupSalt = "0xe8d3a308a0d3fdaeed6c196f78aad4f9620b571da6dd5b886e7fa5eba07c83e0" // IsValidFork returns true if the given fork identifier is supported. func IsValidFork(fork string) bool { @@ -37,32 +39,64 @@ func IsValidFork(fork string) bool { } // Generator transforms engine_newPayload* JSON-RPC lines into warmup -// equivalents (modified stateRoot + recomputed blockHash). Lines whose -// method is not engine_newPayload* are returned unchanged. +// equivalents (modified stateRoot + recomputed blockHash). Each +// engine_newPayload* line expands to Count variants, each with a different +// stateRoot derived from a salt and the iteration index. Lines whose +// method is not engine_newPayload* are returned unchanged (a single copy +// regardless of Count). type Generator struct { - fork Fork - stateRoot common.Hash + fork Fork + count int + salt []byte } // NewGenerator returns a Generator for the given fork. Currently only -// ForkOsaka is accepted. -func NewGenerator(fork Fork) (*Generator, error) { +// ForkOsaka is accepted. Count <= 0 is treated as 1. +func NewGenerator(fork Fork, count int) (*Generator, error) { if fork != ForkOsaka { return nil, fmt.Errorf("unsupported fork %q (only %q is supported)", fork, ForkOsaka) } + if count <= 0 { + count = 1 + } + + salt := common.FromHex(OsakaWarmupSalt) + return &Generator{ - fork: fork, - stateRoot: common.HexToHash(OsakaWarmupStateRoot), + fork: fork, + count: count, + salt: salt, }, nil } +// Count returns the configured number of warmup iterations per +// engine_newPayload* line. +func (g *Generator) Count() int { + return g.count +} + +// StateRootForIteration returns the deterministic stateRoot used for +// warmup iteration i. It is exported primarily for tests. +func (g *Generator) StateRootForIteration(i int) common.Hash { + buf := make([]byte, 0, len(g.salt)+8) + buf = append(buf, g.salt...) + + var ibe [8]byte + binary.BigEndian.PutUint64(ibe[:], uint64(i)) //nolint:gosec // i is non-negative. + buf = append(buf, ibe[:]...) + + return common.BytesToHash(crypto.Keccak256(buf)) +} + // Transform rewrites a single JSON-RPC line. Non-engine_newPayload* lines -// pass through unchanged. For new-payload lines the stateRoot is replaced -// and the blockHash is recomputed before re-serializing the request. -func (g *Generator) Transform(line string) (string, error) { +// pass through unchanged (a single-element slice). For engine_newPayload* +// lines, returns Count variants, each with its iteration's derived +// stateRoot and a recomputed blockHash. Empty/whitespace lines pass +// through unchanged. +func (g *Generator) Transform(line string) ([]string, error) { if strings.TrimSpace(line) == "" { - return line, nil + return []string{line}, nil } var raw struct { @@ -72,60 +106,87 @@ func (g *Generator) Transform(line string) (string, error) { Params []json.RawMessage `json:"params"` } if err := json.Unmarshal([]byte(line), &raw); err != nil { - return "", fmt.Errorf("parse jsonrpc line: %w", err) + return nil, fmt.Errorf("parse jsonrpc line: %w", err) } if !strings.HasPrefix(raw.Method, "engine_newPayload") { - return line, nil + return []string{line}, nil } if len(raw.Params) < 1 { - return "", fmt.Errorf("%s: expected at least 1 param, got %d", raw.Method, len(raw.Params)) + return nil, fmt.Errorf("%s: expected at least 1 param, got %d", raw.Method, len(raw.Params)) } var data engine.ExecutableData if err := json.Unmarshal(raw.Params[0], &data); err != nil { - return "", fmt.Errorf("parse executionPayload: %w", err) + return nil, fmt.Errorf("parse executionPayload: %w", err) } // engine_newPayloadV3+ carry blobVersionedHashes (params[1]), // parentBeaconBlockRoot (params[2]) and (V4+) executionRequests (params[3]). versionedHashes, beaconRoot, requests, err := decodeExtraParams(raw.Method, raw.Params) if err != nil { - return "", err + return nil, err } - // Replace stateRoot and recompute blockHash. ExecutableDataToBlockNoHash - // builds a block with all derived roots (txRoot, withdrawalsRoot, - // requestsHash) without verifying the supplied blockHash matches. - data.StateRoot = g.stateRoot + out := make([]string, 0, g.count) - block, err := engine.ExecutableDataToBlockNoHash(data, versionedHashes, beaconRoot, requests) - if err != nil { - return "", fmt.Errorf("build block from payload: %w", err) - } + for i := range g.count { + // Replace stateRoot for this iteration and recompute blockHash. + // ExecutableDataToBlockNoHash builds a block with all derived roots + // (txRoot, withdrawalsRoot, requestsHash) without verifying the + // supplied blockHash matches. + variant := data + variant.StateRoot = g.StateRootForIteration(i) + + block, err := engine.ExecutableDataToBlockNoHash(variant, versionedHashes, beaconRoot, requests) + if err != nil { + return nil, fmt.Errorf("iteration %d: build block from payload: %w", i, err) + } - data.BlockHash = block.Hash() + variant.BlockHash = block.Hash() - newPayload, err := json.Marshal(&data) - if err != nil { - return "", fmt.Errorf("marshal warmup payload: %w", err) - } + newPayload, err := json.Marshal(&variant) + if err != nil { + return nil, fmt.Errorf("iteration %d: marshal warmup payload: %w", i, err) + } - raw.Params[0] = newPayload + // Clone params so each line gets its own params[0]. The other + // params (blobVersionedHashes, beaconRoot, requests) are shared + // raw bytes — safe to alias. + params := make([]json.RawMessage, len(raw.Params)) + copy(params, raw.Params) + params[0] = newPayload + + envelope := struct { + JSONRPC string `json:"jsonrpc"` + ID json.RawMessage `json:"id"` + Method string `json:"method"` + Params []json.RawMessage `json:"params"` + }{ + JSONRPC: raw.JSONRPC, + ID: raw.ID, + Method: raw.Method, + Params: params, + } - out, err := json.Marshal(&raw) - if err != nil { - return "", fmt.Errorf("marshal jsonrpc line: %w", err) + encoded, err := json.Marshal(&envelope) + if err != nil { + return nil, fmt.Errorf("iteration %d: marshal jsonrpc line: %w", i, err) + } + + out = append(out, string(encoded)) } - return string(out), nil + return out, nil } // TransformLines applies Transform to every line in the input slice. The -// returned slice has the same length and ordering as input. +// returned slice may be longer than the input: each engine_newPayload* +// line expands to Count variants while non-newPayload lines pass through +// once. func (g *Generator) TransformLines(lines []string) ([]string, error) { - out := make([]string, len(lines)) + out := make([]string, 0, len(lines)*g.count) for i, line := range lines { transformed, err := g.Transform(line) @@ -133,7 +194,7 @@ func (g *Generator) TransformLines(lines []string) ([]string, error) { return nil, fmt.Errorf("line %d: %w", i+1, err) } - out[i] = transformed + out = append(out, transformed...) } return out, nil diff --git a/pkg/warmup/warmup_test.go b/pkg/warmup/warmup_test.go index bf0fb06fa..a980dea30 100644 --- a/pkg/warmup/warmup_test.go +++ b/pkg/warmup/warmup_test.go @@ -50,67 +50,109 @@ func makeOsakaPayload(t *testing.T, stateRoot common.Hash) (engine.ExecutableDat return data, block.Hash(), &beaconRoot } +// makeNewPayloadLine builds an engine_newPayloadV4 JSON-RPC envelope around +// a self-consistent ExecutableData payload. +func makeNewPayloadLine(t *testing.T, originalStateRoot common.Hash) (string, engine.ExecutableData, common.Hash, *common.Hash) { + t.Helper() + + data, originalHash, beaconRoot := makeOsakaPayload(t, originalStateRoot) + + payloadJSON, err := json.Marshal(&data) + require.NoError(t, err) + + beaconRootJSON, err := json.Marshal(beaconRoot) + require.NoError(t, err) + + emptyRequests, err := json.Marshal([]hexutil.Bytes{}) + require.NoError(t, err) + + line := `{"jsonrpc":"2.0","id":1,"method":"engine_newPayloadV4","params":[` + + string(payloadJSON) + `,[],` + string(beaconRootJSON) + `,` + string(emptyRequests) + `]}` + + return line, data, originalHash, beaconRoot +} + func TestNewGenerator_RejectsUnknownFork(t *testing.T) { - _, err := NewGenerator(Fork("prague")) + _, err := NewGenerator(Fork("prague"), 1) assert.Error(t, err) } +func TestNewGenerator_DefaultsZeroCountToOne(t *testing.T) { + g, err := NewGenerator(ForkOsaka, 0) + require.NoError(t, err) + assert.Equal(t, 1, g.Count()) +} + +func TestNewGenerator_NegativeCountTreatedAsOne(t *testing.T) { + g, err := NewGenerator(ForkOsaka, -3) + require.NoError(t, err) + assert.Equal(t, 1, g.Count()) +} + +func TestStateRootForIteration_Deterministic(t *testing.T) { + g, err := NewGenerator(ForkOsaka, 1) + require.NoError(t, err) + + // Same iteration → same hash. + assert.Equal(t, g.StateRootForIteration(0), g.StateRootForIteration(0)) + assert.Equal(t, g.StateRootForIteration(7), g.StateRootForIteration(7)) + + // Different iterations → different hashes. + assert.NotEqual(t, g.StateRootForIteration(0), g.StateRootForIteration(1)) + assert.NotEqual(t, g.StateRootForIteration(1), g.StateRootForIteration(2)) + + // Hashes are non-zero. + assert.NotEqual(t, common.Hash{}, g.StateRootForIteration(0)) +} + func TestTransform_NonNewPayloadPassesThrough(t *testing.T) { - g, err := NewGenerator(ForkOsaka) + g, err := NewGenerator(ForkOsaka, 5) // count > 1 must NOT duplicate FCUs require.NoError(t, err) in := `{"jsonrpc":"2.0","id":1,"method":"engine_forkchoiceUpdatedV3","params":[]}` out, err := g.Transform(in) require.NoError(t, err) - assert.Equal(t, in, out) + require.Len(t, out, 1) + assert.Equal(t, in, out[0]) } func TestTransform_EmptyLinePassesThrough(t *testing.T) { - g, err := NewGenerator(ForkOsaka) + g, err := NewGenerator(ForkOsaka, 1) require.NoError(t, err) out, err := g.Transform("") require.NoError(t, err) - assert.Equal(t, "", out) + require.Len(t, out, 1) + assert.Equal(t, "", out[0]) } func TestTransform_NewPayloadOverridesStateRootAndRecomputesHash(t *testing.T) { originalStateRoot := common.HexToHash("0xde94bab83ce96d440db7a3e2dc95ebbab73dc5aa88dc80b3d99ae8f0cff4e96c") - data, originalHash, beaconRoot := makeOsakaPayload(t, originalStateRoot) + line, _, originalHash, beaconRoot := makeNewPayloadLine(t, originalStateRoot) - payloadJSON, err := json.Marshal(&data) - require.NoError(t, err) - - beaconRootJSON, err := json.Marshal(beaconRoot) - require.NoError(t, err) - - emptyRequests, err := json.Marshal([]hexutil.Bytes{}) - require.NoError(t, err) - - line := `{"jsonrpc":"2.0","id":1,"method":"engine_newPayloadV4","params":[` + - string(payloadJSON) + `,[],` + string(beaconRootJSON) + `,` + string(emptyRequests) + `]}` - - g, err := NewGenerator(ForkOsaka) + g, err := NewGenerator(ForkOsaka, 1) require.NoError(t, err) out, err := g.Transform(line) require.NoError(t, err) + require.Len(t, out, 1) - // Parse the transformed line back out. + // Parse the transformed line. var parsed struct { Method string `json:"method"` Params []json.RawMessage `json:"params"` } - require.NoError(t, json.Unmarshal([]byte(out), &parsed)) + require.NoError(t, json.Unmarshal([]byte(out[0]), &parsed)) assert.Equal(t, "engine_newPayloadV4", parsed.Method) require.Len(t, parsed.Params, 4) var transformed engine.ExecutableData require.NoError(t, json.Unmarshal(parsed.Params[0], &transformed)) - // stateRoot replaced with the warmup placeholder. - assert.Equal(t, common.HexToHash(OsakaWarmupStateRoot), transformed.StateRoot) + // stateRoot replaced with iteration-0 derived value. + assert.Equal(t, g.StateRootForIteration(0), transformed.StateRoot) + assert.NotEqual(t, originalStateRoot, transformed.StateRoot) // blockHash changed. assert.NotEqual(t, originalHash, transformed.BlockHash) @@ -121,33 +163,87 @@ func TestTransform_NewPayloadOverridesStateRootAndRecomputesHash(t *testing.T) { assert.Equal(t, expected.Hash(), transformed.BlockHash) } -func TestTransformLines_PreservesOrderAndCount(t *testing.T) { +func TestTransform_CountProducesDistinctVariants(t *testing.T) { originalStateRoot := common.HexToHash("0xde94bab83ce96d440db7a3e2dc95ebbab73dc5aa88dc80b3d99ae8f0cff4e96c") - data, _, beaconRoot := makeOsakaPayload(t, originalStateRoot) + line, _, originalHash, beaconRoot := makeNewPayloadLine(t, originalStateRoot) - payloadJSON, err := json.Marshal(&data) + const count = 4 + + g, err := NewGenerator(ForkOsaka, count) require.NoError(t, err) - beaconRootJSON, err := json.Marshal(beaconRoot) + out, err := g.Transform(line) require.NoError(t, err) + require.Len(t, out, count) - emptyRequests, err := json.Marshal([]hexutil.Bytes{}) + seenStateRoots := make(map[common.Hash]struct{}, count) + seenBlockHashes := make(map[common.Hash]struct{}, count) + + for i, variantLine := range out { + var parsed struct { + Method string `json:"method"` + Params []json.RawMessage `json:"params"` + } + require.NoError(t, json.Unmarshal([]byte(variantLine), &parsed)) + + var data engine.ExecutableData + require.NoError(t, json.Unmarshal(parsed.Params[0], &data)) + + // Each variant has the iteration-i derived stateRoot. + assert.Equal(t, g.StateRootForIteration(i), data.StateRoot, "iteration %d stateRoot", i) + assert.NotEqual(t, originalHash, data.BlockHash, "iteration %d blockHash matches original", i) + + // blockHash matches a fresh recomputation. + expected, err := engine.ExecutableDataToBlockNoHash(data, nil, beaconRoot, [][]byte{}) + require.NoError(t, err) + assert.Equal(t, expected.Hash(), data.BlockHash, "iteration %d blockHash mismatch", i) + + seenStateRoots[data.StateRoot] = struct{}{} + seenBlockHashes[data.BlockHash] = struct{}{} + } + + // All stateRoots and blockHashes are unique across iterations. + assert.Len(t, seenStateRoots, count) + assert.Len(t, seenBlockHashes, count) +} + +func TestTransformLines_ExpandsNewPayloadAndPassesThroughOthers(t *testing.T) { + originalStateRoot := common.HexToHash("0xde94bab83ce96d440db7a3e2dc95ebbab73dc5aa88dc80b3d99ae8f0cff4e96c") + newPayload, _, _, _ := makeNewPayloadLine(t, originalStateRoot) + fcu := `{"jsonrpc":"2.0","id":2,"method":"engine_forkchoiceUpdatedV3","params":[]}` + + const count = 3 + + g, err := NewGenerator(ForkOsaka, count) require.NoError(t, err) - newPayload := `{"jsonrpc":"2.0","id":1,"method":"engine_newPayloadV4","params":[` + - string(payloadJSON) + `,[],` + string(beaconRootJSON) + `,` + string(emptyRequests) + `]}` + out, err := g.TransformLines([]string{newPayload, fcu}) + require.NoError(t, err) + + // 3 newPayload variants + 1 fcu = 4 lines. + require.Len(t, out, count+1) + + // FCU is the last entry, unchanged. + assert.Equal(t, fcu, out[count]) + + // First `count` entries are newPayload variants, all different from the + // original line. + for i := range count { + assert.NotEqual(t, newPayload, out[i], "variant %d should differ from original", i) + } +} + +func TestTransformLines_CountOnePreservesLength(t *testing.T) { + originalStateRoot := common.HexToHash("0xde94bab83ce96d440db7a3e2dc95ebbab73dc5aa88dc80b3d99ae8f0cff4e96c") + newPayload, _, _, _ := makeNewPayloadLine(t, originalStateRoot) fcu := `{"jsonrpc":"2.0","id":2,"method":"engine_forkchoiceUpdatedV3","params":[]}` - g, err := NewGenerator(ForkOsaka) + g, err := NewGenerator(ForkOsaka, 1) require.NoError(t, err) out, err := g.TransformLines([]string{newPayload, fcu}) require.NoError(t, err) require.Len(t, out, 2) - - // Second line passes through unchanged. assert.Equal(t, fcu, out[1]) - - // First line has been rewritten (different from original because stateRoot/blockHash changed). assert.NotEqual(t, newPayload, out[0]) } diff --git a/ui/src/api/types.ts b/ui/src/api/types.ts index 80c0a479c..f24bf4bf5 100644 --- a/ui/src/api/types.ts +++ b/ui/src/api/types.ts @@ -268,6 +268,7 @@ export interface CheckpointRestoreStrategyOptions { export interface WarmupTestPayloadConfig { enabled: boolean fork?: string + count?: number } export interface InstanceConfig { diff --git a/ui/src/components/compare/ConfigDiff.tsx b/ui/src/components/compare/ConfigDiff.tsx index a2f53dec4..443c4a0b3 100644 --- a/ui/src/components/compare/ConfigDiff.tsx +++ b/ui/src/components/compare/ConfigDiff.tsx @@ -118,7 +118,10 @@ export function ConfigDiff({ runs, labelMode }: ConfigDiffProps) { values={instances.map((i) => { const w = i.warmup_test_payload if (!w || !w.enabled) return 'disabled' - return w.fork ? `enabled (${w.fork})` : 'enabled' + const count = w.count && w.count > 0 ? w.count : 1 + const parts = [`count=${count}`] + if (w.fork) parts.unshift(w.fork) + return `enabled (${parts.join(', ')})` })} /> )} diff --git a/ui/src/components/run-detail/RunConfiguration.tsx b/ui/src/components/run-detail/RunConfiguration.tsx index 3b5db2455..1fa4a9a1e 100644 --- a/ui/src/components/run-detail/RunConfiguration.tsx +++ b/ui/src/components/run-detail/RunConfiguration.tsx @@ -349,11 +349,19 @@ export function RunConfiguration({ instance, system, startBlock, metadata, bench {instance.warmup_test_payload.fork}
)} +
+ count: + {instance.warmup_test_payload.count && instance.warmup_test_payload.count > 0 + ? instance.warmup_test_payload.count + : 1} +

Inserts a warmup phase between setup and test that sends modified engine_newPayload calls (stateRoot replaced, blockHash recomputed) to - warm client caches before the real test runs. + warm client caches before the real test runs. Each newPayload is sent + count + times with a different stateRoot per iteration.

From a3f451bd5eb7a02d8d48fa87d6093b1b392178ef Mon Sep 17 00:00:00 2001 From: Rafael Matias Date: Thu, 30 Apr 2026 18:14:26 +0200 Subject: [PATCH 3/4] feat(warmup): add method field with invalid-stateroot as the only value Adds runner.client.config.warmup_test_payload.method to select the warmup strategy. Today only "invalid-stateroot" (the existing stateRoot-rewrite + blockHash-recompute behavior) is supported; the field is in place so future strategies can be added without breaking existing configs. EffectiveMethod() returns "invalid-stateroot" when the field is unset/empty so existing configs keep working unchanged. The validator rejects any non-empty method other than "invalid-stateroot". The runWarmupStep dispatch now switches on EffectiveMethod(); the invalid-stateroot branch wraps the existing generator code. UI run-detail card and compare diff render the method value; docs document the field, its default, and explicitly note that warmup_test_payload supports both global and per-instance config. --- docs/configuration.md | 6 ++- pkg/config/config.go | 43 +++++++++++++++---- pkg/config/config_test.go | 34 +++++++++++++++ pkg/executor/executor.go | 41 ++++++++++-------- ui/src/api/types.ts | 1 + ui/src/components/compare/ConfigDiff.tsx | 5 ++- .../run-detail/RunConfiguration.tsx | 4 ++ 7 files changed, 106 insertions(+), 28 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 7f04fcc63..fc8456ceb 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -949,7 +949,7 @@ When using the `container-recreate` rollback strategy, the bootstrap FCU is sent ##### Warmup Test Payload -The `warmup_test_payload` option inserts a warmup phase between the setup and test steps. For each `engine_newPayload*` call in the test step, the runner creates one or more copies with the `stateRoot` replaced by a fork-specific placeholder and the `blockHash` recomputed to match. These warmup payloads are sent to the client before the real test runs. The client is expected to reject them with a state-root mismatch — the value is in the work the client performs (cache fills, codepath warming) before that rejection. +The `warmup_test_payload` option inserts a warmup phase between the setup and test steps. The exact transformation is selected by `method`; today only `invalid-stateroot` is supported, which rewrites each `engine_newPayload*` call's `stateRoot` to a deterministic placeholder and recomputes `blockHash`. The client is expected to reject the resulting payload on state-root mismatch — the value is in the work it performs first (cache fills, codepath warming) before that rejection. ```yaml runner: @@ -957,6 +957,7 @@ runner: config: warmup_test_payload: enabled: true + method: invalid-stateroot # default; only supported value today fork: osaka count: 3 ``` @@ -964,9 +965,12 @@ runner: | Option | Type | Required | Default | Description | |--------|------|----------|---------|-------------| | `enabled` | bool | Yes | `false` | Enable the warmup phase | +| `method` | string | No | `invalid-stateroot` | Warmup strategy. Currently only `invalid-stateroot` is supported (rewrites stateRoot + recomputes blockHash). Future methods will be added as separate strings | | `fork` | string | Yes | - | Fork used to compute the warmup `blockHash`. Only `osaka` is currently supported | | `count` | int | No | `1` | How many times each `engine_newPayload*` line is sent. Each iteration uses a different deterministic stateRoot (derived as `keccak256(salt ‖ uint64BE(i))`), so the client treats the calls as distinct payloads. Must be `>= 1` when enabled. Non-newPayload lines are sent once regardless | +`warmup_test_payload` can be set globally under `runner.client.config` and/or per-instance under `runner.instances[]`. Instance-level config (when non-nil) fully replaces the global default. + Warmup steps reuse the test step's lines as the source. Non-`engine_newPayload*` lines pass through unchanged. Warmup results are written to `/warmup.{response,result-details.json,result-aggregated.json}` alongside the existing setup/test/cleanup outputs. **When to use:** diff --git a/pkg/config/config.go b/pkg/config/config.go index 04b92157f..21c18f05f 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -519,23 +519,29 @@ type BootstrapFCUConfig struct { HeadBlockHash string `yaml:"head_block_hash" mapstructure:"head_block_hash" json:"head_block_hash,omitempty"` } +// WarmupMethodInvalidStateRoot is the only warmup method currently +// supported. The runner takes each engine_newPayload* call from the test +// step, replaces stateRoot with a deterministically-derived placeholder, +// and recomputes blockHash. The client typically rejects the payload on +// state-root mismatch, but the work it does first warms its caches. +const WarmupMethodInvalidStateRoot = "invalid-stateroot" + // WarmupTestPayloadConfig configures the warmup phase that runs between -// setup and test steps. When enabled, the runner takes the test step's -// engine_newPayload* calls, replaces stateRoot with a fork-specific -// placeholder, and recomputes blockHash before sending the resulting -// payloads to the client. The expected outcome is a fast state-root -// rejection by the client; the value of doing this is that caches and -// codepaths get warmed up before the real test runs. +// setup and test steps. The behavior is determined by Method. // // Count controls how many times each engine_newPayload* line is sent. Each // iteration uses a different (deterministically derived) stateRoot so the // client treats the calls as distinct payloads. Default is 1; must be >= 1 // when enabled. Non-newPayload lines (e.g. forkchoiceUpdated) are sent once // regardless of Count. +// +// Method selects the warmup strategy. Currently only "invalid-stateroot" +// (the default) is supported. type WarmupTestPayloadConfig struct { Enabled bool `yaml:"enabled" mapstructure:"enabled" json:"enabled"` Fork string `yaml:"fork" mapstructure:"fork" json:"fork,omitempty"` Count int `yaml:"count,omitempty" mapstructure:"count" json:"count,omitempty"` + Method string `yaml:"method,omitempty" mapstructure:"method" json:"method,omitempty"` } // EffectiveCount returns the number of warmup iterations per newPayload @@ -548,6 +554,16 @@ func (c *WarmupTestPayloadConfig) EffectiveCount() int { return c.Count } +// EffectiveMethod returns the warmup method, defaulting to +// WarmupMethodInvalidStateRoot when unset. +func (c *WarmupTestPayloadConfig) EffectiveMethod() string { + if c == nil || c.Method == "" { + return WarmupMethodInvalidStateRoot + } + + return c.Method +} + // PostTestRPCCall defines an arbitrary RPC call to execute after the test step. type PostTestRPCCall struct { Method string `yaml:"method" mapstructure:"method" json:"method"` @@ -2191,8 +2207,10 @@ func (c *Config) validateBootstrapFCU() error { } // validateWarmupTestPayload validates warmup_test_payload settings. -// Currently only "osaka" is a supported fork. Count must be >= 1 when set; -// an unset (zero) Count is treated as the default 1. +// Currently only "osaka" is a supported fork and "invalid-stateroot" the +// only supported method. Count must be >= 1 when set; an unset (zero) +// Count is treated as the default 1. An unset Method defaults to +// "invalid-stateroot". func (c *Config) validateWarmupTestPayload() error { for _, instance := range c.Runner.Instances { cfg := c.GetWarmupTestPayload(&instance) @@ -2213,6 +2231,15 @@ func (c *Config) validateWarmupTestPayload() error { instance.ID, cfg.Count, ) } + + // Empty method is allowed (defaults to invalid-stateroot via + // EffectiveMethod). A non-empty method must be one we recognize. + if cfg.Method != "" && cfg.Method != WarmupMethodInvalidStateRoot { + return fmt.Errorf( + "instance %q: warmup_test_payload.method must be %q (got %q)", + instance.ID, WarmupMethodInvalidStateRoot, cfg.Method, + ) + } } return nil diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 40d8f978b..216dcd9d4 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -2774,6 +2774,22 @@ func TestValidateWarmupTestPayload(t *testing.T) { wantErr: true, errSubstr: "warmup_test_payload.count must be >= 1", }, + { + name: "enabled with explicit invalid-stateroot method is valid", + global: &WarmupTestPayloadConfig{Enabled: true, Fork: "osaka", Method: "invalid-stateroot"}, + wantErr: false, + }, + { + name: "enabled with empty method defaults (valid)", + global: &WarmupTestPayloadConfig{Enabled: true, Fork: "osaka", Method: ""}, + wantErr: false, + }, + { + name: "enabled with unknown method is invalid", + global: &WarmupTestPayloadConfig{Enabled: true, Fork: "osaka", Method: "rewrite-everything"}, + wantErr: true, + errSubstr: `warmup_test_payload.method must be "invalid-stateroot"`, + }, } for _, tt := range tests { @@ -2824,6 +2840,24 @@ func TestWarmupTestPayloadConfig_EffectiveCount(t *testing.T) { } } +func TestWarmupTestPayloadConfig_EffectiveMethod(t *testing.T) { + tests := []struct { + name string + cfg *WarmupTestPayloadConfig + expected string + }{ + {name: "nil returns invalid-stateroot", cfg: nil, expected: "invalid-stateroot"}, + {name: "empty returns invalid-stateroot", cfg: &WarmupTestPayloadConfig{Method: ""}, expected: "invalid-stateroot"}, + {name: "explicit returns the value", cfg: &WarmupTestPayloadConfig{Method: "invalid-stateroot"}, expected: "invalid-stateroot"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expected, tt.cfg.EffectiveMethod()) + }) + } +} + func TestGetRetryNewPayloadsFailedState(t *testing.T) { tests := []struct { name string diff --git a/pkg/executor/executor.go b/pkg/executor/executor.go index d4a5fef4c..904b5579b 100644 --- a/pkg/executor/executor.go +++ b/pkg/executor/executor.go @@ -791,7 +791,7 @@ writeResults: } // runWarmupStep generates warmup payloads from the given test step's -// engine_newPayload* lines (stateRoot replaced, blockHash recomputed) and +// engine_newPayload* lines (transformed per the configured method) and // runs them via runStepLines so the client populates caches before the // real test runs. func (e *executor) runWarmupStep( @@ -800,25 +800,32 @@ func (e *executor) runWarmupStep( testStep *StepFile, result *TestResult, ) error { - gen, err := warmup.NewGenerator( - warmup.Fork(opts.WarmupTestPayload.Fork), - opts.WarmupTestPayload.EffectiveCount(), - ) - if err != nil { - return fmt.Errorf("creating warmup generator: %w", err) - } + cfg := opts.WarmupTestPayload - lines, err := readStepLines(testStep) - if err != nil { - return fmt.Errorf("reading test step for warmup: %w", err) - } + switch method := cfg.EffectiveMethod(); method { + case config.WarmupMethodInvalidStateRoot: + gen, err := warmup.NewGenerator( + warmup.Fork(cfg.Fork), + cfg.EffectiveCount(), + ) + if err != nil { + return fmt.Errorf("creating warmup generator: %w", err) + } - transformed, err := gen.TransformLines(lines) - if err != nil { - return fmt.Errorf("transforming warmup payloads: %w", err) - } + lines, err := readStepLines(testStep) + if err != nil { + return fmt.Errorf("reading test step for warmup: %w", err) + } + + transformed, err := gen.TransformLines(lines) + if err != nil { + return fmt.Errorf("transforming warmup payloads: %w", err) + } - return e.runStepLines(ctx, opts, testStep.Name, transformed, result, false, 0) + return e.runStepLines(ctx, opts, testStep.Name, transformed, result, false, 0) + default: + return fmt.Errorf("unsupported warmup method %q", method) + } } // readStepLines returns the JSON-RPC lines from a step (file or provider). diff --git a/ui/src/api/types.ts b/ui/src/api/types.ts index f24bf4bf5..ef9413a68 100644 --- a/ui/src/api/types.ts +++ b/ui/src/api/types.ts @@ -269,6 +269,7 @@ export interface WarmupTestPayloadConfig { enabled: boolean fork?: string count?: number + method?: string } export interface InstanceConfig { diff --git a/ui/src/components/compare/ConfigDiff.tsx b/ui/src/components/compare/ConfigDiff.tsx index 443c4a0b3..6e08fd68f 100644 --- a/ui/src/components/compare/ConfigDiff.tsx +++ b/ui/src/components/compare/ConfigDiff.tsx @@ -119,8 +119,9 @@ export function ConfigDiff({ runs, labelMode }: ConfigDiffProps) { const w = i.warmup_test_payload if (!w || !w.enabled) return 'disabled' const count = w.count && w.count > 0 ? w.count : 1 - const parts = [`count=${count}`] - if (w.fork) parts.unshift(w.fork) + const method = w.method || 'invalid-stateroot' + const parts = [`method=${method}`, `count=${count}`] + if (w.fork) parts.splice(1, 0, w.fork) return `enabled (${parts.join(', ')})` })} /> diff --git a/ui/src/components/run-detail/RunConfiguration.tsx b/ui/src/components/run-detail/RunConfiguration.tsx index 1fa4a9a1e..da4cff47b 100644 --- a/ui/src/components/run-detail/RunConfiguration.tsx +++ b/ui/src/components/run-detail/RunConfiguration.tsx @@ -343,6 +343,10 @@ export function RunConfiguration({ instance, system, startBlock, metadata, bench enabled: true +
+ method: + {instance.warmup_test_payload.method || 'invalid-stateroot'} +
{instance.warmup_test_payload.fork && (
fork: From 0a3cd7a7eae97b38cebc54e44702be089cfb2b54 Mon Sep 17 00:00:00 2001 From: Rafael Matias Date: Mon, 11 May 2026 16:52:53 +0200 Subject: [PATCH 4/4] feat(warmup): add invalid-gasused method alongside invalid-stateroot Adds a second warmup mutation strategy that keeps stateRoot intact and instead subtracts (1+i) from gasUsed for iteration i, then recomputes blockHash so the client still does a real payload-decode and tx pass before rejecting on the gas mismatch. Iteration 0 = original-1, iteration 1 = original-2, and so on, which preserves the "subtract 1 from the original" semantics for the common count=1 case while keeping every iteration distinct for count>1. The Generator now takes a Method parameter and dispatches the per- iteration mutation; underflow on gasUsed surfaces as an error so we don't silently send malformed payloads. The config validator accepts either method; the executor's switch passes both through to the same generator pipeline. --- docs/configuration.md | 11 ++- pkg/config/config.go | 42 +++++---- pkg/config/config_test.go | 7 +- pkg/executor/executor.go | 43 +++++---- pkg/warmup/warmup.go | 123 +++++++++++++++++++----- pkg/warmup/warmup_test.go | 192 ++++++++++++++++++++++++++++++++++++-- 6 files changed, 346 insertions(+), 72 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index bb41435a3..b98eaa2ba 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -982,7 +982,12 @@ When using the `container-recreate` rollback strategy, the bootstrap FCU is sent ##### Warmup Test Payload -The `warmup_test_payload` option inserts a warmup phase between the setup and test steps. The exact transformation is selected by `method`; today only `invalid-stateroot` is supported, which rewrites each `engine_newPayload*` call's `stateRoot` to a deterministic placeholder and recomputes `blockHash`. The client is expected to reject the resulting payload on state-root mismatch — the value is in the work it performs first (cache fills, codepath warming) before that rejection. +The `warmup_test_payload` option inserts a warmup phase between the setup and test steps. The exact transformation is selected by `method`: + +- `invalid-stateroot` (default): rewrites each `engine_newPayload*` call's `stateRoot` to a deterministic per-iteration placeholder and recomputes `blockHash`. The client typically rejects on state-root mismatch. +- `invalid-gasused`: keeps `stateRoot`, subtracts `(1+i)` from `gasUsed` for iteration `i` (so iteration 0 = original-1, iteration 1 = original-2, …), and recomputes `blockHash`. The client typically rejects once it notices the gas mismatch. + +Both methods do real header validation + tx decoding + execution work before the rejection, which is where the cache-warming value comes from. ```yaml runner: @@ -998,9 +1003,9 @@ runner: | Option | Type | Required | Default | Description | |--------|------|----------|---------|-------------| | `enabled` | bool | Yes | `false` | Enable the warmup phase | -| `method` | string | No | `invalid-stateroot` | Warmup strategy. Currently only `invalid-stateroot` is supported (rewrites stateRoot + recomputes blockHash). Future methods will be added as separate strings | +| `method` | string | No | `invalid-stateroot` | Warmup strategy: `invalid-stateroot` (rewrites stateRoot + recomputes blockHash) or `invalid-gasused` (subtracts `1+i` from gasUsed + recomputes blockHash) | | `fork` | string | Yes | - | Fork used to compute the warmup `blockHash`. Only `osaka` is currently supported | -| `count` | int | No | `1` | How many times each `engine_newPayload*` line is sent. Each iteration uses a different deterministic stateRoot (derived as `keccak256(salt ‖ uint64BE(i))`), so the client treats the calls as distinct payloads. Must be `>= 1` when enabled. Non-newPayload lines are sent once regardless | +| `count` | int | No | `1` | How many times each `engine_newPayload*` line is sent. Each iteration produces a distinct payload (per-iteration stateRoot for `invalid-stateroot` derived as `keccak256(salt ‖ uint64BE(i))`, or `original-(1+i)` gasUsed for `invalid-gasused`) so the client treats the calls as distinct. Must be `>= 1` when enabled. Non-newPayload lines are sent once regardless. For `invalid-gasused`, count must not exceed the smallest original `gasUsed` in the step or the warmup fails with an underflow error | `warmup_test_payload` can be set globally under `runner.client.config` and/or per-instance under `runner.instances[]`. Instance-level config (when non-nil) fully replaces the global default. diff --git a/pkg/config/config.go b/pkg/config/config.go index 3c1475ccd..f3e08ea38 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -520,24 +520,32 @@ type BootstrapFCUConfig struct { HeadBlockHash string `yaml:"head_block_hash" mapstructure:"head_block_hash" json:"head_block_hash,omitempty"` } -// WarmupMethodInvalidStateRoot is the only warmup method currently -// supported. The runner takes each engine_newPayload* call from the test -// step, replaces stateRoot with a deterministically-derived placeholder, -// and recomputes blockHash. The client typically rejects the payload on -// state-root mismatch, but the work it does first warms its caches. -const WarmupMethodInvalidStateRoot = "invalid-stateroot" +// Supported warmup methods. Both rewrite a single header field on each +// engine_newPayload* call and recompute blockHash; the client typically +// rejects the resulting payload but warms its caches first. +// +// - WarmupMethodInvalidStateRoot replaces stateRoot with a +// deterministically-derived placeholder. +// - WarmupMethodInvalidGasUsed subtracts (1+i) from gasUsed for +// iteration i (so iteration 0 = original-1, iteration 1 = original-2, +// and so on). stateRoot is left untouched. +const ( + WarmupMethodInvalidStateRoot = "invalid-stateroot" + WarmupMethodInvalidGasUsed = "invalid-gasused" +) // WarmupTestPayloadConfig configures the warmup phase that runs between // setup and test steps. The behavior is determined by Method. // -// Count controls how many times each engine_newPayload* line is sent. Each -// iteration uses a different (deterministically derived) stateRoot so the -// client treats the calls as distinct payloads. Default is 1; must be >= 1 -// when enabled. Non-newPayload lines (e.g. forkchoiceUpdated) are sent once -// regardless of Count. +// Count controls how many times each engine_newPayload* line is sent. +// Each iteration produces a distinct payload (per-iteration stateRoot for +// "invalid-stateroot", or original-(1+i) gasUsed for "invalid-gasused") +// so the client treats the calls as distinct. Default is 1; must be >= 1 +// when enabled. Non-newPayload lines (e.g. forkchoiceUpdated) are sent +// once regardless of Count. // -// Method selects the warmup strategy. Currently only "invalid-stateroot" -// (the default) is supported. +// Method selects the warmup strategy: "invalid-stateroot" (default) or +// "invalid-gasused". type WarmupTestPayloadConfig struct { Enabled bool `yaml:"enabled" mapstructure:"enabled" json:"enabled"` Fork string `yaml:"fork" mapstructure:"fork" json:"fork,omitempty"` @@ -2317,10 +2325,12 @@ func (c *Config) validateWarmupTestPayload() error { // Empty method is allowed (defaults to invalid-stateroot via // EffectiveMethod). A non-empty method must be one we recognize. - if cfg.Method != "" && cfg.Method != WarmupMethodInvalidStateRoot { + if cfg.Method != "" && + cfg.Method != WarmupMethodInvalidStateRoot && + cfg.Method != WarmupMethodInvalidGasUsed { return fmt.Errorf( - "instance %q: warmup_test_payload.method must be %q (got %q)", - instance.ID, WarmupMethodInvalidStateRoot, cfg.Method, + "instance %q: warmup_test_payload.method must be %q or %q (got %q)", + instance.ID, WarmupMethodInvalidStateRoot, WarmupMethodInvalidGasUsed, cfg.Method, ) } } diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index e8bbc0a45..379b97a54 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -2779,6 +2779,11 @@ func TestValidateWarmupTestPayload(t *testing.T) { global: &WarmupTestPayloadConfig{Enabled: true, Fork: "osaka", Method: "invalid-stateroot"}, wantErr: false, }, + { + name: "enabled with explicit invalid-gasused method is valid", + global: &WarmupTestPayloadConfig{Enabled: true, Fork: "osaka", Method: "invalid-gasused"}, + wantErr: false, + }, { name: "enabled with empty method defaults (valid)", global: &WarmupTestPayloadConfig{Enabled: true, Fork: "osaka", Method: ""}, @@ -2788,7 +2793,7 @@ func TestValidateWarmupTestPayload(t *testing.T) { name: "enabled with unknown method is invalid", global: &WarmupTestPayloadConfig{Enabled: true, Fork: "osaka", Method: "rewrite-everything"}, wantErr: true, - errSubstr: `warmup_test_payload.method must be "invalid-stateroot"`, + errSubstr: `warmup_test_payload.method must be "invalid-stateroot" or "invalid-gasused"`, }, } diff --git a/pkg/executor/executor.go b/pkg/executor/executor.go index aedfc34dd..b36882f51 100644 --- a/pkg/executor/executor.go +++ b/pkg/executor/executor.go @@ -842,31 +842,34 @@ func (e *executor) runWarmupStep( result *TestResult, ) error { cfg := opts.WarmupTestPayload + method := cfg.EffectiveMethod() - switch method := cfg.EffectiveMethod(); method { - case config.WarmupMethodInvalidStateRoot: - gen, err := warmup.NewGenerator( - warmup.Fork(cfg.Fork), - cfg.EffectiveCount(), - ) - if err != nil { - return fmt.Errorf("creating warmup generator: %w", err) - } + switch method { + case config.WarmupMethodInvalidStateRoot, config.WarmupMethodInvalidGasUsed: + default: + return fmt.Errorf("unsupported warmup method %q", method) + } - lines, err := readStepLines(testStep) - if err != nil { - return fmt.Errorf("reading test step for warmup: %w", err) - } + gen, err := warmup.NewGenerator( + warmup.Fork(cfg.Fork), + warmup.Method(method), + cfg.EffectiveCount(), + ) + if err != nil { + return fmt.Errorf("creating warmup generator: %w", err) + } - transformed, err := gen.TransformLines(lines) - if err != nil { - return fmt.Errorf("transforming warmup payloads: %w", err) - } + lines, err := readStepLines(testStep) + if err != nil { + return fmt.Errorf("reading test step for warmup: %w", err) + } - return e.runStepLines(ctx, opts, testStep.Name, transformed, result, false, 0) - default: - return fmt.Errorf("unsupported warmup method %q", method) + transformed, err := gen.TransformLines(lines) + if err != nil { + return fmt.Errorf("transforming warmup payloads: %w", err) } + + return e.runStepLines(ctx, opts, testStep.Name, transformed, result, false, 0) } // readStepLines returns the JSON-RPC lines from a step (file or provider). diff --git a/pkg/warmup/warmup.go b/pkg/warmup/warmup.go index 96739a02c..fbd5ea4b8 100644 --- a/pkg/warmup/warmup.go +++ b/pkg/warmup/warmup.go @@ -1,9 +1,11 @@ // Package warmup generates "warmup" engine_newPayload* requests by taking -// the new-payload calls from a test step, replacing the stateRoot with a -// fork-specific placeholder derived from a salt and an iteration index, and -// recomputing the blockHash so the EL client accepts the payload header -// and proceeds to execute it. The point of warmup is to populate caches -// before the real test runs. +// the new-payload calls from a test step, mutating one header field per +// iteration, and recomputing the blockHash so the EL client accepts the +// payload header and proceeds to execute it. Two mutation strategies are +// supported: replacing the stateRoot with a deterministic placeholder +// ("invalid-stateroot") or subtracting 1+i from gasUsed +// ("invalid-gasused"). Either way the point of warmup is to populate +// caches before the real test runs. package warmup import ( @@ -28,6 +30,20 @@ const ( ForkOsaka Fork = "osaka" ) +// Method selects how each warmup iteration mutates an engine_newPayload* +// header field before the blockHash is recomputed. +type Method string + +const ( + // MethodInvalidStateRoot rewrites stateRoot to a deterministic + // per-iteration value derived from a salt and the iteration index. + MethodInvalidStateRoot Method = "invalid-stateroot" + // MethodInvalidGasUsed subtracts (1+i) from the original gasUsed for + // iteration i (so iteration 0 = original-1, iteration 1 = original-2, + // and so on). stateRoot and the other fields are left untouched. + MethodInvalidGasUsed Method = "invalid-gasused" +) + // OsakaWarmupSalt is the 32-byte salt mixed with the iteration index to // derive the warmup stateRoot. Picked as an arbitrary non-zero constant so // the resulting roots are non-empty and deterministic across runs. @@ -38,25 +54,41 @@ func IsValidFork(fork string) bool { return Fork(fork) == ForkOsaka } +// IsValidMethod returns true if the given method identifier is supported. +func IsValidMethod(method string) bool { + m := Method(method) + + return m == MethodInvalidStateRoot || m == MethodInvalidGasUsed +} + // Generator transforms engine_newPayload* JSON-RPC lines into warmup -// equivalents (modified stateRoot + recomputed blockHash). Each -// engine_newPayload* line expands to Count variants, each with a different -// stateRoot derived from a salt and the iteration index. Lines whose -// method is not engine_newPayload* are returned unchanged (a single copy -// regardless of Count). +// equivalents. Per-iteration mutation is selected by Method; the +// blockHash is always recomputed afterwards. Each engine_newPayload* line +// expands to Count variants. Lines whose method is not engine_newPayload* +// are returned unchanged (a single copy regardless of Count). type Generator struct { - fork Fork - count int - salt []byte + fork Fork + method Method + count int + salt []byte } -// NewGenerator returns a Generator for the given fork. Currently only -// ForkOsaka is accepted. Count <= 0 is treated as 1. -func NewGenerator(fork Fork, count int) (*Generator, error) { +// NewGenerator returns a Generator for the given fork and method. +// Currently only ForkOsaka is accepted. Method must be one of +// MethodInvalidStateRoot or MethodInvalidGasUsed. Count <= 0 is treated +// as 1. +func NewGenerator(fork Fork, method Method, count int) (*Generator, error) { if fork != ForkOsaka { return nil, fmt.Errorf("unsupported fork %q (only %q is supported)", fork, ForkOsaka) } + if !IsValidMethod(string(method)) { + return nil, fmt.Errorf( + "unsupported method %q (supported: %q, %q)", + method, MethodInvalidStateRoot, MethodInvalidGasUsed, + ) + } + if count <= 0 { count = 1 } @@ -64,9 +96,10 @@ func NewGenerator(fork Fork, count int) (*Generator, error) { salt := common.FromHex(OsakaWarmupSalt) return &Generator{ - fork: fork, - count: count, - salt: salt, + fork: fork, + method: method, + count: count, + salt: salt, }, nil } @@ -76,8 +109,14 @@ func (g *Generator) Count() int { return g.count } +// Method returns the configured per-iteration mutation method. +func (g *Generator) Method() Method { + return g.method +} + // StateRootForIteration returns the deterministic stateRoot used for -// warmup iteration i. It is exported primarily for tests. +// warmup iteration i when the method is MethodInvalidStateRoot. It is +// exported primarily for tests. func (g *Generator) StateRootForIteration(i int) common.Hash { buf := make([]byte, 0, len(g.salt)+8) buf = append(buf, g.salt...) @@ -89,6 +128,44 @@ func (g *Generator) StateRootForIteration(i int) common.Hash { return common.BytesToHash(crypto.Keccak256(buf)) } +// GasUsedForIteration returns the gasUsed value used for warmup iteration +// i when the method is MethodInvalidGasUsed: original - (i+1). Returns +// an error if the subtraction would underflow (i.e. original gasUsed is +// smaller than the iteration count requires). +func (g *Generator) GasUsedForIteration(original uint64, i int) (uint64, error) { + delta := uint64(i + 1) //nolint:gosec // i is non-negative. + if delta > original { + return 0, fmt.Errorf( + "cannot subtract %d from gasUsed %d (would underflow)", delta, original, + ) + } + + return original - delta, nil +} + +// applyMutation rewrites a single header field on the payload to make +// iteration i distinct from the original (and from other iterations). The +// blockHash is recomputed by the caller after this returns. +func (g *Generator) applyMutation(data *engine.ExecutableData, i int) error { + switch g.method { + case MethodInvalidStateRoot: + data.StateRoot = g.StateRootForIteration(i) + + return nil + case MethodInvalidGasUsed: + gas, err := g.GasUsedForIteration(data.GasUsed, i) + if err != nil { + return err + } + + data.GasUsed = gas + + return nil + default: + return fmt.Errorf("unsupported method %q", g.method) + } +} + // Transform rewrites a single JSON-RPC line. Non-engine_newPayload* lines // pass through unchanged (a single-element slice). For engine_newPayload* // lines, returns Count variants, each with its iteration's derived @@ -132,12 +209,14 @@ func (g *Generator) Transform(line string) ([]string, error) { out := make([]string, 0, g.count) for i := range g.count { - // Replace stateRoot for this iteration and recompute blockHash. + // Mutate one header field per iteration and recompute blockHash. // ExecutableDataToBlockNoHash builds a block with all derived roots // (txRoot, withdrawalsRoot, requestsHash) without verifying the // supplied blockHash matches. variant := data - variant.StateRoot = g.StateRootForIteration(i) + if err := g.applyMutation(&variant, i); err != nil { + return nil, fmt.Errorf("iteration %d: %w", i, err) + } block, err := engine.ExecutableDataToBlockNoHash(variant, versionedHashes, beaconRoot, requests) if err != nil { diff --git a/pkg/warmup/warmup_test.go b/pkg/warmup/warmup_test.go index a980dea30..25c379245 100644 --- a/pkg/warmup/warmup_test.go +++ b/pkg/warmup/warmup_test.go @@ -73,24 +73,24 @@ func makeNewPayloadLine(t *testing.T, originalStateRoot common.Hash) (string, en } func TestNewGenerator_RejectsUnknownFork(t *testing.T) { - _, err := NewGenerator(Fork("prague"), 1) + _, err := NewGenerator(Fork("prague"), MethodInvalidStateRoot, 1) assert.Error(t, err) } func TestNewGenerator_DefaultsZeroCountToOne(t *testing.T) { - g, err := NewGenerator(ForkOsaka, 0) + g, err := NewGenerator(ForkOsaka, MethodInvalidStateRoot, 0) require.NoError(t, err) assert.Equal(t, 1, g.Count()) } func TestNewGenerator_NegativeCountTreatedAsOne(t *testing.T) { - g, err := NewGenerator(ForkOsaka, -3) + g, err := NewGenerator(ForkOsaka, MethodInvalidStateRoot, -3) require.NoError(t, err) assert.Equal(t, 1, g.Count()) } func TestStateRootForIteration_Deterministic(t *testing.T) { - g, err := NewGenerator(ForkOsaka, 1) + g, err := NewGenerator(ForkOsaka, MethodInvalidStateRoot, 1) require.NoError(t, err) // Same iteration → same hash. @@ -106,7 +106,7 @@ func TestStateRootForIteration_Deterministic(t *testing.T) { } func TestTransform_NonNewPayloadPassesThrough(t *testing.T) { - g, err := NewGenerator(ForkOsaka, 5) // count > 1 must NOT duplicate FCUs + g, err := NewGenerator(ForkOsaka, MethodInvalidStateRoot, 5) // count > 1 must NOT duplicate FCUs require.NoError(t, err) in := `{"jsonrpc":"2.0","id":1,"method":"engine_forkchoiceUpdatedV3","params":[]}` @@ -118,7 +118,7 @@ func TestTransform_NonNewPayloadPassesThrough(t *testing.T) { } func TestTransform_EmptyLinePassesThrough(t *testing.T) { - g, err := NewGenerator(ForkOsaka, 1) + g, err := NewGenerator(ForkOsaka, MethodInvalidStateRoot, 1) require.NoError(t, err) out, err := g.Transform("") @@ -131,7 +131,7 @@ func TestTransform_NewPayloadOverridesStateRootAndRecomputesHash(t *testing.T) { originalStateRoot := common.HexToHash("0xde94bab83ce96d440db7a3e2dc95ebbab73dc5aa88dc80b3d99ae8f0cff4e96c") line, _, originalHash, beaconRoot := makeNewPayloadLine(t, originalStateRoot) - g, err := NewGenerator(ForkOsaka, 1) + g, err := NewGenerator(ForkOsaka, MethodInvalidStateRoot, 1) require.NoError(t, err) out, err := g.Transform(line) @@ -169,7 +169,7 @@ func TestTransform_CountProducesDistinctVariants(t *testing.T) { const count = 4 - g, err := NewGenerator(ForkOsaka, count) + g, err := NewGenerator(ForkOsaka, MethodInvalidStateRoot, count) require.NoError(t, err) out, err := g.Transform(line) @@ -214,7 +214,7 @@ func TestTransformLines_ExpandsNewPayloadAndPassesThroughOthers(t *testing.T) { const count = 3 - g, err := NewGenerator(ForkOsaka, count) + g, err := NewGenerator(ForkOsaka, MethodInvalidStateRoot, count) require.NoError(t, err) out, err := g.TransformLines([]string{newPayload, fcu}) @@ -238,7 +238,7 @@ func TestTransformLines_CountOnePreservesLength(t *testing.T) { newPayload, _, _, _ := makeNewPayloadLine(t, originalStateRoot) fcu := `{"jsonrpc":"2.0","id":2,"method":"engine_forkchoiceUpdatedV3","params":[]}` - g, err := NewGenerator(ForkOsaka, 1) + g, err := NewGenerator(ForkOsaka, MethodInvalidStateRoot, 1) require.NoError(t, err) out, err := g.TransformLines([]string{newPayload, fcu}) @@ -247,3 +247,175 @@ func TestTransformLines_CountOnePreservesLength(t *testing.T) { assert.Equal(t, fcu, out[1]) assert.NotEqual(t, newPayload, out[0]) } + +// makeOsakaPayloadWithGasUsed is like makeOsakaPayload but uses the +// supplied gasUsed instead of zero, so MethodInvalidGasUsed tests have +// headroom for subtraction. +func makeOsakaPayloadWithGasUsed(t *testing.T, stateRoot common.Hash, gasUsed uint64) (engine.ExecutableData, common.Hash, *common.Hash) { + t.Helper() + + zero := uint64(0) + beaconRoot := common.HexToHash("0x000102030405060708090a0b0c0d0e0f000102030405060708090a0b0c0d0e0f") + data := engine.ExecutableData{ + ParentHash: common.HexToHash("0x58b0689a8ff37dc82cd2840dabcd79afae585d9a261ac5658e4720a9a5ade187"), + FeeRecipient: common.HexToAddress("0x2adc25665018aa1fe0e6bc666dac8fc2697ff9ba"), + StateRoot: stateRoot, + ReceiptsRoot: common.HexToHash("0x9764a9b29133339b51f219a18d3bc2b617ce983cb602f95df5647fa4d7362828"), + LogsBloom: make([]byte, 256), + Random: common.HexToHash("0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"), + Number: 100, + GasLimit: 30_000_000, + GasUsed: gasUsed, + Timestamp: 1_700_000_000, + ExtraData: []byte{}, + BaseFeePerGas: big.NewInt(7), + Transactions: [][]byte{}, + Withdrawals: []*types.Withdrawal{}, + BlobGasUsed: &zero, + ExcessBlobGas: &zero, + } + + block, err := engine.ExecutableDataToBlockNoHash(data, nil, &beaconRoot, [][]byte{}) + require.NoError(t, err) + + data.BlockHash = block.Hash() + + return data, block.Hash(), &beaconRoot +} + +// makeNewPayloadLineWithGasUsed wraps makeOsakaPayloadWithGasUsed in a +// JSON-RPC envelope. +func makeNewPayloadLineWithGasUsed(t *testing.T, originalStateRoot common.Hash, gasUsed uint64) (string, engine.ExecutableData, common.Hash, *common.Hash) { + t.Helper() + + data, originalHash, beaconRoot := makeOsakaPayloadWithGasUsed(t, originalStateRoot, gasUsed) + + payloadJSON, err := json.Marshal(&data) + require.NoError(t, err) + + beaconRootJSON, err := json.Marshal(beaconRoot) + require.NoError(t, err) + + emptyRequests, err := json.Marshal([]hexutil.Bytes{}) + require.NoError(t, err) + + line := `{"jsonrpc":"2.0","id":1,"method":"engine_newPayloadV4","params":[` + + string(payloadJSON) + `,[],` + string(beaconRootJSON) + `,` + string(emptyRequests) + `]}` + + return line, data, originalHash, beaconRoot +} + +func TestNewGenerator_RejectsUnknownMethod(t *testing.T) { + _, err := NewGenerator(ForkOsaka, Method("invalid-coinbase"), 1) + assert.Error(t, err) +} + +func TestIsValidMethod(t *testing.T) { + assert.True(t, IsValidMethod("invalid-stateroot")) + assert.True(t, IsValidMethod("invalid-gasused")) + assert.False(t, IsValidMethod("")) + assert.False(t, IsValidMethod("invalid-coinbase")) +} + +func TestGasUsedForIteration(t *testing.T) { + g, err := NewGenerator(ForkOsaka, MethodInvalidGasUsed, 1) + require.NoError(t, err) + + // iteration 0 → original-1, iteration 3 → original-4 + v0, err := g.GasUsedForIteration(1_000_000, 0) + require.NoError(t, err) + assert.Equal(t, uint64(999_999), v0) + + v3, err := g.GasUsedForIteration(1_000_000, 3) + require.NoError(t, err) + assert.Equal(t, uint64(999_996), v3) + + // Underflow guard: original=2, iteration=5 → delta=6 > original. + _, err = g.GasUsedForIteration(2, 5) + assert.Error(t, err) +} + +func TestTransform_InvalidGasUsed_DecrementsAndRecomputesHash(t *testing.T) { + originalStateRoot := common.HexToHash("0xde94bab83ce96d440db7a3e2dc95ebbab73dc5aa88dc80b3d99ae8f0cff4e96c") + const originalGasUsed = uint64(1_000_000) + line, _, originalHash, beaconRoot := makeNewPayloadLineWithGasUsed(t, originalStateRoot, originalGasUsed) + + g, err := NewGenerator(ForkOsaka, MethodInvalidGasUsed, 1) + require.NoError(t, err) + + out, err := g.Transform(line) + require.NoError(t, err) + require.Len(t, out, 1) + + var parsed struct { + Params []json.RawMessage `json:"params"` + } + require.NoError(t, json.Unmarshal([]byte(out[0]), &parsed)) + require.Len(t, parsed.Params, 4) + + var transformed engine.ExecutableData + require.NoError(t, json.Unmarshal(parsed.Params[0], &transformed)) + + // stateRoot left untouched, gasUsed decremented by 1. + assert.Equal(t, originalStateRoot, transformed.StateRoot) + assert.Equal(t, originalGasUsed-1, transformed.GasUsed) + + // blockHash recomputed and different from the original. + assert.NotEqual(t, originalHash, transformed.BlockHash) + + expected, err := engine.ExecutableDataToBlockNoHash(transformed, nil, beaconRoot, [][]byte{}) + require.NoError(t, err) + assert.Equal(t, expected.Hash(), transformed.BlockHash) +} + +func TestTransform_InvalidGasUsed_CountProducesDistinctVariants(t *testing.T) { + originalStateRoot := common.HexToHash("0xde94bab83ce96d440db7a3e2dc95ebbab73dc5aa88dc80b3d99ae8f0cff4e96c") + const originalGasUsed = uint64(1_000_000) + line, _, originalHash, beaconRoot := makeNewPayloadLineWithGasUsed(t, originalStateRoot, originalGasUsed) + + const count = 4 + g, err := NewGenerator(ForkOsaka, MethodInvalidGasUsed, count) + require.NoError(t, err) + + out, err := g.Transform(line) + require.NoError(t, err) + require.Len(t, out, count) + + seenGasUsed := make(map[uint64]struct{}, count) + seenBlockHashes := make(map[common.Hash]struct{}, count) + + for i, variantLine := range out { + var parsed struct { + Params []json.RawMessage `json:"params"` + } + require.NoError(t, json.Unmarshal([]byte(variantLine), &parsed)) + + var data engine.ExecutableData + require.NoError(t, json.Unmarshal(parsed.Params[0], &data)) + + assert.Equal(t, originalStateRoot, data.StateRoot, "iteration %d should leave stateRoot untouched", i) + assert.Equal(t, originalGasUsed-uint64(i+1), data.GasUsed, "iteration %d gasUsed", i) //nolint:gosec + assert.NotEqual(t, originalHash, data.BlockHash, "iteration %d blockHash matches original", i) + + expected, err := engine.ExecutableDataToBlockNoHash(data, nil, beaconRoot, [][]byte{}) + require.NoError(t, err) + assert.Equal(t, expected.Hash(), data.BlockHash, "iteration %d blockHash mismatch", i) + + seenGasUsed[data.GasUsed] = struct{}{} + seenBlockHashes[data.BlockHash] = struct{}{} + } + + assert.Len(t, seenGasUsed, count) + assert.Len(t, seenBlockHashes, count) +} + +func TestTransform_InvalidGasUsed_UnderflowReturnsError(t *testing.T) { + // Original gasUsed=2 with count=5 must fail at iteration 2 (delta=3). + line, _, _, _ := makeNewPayloadLineWithGasUsed(t, common.Hash{}, 2) + + g, err := NewGenerator(ForkOsaka, MethodInvalidGasUsed, 5) + require.NoError(t, err) + + _, err = g.Transform(line) + assert.Error(t, err) +}