eth/engineapi: proper pre-amsterdam support

This commit is contained in:
MariusVanDerWijden 2026-06-17 08:31:47 +02:00
parent 00a4a9e72c
commit ac6751b845
No known key found for this signature in database
7 changed files with 134 additions and 24 deletions

View file

@ -125,23 +125,29 @@ type ForkchoiceUpdateAmsterdam struct {
} }
func (f *ForkchoiceUpdateAmsterdam) SizeSSZ(siz *ssz.Sizer, fixed bool) uint32 { func (f *ForkchoiceUpdateAmsterdam) SizeSSZ(siz *ssz.Sizer, fixed bool) uint32 {
// 96 (state) + 4(offset attrs) + 4(offset custody) // 96 (state) + 4(offset attrs); the custody offset only exists from
size := uint32(96 + 8) // Amsterdam, matching the OnFork gating below.
size := uint32(96 + 4)
if siz.Fork() >= forkAmsterdam {
size += 4 // offset custody
}
if fixed { if fixed {
return size return size
} }
size += ssz.SizeSliceOfDynamicObjects(siz, f.PayloadAttributes) size += ssz.SizeSliceOfDynamicObjects(siz, f.PayloadAttributes)
if siz.Fork() >= forkAmsterdam {
size += ssz.SizeSliceOfStaticObjects(siz, f.CustodyColumns) size += ssz.SizeSliceOfStaticObjects(siz, f.CustodyColumns)
}
return size return size
} }
func (f *ForkchoiceUpdateAmsterdam) DefineSSZ(c *ssz.Codec) { func (f *ForkchoiceUpdateAmsterdam) DefineSSZ(c *ssz.Codec) {
ssz.DefineStaticObject(c, &f.ForkchoiceState) ssz.DefineStaticObject(c, &f.ForkchoiceState)
ssz.DefineSliceOfDynamicObjectsOffset(c, &f.PayloadAttributes, 1) ssz.DefineSliceOfDynamicObjectsOffset(c, &f.PayloadAttributes, 1)
ssz.DefineSliceOfStaticObjectsOffset(c, &f.CustodyColumns, 1) ssz.DefineSliceOfStaticObjectsOffsetOnFork(c, &f.CustodyColumns, 1, fromAmsterdam)
ssz.DefineSliceOfDynamicObjectsContent(c, &f.PayloadAttributes, 1) ssz.DefineSliceOfDynamicObjectsContent(c, &f.PayloadAttributes, 1)
ssz.DefineSliceOfStaticObjectsContent(c, &f.CustodyColumns, 1) ssz.DefineSliceOfStaticObjectsContentOnFork(c, &f.CustodyColumns, 1, fromAmsterdam)
} }
// Validate enforces the Optional[T] = List[T,1] length invariants. // Validate enforces the Optional[T] = List[T,1] length invariants.

View file

