From e7df142f38b627b79a2e7e2074d1da358f48619d Mon Sep 17 00:00:00 2001 From: tac0turtle Date: Thu, 11 Jun 2026 15:14:02 +0200 Subject: [PATCH] update reth 2.3 and prep for amsterdam fork --- ams_update.md | 176 +++++++++++ docs/ev-reth/engine-api.md | 48 ++- docs/reference/api/engine-api.md | 47 ++- execution/evm/README.md | 21 +- execution/evm/engine_payload.go | 151 +++++++++ execution/evm/engine_rpc_client.go | 228 +++++++++++--- execution/evm/engine_rpc_client_test.go | 288 ++++++++++++++++-- execution/evm/engine_rpc_tracing.go | 31 +- execution/evm/execution.go | 60 ++-- execution/evm/execution_payload_attrs_test.go | 16 + execution/evm/execution_reconcile_test.go | 8 +- execution/evm/test/test_helpers.go | 17 +- 12 files changed, 961 insertions(+), 130 deletions(-) create mode 100644 ams_update.md create mode 100644 execution/evm/engine_payload.go create mode 100644 execution/evm/execution_payload_attrs_test.go diff --git a/ams_update.md b/ams_update.md new file mode 100644 index 0000000000..b24446e805 --- /dev/null +++ b/ams_update.md @@ -0,0 +1,176 @@ +# ev-node Amsterdam Engine API Updates + +This note summarizes the ev-node changes needed before enabling Amsterdam in an +ev-reth 2.3 chainspec. + +## Current ev-node support + +Current ev-node main has Prague/Osaka/Fusaka-style Engine API support: + +- `engine_forkchoiceUpdatedV3` +- `engine_getPayloadV4` with fallback/cache to `engine_getPayloadV5` +- `engine_newPayloadV4` + +That is enough while the execution chain remains pre-Amsterdam. It is not enough +for Amsterdam payloads. + +The checked-in ev-node EVM genesis currently has `pragueTime: 0` and no +`amsterdamTime`, so this is not an immediate break for that default config. + +## Why Amsterdam needs more work + +Amsterdam introduces an Engine payload shape that ev-node must preserve: + +- `PayloadAttributes` require `slotNumber`. +- Built execution payloads include `executionPayload.blockAccessList`. +- Reth 2.3 expects Amsterdam payloads through: + - `engine_forkchoiceUpdatedV4` + - `engine_getPayloadV6` + - `engine_newPayloadV5` + +The most important gap is `blockAccessList` passthrough. ev-node currently +decodes `getPayload` responses into go-ethereum's `engine.ExecutionPayloadEnvelope`. +In `go-ethereum v1.17.2`, `engine.ExecutableData` includes `slotNumber`, but the +Engine API envelope does not expose `executionPayload.blockAccessList`. Decoding +an Amsterdam payload into that type will lose the BAL before ev-node submits the +payload back with `newPayload`. + +## Required code changes + +### 1. Add Amsterdam Engine API method selection + +Update `execution/evm/engine_rpc_client.go`: + +- Add `engine_forkchoiceUpdatedV4`. +- Add `engine_getPayloadV6`. +- Add `engine_newPayloadV5`. +- Track the selected payload version beyond the current V4/V5 boolean. + +Recommended shape: + +- Replace `useV5 atomic.Bool` with a small enum or atomic version value. +- Keep unsupported-fork fallback for `getPayload`, but include V6. +- Select `newPayloadV5` whenever the payload has Amsterdam fields. +- Select `forkchoiceUpdatedV4` whenever payload attributes are Amsterdam. + +### 2. Add `slotNumber` to Amsterdam payload attributes + +Update the payload attributes map in `execution/evm/execution.go`. + +Current map includes: + +- `timestamp` +- `prevRandao` +- `suggestedFeeRecipient` +- `withdrawals` +- `parentBeaconBlockRoot` +- evolve-specific `transactions` +- evolve-specific `gasLimit` + +For Amsterdam-active timestamps, add: + +```json +"slotNumber": "" +``` + +The slot number should come from an explicit chain rule. A reasonable default is +to derive it from rollup block height, but this should be agreed with the +ev-node/ev-reth chain semantics before enabling the fork. + +### 3. Preserve `blockAccessList` from `getPayloadV6` to `newPayloadV5` + +Do not try to compute the block access list in ev-node. ev-reth builds it. + +Add a local Amsterdam-capable payload wrapper that can decode and re-encode the +full `executionPayload` object, including: + +- all fields currently in `engine.ExecutableData` +- `slotNumber` +- `blockAccessList` + +Implementation options: + +- Use a custom struct embedding or mirroring `engine.ExecutableData` and adding + `BlockAccessList hexutil.Bytes`. +- Or keep the raw `json.RawMessage` for `executionPayload`, decode only the + fields ev-node needs for `blockNumber`, `timestamp`, `blockHash`, and + `stateRoot`, then submit the raw payload object unchanged to `newPayloadV5`. + +The raw-object approach is safest for Engine API compatibility because it avoids +dropping future EL payload fields. + +### 4. Add fork activation awareness + +ev-node currently does not have an EVM fork schedule option. It receives: + +- ETH RPC URL +- Engine RPC URL +- JWT secret +- genesis hash +- fee recipient + +Amsterdam selection needs one of: + +- a new flag such as `--evm.amsterdam-time`, kept in sync with ev-reth genesis; +- parsing the ev-reth genesis/chainspec from a configured path; +- an Engine API capability probe plus explicit local state that knows when + `slotNumber` must be present. + +Do not rely only on `getPayload` unsupported-fork fallback. `forkchoiceUpdatedV4` +requires `slotNumber` when starting an Amsterdam build, so ev-node must know the +fork state before asking ev-reth to build a payload. + +### 5. Update tracing and logs + +Update `execution/evm/engine_rpc_tracing.go` so spans record the actual selected +method: + +- `engine_forkchoiceUpdatedV3` or `engine_forkchoiceUpdatedV4` +- `engine_getPayloadV4`, `engine_getPayloadV5`, or `engine_getPayloadV6` +- `engine_newPayloadV4` or `engine_newPayloadV5` + +Also update hard-coded log messages in `execution/evm/execution.go` that still +name V3/V4 methods. + +## Tests to add + +Add or extend tests in `execution/evm/engine_rpc_client_test.go`: + +- Prague path uses `getPayloadV4` and `newPayloadV4`. +- Osaka/Fusaka path falls back to and caches `getPayloadV5`. +- Amsterdam path uses `forkchoiceUpdatedV4` with `slotNumber`. +- Amsterdam path uses `getPayloadV6`. +- Amsterdam path submits with `newPayloadV5`. +- `blockAccessList` returned by `getPayloadV6` is present in the submitted + `newPayloadV5` execution payload. +- Unsupported-fork errors still switch only to valid alternate methods. + +Update integration test helpers: + +- `execution/evm/test/test_helpers.go` currently hard-codes + `engine_forkchoiceUpdatedV3` for readiness. +- Any E2E config that enables `amsterdamTime` must use the updated method path. + +## Docs to update + +Update ev-node docs that currently describe only older Engine API versions: + +- `execution/evm/README.md` +- `docs/reference/api/engine-api.md` +- `docs/ev-reth/engine-api.md` + +Document that Prague/Osaka/Fusaka support is insufficient for Amsterdam because +Amsterdam adds `slotNumber`, `blockAccessList`, and newer Engine API methods. + +## Enablement checklist + +Before setting `amsterdamTime` in an ev-reth chainspec: + +- [ ] ev-node can call `engine_forkchoiceUpdatedV4`. +- [ ] ev-node sends `slotNumber` in Amsterdam payload attributes. +- [ ] ev-node can call `engine_getPayloadV6`. +- [ ] ev-node preserves `executionPayload.blockAccessList`. +- [ ] ev-node can call `engine_newPayloadV5`. +- [ ] tracing/logs report the selected Engine API version. +- [ ] unit tests cover V4/V5/V6 version selection and BAL passthrough. +- [ ] E2E tests pass against an Amsterdam-enabled ev-reth chainspec. diff --git a/docs/ev-reth/engine-api.md b/docs/ev-reth/engine-api.md index 2f8f078513..11771196cc 100644 --- a/docs/ev-reth/engine-api.md +++ b/docs/ev-reth/engine-api.md @@ -25,14 +25,14 @@ Configure both sides: ```text ev-node ev-reth │ │ - │ 1. engine_forkchoiceUpdatedV3 │ + │ 1. engine_forkchoiceUpdatedV3/V4 │ │ (headBlockHash, payloadAttributes) │ │─────────────────────────────────────────►│ │ │ │ 2. {payloadId} │ │◄─────────────────────────────────────────│ │ │ - │ 3. engine_getPayloadV3(payloadId) │ + │ 3. engine_getPayloadV4/V5/V6(payloadId) │ │─────────────────────────────────────────►│ │ │ │ 4. {executionPayload, blockValue} │ @@ -40,7 +40,7 @@ ev-node ev-reth │ │ │ [ev-node broadcasts to P2P, submits DA] │ │ │ - │ 5. engine_newPayloadV3(executionPayload)│ + │ 5. engine_newPayloadV4/V5(payload) │ │─────────────────────────────────────────►│ │ │ │ 6. {status: VALID} │ @@ -54,7 +54,20 @@ ev-node ev-reth ## Methods -### engine_forkchoiceUpdatedV3 +| Fork family | Forkchoice | Get payload | New payload | +|-------------|------------|-------------|-------------| +| Prague | `engine_forkchoiceUpdatedV3` | `engine_getPayloadV4` | `engine_newPayloadV4` | +| Osaka/Fusaka | `engine_forkchoiceUpdatedV3` | `engine_getPayloadV5` | `engine_newPayloadV4` | +| Amsterdam | `engine_forkchoiceUpdatedV4` | `engine_getPayloadV6` | `engine_newPayloadV5` | + +Amsterdam support is more than a method rename: payload attributes include +`slotNumber`, and built payloads include `executionPayload.blockAccessList`. +ev-node preserves the raw Amsterdam `executionPayload` returned by ev-reth and +submits it unchanged to `engine_newPayloadV5`. +ev-node detects Amsterdam by retrying `engine_forkchoiceUpdatedV4` after an +unsupported-fork response from `engine_forkchoiceUpdatedV3`, then caches V4. + +### engine_forkchoiceUpdatedV3 / V4 Update the fork choice and optionally start building a new block. @@ -62,7 +75,7 @@ Update the fork choice and optionally start building a new block. ```json { - "method": "engine_forkchoiceUpdatedV3", + "method": "engine_forkchoiceUpdatedV4", "params": [ { "headBlockHash": "0x...", @@ -74,7 +87,8 @@ Update the fork choice and optionally start building a new block. "prevRandao": "0x...", "suggestedFeeRecipient": "0x...", "withdrawals": [], - "parentBeaconBlockRoot": "0x..." + "parentBeaconBlockRoot": "0x...", + "slotNumber": "0x..." } ] } @@ -92,7 +106,7 @@ Update the fork choice and optionally start building a new block. } ``` -### engine_getPayloadV3 +### engine_getPayloadV4 / V5 / V6 Retrieve a built payload. @@ -100,7 +114,7 @@ Retrieve a built payload. ```json { - "method": "engine_getPayloadV3", + "method": "engine_getPayloadV6", "params": ["0x...payloadId"] } ``` @@ -123,13 +137,15 @@ Retrieve a built payload. "extraData": "0x", "baseFeePerGas": "0x...", "blockHash": "0x...", - "transactions": ["0x..."] + "transactions": ["0x..."], + "slotNumber": "0x...", + "blockAccessList": [] }, "blockValue": "0x..." } ``` -### engine_newPayloadV3 +### engine_newPayloadV4 / V5 Validate and execute a payload. @@ -137,11 +153,17 @@ Validate and execute a payload. ```json { - "method": "engine_newPayloadV3", + "method": "engine_newPayloadV5", "params": [ - { "executionPayload": "..." }, + { + "parentHash": "0x...", + "blockHash": "0x...", + "slotNumber": "0x...", + "blockAccessList": [] + }, ["0x...versionedHashes"], - "0x...parentBeaconBlockRoot" + "0x...parentBeaconBlockRoot", + [] ] } ``` diff --git a/docs/reference/api/engine-api.md b/docs/reference/api/engine-api.md index 9e8c4e9888..f44500ad49 100644 --- a/docs/reference/api/engine-api.md +++ b/docs/reference/api/engine-api.md @@ -18,16 +18,29 @@ openssl rand -hex 32 > jwt.hex ## Methods -### engine_forkchoiceUpdatedV3 +ev-node selects Engine API methods by fork: + +| Fork family | Forkchoice | Get payload | New payload | +|-------------|------------|-------------|-------------| +| Prague | `engine_forkchoiceUpdatedV3` | `engine_getPayloadV4` | `engine_newPayloadV4` | +| Osaka/Fusaka | `engine_forkchoiceUpdatedV3` | `engine_getPayloadV5` | `engine_newPayloadV4` | +| Amsterdam | `engine_forkchoiceUpdatedV4` | `engine_getPayloadV6` | `engine_newPayloadV5` | + +### engine_forkchoiceUpdatedV3 / V4 Update fork choice and optionally build a new block. +Payload builds start with `engine_forkchoiceUpdatedV3`. If the execution layer +returns unsupported fork, ev-node retries `engine_forkchoiceUpdatedV4` with +`slotNumber` in the payload attributes and caches V4 for future calls. +`slotNumber` is derived deterministically from rollup block height. + **Request:** ```json { "jsonrpc": "2.0", - "method": "engine_forkchoiceUpdatedV3", + "method": "engine_forkchoiceUpdatedV4", "params": [ { "headBlockHash": "0x...", @@ -39,7 +52,8 @@ Update fork choice and optionally build a new block. "prevRandao": "0x...", "suggestedFeeRecipient": "0x...", "withdrawals": [], - "parentBeaconBlockRoot": "0x..." + "parentBeaconBlockRoot": "0x...", + "slotNumber": "0x..." } ], "id": 1 @@ -62,16 +76,20 @@ Update fork choice and optionally build a new block. } ``` -### engine_getPayloadV3 +### engine_getPayloadV4 / V5 / V6 Get a built payload. +ev-node starts with `engine_getPayloadV4`, caches `engine_getPayloadV5` or +`engine_getPayloadV6` after successful unsupported-fork fallback, and switches +directly to V6 after an Amsterdam `forkchoiceUpdatedV4` build request. + **Request:** ```json { "jsonrpc": "2.0", - "method": "engine_getPayloadV3", + "method": "engine_getPayloadV6", "params": ["0x...payloadId"], "id": 1 } @@ -100,7 +118,9 @@ Get a built payload. "transactions": ["0x..."], "withdrawals": [], "blobGasUsed": "0x0", - "excessBlobGas": "0x0" + "excessBlobGas": "0x0", + "slotNumber": "0x...", + "blockAccessList": [] }, "blockValue": "0x...", "blobsBundle": { @@ -114,16 +134,20 @@ Get a built payload. } ``` -### engine_newPayloadV3 +### engine_newPayloadV4 / V5 Validate and execute a payload. +Amsterdam payloads use `engine_newPayloadV5`. ev-node passes through the raw +`executionPayload` object returned by `engine_getPayloadV6` so +`blockAccessList` is preserved. + **Request:** ```json { "jsonrpc": "2.0", - "method": "engine_newPayloadV3", + "method": "engine_newPayloadV5", "params": [ { "parentHash": "0x...", @@ -142,10 +166,13 @@ Validate and execute a payload. "transactions": ["0x..."], "withdrawals": [], "blobGasUsed": "0x0", - "excessBlobGas": "0x0" + "excessBlobGas": "0x0", + "slotNumber": "0x...", + "blockAccessList": [] }, ["0x...expectedBlobVersionedHashes"], - "0x...parentBeaconBlockRoot" + "0x...parentBeaconBlockRoot", + [] ], "id": 1 } diff --git a/execution/evm/README.md b/execution/evm/README.md index b44ea71b01..21ea79cd22 100644 --- a/execution/evm/README.md +++ b/execution/evm/README.md @@ -20,7 +20,7 @@ Since the `PureEngineClient` relies on the Engine API, the genesis configuration 1. The genesis file must include post-merge hardfork configurations 2. `terminalTotalDifficulty` must be set to 0 3. `terminalTotalDifficultyPassed` must be set to true -4. Hardforks like `mergeNetsplitBlock`, `shanghaiTime`, `cancunTime` and `pragueTime` should be properly configured +4. Hardforks like `mergeNetsplitBlock`, `shanghaiTime`, `cancunTime`, `pragueTime`, and optionally `amsterdamTime` should be properly configured Example of required genesis configuration: @@ -43,18 +43,31 @@ Example of required genesis configuration: "terminalTotalDifficultyPassed": true, "shanghaiTime": 0, "cancunTime": 0, - "pragueTime": 0 + "pragueTime": 0, + "amsterdamTime": 0 } } ``` Without these settings, the Engine API will not be available, and the `PureEngineClient` will not function correctly. +### Engine API Versions + +ev-node uses fork-aware Engine API methods: + +- Prague payloads use `engine_forkchoiceUpdatedV3`, `engine_getPayloadV4`, and `engine_newPayloadV4`. +- Osaka/Fusaka payloads fall forward from `engine_getPayloadV4` to `engine_getPayloadV5` on unsupported-fork responses. +- Amsterdam payloads use `engine_forkchoiceUpdatedV4`, `engine_getPayloadV6`, and `engine_newPayloadV5`. + +ev-node auto-detects Engine API versions by retrying on unsupported-fork responses. For payload builds, it first tries `engine_forkchoiceUpdatedV3`; if the execution layer rejects that method for Amsterdam, ev-node retries `engine_forkchoiceUpdatedV4` with a deterministic `slotNumber` derived from rollup block height and caches V4 for future calls. + +Amsterdam adds `slotNumber` to payload attributes and `executionPayload.blockAccessList` to built payloads. ev-node does not compute the block access list; it preserves the raw `executionPayload` returned by ev-reth and submits that object unchanged to `engine_newPayloadV5`. + ### PayloadID Storage The `PureEngineClient` maintains the `payloadID` between calls: -1. During `InitChain`, a payload ID is obtained from the Engine API via `engine_forkchoiceUpdatedV3` +1. During `InitChain`, the genesis forkchoice is acknowledged through the Engine API 2. This payload ID is stored in the client instance as `c.payloadID` 3. The stored payload ID is used in subsequent calls to `GetTxs` to retrieve the current execution payload 4. After each `ExecuteTxs` call, a new payload ID is obtained and stored for the next block @@ -66,7 +79,7 @@ The `PureEngineClient` implements a unique approach to transaction execution: 1. In `GetTxs`, the entire execution payload is serialized to JSON and returned as the first transaction 2. In `ExecuteTxs`, this first transaction is deserialized back into an execution payload 3. The remaining transactions are added to the payload's transaction list -4. The complete payload is then submitted to the execution client via `engine_newPayloadV4` +4. The complete payload is then submitted to the execution client via the fork-appropriate `engine_newPayload` method This approach ensures that: diff --git a/execution/evm/engine_payload.go b/execution/evm/engine_payload.go new file mode 100644 index 0000000000..bc61a00ae3 --- /dev/null +++ b/execution/evm/engine_payload.go @@ -0,0 +1,151 @@ +package evm + +import ( + "encoding/json" + "errors" + "math/big" + + "github.com/ethereum/go-ethereum/beacon/engine" + "github.com/ethereum/go-ethereum/common/hexutil" +) + +// EnginePayloadEnvelope mirrors engine.ExecutionPayloadEnvelope while preserving +// the raw executionPayload object for Amsterdam fields that go-ethereum does not +// yet expose on the typed envelope, such as blockAccessList. +type EnginePayloadEnvelope struct { + ExecutionPayload *engine.ExecutableData + RawExecutionPayload json.RawMessage + BlockValue *big.Int + BlobsBundle *engine.BlobsBundle + Requests [][]byte + Override bool + Witness *hexutil.Bytes +} + +func (e *EnginePayloadEnvelope) UnmarshalJSON(input []byte) error { + type executionPayloadEnvelope struct { + ExecutionPayload json.RawMessage `json:"executionPayload"` + BlockValue *hexutil.Big `json:"blockValue"` + BlobsBundle *engine.BlobsBundle + Requests []hexutil.Bytes `json:"executionRequests"` + Override *bool `json:"shouldOverrideBuilder"` + Witness *hexutil.Bytes `json:"witness,omitempty"` + } + + var dec executionPayloadEnvelope + if err := json.Unmarshal(input, &dec); err != nil { + return err + } + if len(dec.ExecutionPayload) == 0 { + return errors.New("missing required field 'executionPayload' for EnginePayloadEnvelope") + } + + var payload engine.ExecutableData + if err := json.Unmarshal(dec.ExecutionPayload, &payload); err != nil { + return err + } + e.ExecutionPayload = &payload + e.RawExecutionPayload = append(e.RawExecutionPayload[:0], dec.ExecutionPayload...) + + if dec.BlockValue == nil { + return errors.New("missing required field 'blockValue' for EnginePayloadEnvelope") + } + e.BlockValue = (*big.Int)(dec.BlockValue) + e.BlobsBundle = dec.BlobsBundle + + if dec.Requests != nil { + e.Requests = make([][]byte, len(dec.Requests)) + for i, request := range dec.Requests { + e.Requests[i] = request + } + } + if dec.Override != nil { + e.Override = *dec.Override + } + e.Witness = dec.Witness + return nil +} + +func (e EnginePayloadEnvelope) MarshalJSON() ([]byte, error) { + type executionPayloadEnvelope struct { + ExecutionPayload json.RawMessage `json:"executionPayload"` + BlockValue *hexutil.Big `json:"blockValue"` + BlobsBundle *engine.BlobsBundle + Requests []hexutil.Bytes `json:"executionRequests"` + Override bool `json:"shouldOverrideBuilder"` + Witness *hexutil.Bytes `json:"witness,omitempty"` + } + + executionPayload, err := e.executionPayloadJSON() + if err != nil { + return nil, err + } + + var requests []hexutil.Bytes + if e.Requests != nil { + requests = make([]hexutil.Bytes, len(e.Requests)) + for i, request := range e.Requests { + requests[i] = request + } + } + + return json.Marshal(executionPayloadEnvelope{ + ExecutionPayload: executionPayload, + BlockValue: (*hexutil.Big)(e.BlockValue), + BlobsBundle: e.BlobsBundle, + Requests: requests, + Override: e.Override, + Witness: e.Witness, + }) +} + +func (e *EnginePayloadEnvelope) executionPayloadParam() (any, error) { + if e == nil { + return nil, errors.New("nil EnginePayloadEnvelope") + } + payload, err := e.executionPayloadJSON() + if err != nil { + return nil, err + } + return payload, nil +} + +func (e *EnginePayloadEnvelope) executionPayloadJSON() (json.RawMessage, error) { + if e == nil { + return nil, errors.New("nil EnginePayloadEnvelope") + } + if len(e.RawExecutionPayload) > 0 { + return json.RawMessage(e.RawExecutionPayload), nil + } + if e.ExecutionPayload == nil { + return nil, errors.New("nil execution payload") + } + payload, err := json.Marshal(e.ExecutionPayload) + if err != nil { + return nil, err + } + return json.RawMessage(payload), nil +} + +func (e *EnginePayloadEnvelope) hasAmsterdamFields() bool { + if e == nil { + return false + } + if e.ExecutionPayload != nil && e.ExecutionPayload.SlotNumber != nil { + return true + } + return rawExecutionPayloadHasField(e.RawExecutionPayload, "slotNumber") || + rawExecutionPayloadHasField(e.RawExecutionPayload, "blockAccessList") +} + +func rawExecutionPayloadHasField(payload json.RawMessage, field string) bool { + if len(payload) == 0 { + return false + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(payload, &fields); err != nil { + return false + } + _, ok := fields[field] + return ok +} diff --git a/execution/evm/engine_rpc_client.go b/execution/evm/engine_rpc_client.go index 536bf805e3..288535e34a 100644 --- a/execution/evm/engine_rpc_client.go +++ b/execution/evm/engine_rpc_client.go @@ -16,21 +16,44 @@ const engineErrUnsupportedFork = -38005 // Engine API method names for GetPayload versions. const ( - getPayloadV4Method = "engine_getPayloadV4" - getPayloadV5Method = "engine_getPayloadV5" + forkchoiceUpdatedV3Method = "engine_forkchoiceUpdatedV3" + forkchoiceUpdatedV4Method = "engine_forkchoiceUpdatedV4" + getPayloadV4Method = "engine_getPayloadV4" + getPayloadV5Method = "engine_getPayloadV5" + getPayloadV6Method = "engine_getPayloadV6" + newPayloadV4Method = "engine_newPayloadV4" + newPayloadV5Method = "engine_newPayloadV5" ) var _ EngineRPCClient = (*engineRPCClient)(nil) +type enginePayloadVersion uint32 + +const ( + enginePayloadVersionV4 enginePayloadVersion = 4 + enginePayloadVersionV5 enginePayloadVersion = 5 + enginePayloadVersionV6 enginePayloadVersion = 6 +) + +type engineForkchoiceVersion uint32 + +const ( + engineForkchoiceVersionV3 engineForkchoiceVersion = 3 + engineForkchoiceVersionV4 engineForkchoiceVersion = 4 +) + // engineRPCClient is the concrete implementation wrapping *rpc.Client. -// It auto-detects whether to use engine_getPayloadV4 (Prague) or -// engine_getPayloadV5 (Osaka) by caching the last successful version -// and falling back on "Unsupported fork" errors. +// It auto-detects whether to use engine_getPayloadV4, engine_getPayloadV5, or +// engine_getPayloadV6 by caching the last successful version and falling back on +// "Unsupported fork" errors. type engineRPCClient struct { client *rpc.Client - // useV5 tracks whether GetPayload should prefer V5 (Osaka). - // Starts false (V4/Prague). Flips automatically on unsupported-fork errors. - useV5 atomic.Bool + // payloadVersion tracks the preferred GetPayload version. The zero value + // means V4, which keeps the default Prague path unchanged. + payloadVersion atomic.Uint32 + // forkchoiceVersion tracks the preferred ForkchoiceUpdated version. The zero + // value means V3, which keeps the default pre-Amsterdam path unchanged. + forkchoiceVersion atomic.Uint32 } // NewEngineRPCClient creates a new Engine API client. @@ -39,59 +62,182 @@ func NewEngineRPCClient(client *rpc.Client) EngineRPCClient { } func (e *engineRPCClient) ForkchoiceUpdated(ctx context.Context, state engine.ForkchoiceStateV1, args map[string]any) (*engine.ForkChoiceResponse, error) { - var result engine.ForkChoiceResponse - err := e.client.CallContext(ctx, &result, "engine_forkchoiceUpdatedV3", state, args) + preferredVersion := e.preferredForkchoiceVersion() + versions := forkchoiceFallbackOrder(preferredVersion) + + var unsupportedMethods []string + var lastUnsupportedErr error + for _, version := range versions { + method := version.forkchoiceUpdatedMethod() + methodArgs, err := forkchoiceArgsForMethod(args, method) + if err != nil { + return nil, err + } + + var result engine.ForkChoiceResponse + err = e.client.CallContext(ctx, &result, method, state, methodArgs) + if err == nil { + e.forkchoiceVersion.Store(uint32(version)) + if version == engineForkchoiceVersionV4 { + e.payloadVersion.Store(uint32(enginePayloadVersionV6)) + } + return &result, nil + } + + if !isUnsupportedForkErr(err) { + if len(unsupportedMethods) == 0 { + return nil, fmt.Errorf("%s failed: %w", method, err) + } + return nil, fmt.Errorf("%s fallback after unsupported fork from %v: %w", method, unsupportedMethods, err) + } + + unsupportedMethods = append(unsupportedMethods, method) + lastUnsupportedErr = err + } + + return nil, fmt.Errorf("forkchoice update unsupported fork after trying %v: %w", unsupportedMethods, lastUnsupportedErr) +} + +func (e *engineRPCClient) GetPayload(ctx context.Context, payloadID engine.PayloadID) (*EnginePayloadEnvelope, error) { + preferredVersion := e.preferredPayloadVersion() + versions := getPayloadFallbackOrder(preferredVersion) + + var unsupportedMethods []string + var lastUnsupportedErr error + for _, version := range versions { + method := version.getPayloadMethod() + + var result EnginePayloadEnvelope + err := e.client.CallContext(ctx, &result, method, payloadID) + if err == nil { + e.payloadVersion.Store(uint32(version)) + return &result, nil + } + + if !isUnsupportedForkErr(err) { + if len(unsupportedMethods) == 0 { + return nil, fmt.Errorf("%s payload %s: %w", method, payloadID, err) + } + return nil, fmt.Errorf("%s fallback after unsupported fork from %v, payload %s: %w", method, unsupportedMethods, payloadID, err) + } + + unsupportedMethods = append(unsupportedMethods, method) + lastUnsupportedErr = err + } + + return nil, fmt.Errorf("get payload unsupported fork for payload %s after trying %v: %w", payloadID, unsupportedMethods, lastUnsupportedErr) +} + +// GetPayloadMethod returns the Engine API method name currently used by GetPayload. +// This allows wrappers (e.g. tracing) to report the resolved version. +func (e *engineRPCClient) GetPayloadMethod() string { + return e.preferredPayloadVersion().getPayloadMethod() +} + +// GetForkchoiceUpdatedMethod returns the currently preferred Engine API +// forkchoiceUpdated method. Tracing uses this to report the resolved version. +func (e *engineRPCClient) GetForkchoiceUpdatedMethod() string { + return e.preferredForkchoiceVersion().forkchoiceUpdatedMethod() +} + +func (e *engineRPCClient) NewPayload(ctx context.Context, payload *EnginePayloadEnvelope, blobHashes []string, parentBeaconBlockRoot string, executionRequests [][]byte) (*engine.PayloadStatusV1, error) { + payloadParam, err := payload.executionPayloadParam() + if err != nil { + return nil, err + } + + var result engine.PayloadStatusV1 + err = e.client.CallContext(ctx, &result, newPayloadMethod(payload), payloadParam, blobHashes, parentBeaconBlockRoot, executionRequests) if err != nil { return nil, err } return &result, nil } -func (e *engineRPCClient) GetPayload(ctx context.Context, payloadID engine.PayloadID) (*engine.ExecutionPayloadEnvelope, error) { - method := getPayloadV4Method - altMethod := getPayloadV5Method - if e.useV5.Load() { - method = getPayloadV5Method - altMethod = getPayloadV4Method +func (e *engineRPCClient) preferredPayloadVersion() enginePayloadVersion { + switch version := enginePayloadVersion(e.payloadVersion.Load()); version { + case enginePayloadVersionV5, enginePayloadVersionV6: + return version + default: + return enginePayloadVersionV4 } +} - var result engine.ExecutionPayloadEnvelope - err := e.client.CallContext(ctx, &result, method, payloadID) - if err == nil { - return &result, nil +func (v enginePayloadVersion) getPayloadMethod() string { + switch v { + case enginePayloadVersionV5: + return getPayloadV5Method + case enginePayloadVersionV6: + return getPayloadV6Method + default: + return getPayloadV4Method } +} - if !isUnsupportedForkErr(err) { - return nil, fmt.Errorf("%s payload %s: %w", method, payloadID, err) +func getPayloadFallbackOrder(preferred enginePayloadVersion) []enginePayloadVersion { + switch preferred { + case enginePayloadVersionV5: + return []enginePayloadVersion{enginePayloadVersionV5, enginePayloadVersionV6, enginePayloadVersionV4} + case enginePayloadVersionV6: + return []enginePayloadVersion{enginePayloadVersionV6, enginePayloadVersionV5, enginePayloadVersionV4} + default: + return []enginePayloadVersion{enginePayloadVersionV4, enginePayloadVersionV5, enginePayloadVersionV6} } +} - // Primary method returned "Unsupported fork" -- try the other version. - err = e.client.CallContext(ctx, &result, altMethod, payloadID) - if err != nil { - return nil, fmt.Errorf("%s fallback after %s unsupported fork, payload %s: %w", altMethod, method, payloadID, err) +func (e *engineRPCClient) preferredForkchoiceVersion() engineForkchoiceVersion { + switch version := engineForkchoiceVersion(e.forkchoiceVersion.Load()); version { + case engineForkchoiceVersionV4: + return version + default: + return engineForkchoiceVersionV3 } +} - // The alt method worked -- cache it for future calls. - e.useV5.Store(altMethod == getPayloadV5Method) - return &result, nil +func (v engineForkchoiceVersion) forkchoiceUpdatedMethod() string { + if v == engineForkchoiceVersionV4 { + return forkchoiceUpdatedV4Method + } + return forkchoiceUpdatedV3Method } -// GetPayloadMethod returns the Engine API method name currently used by GetPayload. -// This allows wrappers (e.g. tracing) to report the resolved version. -func (e *engineRPCClient) GetPayloadMethod() string { - if e.useV5.Load() { - return getPayloadV5Method +func forkchoiceFallbackOrder(preferred engineForkchoiceVersion) []engineForkchoiceVersion { + if preferred == engineForkchoiceVersionV4 { + return []engineForkchoiceVersion{engineForkchoiceVersionV4, engineForkchoiceVersionV3} } - return getPayloadV4Method + return []engineForkchoiceVersion{engineForkchoiceVersionV3, engineForkchoiceVersionV4} } -func (e *engineRPCClient) NewPayload(ctx context.Context, payload *engine.ExecutableData, blobHashes []string, parentBeaconBlockRoot string, executionRequests [][]byte) (*engine.PayloadStatusV1, error) { - var result engine.PayloadStatusV1 - err := e.client.CallContext(ctx, &result, "engine_newPayloadV4", payload, blobHashes, parentBeaconBlockRoot, executionRequests) - if err != nil { - return nil, err +func forkchoiceArgsForMethod(args map[string]any, method string) (map[string]any, error) { + if args == nil { + return nil, nil } - return &result, nil + + methodArgs := cloneMap(args) + switch method { + case forkchoiceUpdatedV4Method: + if _, ok := methodArgs["slotNumber"]; !ok { + return nil, fmt.Errorf("%s requires slotNumber payload attribute", method) + } + case forkchoiceUpdatedV3Method: + delete(methodArgs, "slotNumber") + } + return methodArgs, nil +} + +func cloneMap(args map[string]any) map[string]any { + cloned := make(map[string]any, len(args)) + for key, value := range args { + cloned[key] = value + } + return cloned +} + +func newPayloadMethod(payload *EnginePayloadEnvelope) string { + if payload.hasAmsterdamFields() { + return newPayloadV5Method + } + return newPayloadV4Method } // isUnsupportedForkErr reports whether err is an Engine API "Unsupported fork" diff --git a/execution/evm/engine_rpc_client_test.go b/execution/evm/engine_rpc_client_test.go index f13270eec6..dd5ac513aa 100644 --- a/execution/evm/engine_rpc_client_test.go +++ b/execution/evm/engine_rpc_client_test.go @@ -6,6 +6,7 @@ import ( "fmt" "net/http" "net/http/httptest" + "strings" "sync" "testing" @@ -22,11 +23,11 @@ type jsonRPCRequest struct { ID json.RawMessage `json:"id"` } -// fakeEngineServer returns an httptest.Server that responds to engine_getPayloadV4 -// and engine_getPayloadV5 according to the provided handler. The handler receives -// the method name and returns (result JSON, error code, error message). +// fakeEngineServer returns an httptest.Server that responds according to the +// provided handler. The handler receives the full JSON-RPC request and returns +// (result JSON, error code, error message). // If errorCode is 0, a success response is sent. -func fakeEngineServer(t *testing.T, handler func(method string) (resultJSON string, errCode int, errMsg string)) *httptest.Server { +func fakeEngineServer(t *testing.T, handler func(req jsonRPCRequest) (resultJSON string, errCode int, errMsg string)) *httptest.Server { t.Helper() var mu sync.Mutex @@ -41,7 +42,7 @@ func fakeEngineServer(t *testing.T, handler func(method string) (resultJSON stri return } - resultJSON, errCode, errMsg := handler(req.Method) + resultJSON, errCode, errMsg := handler(req) w.Header().Set("Content-Type", "application/json") if errCode != 0 { @@ -86,6 +87,37 @@ const minimalPayloadEnvelopeJSON = `{ "shouldOverrideBuilder": false }` +const ( + validForkchoiceResponseJSON = `{ + "payloadStatus": { + "status": "VALID", + "latestValidHash": null, + "validationError": null + }, + "payloadId": "0x0000000000000001" + }` + validPayloadStatusJSON = `{ + "status": "VALID", + "latestValidHash": null, + "validationError": null + }` + zeroHashHex = "0x0000000000000000000000000000000000000000000000000000000000000000" +) + +func minimalAmsterdamPayloadEnvelopeJSON(t *testing.T) string { + t.Helper() + withAmsterdamFields := strings.Replace( + minimalPayloadEnvelopeJSON, + `"excessBlobGas": "0x0"`, + `"excessBlobGas": "0x0", + "slotNumber": "0x1", + "blockAccessList": ["0x1234"]`, + 1, + ) + require.NotEqual(t, minimalPayloadEnvelopeJSON, withAmsterdamFields) + return withAmsterdamFields +} + func dialTestServer(t *testing.T, serverURL string) *rpc.Client { t.Helper() client, err := rpc.Dial(serverURL) @@ -97,12 +129,12 @@ func TestGetPayload_PragueChain_UsesV4(t *testing.T) { var calledMethods []string var mu sync.Mutex - srv := fakeEngineServer(t, func(method string) (string, int, string) { + srv := fakeEngineServer(t, func(req jsonRPCRequest) (string, int, string) { mu.Lock() - calledMethods = append(calledMethods, method) + calledMethods = append(calledMethods, req.Method) mu.Unlock() - if method == "engine_getPayloadV4" { + if req.Method == getPayloadV4Method { return minimalPayloadEnvelopeJSON, 0, "" } return "", -38005, "Unsupported fork" @@ -117,7 +149,7 @@ func TestGetPayload_PragueChain_UsesV4(t *testing.T) { require.NoError(t, err) mu.Lock() - assert.Equal(t, []string{"engine_getPayloadV4"}, calledMethods, "should call V4 only") + assert.Equal(t, []string{getPayloadV4Method}, calledMethods, "should call V4 only") calledMethods = nil mu.Unlock() @@ -126,7 +158,7 @@ func TestGetPayload_PragueChain_UsesV4(t *testing.T) { require.NoError(t, err) mu.Lock() - assert.Equal(t, []string{"engine_getPayloadV4"}, calledMethods, "should still use V4") + assert.Equal(t, []string{getPayloadV4Method}, calledMethods, "should still use V4") mu.Unlock() } @@ -134,12 +166,12 @@ func TestGetPayload_OsakaChain_FallsBackToV5(t *testing.T) { var calledMethods []string var mu sync.Mutex - srv := fakeEngineServer(t, func(method string) (string, int, string) { + srv := fakeEngineServer(t, func(req jsonRPCRequest) (string, int, string) { mu.Lock() - calledMethods = append(calledMethods, method) + calledMethods = append(calledMethods, req.Method) mu.Unlock() - if method == "engine_getPayloadV5" { + if req.Method == getPayloadV5Method { return minimalPayloadEnvelopeJSON, 0, "" } return "", -38005, "Unsupported fork" @@ -154,7 +186,7 @@ func TestGetPayload_OsakaChain_FallsBackToV5(t *testing.T) { require.NoError(t, err) mu.Lock() - assert.Equal(t, []string{"engine_getPayloadV4", "engine_getPayloadV5"}, calledMethods, + assert.Equal(t, []string{getPayloadV4Method, getPayloadV5Method}, calledMethods, "should try V4 then fall back to V5") calledMethods = nil mu.Unlock() @@ -164,31 +196,68 @@ func TestGetPayload_OsakaChain_FallsBackToV5(t *testing.T) { require.NoError(t, err) mu.Lock() - assert.Equal(t, []string{"engine_getPayloadV5"}, calledMethods, + assert.Equal(t, []string{getPayloadV5Method}, calledMethods, "should use cached V5 without trying V4") mu.Unlock() } +func TestGetPayload_AmsterdamChain_FallsBackToV6(t *testing.T) { + var calledMethods []string + var mu sync.Mutex + + srv := fakeEngineServer(t, func(req jsonRPCRequest) (string, int, string) { + mu.Lock() + calledMethods = append(calledMethods, req.Method) + mu.Unlock() + + if req.Method == getPayloadV6Method { + return minimalAmsterdamPayloadEnvelopeJSON(t), 0, "" + } + return "", -38005, "Unsupported fork" + }) + defer srv.Close() + + client := NewEngineRPCClient(dialTestServer(t, srv.URL)) + ctx := context.Background() + + _, err := client.GetPayload(ctx, engine.PayloadID{}) + require.NoError(t, err) + + mu.Lock() + assert.Equal(t, []string{getPayloadV4Method, getPayloadV5Method, getPayloadV6Method}, calledMethods, + "should try V4, V5, then fall back to V6") + calledMethods = nil + mu.Unlock() + + _, err = client.GetPayload(ctx, engine.PayloadID{}) + require.NoError(t, err) + + mu.Lock() + assert.Equal(t, []string{getPayloadV6Method}, calledMethods, + "should use cached V6 without trying earlier versions") + mu.Unlock() +} + func TestGetPayload_ForkUpgrade_SwitchesV4ToV5(t *testing.T) { var mu sync.Mutex var calledMethods []string osakaActive := false - srv := fakeEngineServer(t, func(method string) (string, int, string) { + srv := fakeEngineServer(t, func(req jsonRPCRequest) (string, int, string) { mu.Lock() - calledMethods = append(calledMethods, method) + calledMethods = append(calledMethods, req.Method) active := osakaActive mu.Unlock() if active { // Post-Osaka: V5 works, V4 rejected - if method == "engine_getPayloadV5" { + if req.Method == getPayloadV5Method { return minimalPayloadEnvelopeJSON, 0, "" } return "", -38005, "Unsupported fork" } // Pre-Osaka: V4 works, V5 rejected - if method == "engine_getPayloadV4" { + if req.Method == getPayloadV4Method { return minimalPayloadEnvelopeJSON, 0, "" } return "", -38005, "Unsupported fork" @@ -203,7 +272,7 @@ func TestGetPayload_ForkUpgrade_SwitchesV4ToV5(t *testing.T) { require.NoError(t, err) mu.Lock() - assert.Equal(t, []string{"engine_getPayloadV4"}, calledMethods, "pre-upgrade should call V4 only") + assert.Equal(t, []string{getPayloadV4Method}, calledMethods, "pre-upgrade should call V4 only") calledMethods = nil mu.Unlock() @@ -217,7 +286,7 @@ func TestGetPayload_ForkUpgrade_SwitchesV4ToV5(t *testing.T) { require.NoError(t, err) mu.Lock() - assert.Equal(t, []string{"engine_getPayloadV4", "engine_getPayloadV5"}, calledMethods, + assert.Equal(t, []string{getPayloadV4Method, getPayloadV5Method}, calledMethods, "first post-upgrade call should try V4 then fall back to V5") calledMethods = nil mu.Unlock() @@ -227,13 +296,20 @@ func TestGetPayload_ForkUpgrade_SwitchesV4ToV5(t *testing.T) { require.NoError(t, err) mu.Lock() - assert.Equal(t, []string{"engine_getPayloadV5"}, calledMethods, + assert.Equal(t, []string{getPayloadV5Method}, calledMethods, "subsequent calls should use cached V5 directly") mu.Unlock() } func TestGetPayload_NonForkError_Propagated(t *testing.T) { - srv := fakeEngineServer(t, func(method string) (string, int, string) { + var calledMethods []string + var mu sync.Mutex + + srv := fakeEngineServer(t, func(req jsonRPCRequest) (string, int, string) { + mu.Lock() + calledMethods = append(calledMethods, req.Method) + mu.Unlock() + // Return a different error (e.g., unknown payload) return "", -38001, "Unknown payload" }) @@ -245,6 +321,172 @@ func TestGetPayload_NonForkError_Propagated(t *testing.T) { _, err := client.GetPayload(ctx, engine.PayloadID{}) require.Error(t, err) assert.Contains(t, err.Error(), "Unknown payload") + + mu.Lock() + assert.Equal(t, []string{getPayloadV4Method}, calledMethods, + "non-fork errors should not fall back to alternate versions") + mu.Unlock() +} + +func TestForkchoiceUpdated_AmsterdamAttributes_UsesV4AndPrefersGetPayloadV6(t *testing.T) { + var calledMethods []string + var mu sync.Mutex + + srv := fakeEngineServer(t, func(req jsonRPCRequest) (string, int, string) { + mu.Lock() + calledMethods = append(calledMethods, req.Method) + mu.Unlock() + + switch req.Method { + case forkchoiceUpdatedV3Method: + require.Len(t, req.Params, 2) + var attrs map[string]json.RawMessage + require.NoError(t, json.Unmarshal(req.Params[1], &attrs)) + require.NotContains(t, attrs, "slotNumber") + return "", -38005, "Unsupported fork" + case forkchoiceUpdatedV4Method: + require.Len(t, req.Params, 2) + var attrs map[string]json.RawMessage + require.NoError(t, json.Unmarshal(req.Params[1], &attrs)) + require.Contains(t, attrs, "slotNumber") + return validForkchoiceResponseJSON, 0, "" + case getPayloadV6Method: + return minimalAmsterdamPayloadEnvelopeJSON(t), 0, "" + default: + return "", -38005, "Unsupported fork" + } + }) + defer srv.Close() + + client := NewEngineRPCClient(dialTestServer(t, srv.URL)) + ctx := context.Background() + + _, err := client.ForkchoiceUpdated(ctx, engine.ForkchoiceStateV1{}, map[string]any{ + "timestamp": uint64(1), + "slotNumber": uint64(1), + }) + require.NoError(t, err) + + _, err = client.GetPayload(ctx, engine.PayloadID{}) + require.NoError(t, err) + + mu.Lock() + assert.Equal(t, []string{forkchoiceUpdatedV3Method, forkchoiceUpdatedV4Method, getPayloadV6Method}, calledMethods) + mu.Unlock() +} + +func TestForkchoiceUpdated_PragueAttributes_UsesV3AndStripsSlotNumber(t *testing.T) { + var calledMethods []string + var mu sync.Mutex + + srv := fakeEngineServer(t, func(req jsonRPCRequest) (string, int, string) { + mu.Lock() + calledMethods = append(calledMethods, req.Method) + mu.Unlock() + + if req.Method != forkchoiceUpdatedV3Method { + return "", -38005, "Unsupported fork" + } + require.Len(t, req.Params, 2) + var attrs map[string]json.RawMessage + require.NoError(t, json.Unmarshal(req.Params[1], &attrs)) + require.NotContains(t, attrs, "slotNumber") + return validForkchoiceResponseJSON, 0, "" + }) + defer srv.Close() + + client := NewEngineRPCClient(dialTestServer(t, srv.URL)) + _, err := client.ForkchoiceUpdated(context.Background(), engine.ForkchoiceStateV1{}, map[string]any{ + "timestamp": uint64(1), + "slotNumber": uint64(1), + }) + require.NoError(t, err) + + mu.Lock() + assert.Equal(t, []string{forkchoiceUpdatedV3Method}, calledMethods) + mu.Unlock() +} + +func TestNewPayload_PraguePayload_UsesV4(t *testing.T) { + var calledMethods []string + var mu sync.Mutex + + srv := fakeEngineServer(t, func(req jsonRPCRequest) (string, int, string) { + mu.Lock() + calledMethods = append(calledMethods, req.Method) + mu.Unlock() + + if req.Method != newPayloadV4Method { + return "", -38005, "Unsupported fork" + } + + require.Len(t, req.Params, 4) + var payload map[string]json.RawMessage + require.NoError(t, json.Unmarshal(req.Params[0], &payload)) + require.NotContains(t, payload, "blockAccessList") + return validPayloadStatusJSON, 0, "" + }) + defer srv.Close() + + var envelope EnginePayloadEnvelope + require.NoError(t, json.Unmarshal([]byte(minimalPayloadEnvelopeJSON), &envelope)) + + client := NewEngineRPCClient(dialTestServer(t, srv.URL)) + _, err := client.NewPayload(context.Background(), &envelope, []string{}, zeroHashHex, envelope.Requests) + require.NoError(t, err) + + mu.Lock() + assert.Equal(t, []string{newPayloadV4Method}, calledMethods) + mu.Unlock() +} + +func TestNewPayload_AmsterdamPayload_UsesV5AndPreservesBlockAccessList(t *testing.T) { + var calledMethods []string + var mu sync.Mutex + + srv := fakeEngineServer(t, func(req jsonRPCRequest) (string, int, string) { + mu.Lock() + calledMethods = append(calledMethods, req.Method) + mu.Unlock() + + switch req.Method { + case forkchoiceUpdatedV3Method: + return "", -38005, "Unsupported fork" + case forkchoiceUpdatedV4Method: + return validForkchoiceResponseJSON, 0, "" + case getPayloadV6Method: + return minimalAmsterdamPayloadEnvelopeJSON(t), 0, "" + case newPayloadV5Method: + require.Len(t, req.Params, 4) + var payload map[string]json.RawMessage + require.NoError(t, json.Unmarshal(req.Params[0], &payload)) + require.Contains(t, payload, "slotNumber") + require.Contains(t, payload, "blockAccessList") + require.JSONEq(t, `["0x1234"]`, string(payload["blockAccessList"])) + return validPayloadStatusJSON, 0, "" + default: + return "", -38005, "Unsupported fork" + } + }) + defer srv.Close() + + client := NewEngineRPCClient(dialTestServer(t, srv.URL)) + ctx := context.Background() + + _, err := client.ForkchoiceUpdated(ctx, engine.ForkchoiceStateV1{}, map[string]any{ + "slotNumber": uint64(1), + }) + require.NoError(t, err) + + payload, err := client.GetPayload(ctx, engine.PayloadID{}) + require.NoError(t, err) + + _, err = client.NewPayload(ctx, payload, []string{}, zeroHashHex, payload.Requests) + require.NoError(t, err) + + mu.Lock() + assert.Equal(t, []string{forkchoiceUpdatedV3Method, forkchoiceUpdatedV4Method, getPayloadV6Method, newPayloadV5Method}, calledMethods) + mu.Unlock() } func TestIsUnsupportedForkErr(t *testing.T) { diff --git a/execution/evm/engine_rpc_tracing.go b/execution/evm/engine_rpc_tracing.go index 5bba9564fd..5459761dd4 100644 --- a/execution/evm/engine_rpc_tracing.go +++ b/execution/evm/engine_rpc_tracing.go @@ -29,7 +29,6 @@ func withTracingEngineRPCClient(inner EngineRPCClient) EngineRPCClient { func (t *tracedEngineRPCClient) ForkchoiceUpdated(ctx context.Context, state engine.ForkchoiceStateV1, args map[string]any) (*engine.ForkChoiceResponse, error) { ctx, span := t.tracer.Start(ctx, "Engine.ForkchoiceUpdated", trace.WithAttributes( - attribute.String("method", "engine_forkchoiceUpdatedV3"), attribute.String("head_block_hash", state.HeadBlockHash.Hex()), attribute.String("safe_block_hash", state.SafeBlockHash.Hex()), attribute.String("finalized_block_hash", state.FinalizedBlockHash.Hex()), @@ -38,6 +37,11 @@ func (t *tracedEngineRPCClient) ForkchoiceUpdated(ctx context.Context, state eng defer span.End() result, err := t.inner.ForkchoiceUpdated(ctx, state, args) + + if m, ok := t.inner.(forkchoiceMethodGetter); ok { + span.SetAttributes(attribute.String("method", m.GetForkchoiceUpdatedMethod())) + } + if err != nil { span.RecordError(err) span.SetStatus(codes.Error, err.Error()) @@ -63,13 +67,19 @@ func (t *tracedEngineRPCClient) ForkchoiceUpdated(ctx context.Context, state eng return result, nil } +// forkchoiceMethodGetter is implemented by engineRPCClient to expose the +// resolved forkchoiceUpdated Engine API method name (V3 or V4) for tracing. +type forkchoiceMethodGetter interface { + GetForkchoiceUpdatedMethod() string +} + // payloadMethodGetter is implemented by engineRPCClient to expose the resolved -// GetPayload Engine API method name (V4 or V5) for tracing. +// GetPayload Engine API method name (V4, V5, or V6) for tracing. type payloadMethodGetter interface { GetPayloadMethod() string } -func (t *tracedEngineRPCClient) GetPayload(ctx context.Context, payloadID engine.PayloadID) (*engine.ExecutionPayloadEnvelope, error) { +func (t *tracedEngineRPCClient) GetPayload(ctx context.Context, payloadID engine.PayloadID) (*EnginePayloadEnvelope, error) { ctx, span := t.tracer.Start(ctx, "Engine.GetPayload", trace.WithAttributes( attribute.String("payload_id", payloadID.String()), @@ -101,15 +111,16 @@ func (t *tracedEngineRPCClient) GetPayload(ctx context.Context, payloadID engine return result, nil } -func (t *tracedEngineRPCClient) NewPayload(ctx context.Context, payload *engine.ExecutableData, blobHashes []string, parentBeaconBlockRoot string, executionRequests [][]byte) (*engine.PayloadStatusV1, error) { +func (t *tracedEngineRPCClient) NewPayload(ctx context.Context, payload *EnginePayloadEnvelope, blobHashes []string, parentBeaconBlockRoot string, executionRequests [][]byte) (*engine.PayloadStatusV1, error) { + executionPayload := payload.ExecutionPayload ctx, span := t.tracer.Start(ctx, "Engine.NewPayload", trace.WithAttributes( - attribute.String("method", "engine_newPayloadV4"), - attribute.Int64("block_number", int64(payload.Number)), - attribute.String("block_hash", payload.BlockHash.Hex()), - attribute.String("parent_hash", payload.ParentHash.Hex()), - attribute.Int("tx_count", len(payload.Transactions)), - attribute.Int64("gas_used", int64(payload.GasUsed)), + attribute.String("method", newPayloadMethod(payload)), + attribute.Int64("block_number", int64(executionPayload.Number)), + attribute.String("block_hash", executionPayload.BlockHash.Hex()), + attribute.String("parent_hash", executionPayload.ParentHash.Hex()), + attribute.Int("tx_count", len(executionPayload.Transactions)), + attribute.Int64("gas_used", int64(executionPayload.GasUsed)), ), ) defer span.End() diff --git a/execution/evm/execution.go b/execution/evm/execution.go index 3067893360..7168c04a26 100644 --- a/execution/evm/execution.go +++ b/execution/evm/execution.go @@ -16,6 +16,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/core/types" "github.com/ethereum/go-ethereum/ethclient" "github.com/ethereum/go-ethereum/rpc" @@ -147,10 +148,10 @@ type EngineRPCClient interface { ForkchoiceUpdated(ctx context.Context, state engine.ForkchoiceStateV1, args map[string]any) (*engine.ForkChoiceResponse, error) // GetPayload retrieves a previously requested execution payload. - GetPayload(ctx context.Context, payloadID engine.PayloadID) (*engine.ExecutionPayloadEnvelope, error) + GetPayload(ctx context.Context, payloadID engine.PayloadID) (*EnginePayloadEnvelope, error) // NewPayload submits a new execution payload for validation. - NewPayload(ctx context.Context, payload *engine.ExecutableData, blobHashes []string, parentBeaconBlockRoot string, executionRequests [][]byte) (*engine.PayloadStatusV1, error) + NewPayload(ctx context.Context, payload *EnginePayloadEnvelope, blobHashes []string, parentBeaconBlockRoot string, executionRequests [][]byte) (*engine.PayloadStatusV1, error) } // EthRPCClient abstracts Ethereum JSON-RPC calls for tracing and testing. @@ -287,7 +288,7 @@ func (c *EngineClient) InitChain(ctx context.Context, genesisTime time.Time, ini nil, ) if err != nil { - return fmt.Errorf("engine_forkchoiceUpdatedV3 failed: %w", err) + return fmt.Errorf("forkchoice update failed: %w", err) } // Validate payload status @@ -296,7 +297,7 @@ func (c *EngineClient) InitChain(ctx context.Context, genesisTime time.Time, ini Str("status", forkchoiceResult.PayloadStatus.Status). Str("latestValidHash", latestValidHashHex(forkchoiceResult.PayloadStatus.LatestValidHash)). Interface("validationError", forkchoiceResult.PayloadStatus.ValidationError). - Msg("InitChain: engine_forkchoiceUpdatedV3 returned non-VALID status") + Msg("InitChain: forkchoice update returned non-VALID status") return err } @@ -388,24 +389,12 @@ func (c *EngineClient) ExecuteTxs(ctx context.Context, txs [][]byte, blockHeight c.mu.Unlock() // update forkchoice to get the next payload id - // Create evolve-compatible payloadtimestamp.Unix() - evPayloadAttrs := map[string]any{ - // Standard Ethereum payload attributes (flattened) - using camelCase as expected by JSON - "timestamp": timestamp.Unix(), - "prevRandao": c.derivePrevRandao(blockHeight), - "suggestedFeeRecipient": c.feeRecipient, - "withdrawals": []*types.Withdrawal{}, - // V3 requires parentBeaconBlockRoot - "parentBeaconBlockRoot": common.Hash{}.Hex(), // Use zero hash for evolve - // evolve-specific fields - "transactions": txsPayload, - "gasLimit": prevGasLimit, // Use camelCase to match JSON conventions - } + evPayloadAttrs := c.buildPayloadAttributes(txsPayload, blockHeight, timestamp, prevGasLimit) c.logger.Debug(). Uint64("height", blockHeight). Int("tx_count", len(txs)). - Msg("engine_forkchoiceUpdatedV3") + Msg("engine_forkchoiceUpdated") // 3. Call forkchoice update to get PayloadID var newPayloadID *engine.PayloadID @@ -422,7 +411,7 @@ func (c *EngineClient) ExecuteTxs(ctx context.Context, txs [][]byte, blockHeight Str("latestValidHash", latestValidHashHex(forkchoiceResult.PayloadStatus.LatestValidHash)). Interface("validationError", forkchoiceResult.PayloadStatus.ValidationError). Uint64("blockHeight", blockHeight). - Msg("ExecuteTxs: engine_forkchoiceUpdatedV3 returned non-VALID status") + Msg("ExecuteTxs: forkchoice update returned non-VALID status") return err } @@ -572,7 +561,7 @@ func (c *EngineClient) doForkchoiceUpdate(ctx context.Context, args engine.Forkc Str("latestValidHash", latestValidHashHex(forkchoiceResult.PayloadStatus.LatestValidHash)). Interface("validationError", forkchoiceResult.PayloadStatus.ValidationError). Str("operation", operation). - Msg("forkchoiceUpdatedV3 returned non-VALID status") + Msg("forkchoice update returned non-VALID status") return err } return nil @@ -963,12 +952,13 @@ func (c *EngineClient) processPayload(ctx context.Context, payloadID engine.Payl blockTimestamp := int64(payloadResult.ExecutionPayload.Timestamp) // 2. Submit Payload (newPayload) + selectedNewPayloadMethod := newPayloadMethod(payloadResult) err = retryWithBackoffOnPayloadStatus(ctx, func() error { newPayloadResult, err := c.engineClient.NewPayload(ctx, - payloadResult.ExecutionPayload, + payloadResult, []string{}, // No blob hashes common.Hash{}.Hex(), // Use zero hash for parentBeaconBlockRoot - [][]byte{}, // No execution requests + payloadResult.Requests, ) if err != nil { return fmt.Errorf("new payload submission failed: %w", err) @@ -976,11 +966,12 @@ func (c *EngineClient) processPayload(ctx context.Context, payloadID engine.Payl if err := validatePayloadStatus(*newPayloadResult); err != nil { c.logger.Warn(). + Str("method", selectedNewPayloadMethod). Str("status", newPayloadResult.Status). Str("latestValidHash", latestValidHashHex(newPayloadResult.LatestValidHash)). Interface("validationError", newPayloadResult.ValidationError). Uint64("blockHeight", blockHeight). - Msg("processPayload: engine_newPayloadV4 returned non-VALID status") + Msg("processPayload: new payload returned non-VALID status") return err } return nil @@ -1008,6 +999,29 @@ func (c *EngineClient) derivePrevRandao(blockHeight uint64) common.Hash { return common.BigToHash(new(big.Int).SetUint64(blockHeight)) } +func (c *EngineClient) buildPayloadAttributes(txsPayload []string, blockHeight uint64, timestamp time.Time, prevGasLimit uint64) map[string]any { + attrs := map[string]any{ + // Standard Ethereum payload attributes (flattened) - using camelCase as expected by JSON + "timestamp": timestamp.Unix(), + "prevRandao": c.derivePrevRandao(blockHeight), + "suggestedFeeRecipient": c.feeRecipient, + "withdrawals": []*types.Withdrawal{}, + "parentBeaconBlockRoot": common.Hash{}.Hex(), // Use zero hash for evolve + // evolve-specific fields + "transactions": txsPayload, + "gasLimit": prevGasLimit, + // slotNumber is included as a local candidate for Amsterdam. The RPC + // client strips it from V3 requests and keeps it on V4 retries. + "slotNumber": hexutil.Uint64(c.deriveSlotNumber(blockHeight)), + } + + return attrs +} + +func (c *EngineClient) deriveSlotNumber(blockHeight uint64) uint64 { + return blockHeight +} + func (c *EngineClient) getBlockInfo(ctx context.Context, height uint64) (common.Hash, common.Hash, uint64, uint64, error) { header, err := c.ethClient.HeaderByNumber(ctx, new(big.Int).SetUint64(height)) diff --git a/execution/evm/execution_payload_attrs_test.go b/execution/evm/execution_payload_attrs_test.go new file mode 100644 index 0000000000..8cb949cdd3 --- /dev/null +++ b/execution/evm/execution_payload_attrs_test.go @@ -0,0 +1,16 @@ +package evm + +import ( + "testing" + "time" + + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/stretchr/testify/require" +) + +func TestBuildPayloadAttributes_AddsSlotNumberCandidate(t *testing.T) { + client := &EngineClient{} + + attrs := client.buildPayloadAttributes(nil, 12, time.Unix(1_700_000_000, 0), 30_000_000) + require.Equal(t, hexutil.Uint64(12), attrs["slotNumber"]) +} diff --git a/execution/evm/execution_reconcile_test.go b/execution/evm/execution_reconcile_test.go index 7d1243f81b..eef2d4373d 100644 --- a/execution/evm/execution_reconcile_test.go +++ b/execution/evm/execution_reconcile_test.go @@ -66,7 +66,7 @@ func TestReconcileExecutionAtHeight_StartedExecMeta(t *testing.T) { })) engineRPC := &mockReconcileEngineRPCClient{ - payloads: map[engine.PayloadID]*engine.ExecutionPayloadEnvelope{ + payloads: map[engine.PayloadID]*EnginePayloadEnvelope{ payloadID: {}, }, } @@ -92,7 +92,7 @@ func TestReconcileExecutionAtHeight_StartedExecMeta(t *testing.T) { } type mockReconcileEngineRPCClient struct { - payloads map[engine.PayloadID]*engine.ExecutionPayloadEnvelope + payloads map[engine.PayloadID]*EnginePayloadEnvelope getPayloadCalls int } @@ -100,7 +100,7 @@ func (m *mockReconcileEngineRPCClient) ForkchoiceUpdated(_ context.Context, _ en return nil, errors.New("unexpected ForkchoiceUpdated call") } -func (m *mockReconcileEngineRPCClient) GetPayload(_ context.Context, payloadID engine.PayloadID) (*engine.ExecutionPayloadEnvelope, error) { +func (m *mockReconcileEngineRPCClient) GetPayload(_ context.Context, payloadID engine.PayloadID) (*EnginePayloadEnvelope, error) { m.getPayloadCalls++ payload, ok := m.payloads[payloadID] if !ok { @@ -110,7 +110,7 @@ func (m *mockReconcileEngineRPCClient) GetPayload(_ context.Context, payloadID e return payload, nil } -func (m *mockReconcileEngineRPCClient) NewPayload(_ context.Context, _ *engine.ExecutableData, _ []string, _ string, _ [][]byte) (*engine.PayloadStatusV1, error) { +func (m *mockReconcileEngineRPCClient) NewPayload(_ context.Context, _ *EnginePayloadEnvelope, _ []string, _ string, _ [][]byte) (*engine.PayloadStatusV1, error) { return nil, errors.New("unexpected NewPayload call") } diff --git a/execution/evm/test/test_helpers.go b/execution/evm/test/test_helpers.go index 64ff6cafa8..6cd7e279af 100644 --- a/execution/evm/test/test_helpers.go +++ b/execution/evm/test/test_helpers.go @@ -151,7 +151,20 @@ func getGenesisHash(client *http.Client, ethURL string) (string, error) { } func waitForEngineForkchoice(client *http.Client, engineURL, authToken, genesisHash string) error { - body := fmt.Sprintf(`{"jsonrpc":"2.0","method":"engine_forkchoiceUpdatedV3","params":[{"headBlockHash":%q,"safeBlockHash":%q,"finalizedBlockHash":%q},null],"id":1}`, genesisHash, genesisHash, genesisHash) + methods := []string{"engine_forkchoiceUpdatedV3", "engine_forkchoiceUpdatedV4"} + var lastErr error + for _, method := range methods { + if err := waitForEngineForkchoiceMethod(client, engineURL, authToken, genesisHash, method); err == nil { + return nil + } else { + lastErr = err + } + } + return lastErr +} + +func waitForEngineForkchoiceMethod(client *http.Client, engineURL, authToken, genesisHash, method string) error { + body := fmt.Sprintf(`{"jsonrpc":"2.0","method":%q,"params":[{"headBlockHash":%q,"safeBlockHash":%q,"finalizedBlockHash":%q},null],"id":1}`, method, genesisHash, genesisHash, genesisHash) req, err := http.NewRequest("POST", engineURL, strings.NewReader(body)) if err != nil { return err @@ -181,7 +194,7 @@ func waitForEngineForkchoice(client *http.Client, engineURL, authToken, genesisH return fmt.Errorf("decode engine forkchoice response: %w", err) } if rpcResp.Error != nil { - return fmt.Errorf("engine_forkchoiceUpdatedV3 failed: %s", rpcResp.Error.Message) + return fmt.Errorf("%s failed: %s", method, rpcResp.Error.Message) } if rpcResp.Result.PayloadStatus.Status != "VALID" { return fmt.Errorf("engine forkchoice status %s", rpcResp.Result.PayloadStatus.Status)