Skip to content

Commit 805f927

Browse files
tac0turtleclaude
andauthored
feat: auto-detect Engine API GetPayload version for Osaka fork (#3113)
* feat: auto-detect Engine API GetPayload version for Osaka fork GetPayload now automatically selects between engine_getPayloadV4 (Prague) and engine_getPayloadV5 (Osaka) by caching the last successful version and retrying with the alternative on "Unsupported fork" errors (code -38005). This handles Prague chains, Osaka-at-genesis chains, and time-based Prague-to-Osaka upgrades with zero configuration. At most one extra RPC call occurs at the fork transition point. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix comments * rename --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent fe99227 commit 805f927

3 files changed

Lines changed: 346 additions & 3 deletions

File tree

execution/evm/engine_rpc_client.go

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,35 @@ package evm
22

33
import (
44
"context"
5+
"errors"
6+
"fmt"
7+
"sync/atomic"
58

69
"github.com/ethereum/go-ethereum/beacon/engine"
710
"github.com/ethereum/go-ethereum/rpc"
811
)
912

13+
// engineErrUnsupportedFork is the Engine API error code for "Unsupported fork".
14+
// Defined in the Engine API specification.
15+
const engineErrUnsupportedFork = -38005
16+
17+
// Engine API method names for GetPayload versions.
18+
const (
19+
getPayloadV4Method = "engine_getPayloadV4"
20+
getPayloadV5Method = "engine_getPayloadV5"
21+
)
22+
1023
var _ EngineRPCClient = (*engineRPCClient)(nil)
1124

1225
// engineRPCClient is the concrete implementation wrapping *rpc.Client.
26+
// It auto-detects whether to use engine_getPayloadV4 (Prague) or
27+
// engine_getPayloadV5 (Osaka) by caching the last successful version
28+
// and falling back on "Unsupported fork" errors.
1329
type engineRPCClient struct {
1430
client *rpc.Client
31+
// useV5 tracks whether GetPayload should prefer V5 (Osaka).
32+
// Starts false (V4/Prague). Flips automatically on unsupported-fork errors.
33+
useV5 atomic.Bool
1534
}
1635

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

3150
func (e *engineRPCClient) GetPayload(ctx context.Context, payloadID engine.PayloadID) (*engine.ExecutionPayloadEnvelope, error) {
51+
method := getPayloadV4Method
52+
altMethod := getPayloadV5Method
53+
if e.useV5.Load() {
54+
method = getPayloadV5Method
55+
altMethod = getPayloadV4Method
56+
}
57+
3258
var result engine.ExecutionPayloadEnvelope
33-
err := e.client.CallContext(ctx, &result, "engine_getPayloadV4", payloadID)
59+
err := e.client.CallContext(ctx, &result, method, payloadID)
60+
if err == nil {
61+
return &result, nil
62+
}
63+
64+
if !isUnsupportedForkErr(err) {
65+
return nil, fmt.Errorf("%s payload %s: %w", method, payloadID, err)
66+
}
67+
68+
// Primary method returned "Unsupported fork" -- try the other version.
69+
err = e.client.CallContext(ctx, &result, altMethod, payloadID)
3470
if err != nil {
35-
return nil, err
71+
return nil, fmt.Errorf("%s fallback after %s unsupported fork, payload %s: %w", altMethod, method, payloadID, err)
3672
}
73+
74+
// The alt method worked -- cache it for future calls.
75+
e.useV5.Store(altMethod == getPayloadV5Method)
3776
return &result, nil
3877
}
3978

79+
// GetPayloadMethod returns the Engine API method name currently used by GetPayload.
80+
// This allows wrappers (e.g. tracing) to report the resolved version.
81+
func (e *engineRPCClient) GetPayloadMethod() string {
82+
if e.useV5.Load() {
83+
return getPayloadV5Method
84+
}
85+
return getPayloadV4Method
86+
}
87+
4088
func (e *engineRPCClient) NewPayload(ctx context.Context, payload *engine.ExecutableData, blobHashes []string, parentBeaconBlockRoot string, executionRequests [][]byte) (*engine.PayloadStatusV1, error) {
4189
var result engine.PayloadStatusV1
4290
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
4593
}
4694
return &result, nil
4795
}
96+
97+
// isUnsupportedForkErr reports whether err is an Engine API "Unsupported fork"
98+
// JSON-RPC error (code -38005).
99+
func isUnsupportedForkErr(err error) bool {
100+
var rpcErr rpc.Error
101+
return errors.As(err, &rpcErr) && rpcErr.ErrorCode() == engineErrUnsupportedFork
102+
}
Lines changed: 277 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,277 @@
1+
package evm
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"net/http"
8+
"net/http/httptest"
9+
"sync"
10+
"testing"
11+
12+
"github.com/ethereum/go-ethereum/beacon/engine"
13+
"github.com/ethereum/go-ethereum/rpc"
14+
"github.com/stretchr/testify/assert"
15+
"github.com/stretchr/testify/require"
16+
)
17+
18+
// jsonRPCRequest is a minimal JSON-RPC request for test inspection.
19+
type jsonRPCRequest struct {
20+
Method string `json:"method"`
21+
Params []json.RawMessage `json:"params"`
22+
ID json.RawMessage `json:"id"`
23+
}
24+
25+
// fakeEngineServer returns an httptest.Server that responds to engine_getPayloadV4
26+
// and engine_getPayloadV5 according to the provided handler. The handler receives
27+
// the method name and returns (result JSON, error code, error message).
28+
// If errorCode is 0, a success response is sent.
29+
func fakeEngineServer(t *testing.T, handler func(method string) (resultJSON string, errCode int, errMsg string)) *httptest.Server {
30+
t.Helper()
31+
32+
var mu sync.Mutex
33+
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
34+
mu.Lock()
35+
defer mu.Unlock()
36+
37+
var req jsonRPCRequest
38+
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
39+
t.Logf("failed to decode request: %v", err)
40+
http.Error(w, "bad request", http.StatusBadRequest)
41+
return
42+
}
43+
44+
resultJSON, errCode, errMsg := handler(req.Method)
45+
46+
w.Header().Set("Content-Type", "application/json")
47+
if errCode != 0 {
48+
resp := fmt.Sprintf(`{"jsonrpc":"2.0","id":%s,"error":{"code":%d,"message":"%s"}}`,
49+
req.ID, errCode, errMsg)
50+
_, _ = w.Write([]byte(resp))
51+
} else {
52+
resp := fmt.Sprintf(`{"jsonrpc":"2.0","id":%s,"result":%s}`, req.ID, resultJSON)
53+
_, _ = w.Write([]byte(resp))
54+
}
55+
}))
56+
}
57+
58+
// minimalPayloadEnvelopeJSON is a minimal valid ExecutionPayloadEnvelope JSON
59+
// that go-ethereum can unmarshal without error.
60+
const minimalPayloadEnvelopeJSON = `{
61+
"executionPayload": {
62+
"parentHash": "0x0000000000000000000000000000000000000000000000000000000000000000",
63+
"feeRecipient": "0x0000000000000000000000000000000000000000",
64+
"stateRoot": "0x0000000000000000000000000000000000000000000000000000000000000001",
65+
"receiptsRoot": "0x0000000000000000000000000000000000000000000000000000000000000000",
66+
"logsBloom": "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000",
67+
"prevRandao": "0x0000000000000000000000000000000000000000000000000000000000000000",
68+
"blockNumber": "0x1",
69+
"gasLimit": "0x1000000",
70+
"gasUsed": "0x0",
71+
"timestamp": "0x1",
72+
"extraData": "0x",
73+
"baseFeePerGas": "0x1",
74+
"blockHash": "0x0000000000000000000000000000000000000000000000000000000000000002",
75+
"transactions": [],
76+
"blobGasUsed": "0x0",
77+
"excessBlobGas": "0x0"
78+
},
79+
"blockValue": "0x0",
80+
"blobsBundle": {
81+
"commitments": [],
82+
"proofs": [],
83+
"blobs": []
84+
},
85+
"executionRequests": [],
86+
"shouldOverrideBuilder": false
87+
}`
88+
89+
func dialTestServer(t *testing.T, serverURL string) *rpc.Client {
90+
t.Helper()
91+
client, err := rpc.Dial(serverURL)
92+
require.NoError(t, err)
93+
return client
94+
}
95+
96+
func TestGetPayload_PragueChain_UsesV4(t *testing.T) {
97+
var calledMethods []string
98+
var mu sync.Mutex
99+
100+
srv := fakeEngineServer(t, func(method string) (string, int, string) {
101+
mu.Lock()
102+
calledMethods = append(calledMethods, method)
103+
mu.Unlock()
104+
105+
if method == "engine_getPayloadV4" {
106+
return minimalPayloadEnvelopeJSON, 0, ""
107+
}
108+
return "", -38005, "Unsupported fork"
109+
})
110+
defer srv.Close()
111+
112+
client := NewEngineRPCClient(dialTestServer(t, srv.URL))
113+
ctx := context.Background()
114+
115+
// First call -- should use V4 directly, succeed.
116+
_, err := client.GetPayload(ctx, engine.PayloadID{})
117+
require.NoError(t, err)
118+
119+
mu.Lock()
120+
assert.Equal(t, []string{"engine_getPayloadV4"}, calledMethods, "should call V4 only")
121+
calledMethods = nil
122+
mu.Unlock()
123+
124+
// Second call -- still V4 (cached).
125+
_, err = client.GetPayload(ctx, engine.PayloadID{})
126+
require.NoError(t, err)
127+
128+
mu.Lock()
129+
assert.Equal(t, []string{"engine_getPayloadV4"}, calledMethods, "should still use V4")
130+
mu.Unlock()
131+
}
132+
133+
func TestGetPayload_OsakaChain_FallsBackToV5(t *testing.T) {
134+
var calledMethods []string
135+
var mu sync.Mutex
136+
137+
srv := fakeEngineServer(t, func(method string) (string, int, string) {
138+
mu.Lock()
139+
calledMethods = append(calledMethods, method)
140+
mu.Unlock()
141+
142+
if method == "engine_getPayloadV5" {
143+
return minimalPayloadEnvelopeJSON, 0, ""
144+
}
145+
return "", -38005, "Unsupported fork"
146+
})
147+
defer srv.Close()
148+
149+
client := NewEngineRPCClient(dialTestServer(t, srv.URL))
150+
ctx := context.Background()
151+
152+
// First call -- V4 fails with -38005, falls back to V5.
153+
_, err := client.GetPayload(ctx, engine.PayloadID{})
154+
require.NoError(t, err)
155+
156+
mu.Lock()
157+
assert.Equal(t, []string{"engine_getPayloadV4", "engine_getPayloadV5"}, calledMethods,
158+
"should try V4 then fall back to V5")
159+
calledMethods = nil
160+
mu.Unlock()
161+
162+
// Second call -- should go directly to V5 (cached).
163+
_, err = client.GetPayload(ctx, engine.PayloadID{})
164+
require.NoError(t, err)
165+
166+
mu.Lock()
167+
assert.Equal(t, []string{"engine_getPayloadV5"}, calledMethods,
168+
"should use cached V5 without trying V4")
169+
mu.Unlock()
170+
}
171+
172+
func TestGetPayload_ForkUpgrade_SwitchesV4ToV5(t *testing.T) {
173+
var mu sync.Mutex
174+
var calledMethods []string
175+
osakaActive := false
176+
177+
srv := fakeEngineServer(t, func(method string) (string, int, string) {
178+
mu.Lock()
179+
calledMethods = append(calledMethods, method)
180+
active := osakaActive
181+
mu.Unlock()
182+
183+
if active {
184+
// Post-Osaka: V5 works, V4 rejected
185+
if method == "engine_getPayloadV5" {
186+
return minimalPayloadEnvelopeJSON, 0, ""
187+
}
188+
return "", -38005, "Unsupported fork"
189+
}
190+
// Pre-Osaka: V4 works, V5 rejected
191+
if method == "engine_getPayloadV4" {
192+
return minimalPayloadEnvelopeJSON, 0, ""
193+
}
194+
return "", -38005, "Unsupported fork"
195+
})
196+
defer srv.Close()
197+
198+
client := NewEngineRPCClient(dialTestServer(t, srv.URL))
199+
ctx := context.Background()
200+
201+
// Pre-upgrade: V4 works.
202+
_, err := client.GetPayload(ctx, engine.PayloadID{})
203+
require.NoError(t, err)
204+
205+
mu.Lock()
206+
assert.Equal(t, []string{"engine_getPayloadV4"}, calledMethods, "pre-upgrade should call V4 only")
207+
calledMethods = nil
208+
mu.Unlock()
209+
210+
// Simulate fork activation.
211+
mu.Lock()
212+
osakaActive = true
213+
mu.Unlock()
214+
215+
// First post-upgrade call: V4 fails, falls back to V5, caches.
216+
_, err = client.GetPayload(ctx, engine.PayloadID{})
217+
require.NoError(t, err)
218+
219+
mu.Lock()
220+
assert.Equal(t, []string{"engine_getPayloadV4", "engine_getPayloadV5"}, calledMethods,
221+
"first post-upgrade call should try V4 then fall back to V5")
222+
calledMethods = nil
223+
mu.Unlock()
224+
225+
// Subsequent calls: V5 directly (cached).
226+
_, err = client.GetPayload(ctx, engine.PayloadID{})
227+
require.NoError(t, err)
228+
229+
mu.Lock()
230+
assert.Equal(t, []string{"engine_getPayloadV5"}, calledMethods,
231+
"subsequent calls should use cached V5 directly")
232+
mu.Unlock()
233+
}
234+
235+
func TestGetPayload_NonForkError_Propagated(t *testing.T) {
236+
srv := fakeEngineServer(t, func(method string) (string, int, string) {
237+
// Return a different error (e.g., unknown payload)
238+
return "", -38001, "Unknown payload"
239+
})
240+
defer srv.Close()
241+
242+
client := NewEngineRPCClient(dialTestServer(t, srv.URL))
243+
ctx := context.Background()
244+
245+
_, err := client.GetPayload(ctx, engine.PayloadID{})
246+
require.Error(t, err)
247+
assert.Contains(t, err.Error(), "Unknown payload")
248+
}
249+
250+
func TestIsUnsupportedForkErr(t *testing.T) {
251+
tests := []struct {
252+
name string
253+
err error
254+
expected bool
255+
}{
256+
{"nil error", nil, false},
257+
{"generic error", fmt.Errorf("something went wrong"), false},
258+
{"unsupported fork code", &testRPCError{code: -38005, msg: "Unsupported fork"}, true},
259+
{"different code", &testRPCError{code: -38001, msg: "Unknown payload"}, false},
260+
{"wrapped unsupported fork", fmt.Errorf("call failed: %w", &testRPCError{code: -38005, msg: "Unsupported fork"}), true},
261+
}
262+
263+
for _, tt := range tests {
264+
t.Run(tt.name, func(t *testing.T) {
265+
assert.Equal(t, tt.expected, isUnsupportedForkErr(tt.err))
266+
})
267+
}
268+
}
269+
270+
// testRPCError implements rpc.Error for testing.
271+
type testRPCError struct {
272+
code int
273+
msg string
274+
}
275+
276+
func (e *testRPCError) Error() string { return e.msg }
277+
func (e *testRPCError) ErrorCode() int { return e.code }