@ -79,7 +79,7 @@ func buildBodiesResponse(b Backend, fork forks.Fork, sf ssz.Fork, bodies []*type
} }
for i, body := range bodies { for i, body := range bodies {
entry := &sszt.BodyEntry{Body: new(sszt.ExecutionPayloadBody)} entry := &sszt.BodyEntry{Body: new(sszt.ExecutionPayloadBody)}
if body != nil && b.ForkFromTimestamp(ts[i]) == fork { if body != nil && baseFork(b.ForkFromTimestamp(ts[i])) == fork {
entry.Available = true entry.Available = true
entry.Body = bodyToSSZ(body, sf) entry.Body = bodyToSSZ(body, sf)
} }

View file

@ -26,9 +26,9 @@ import (
// handleForkchoice implements POST /engine/v2/{fork}/forkchoice. // handleForkchoice implements POST /engine/v2/{fork}/forkchoice.
func (rt *Router) handleForkchoice(w http.ResponseWriter, r *http.Request, fork forks.Fork) { func (rt *Router) handleForkchoice(w http.ResponseWriter, r *http.Request, fork forks.Fork) {
// The execution-apis spec currently only defines the forkchoice envelope // The forkchoice envelope shape is fork-driven by the codec; every fork
// for Amsterdam; the inner attributes shape is fork-driven by the codec. // from Paris on has a valid wire shape.
sf, ok := resolveFork(w, fork, forks.Amsterdam) sf, ok := resolveFork(w, fork, forks.Paris)
if !ok { if !ok {
return return
} }
@ -49,10 +49,10 @@ func (rt *Router) handleForkchoice(w http.ResponseWriter, r *http.Request, fork
return return
} }
// If PayloadAttributes is present the URL fork MUST match the fork // If PayloadAttributes is present the URL fork MUST match the fork
// the new payload would belong to. Today only Amsterdam URL exists // the new payload would belong to. ForkFromTimestamp can return a BPO
// in this implementation so the timestamp check is implicit; we // fork (which has no URL segment of its own); collapse it onto the
// keep an explicit guard for future fork URLs. // named fork it layers on before comparing.
if rt.backend.ForkFromTimestamp(attr.Timestamp) != fork { if baseFork(rt.backend.ForkFromTimestamp(attr.Timestamp)) != fork {
writeProblem(w, http.StatusBadRequest, ErrUnsupportedFork, writeProblem(w, http.StatusBadRequest, ErrUnsupportedFork,
"payload_attributes timestamp does not match URL fork") "payload_attributes timestamp does not match URL fork")
return return
@ -64,7 +64,7 @@ func (rt *Router) handleForkchoice(w http.ResponseWriter, r *http.Request, fork
// underlying miner gains the corresponding setting. // underlying miner gains the corresponding setting.
} }
resp, err := rt.backend.ForkchoiceUpdated(r.Context(), state, attrs, engine.PayloadV4) resp, err := rt.backend.ForkchoiceUpdated(r.Context(), state, attrs, payloadVersionFor(fork))
if err != nil { if err != nil {
mapBackendErr(w, err) mapBackendErr(w, err)
return return

View file

@ -19,6 +19,7 @@ package engineapi
import ( import (
"net/http" "net/http"
"github.com/ethereum/go-ethereum/beacon/engine"
sszt "github.com/ethereum/go-ethereum/beacon/engine/ssz" sszt "github.com/ethereum/go-ethereum/beacon/engine/ssz"
"github.com/ethereum/go-ethereum/params/forks" "github.com/ethereum/go-ethereum/params/forks"
"github.com/karalabe/ssz" "github.com/karalabe/ssz"
@ -39,3 +40,44 @@ func resolveFork(w http.ResponseWriter, fork, min forks.Fork) (ssz.Fork, bool) {
} }
return sf, true return sf, true
} }
// baseFork collapses a BPO fork onto the named fork that it layers on. BPO1..5
// sit between Osaka and Amsterdam in params/forks but have no Engine API URL
// segment of their own: a chain in a BPO era still negotiates /osaka/*. Named
// forks map to themselves.
func baseFork(f forks.Fork) forks.Fork {
switch f {
case forks.BPO1, forks.BPO2, forks.BPO3, forks.BPO4, forks.BPO5:
return forks.Osaka
default:
return f
}
}
// eraForks returns the set of params/forks values that share a URL fork's
// wire shape: the named fork plus any BPO forks that layer on it. checkFork in
// the catalyst layer derives a cached payload's fork via LatestFork, which can
// return a BPO fork, so the allowed set passed to GetPayload must include them.
func eraForks(fork forks.Fork) []forks.Fork {
if fork == forks.Osaka {
return []forks.Fork{forks.Osaka, forks.BPO1, forks.BPO2, forks.BPO3, forks.BPO4, forks.BPO5}
}
return []forks.Fork{fork}
}
// payloadVersionFor selects the JSON-RPC PayloadVersion that the catalyst layer
// expects for a given URL fork, mirroring the per-fork version table on the
// JSON-RPC ForkchoiceUpdatedVx path (Amsterdam -> V4, Cancun..Osaka -> V3,
// Shanghai -> V2, Paris -> V1).
func payloadVersionFor(fork forks.Fork) engine.PayloadVersion {
switch {
case fork >= forks.Amsterdam:
return engine.PayloadV4
case fork >= forks.Cancun:
return engine.PayloadV3
case fork >= forks.Shanghai:
return engine.PayloadV2
default:
return engine.PayloadV1
}
}

View file

@ -29,7 +29,7 @@ import (
// handleGetPayload implements GET /engine/v2/{fork}/payloads/{payloadId}. // handleGetPayload implements GET /engine/v2/{fork}/payloads/{payloadId}.
func (rt *Router) handleGetPayload(w http.ResponseWriter, r *http.Request, fork forks.Fork, idHex string) { func (rt *Router) handleGetPayload(w http.ResponseWriter, r *http.Request, fork forks.Fork, idHex string) {
sf, ok := resolveFork(w, fork, forks.Amsterdam) sf, ok := resolveFork(w, fork, forks.Paris)
if !ok { if !ok {
return return
} }
@ -41,7 +41,10 @@ func (rt *Router) handleGetPayload(w http.ResponseWriter, r *http.Request, fork
var id engine.PayloadID var id engine.PayloadID
copy(id[:], raw) copy(id[:], raw)
env, err := rt.backend.GetPayload(id, []forks.Fork{forks.Amsterdam}) // allowedForks is the URL fork's era: the named fork plus any BPO forks
// that layer on it. The catalyst layer derives the cached payload's fork
// via LatestFork, which can yield a BPO fork.
env, err := rt.backend.GetPayload(id, eraForks(fork))
if err != nil { if err != nil {
mapBackendErr(w, err) mapBackendErr(w, err)
return return

View file

@ -27,9 +27,9 @@ import (
// handleNewPayload implements POST /engine/v2/{fork}/payloads. // handleNewPayload implements POST /engine/v2/{fork}/payloads.
func (rt *Router) handleNewPayload(w http.ResponseWriter, r *http.Request, fork forks.Fork) { func (rt *Router) handleNewPayload(w http.ResponseWriter, r *http.Request, fork forks.Fork) {
// The execution-apis spec currently only defines the payload envelope for // The payload envelope shape is fork-driven by the codec; every fork from
// Amsterdam; the inner payload shape is fork-driven by the codec. // Paris on has a valid wire shape.
sf, ok := resolveFork(w, fork, forks.Amsterdam) sf, ok := resolveFork(w, fork, forks.Paris)
if !ok { if !ok {
return return
} }

View file

@ -196,7 +196,7 @@ func TestRouterUnsupportedFork(t *testing.T) {
srv := newTestServer(t, &stubBackend{}) srv := newTestServer(t, &stubBackend{})
defer srv.Close() defer srv.Close()
// Bogus fork in URL. // Bogus fork in URL: the router does not recognise the segment at all.
resp, err := srv.Client().Post(srv.URL+BasePath+"/bogus/payloads", sszContentType, bytes.NewReader(nil)) resp, err := srv.Client().Post(srv.URL+BasePath+"/bogus/payloads", sszContentType, bytes.NewReader(nil))
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@ -205,15 +205,74 @@ func TestRouterUnsupportedFork(t *testing.T) {
if resp.StatusCode != 400 { if resp.StatusCode != 400 {
t.Fatalf("want 400, got %d", resp.StatusCode) t.Fatalf("want 400, got %d", resp.StatusCode)
} }
}
// Real fork but not Amsterdam. // TestRouterAdvertisedForkRoutable asserts that a fork the capabilities
resp, err = srv.Client().Post(srv.URL+BasePath+"/cancun/payloads", sszContentType, bytes.NewReader(nil)) // endpoint advertises (here osaka) is actually routable end-to-end: forkchoice
// with attributes, newPayload, and getPayload all succeed. This guards against
// advertising a fork the handlers reject (the failure mode where capabilities
// outran the per-handler fork gating).
func TestRouterAdvertisedForkRoutable(t *testing.T) {
id := engine.PayloadID{4, 4, 4}
env := &engine.ExecutionPayloadEnvelope{
ExecutionPayload: &engine.ExecutableData{LogsBloom: make([]byte, 256)},
BlockValue: uint256.NewInt(0).ToBig(),
}
b := &stubBackend{
fcuStatus: engine.PayloadStatusV1{Status: engine.VALID},
fcuID: &id,
npStatus: engine.PayloadStatusV1{Status: engine.VALID},
envelope: env,
// Osaka chain sitting in a BPO era: ForkFromTimestamp returns a BPO
// fork, which must collapse onto the osaka URL fork.
forkAtTime: func(uint64) forks.Fork { return forks.BPO1 },
}
srv := newTestServer(t, b)
defer srv.Close()
osaka, _ := sszt.ForkFor(forks.Osaka)
blob, excess := uint64(0), uint64(0)
// forkchoice with payload attributes (proposal path).
fcu := &sszt.ForkchoiceUpdateAmsterdam{
ForkchoiceState: &sszt.ForkchoiceState{HeadBlockHash: common.Hash{0xaa}},
PayloadAttributes: []*sszt.PayloadAttributes{{
Timestamp: 1,
ParentBeaconBlockRoot: &common.Hash{0x55},
}},
}
resp := sszPost(t, srv, "/osaka/forkchoice", fcu, osaka)
if resp.StatusCode != 200 {
t.Fatalf("forkchoice: want 200, got %d", resp.StatusCode)
}
resp.Body.Close()
if b.lastFCUAttrs == nil {
t.Fatal("forkchoice: attributes not forwarded to backend")
}
// newPayload.
penv := &sszt.ExecutionPayloadEnvelopeAmsterdam{
Payload: &sszt.ExecutionPayload{
BaseFeePerGas: uint256.NewInt(7e9),
BlobGasUsed: &blob,
ExcessBlobGas: &excess,
},
ParentBeaconBlockRoot: common.Hash{0x55},
}
resp = sszPost(t, srv, "/osaka/payloads", penv, osaka)
if resp.StatusCode != 200 {
t.Fatalf("newPayload: want 200, got %d", resp.StatusCode)
}
resp.Body.Close()
// getPayload.
resp, err := srv.Client().Get(srv.URL + BasePath + "/osaka/payloads/0x0102030405060708")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
resp.Body.Close() resp.Body.Close()
if resp.StatusCode != 400 { if resp.StatusCode != 200 {
t.Fatalf("want 400 for cancun, got %d", resp.StatusCode) t.Fatalf("getPayload: want 200, got %d", resp.StatusCode)
} }
} }