diff --git a/docs/ev-reth/engine-api.md b/docs/ev-reth/engine-api.md index 2f8f078513..f520da909e 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", + [] ] } ``` @@ -155,6 +177,19 @@ Validate and execute a payload. } ``` +## Amsterdam Enablement Checklist + +Before setting `amsterdamTime` in an ev-reth chainspec, verify: + +- [ ] 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 and logs report the selected Engine API version. +- [ ] Unit tests cover V4/V5/V6 version selection and `blockAccessList` passthrough. +- [ ] E2E tests pass against an Amsterdam-enabled ev-reth chainspec. + ## Status Codes | Status | Meaning | 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..ef68f88ceb --- /dev/null +++ b/execution/evm/engine_payload.go @@ -0,0 +1,170 @@ +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 + + // rawHasAmsterdamFields caches whether RawExecutionPayload contains + // slotNumber or blockAccessList. Set during UnmarshalJSON so version + // selection does not re-parse the raw payload on every call. + rawHasAmsterdamFields bool +} + +func (e *EnginePayloadEnvelope) UnmarshalJSON(input []byte) error { + type executionPayloadEnvelope struct { + ExecutionPayload json.RawMessage `json:"executionPayload"` + BlockValue *hexutil.Big `json:"blockValue"` + BlobsBundle *engine.BlobsBundle `json:"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 amsterdamProbe struct { + SlotNumber json.RawMessage `json:"slotNumber"` + BlockAccessList json.RawMessage `json:"blockAccessList"` + } + if err := json.Unmarshal(dec.ExecutionPayload, &amsterdamProbe); err != nil { + return err + } + + // Execution clients disagree on the blockAccessList JSON schema (reth + // encodes the raw access-list bytes as a hex string, go-ethereum uses + // structured objects). Strip the field before the typed decode so either + // shape is accepted; RawExecutionPayload keeps it for passthrough. + typedPayload := dec.ExecutionPayload + if amsterdamProbe.BlockAccessList != nil { + var fields map[string]json.RawMessage + if err := json.Unmarshal(dec.ExecutionPayload, &fields); err != nil { + return err + } + delete(fields, "blockAccessList") + sanitized, err := json.Marshal(fields) + if err != nil { + return err + } + typedPayload = sanitized + } + + var payload engine.ExecutableData + if err := json.Unmarshal(typedPayload, &payload); err != nil { + return err + } + e.ExecutionPayload = &payload + e.RawExecutionPayload = append(e.RawExecutionPayload[:0], dec.ExecutionPayload...) + e.rawHasAmsterdamFields = amsterdamProbe.SlotNumber != nil || amsterdamProbe.BlockAccessList != nil + + 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 `json:"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 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 e.rawHasAmsterdamFields +} diff --git a/execution/evm/engine_rpc_client.go b/execution/evm/engine_rpc_client.go index 536bf805e3..acb73d3664 100644 --- a/execution/evm/engine_rpc_client.go +++ b/execution/evm/engine_rpc_client.go @@ -7,6 +7,7 @@ import ( "sync/atomic" "github.com/ethereum/go-ethereum/beacon/engine" + "github.com/ethereum/go-ethereum/common/hexutil" "github.com/ethereum/go-ethereum/rpc" ) @@ -16,21 +17,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,61 +63,194 @@ 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) - if err != nil { - return nil, err + 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 { + if len(unsupportedMethods) > 0 { + return nil, fmt.Errorf("fallback after unsupported fork from %v: %w", unsupportedMethods, err) + } + 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 &result, nil + + return nil, fmt.Errorf("forkchoice update unsupported fork after trying %v: %w", unsupportedMethods, lastUnsupportedErr) } -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) GetPayload(ctx context.Context, payloadID engine.PayloadID) (*EnginePayloadEnvelope, error) { + preferredVersion := e.preferredPayloadVersion() + versions := getPayloadFallbackOrder(preferredVersion) - var result engine.ExecutionPayloadEnvelope - err := e.client.CallContext(ctx, &result, method, payloadID) - if err == nil { - return &result, nil - } + var unsupportedMethods []string + var lastUnsupportedErr error + for _, version := range versions { + method := version.getPayloadMethod() - if !isUnsupportedForkErr(err) { - return nil, fmt.Errorf("%s payload %s: %w", method, payloadID, err) - } + var result EnginePayloadEnvelope + err := e.client.CallContext(ctx, &result, method, payloadID) + if err == nil { + e.payloadVersion.Store(uint32(version)) + return &result, nil + } - // 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) + 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 } - // The alt method worked -- cache it for future calls. - e.useV5.Store(altMethod == getPayloadV5Method) - return &result, nil + 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 { - if e.useV5.Load() { - return getPayloadV5Method - } - return getPayloadV4Method + return e.preferredPayloadVersion().getPayloadMethod() } -func (e *engineRPCClient) NewPayload(ctx context.Context, payload *engine.ExecutableData, blobHashes []string, parentBeaconBlockRoot string, executionRequests [][]byte) (*engine.PayloadStatusV1, error) { +// 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 + } + + // The Engine API encodes execution requests as hex strings. Raw [][]byte + // would JSON-encode as base64, and a nil slice as null instead of []. + requests := make([]hexutil.Bytes, len(executionRequests)) + for i, request := range executionRequests { + requests[i] = request + } + var result engine.PayloadStatusV1 - err := e.client.CallContext(ctx, &result, "engine_newPayloadV4", payload, blobHashes, parentBeaconBlockRoot, executionRequests) + err = e.client.CallContext(ctx, &result, newPayloadMethod(payload), payloadParam, blobHashes, parentBeaconBlockRoot, requests) if err != nil { return nil, err } return &result, nil } +func (e *engineRPCClient) preferredPayloadVersion() enginePayloadVersion { + switch version := enginePayloadVersion(e.payloadVersion.Load()); version { + case enginePayloadVersionV5, enginePayloadVersionV6: + return version + default: + return enginePayloadVersionV4 + } +} + +func (v enginePayloadVersion) getPayloadMethod() string { + switch v { + case enginePayloadVersionV5: + return getPayloadV5Method + case enginePayloadVersionV6: + return getPayloadV6Method + default: + return getPayloadV4Method + } +} + +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} + } +} + +func (e *engineRPCClient) preferredForkchoiceVersion() engineForkchoiceVersion { + switch version := engineForkchoiceVersion(e.forkchoiceVersion.Load()); version { + case engineForkchoiceVersionV4: + return version + default: + return engineForkchoiceVersionV3 + } +} + +func (v engineForkchoiceVersion) forkchoiceUpdatedMethod() string { + if v == engineForkchoiceVersionV4 { + return forkchoiceUpdatedV4Method + } + return forkchoiceUpdatedV3Method +} + +func forkchoiceFallbackOrder(preferred engineForkchoiceVersion) []engineForkchoiceVersion { + if preferred == engineForkchoiceVersionV4 { + return []engineForkchoiceVersion{engineForkchoiceVersionV4, engineForkchoiceVersionV3} + } + return []engineForkchoiceVersion{engineForkchoiceVersionV3, engineForkchoiceVersionV4} +} + +func forkchoiceArgsForMethod(args map[string]any, method string) (map[string]any, error) { + if args == nil { + return nil, 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" // JSON-RPC error (code -38005). func isUnsupportedForkErr(err error) bool { diff --git a/execution/evm/engine_rpc_client_test.go b/execution/evm/engine_rpc_client_test.go index f13270eec6..6b39961fd9 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,44 @@ const minimalPayloadEnvelopeJSON = `{ "shouldOverrideBuilder": false }` +const ( + validForkchoiceResponseJSON = `{ + "payloadStatus": { + "status": "VALID", + "latestValidHash": null, + "validationError": null + }, + "payloadId": "0x0000000000000001" + }` + validPayloadStatusJSON = `{ + "status": "VALID", + "latestValidHash": null, + "validationError": null + }` + zeroHashHex = "0x0000000000000000000000000000000000000000000000000000000000000000" +) + +// minimalBlockAccessListJSON matches ev-reth's wire encoding of +// executionPayload.blockAccessList in engine_getPayloadV6 responses: the +// encoded access list as a hex string. go-ethereum instead models the field as +// structured JSON objects; ev-node must accept either shape and pass it +// through unchanged (see TestEnginePayloadEnvelope_StructuredBlockAccessList). +const minimalBlockAccessListJSON = `"0x1234"` + +func minimalAmsterdamPayloadEnvelopeJSON(t *testing.T) string { + t.Helper() + withAmsterdamFields := strings.Replace( + minimalPayloadEnvelopeJSON, + `"excessBlobGas": "0x0"`, + `"excessBlobGas": "0x0", + "slotNumber": "0x1", + "blockAccessList": `+minimalBlockAccessListJSON, + 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 +136,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 +156,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 +165,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 +173,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 +193,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 +203,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 +279,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 +293,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 +303,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 +328,249 @@ 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, minimalBlockAccessListJSON, 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 TestNewPayload_ExecutionRequests_EncodedAsHex(t *testing.T) { + srv := fakeEngineServer(t, func(req jsonRPCRequest) (string, int, string) { + require.Equal(t, newPayloadV4Method, req.Method) + require.Len(t, req.Params, 4) + require.JSONEq(t, `["0x1234", "0x01"]`, string(req.Params[3])) + 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, + [][]byte{{0x12, 0x34}, {0x01}}) + require.NoError(t, err) +} + +func TestNewPayload_NilExecutionRequests_EncodedAsEmptyArray(t *testing.T) { + srv := fakeEngineServer(t, func(req jsonRPCRequest) (string, int, string) { + require.Equal(t, newPayloadV4Method, req.Method) + require.Len(t, req.Params, 4) + require.JSONEq(t, `[]`, string(req.Params[3])) + 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, nil) + require.NoError(t, err) +} + +func TestEnginePayloadEnvelope_StructuredBlockAccessList(t *testing.T) { + structured := strings.Replace( + minimalAmsterdamPayloadEnvelopeJSON(t), + `"blockAccessList": `+minimalBlockAccessListJSON, + `"blockAccessList": [{"address": "0x00000000000000000000000000000000000000aa"}]`, + 1, + ) + + var envelope EnginePayloadEnvelope + require.NoError(t, json.Unmarshal([]byte(structured), &envelope)) + require.True(t, envelope.hasAmsterdamFields()) + + payload, err := envelope.executionPayloadParam() + require.NoError(t, err) + raw, ok := payload.(json.RawMessage) + require.True(t, ok) + + var fields map[string]json.RawMessage + require.NoError(t, json.Unmarshal(raw, &fields)) + require.JSONEq(t, `[{"address": "0x00000000000000000000000000000000000000aa"}]`, string(fields["blockAccessList"])) +} + +func TestEnginePayloadEnvelope_MarshalJSON_UsesWireFieldNames(t *testing.T) { + var envelope EnginePayloadEnvelope + require.NoError(t, json.Unmarshal([]byte(minimalAmsterdamPayloadEnvelopeJSON(t)), &envelope)) + + encoded, err := json.Marshal(&envelope) + require.NoError(t, err) + + var fields map[string]json.RawMessage + require.NoError(t, json.Unmarshal(encoded, &fields)) + for _, key := range []string{"executionPayload", "blockValue", "blobsBundle", "executionRequests", "shouldOverrideBuilder"} { + require.Contains(t, fields, key) + } + + // Round trip must preserve Amsterdam fields and version selection. + var decoded EnginePayloadEnvelope + require.NoError(t, json.Unmarshal(encoded, &decoded)) + require.True(t, decoded.hasAmsterdamFields()) + require.JSONEq(t, string(envelope.RawExecutionPayload), string(decoded.RawExecutionPayload)) } 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 adfed592af..0b64e91e72 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. @@ -290,7 +291,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 @@ -299,7 +300,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 } @@ -395,24 +396,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 @@ -429,7 +418,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 } @@ -583,7 +572,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 @@ -1023,12 +1012,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) @@ -1036,11 +1026,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 @@ -1068,6 +1059,34 @@ 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 +} + +// deriveSlotNumber returns the slotNumber payload attribute required by +// engine_forkchoiceUpdatedV4 (Amsterdam). ev-node has no beacon slots, so the +// rollup block height serves as the deterministic slot number. This is a wire +// contract with ev-reth's Amsterdam chainspec handling and must not change +// once the fork is enabled on a live chain. +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 7b4dea78fd..9185d6da56 100644 --- a/execution/evm/execution_reconcile_test.go +++ b/execution/evm/execution_reconcile_test.go @@ -67,7 +67,7 @@ func TestReconcileExecutionAtHeight_StartedExecMeta(t *testing.T) { })) engineRPC := &mockReconcileEngineRPCClient{ - payloads: map[engine.PayloadID]*engine.ExecutionPayloadEnvelope{ + payloads: map[engine.PayloadID]*EnginePayloadEnvelope{ payloadID: {}, }, } @@ -93,7 +93,7 @@ func TestReconcileExecutionAtHeight_StartedExecMeta(t *testing.T) { } type mockReconcileEngineRPCClient struct { - payloads map[engine.PayloadID]*engine.ExecutionPayloadEnvelope + payloads map[engine.PayloadID]*EnginePayloadEnvelope getPayloadCalls int } @@ -101,7 +101,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 { @@ -111,7 +111,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/proposer_test.go b/execution/evm/proposer_test.go index 46d8d5f3a5..498bb6da06 100644 --- a/execution/evm/proposer_test.go +++ b/execution/evm/proposer_test.go @@ -121,10 +121,10 @@ func (proposerEngineRPCClient) ForkchoiceUpdated(context.Context, engine.Forkcho }, nil } -func (proposerEngineRPCClient) GetPayload(context.Context, engine.PayloadID) (*engine.ExecutionPayloadEnvelope, error) { +func (proposerEngineRPCClient) GetPayload(context.Context, engine.PayloadID) (*EnginePayloadEnvelope, error) { return nil, nil } -func (proposerEngineRPCClient) NewPayload(context.Context, *engine.ExecutableData, []string, string, [][]byte) (*engine.PayloadStatusV1, error) { +func (proposerEngineRPCClient) NewPayload(context.Context, *EnginePayloadEnvelope, []string, string, [][]byte) (*engine.PayloadStatusV1, error) { return nil, nil } diff --git a/execution/evm/test/engine_fork_upgrade_test.go b/execution/evm/test/engine_fork_upgrade_test.go new file mode 100644 index 0000000000..c5190d2875 --- /dev/null +++ b/execution/evm/test/engine_fork_upgrade_test.go @@ -0,0 +1,293 @@ +//go:build evm + +package test + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "net/url" + "os" + "strings" + "sync" + "testing" + "time" + + tastoradocker "github.com/celestiaorg/tastora/framework/docker" + "github.com/celestiaorg/tastora/framework/docker/container" + "github.com/celestiaorg/tastora/framework/docker/evstack/reth" + "github.com/ethereum/go-ethereum/common" + ds "github.com/ipfs/go-datastore" + dssync "github.com/ipfs/go-datastore/sync" + "github.com/rs/zerolog" + "github.com/stretchr/testify/require" + + "github.com/evstack/ev-node/execution/evm" +) + +const ( + forkCyclePragueTimestamp = uint64(12) + forkCycleOsakaTimestamp = uint64(24) + forkCycleAmsterdamTimestamp = uint64(36) + + // defaultEvRethForkTag pins the ev-reth image built from + // https://github.com/evstack/ev-reth/pull/266 (reth 2.3), the first build + // with Amsterdam Engine API support. Replace with a release tag once that + // PR ships. EV_RETH_IMAGE_REPO/EV_RETH_TAG override this. + defaultEvRethForkTag = "pr-266" +) + +// TestEngineAPIForkUpgradeCycleE2E drives ev-node's EngineClient against a real +// ev-reth Docker node whose chainspec activates Osaka and Amsterdam at fixed +// payload timestamps. An Engine RPC proxy records the method versions used by +// ev-node, so this verifies the V4 -> V5 -> V6 upgrade path instead of only +// asserting that blocks are produced. +// +// The ghcr.io/evstack/ev-reth image tagged defaultEvRethForkTag is used by +// default. Set EV_RETH_TAG, or set EV_RETH_IMAGE_REPO with optional EV_RETH_TAG, +// to point at a local or registry image. +func TestEngineAPIForkUpgradeCycleE2E(t *testing.T) { + if testing.Short() { + t.Skip("skipping due to short mode") + } + + rethImageOpt := rethImageOptFromEnv() + ctx := context.Background() + dockerClient, networkID := tastoradocker.Setup(t) + + genesis := reth.DefaultEvolveGenesisJSON(withEngineForkTimes(t, forkCycleOsakaTimestamp, forkCycleAmsterdamTimestamp)) + rethNode := SetupTestRethNode(t, dockerClient, networkID, + rethImageOpt, + func(b *reth.NodeBuilder) { + b.WithGenesis([]byte(genesis)) + }, + ) + + networkInfo, err := rethNode.GetNetworkInfo(ctx) + require.NoError(t, err) + + ethURL := "http://127.0.0.1:" + networkInfo.External.Ports.RPC + engineURL := "http://127.0.0.1:" + networkInfo.External.Ports.Engine + engineRecorder := newEngineRPCRecorder(t, engineURL) + defer engineRecorder.Close() + + genesisHash, err := rethNode.GenesisHash(ctx) + require.NoError(t, err) + + store := dssync.MutexWrap(ds.NewMapDatastore()) + executionClient, err := evm.NewEngineExecutionClient( + ethURL, + engineRecorder.URL(), + rethNode.JWTSecretHex(), + common.HexToHash(genesisHash), + common.Address{}, + store, + false, + zerolog.Nop(), + ) + require.NoError(t, err) + + stateRoot, err := executionClient.InitChain(ctx, time.Unix(0, 0), 1, "engine-fork-cycle") + require.NoError(t, err) + + steps := []struct { + name string + height uint64 + timestamp uint64 + }{ + {name: "prague", height: 1, timestamp: forkCyclePragueTimestamp}, + {name: "osaka", height: 2, timestamp: forkCycleOsakaTimestamp}, + {name: "amsterdam", height: 3, timestamp: forkCycleAmsterdamTimestamp}, + } + + for _, step := range steps { + t.Run(step.name, func(t *testing.T) { + result, err := executionClient.ExecuteTxs(ctx, nil, step.height, time.Unix(int64(step.timestamp), 0), stateRoot) + require.NoError(t, err) + stateRoot = result.UpdatedStateRoot + }) + } + + engineRecorder.RequireCalled(t, "engine_forkchoiceUpdatedV3") + engineRecorder.RequireCalled(t, "engine_getPayloadV4") + engineRecorder.RequireCalled(t, "engine_newPayloadV4") + engineRecorder.RequireCalled(t, "engine_getPayloadV5") + engineRecorder.RequireCalled(t, "engine_forkchoiceUpdatedV4") + engineRecorder.RequireCalled(t, "engine_getPayloadV6") + engineRecorder.RequireCalled(t, "engine_newPayloadV5") + require.True(t, engineRecorder.SawNewPayloadV5BlockAccessList(), + "engine_newPayloadV5 should preserve executionPayload.blockAccessList from engine_getPayloadV6") +} + +func withEngineForkTimes(t testing.TB, osakaTime, amsterdamTime uint64) reth.GenesisOpt { + t.Helper() + return func(genesis []byte) ([]byte, error) { + var doc map[string]any + if err := json.Unmarshal(genesis, &doc); err != nil { + return nil, err + } + config, ok := doc["config"].(map[string]any) + if !ok { + return nil, fmt.Errorf("genesis config missing or invalid") + } + config["osakaTime"] = osakaTime + config["amsterdamTime"] = amsterdamTime + return json.Marshal(doc) + } +} + +func rethImageOptFromEnv() RethNodeOpt { + repo := strings.TrimSpace(os.Getenv("EV_RETH_IMAGE_REPO")) + tag := strings.TrimSpace(os.Getenv("EV_RETH_TAG")) + if tag == "" { + if repo == "" { + tag = defaultEvRethForkTag + } else { + tag = "latest" + } + } + return func(b *reth.NodeBuilder) { + if repo != "" { + b.WithImage(container.NewImage(repo, tag, "")) + return + } + b.WithTag(tag) + } +} + +type engineRPCRecorder struct { + server *http.Server + listener net.Listener + target *url.URL + + mu sync.Mutex + calls []string + counts map[string]int + sawNewPayloadV5BlockAccessList bool +} + +func newEngineRPCRecorder(t testing.TB, target string) *engineRPCRecorder { + t.Helper() + + targetURL, err := url.Parse(target) + require.NoError(t, err) + + listener, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + + recorder := &engineRPCRecorder{ + listener: listener, + target: targetURL, + counts: make(map[string]int), + } + recorder.server = &http.Server{Handler: http.HandlerFunc(recorder.proxy)} + + go func() { + if err := recorder.server.Serve(listener); err != nil && err != http.ErrServerClosed { + t.Logf("engine RPC recorder stopped unexpectedly: %v", err) + } + }() + + return recorder +} + +func (r *engineRPCRecorder) URL() string { + return "http://" + r.listener.Addr().String() +} + +func (r *engineRPCRecorder) Close() { + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + _ = r.server.Shutdown(ctx) +} + +func (r *engineRPCRecorder) RequireCalled(t testing.TB, method string) { + t.Helper() + r.mu.Lock() + defer r.mu.Unlock() + require.Greater(t, r.counts[method], 0, "expected Engine RPC method %s to be called; calls=%v", method, r.calls) +} + +func (r *engineRPCRecorder) SawNewPayloadV5BlockAccessList() bool { + r.mu.Lock() + defer r.mu.Unlock() + return r.sawNewPayloadV5BlockAccessList +} + +func (r *engineRPCRecorder) proxy(w http.ResponseWriter, req *http.Request) { + body, err := io.ReadAll(req.Body) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + _ = req.Body.Close() + + method := rpcMethod(body) + if method != "" { + r.record(method, body) + } + + targetReq, err := http.NewRequestWithContext(req.Context(), req.Method, r.target.String(), bytes.NewReader(body)) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + targetReq.Header = req.Header.Clone() + + resp, err := http.DefaultTransport.RoundTrip(targetReq) + if err != nil { + http.Error(w, err.Error(), http.StatusBadGateway) + return + } + defer func() { _ = resp.Body.Close() }() + + for key, values := range resp.Header { + for _, value := range values { + w.Header().Add(key, value) + } + } + w.WriteHeader(resp.StatusCode) + _, _ = io.Copy(w, resp.Body) +} + +func (r *engineRPCRecorder) record(method string, body []byte) { + r.mu.Lock() + defer r.mu.Unlock() + + r.calls = append(r.calls, method) + r.counts[method]++ + + if method == "engine_newPayloadV5" && requestHasBlockAccessList(body) { + r.sawNewPayloadV5BlockAccessList = true + } +} + +func rpcMethod(body []byte) string { + var req struct { + Method string `json:"method"` + } + if err := json.Unmarshal(body, &req); err != nil { + return "" + } + return req.Method +} + +func requestHasBlockAccessList(body []byte) bool { + var req struct { + Params []json.RawMessage `json:"params"` + } + if err := json.Unmarshal(body, &req); err != nil || len(req.Params) == 0 { + return false + } + + var payload map[string]json.RawMessage + if err := json.Unmarshal(req.Params[0], &payload); err != nil { + return false + } + _, ok := payload["blockAccessList"] + return ok +} 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)