execution/evm/engine_rpc_tracing.go

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,16 +63,27 @@ func (t *tracedEngineRPCClient) ForkchoiceUpdated(ctx context.Context, state eng
6363
return result, nil
6464
}
6565

66+
// payloadMethodGetter is implemented by engineRPCClient to expose the resolved
67+
// GetPayload Engine API method name (V4 or V5) for tracing.
68+
type payloadMethodGetter interface {
69+
GetPayloadMethod() string
70+
}
71+
6672
func (t *tracedEngineRPCClient) GetPayload(ctx context.Context, payloadID engine.PayloadID) (*engine.ExecutionPayloadEnvelope, error) {
6773
ctx, span := t.tracer.Start(ctx, "Engine.GetPayload",
6874
trace.WithAttributes(
69-
attribute.String("method", "engine_getPayloadV4"),
7075
attribute.String("payload_id", payloadID.String()),
7176
),
7277
)
7378
defer span.End()
7479

7580
result, err := t.inner.GetPayload(ctx, payloadID)
81+
82+
// Record the resolved method after the call so it reflects any version switch.
83+
if m, ok := t.inner.(payloadMethodGetter); ok {
84+
span.SetAttributes(attribute.String("method", m.GetPayloadMethod()))
85+
}
86+
7687
if err != nil {
7788
span.RecordError(err)
7889
span.SetStatus(codes.Error, err.Error())

0 commit comments

Comments
 (0)