Skip to content

Commit ce75825

Browse files
tac0turtleclaude
andcommitted
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>
1 parent c449847 commit ce75825

2 files changed

Lines changed: 299 additions & 1 deletion

File tree

execution/evm/engine_rpc_client.go

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,28 @@ package evm
22

33
import (
44
"context"
5+
"errors"
6+
"sync/atomic"
57

68
"github.com/ethereum/go-ethereum/beacon/engine"
79
"github.com/ethereum/go-ethereum/rpc"
810
)
911

12+
// engineErrUnsupportedFork is the Engine API error code for "Unsupported fork".
13+
// Defined in the Engine API specification.
14+
const engineErrUnsupportedFork = -38005
15+
1016
var _ EngineRPCClient = (*engineRPCClient)(nil)
1117

1218
// engineRPCClient is the concrete implementation wrapping *rpc.Client.
19+
// It auto-detects whether to use engine_getPayloadV4 (Prague) or
20+
// engine_getPayloadV5 (Osaka) by caching the last successful version
21+
// and falling back on "Unsupported fork" errors.
1322
type engineRPCClient struct {
1423
client *rpc.Client
24+
// useV5 tracks whether GetPayload should prefer V5 (Osaka).
25+
// Starts false (V4/Prague). Flips automatically on unsupported-fork errors.
26+
useV5 atomic.Bool
1527
}
1628

1729
// NewEngineRPCClient creates a new Engine API client.
@@ -29,11 +41,31 @@ func (e *engineRPCClient) ForkchoiceUpdated(ctx context.Context, state engine.Fo
2941
}
3042

3143
func (e *engineRPCClient) GetPayload(ctx context.Context, payloadID engine.PayloadID) (*engine.ExecutionPayloadEnvelope, error) {
44+
method := "engine_getPayloadV4"
45+
altMethod := "engine_getPayloadV5"
46+
if e.useV5.Load() {
47+
method = "engine_getPayloadV5"
48+
altMethod = "engine_getPayloadV4"
49+
}
50+
3251
var result engine.ExecutionPayloadEnvelope
33-
err := e.client.CallContext(ctx, &result, "engine_getPayloadV4", payloadID)
52+
err := e.client.CallContext(ctx, &result, method, payloadID)
53+
if err == nil {
54+
return &result, nil
55+
}
56+
57+
if !isUnsupportedForkErr(err) {
58+
return nil, err
59+
}
60+
61+
// Primary method returned "Unsupported fork" -- try the other version.
62+
err = e.client.CallContext(ctx, &result, altMethod, payloadID)
3463
if err != nil {
3564
return nil, err
3665
}
66+
67+
// The alt method worked -- cache it for future calls.
68+
e.useV5.Store(altMethod == "engine_getPayloadV5")
3769
return &result, nil
3870
}
3971

@@ -45,3 +77,10 @@ func (e *engineRPCClient) NewPayload(ctx context.Context, payload *engine.Execut
4577
}
4678
return &result, nil
4779
}
80+
81+
// isUnsupportedForkErr reports whether err is an Engine API "Unsupported fork"
82+
// JSON-RPC error (code -38005).
83+
func isUnsupportedForkErr(err error) bool {
84+
var rpcErr rpc.Error
85+
return errors.As(err, &rpcErr) && rpcErr.ErrorCode() == engineErrUnsupportedFork
86+
}
Lines changed: 259 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,259 @@
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+
osakaActive := false
175+
176+
srv := fakeEngineServer(t, func(method string) (string, int, string) {
177+
mu.Lock()
178+
active := osakaActive
179+
mu.Unlock()
180+
181+
if active {
182+
// Post-Osaka: V5 works, V4 rejected
183+
if method == "engine_getPayloadV5" {
184+
return minimalPayloadEnvelopeJSON, 0, ""
185+
}
186+
return "", -38005, "Unsupported fork"
187+
}
188+
// Pre-Osaka: V4 works, V5 rejected
189+
if method == "engine_getPayloadV4" {
190+
return minimalPayloadEnvelopeJSON, 0, ""
191+
}
192+
return "", -38005, "Unsupported fork"
193+
})
194+
defer srv.Close()
195+
196+
client := NewEngineRPCClient(dialTestServer(t, srv.URL))
197+
ctx := context.Background()
198+
199+
// Pre-upgrade: V4 works.
200+
_, err := client.GetPayload(ctx, engine.PayloadID{})
201+
require.NoError(t, err)
202+
203+
// Simulate fork activation.
204+
mu.Lock()
205+
osakaActive = true
206+
mu.Unlock()
207+
208+
// First post-upgrade call: V4 fails, falls back to V5, caches.
209+
_, err = client.GetPayload(ctx, engine.PayloadID{})
210+
require.NoError(t, err)
211+
212+
// Subsequent calls: V5 directly.
213+
_, err = client.GetPayload(ctx, engine.PayloadID{})
214+
require.NoError(t, err)
215+
}
216+
217+
func TestGetPayload_NonForkError_Propagated(t *testing.T) {
218+
srv := fakeEngineServer(t, func(method string) (string, int, string) {
219+
// Return a different error (e.g., unknown payload)
220+
return "", -38001, "Unknown payload"
221+
})
222+
defer srv.Close()
223+
224+
client := NewEngineRPCClient(dialTestServer(t, srv.URL))
225+
ctx := context.Background()
226+
227+
_, err := client.GetPayload(ctx, engine.PayloadID{})
228+
require.Error(t, err)
229+
assert.Contains(t, err.Error(), "Unknown payload")
230+
}
231+
232+
func TestIsUnsupportedForkErr(t *testing.T) {
233+
tests := []struct {
234+
name string
235+
err error
236+
expected bool
237+
}{
238+
{"nil error", nil, false},
239+
{"generic error", fmt.Errorf("something went wrong"), false},
240+
{"unsupported fork code", &testRPCError{code: -38005, msg: "Unsupported fork"}, true},
241+
{"different code", &testRPCError{code: -38001, msg: "Unknown payload"}, false},
242+
{"wrapped unsupported fork", fmt.Errorf("call failed: %w", &testRPCError{code: -38005, msg: "Unsupported fork"}), true},
243+
}
244+
245+
for _, tt := range tests {
246+
t.Run(tt.name, func(t *testing.T) {
247+
assert.Equal(t, tt.expected, isUnsupportedForkErr(tt.err))
248+
})
249+
}
250+
}
251+
252+
// testRPCError implements rpc.Error for testing.
253+
type testRPCError struct {
254+
code int
255+
msg string
256+
}
257+
258+
func (e *testRPCError) Error() string { return e.msg }
259+
func (e *testRPCError) ErrorCode() int { return e.code }

0 commit comments

Comments
 (0)