Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 57 additions & 2 deletions execution/evm/engine_rpc_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,35 @@ package evm

import (
"context"
"errors"
"fmt"
"sync/atomic"

"github.com/ethereum/go-ethereum/beacon/engine"
"github.com/ethereum/go-ethereum/rpc"
)

// engineErrUnsupportedFork is the Engine API error code for "Unsupported fork".
// Defined in the Engine API specification.
const engineErrUnsupportedFork = -38005

// Engine API method names for GetPayload versions.
const (
getPayloadV4Method = "engine_getPayloadV4"
getPayloadV5Method = "engine_getPayloadV5"
)

var _ EngineRPCClient = (*engineRPCClient)(nil)

// 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.
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
}

// NewEngineRPCClient creates a new Engine API client.
Expand All @@ -29,14 +48,43 @@ func (e *engineRPCClient) ForkchoiceUpdated(ctx context.Context, state engine.Fo
}

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
}

var result engine.ExecutionPayloadEnvelope
err := e.client.CallContext(ctx, &result, "engine_getPayloadV4", payloadID)
err := e.client.CallContext(ctx, &result, method, payloadID)
if err == nil {
return &result, nil
}

if !isUnsupportedForkErr(err) {
return nil, fmt.Errorf("%s payload %s: %w", method, payloadID, err)
}

// Primary method returned "Unsupported fork" -- try the other version.
err = e.client.CallContext(ctx, &result, altMethod, payloadID)
if err != nil {
return nil, err
return nil, fmt.Errorf("%s fallback after %s unsupported fork, payload %s: %w", altMethod, method, payloadID, err)
}

// The alt method worked -- cache it for future calls.
e.useV5.Store(altMethod == getPayloadV5Method)
return &result, nil
}

// 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
}

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)
Expand All @@ -45,3 +93,10 @@ func (e *engineRPCClient) NewPayload(ctx context.Context, payload *engine.Execut
}
return &result, nil
}

// isUnsupportedForkErr reports whether err is an Engine API "Unsupported fork"
// JSON-RPC error (code -38005).
func isUnsupportedForkErr(err error) bool {
var rpcErr rpc.Error
return errors.As(err, &rpcErr) && rpcErr.ErrorCode() == engineErrUnsupportedFork
}
277 changes: 277 additions & 0 deletions execution/evm/engine_rpc_client_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,277 @@
package evm

import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"sync"
"testing"

"github.com/ethereum/go-ethereum/beacon/engine"
"github.com/ethereum/go-ethereum/rpc"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// jsonRPCRequest is a minimal JSON-RPC request for test inspection.
type jsonRPCRequest struct {
Method string `json:"method"`
Params []json.RawMessage `json:"params"`
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).
// 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 {
t.Helper()

var mu sync.Mutex
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
defer mu.Unlock()

var req jsonRPCRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
t.Logf("failed to decode request: %v", err)
http.Error(w, "bad request", http.StatusBadRequest)
return
}

resultJSON, errCode, errMsg := handler(req.Method)

w.Header().Set("Content-Type", "application/json")
if errCode != 0 {
resp := fmt.Sprintf(`{"jsonrpc":"2.0","id":%s,"error":{"code":%d,"message":"%s"}}`,
req.ID, errCode, errMsg)
_, _ = w.Write([]byte(resp))
} else {
resp := fmt.Sprintf(`{"jsonrpc":"2.0","id":%s,"result":%s}`, req.ID, resultJSON)
_, _ = w.Write([]byte(resp))
}
}))
}

// minimalPayloadEnvelopeJSON is a minimal valid ExecutionPayloadEnvelope JSON
// that go-ethereum can unmarshal without error.
const minimalPayloadEnvelopeJSON = `{
"executionPayload": {
"parentHash": "0x0000000000000000000000000000000000000000000000000000000000000000",
"feeRecipient": "0x0000000000000000000000000000000000000000",
"stateRoot": "0x0000000000000000000000000000000000000000000000000000000000000001",
"receiptsRoot": "0x0000000000000000000000000000000000000000000000000000000000000000",
"logsBloom": "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000",
"prevRandao": "0x0000000000000000000000000000000000000000000000000000000000000000",
"blockNumber": "0x1",
"gasLimit": "0x1000000",
"gasUsed": "0x0",
"timestamp": "0x1",
"extraData": "0x",
"baseFeePerGas": "0x1",
"blockHash": "0x0000000000000000000000000000000000000000000000000000000000000002",
"transactions": [],
"blobGasUsed": "0x0",
"excessBlobGas": "0x0"
},
"blockValue": "0x0",
"blobsBundle": {
"commitments": [],
"proofs": [],
"blobs": []
},
"executionRequests": [],
"shouldOverrideBuilder": false
}`

func dialTestServer(t *testing.T, serverURL string) *rpc.Client {
t.Helper()
client, err := rpc.Dial(serverURL)
require.NoError(t, err)
return client
}

func TestGetPayload_PragueChain_UsesV4(t *testing.T) {
var calledMethods []string
var mu sync.Mutex

srv := fakeEngineServer(t, func(method string) (string, int, string) {
mu.Lock()
calledMethods = append(calledMethods, method)
mu.Unlock()

if method == "engine_getPayloadV4" {
return minimalPayloadEnvelopeJSON, 0, ""
}
return "", -38005, "Unsupported fork"
})
defer srv.Close()

client := NewEngineRPCClient(dialTestServer(t, srv.URL))
ctx := context.Background()

// First call -- should use V4 directly, succeed.
_, err := client.GetPayload(ctx, engine.PayloadID{})
require.NoError(t, err)

mu.Lock()
assert.Equal(t, []string{"engine_getPayloadV4"}, calledMethods, "should call V4 only")
calledMethods = nil
mu.Unlock()

// Second call -- still V4 (cached).
_, err = client.GetPayload(ctx, engine.PayloadID{})
require.NoError(t, err)

mu.Lock()
assert.Equal(t, []string{"engine_getPayloadV4"}, calledMethods, "should still use V4")
mu.Unlock()
}

