diff --git a/execution/evm/engine_rpc_client.go b/execution/evm/engine_rpc_client.go index ec04564aa1..536bf805e3 100644 --- a/execution/evm/engine_rpc_client.go +++ b/execution/evm/engine_rpc_client.go @@ -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. @@ -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) @@ -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 +} diff --git a/execution/evm/engine_rpc_client_test.go b/execution/evm/engine_rpc_client_test.go new file mode 100644 index 0000000000..f13270eec6 --- /dev/null +++ b/execution/evm/engine_rpc_client_test.go @@ -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 } diff --git a/execution/evm/engine_rpc_tracing.go b/execution/evm/engine_rpc_tracing.go index f5bf09e4bc..5bba9564fd 100644 --- a/execution/evm/engine_rpc_tracing.go +++ b/execution/evm/engine_rpc_tracing.go @@ -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())