func TestGetPayload_OsakaChain_FallsBackToV5(t *testing.T) {
var calledMethods []string
var mu sync.Mutex

srv := fakeEngineServer(t, func(method string) (string, int, string) {
mu.Lock()
calledMethods = append(calledMethods, method)
mu.Unlock()

if method == "engine_getPayloadV5" {
return minimalPayloadEnvelopeJSON, 0, ""
}
return "", -38005, "Unsupported fork"
})
defer srv.Close()

client := NewEngineRPCClient(dialTestServer(t, srv.URL))
ctx := context.Background()

// First call -- V4 fails with -38005, falls back to V5.
_, err := client.GetPayload(ctx, engine.PayloadID{})
require.NoError(t, err)

mu.Lock()
assert.Equal(t, []string{"engine_getPayloadV4", "engine_getPayloadV5"}, calledMethods,
"should try V4 then fall back to V5")
calledMethods = nil
mu.Unlock()

// Second call -- should go directly to V5 (cached).
_, err = client.GetPayload(ctx, engine.PayloadID{})
require.NoError(t, err)

mu.Lock()
assert.Equal(t, []string{"engine_getPayloadV5"}, calledMethods,
"should use cached V5 without trying V4")
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) {
mu.Lock()
calledMethods = append(calledMethods, method)
active := osakaActive
mu.Unlock()

if active {
// Post-Osaka: V5 works, V4 rejected
if method == "engine_getPayloadV5" {
return minimalPayloadEnvelopeJSON, 0, ""
}
return "", -38005, "Unsupported fork"
}
// Pre-Osaka: V4 works, V5 rejected
if method == "engine_getPayloadV4" {
return minimalPayloadEnvelopeJSON, 0, ""
}
return "", -38005, "Unsupported fork"
})
defer srv.Close()

client := NewEngineRPCClient(dialTestServer(t, srv.URL))
ctx := context.Background()

// Pre-upgrade: V4 works.
_, err := client.GetPayload(ctx, engine.PayloadID{})
require.NoError(t, err)

mu.Lock()
assert.Equal(t, []string{"engine_getPayloadV4"}, calledMethods, "pre-upgrade should call V4 only")
calledMethods = nil
mu.Unlock()

// Simulate fork activation.
mu.Lock()
osakaActive = true
mu.Unlock()

// First post-upgrade call: V4 fails, falls back to V5, caches.
_, err = client.GetPayload(ctx, engine.PayloadID{})
require.NoError(t, err)

mu.Lock()
assert.Equal(t, []string{"engine_getPayloadV4", "engine_getPayloadV5"}, calledMethods,
"first post-upgrade call should try V4 then fall back to V5")
calledMethods = nil
mu.Unlock()

// Subsequent calls: V5 directly (cached).
_, err = client.GetPayload(ctx, engine.PayloadID{})
require.NoError(t, err)

mu.Lock()
assert.Equal(t, []string{"engine_getPayloadV5"}, 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) {
// Return a different error (e.g., unknown payload)
return "", -38001, "Unknown payload"
})
defer srv.Close()

client := NewEngineRPCClient(dialTestServer(t, srv.URL))
ctx := context.Background()

_, err := client.GetPayload(ctx, engine.PayloadID{})
require.Error(t, err)
assert.Contains(t, err.Error(), "Unknown payload")
}

func TestIsUnsupportedForkErr(t *testing.T) {
tests := []struct {
name string
err error
expected bool
}{
{"nil error", nil, false},
{"generic error", fmt.Errorf("something went wrong"), false},
{"unsupported fork code", &testRPCError{code: -38005, msg: "Unsupported fork"}, true},
{"different code", &testRPCError{code: -38001, msg: "Unknown payload"}, false},
{"wrapped unsupported fork", fmt.Errorf("call failed: %w", &testRPCError{code: -38005, msg: "Unsupported fork"}), true},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.expected, isUnsupportedForkErr(tt.err))
})
}
}

// testRPCError implements rpc.Error for testing.
type testRPCError struct {
code int
msg string
}

func (e *testRPCError) Error() string { return e.msg }
func (e *testRPCError) ErrorCode() int { return e.code }
13 changes: 12 additions & 1 deletion execution/evm/engine_rpc_tracing.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,16 +63,27 @@ func (t *tracedEngineRPCClient) ForkchoiceUpdated(ctx context.Context, state eng
return result, nil
}

// payloadMethodGetter is implemented by engineRPCClient to expose the resolved
// GetPayload Engine API method name (V4 or V5) for tracing.
type payloadMethodGetter interface {
GetPayloadMethod() string
}

func (t *tracedEngineRPCClient) GetPayload(ctx context.Context, payloadID engine.PayloadID) (*engine.ExecutionPayloadEnvelope, error) {
ctx, span := t.tracer.Start(ctx, "Engine.GetPayload",
trace.WithAttributes(
attribute.String("method", "engine_getPayloadV4"),
attribute.String("payload_id", payloadID.String()),
),
)
defer span.End()

result, err := t.inner.GetPayload(ctx, payloadID)

// Record the resolved method after the call so it reflects any version switch.
if m, ok := t.inner.(payloadMethodGetter); ok {
span.SetAttributes(attribute.String("method", m.GetPayloadMethod()))
}

if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
Expand Down
Loading