Prototype for SSZ-REST Engine#16901
Open
syjn99 wants to merge 44 commits into
Open
Conversation
b92d7b1 to
b54a0ae
Compare
Bring in agent instructions from OffchainLabs#16898: AGENTS.md, CLAUDE.md pointer, .claude precheck/test skills, and .gitignore entry for CLAUDE.local.md. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Implement enginehttp, an HTTP/2 (h2c) transport client for the REST + SSZ Engine API v2 (ethereum/execution-apis#793). It round-trips arbitrary SSZ bodies under /engine/v2/{fork}/... with per-request JWT bearer auth, generic over the fastssz Marshaler/Unmarshaler interfaces, and decodes RFC 7807 problem+json errors (branching on HTTP status, not the type URI). Transport-only: not yet wired to the execution service or a feature flag. Reuses network JWT signing via a new network.NewJWTRoundTripper, and promotes golang.org/x/net to a direct dependency for x/net/http2. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds the EnableEngineSSZHTTP feature flag (off by default), the gate for the REST + SSZ Engine API v2 transport (ethereum/execution-apis#793). No behavior yet; JSON-RPC engine_* stays the default transport. Wires the flag into ConfigureBeaconChain and covers it with a test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Lays out all eight REST + SSZ Engine API v2 endpoint operations (ethereum/execution-apis#793) as methods on enginehttp.Client: NewPayload, ForkchoiceUpdated, GetPayload, GetPayloadBodiesBy{Hash,Range}, GetBlobs, Capabilities, Identity. Each is a stub returning errNotImplemented with a per-endpoint TODO(ssz-over-http) comment, plus a skipped test pinning the intended call shape, so the Phase 4 empty spots are easy to find. No behavior. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Route the execution Service's engine_* calls through an internal engineTransport interface, so an alternative transport (SSZ-over-HTTP, execution-apis#793) can be slotted in below the public EngineCaller/Reconstructor surface. The seven engine wire methods (NewPayload, ForkchoiceUpdated, GetPayload, GetBlobs, GetBlobsV2, ExchangeCapabilities, GetClientVersionV1) keep their public signatures as thin dispatchers in engine_transport.go; their bodies move to jsonEngine in engine_jsonrpc.go, which holds only its wire dependencies (the RPCClient and the capability cache). engine_client.go keeps the non-transport code (eth_*, reconstruction, consts). Behavior-preserving; the eth_* namespace is untouched. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Fill the GET /engine/v2/capabilities operation: JSONGet into a Capabilities struct whose shape matches the EL ground truth in docs/fixtures (supported_forks, fork_scoped_endpoints, independently_versioned, unscoped_endpoints, limits). A 404 surfaces as an *Error so the connection-setup probe can fall back to JSON-RPC. Replaces the skipped scaffold test with real decode + 404 coverage. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add the sszEngine implementation of engineTransport and select it per connection. When --engine-ssz-http is set, setupExecutionClientConnections probes GET /engine/v2/capabilities; on success the engine API is driven over SSZ-over-HTTP, otherwise (no v2 surface, non-h2c endpoint, or probe error) it falls back to JSON-RPC for the connection's lifetime — no per-method ladder, re-evaluated on reconnect. Only Capabilities is implemented; the remaining sszEngine ops return a not-implemented error until their Phase 4 PRs land, so the flag stays off in real runs. The default (flag off) path is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Wire the GET /engine/v2/identity diagnostic endpoint (execution-apis#793), the v2 replacement for engine_getClientVersionV1. Client.Identity does a JSON GET returning the EL's client-version array; sszEngine.GetClientVersionV1 adapts it to []*structs.ClientVersionV1 so consumers are unchanged. Prysm's own version travels in the X-Engine-Client-Version header on every request, so the mutual-exchange handshake is dropped. Flip the skipped TestIdentity to a real h2c httptest test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Wire POST /engine/v2/{fork}/payloads (execution-apis#793), the v2 replacement for engine_newPayloadV*. sszEngine.NewPayload builds fork-scoped SSZ envelopes: Osaka/Fulu wraps ExecutionPayloadDeneb in ExecutionPayloadEnvelopeFulu, while Amsterdam/Gloas wraps ExecutionPayloadGloas in ExecutionPayloadEnvelopeGloas. Both fold parent_beacon_block_root and flattened execution_requests into the envelope; versionedHashes is dropped because the EL recomputes blob hashes from payload.transactions.
Client.NewPayload POSTs the envelope and decodes the uint8 PayloadStatus; payloadStatusResult maps the enum onto the same (latestValidHash, sentinel) returns as JSON-RPC, with the removed INVALID_BLOCK_HASH folding into INVALID. Consumers are unchanged.
Pre-Fulu payload shapes still return an explicit no v2 envelope container error. Fulu uses the Deneb payload shape under the Osaka envelope.
Flip the skipped lower-half TestNewPayload to a real h2c test; add an upper-half TestPayloadStatusResult covering every enum -> sentinel mapping.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Wire POST /engine/v2/{fork}/forkchoice (execution-apis#793), the v2 replacement
for engine_forkchoiceUpdatedV*. sszEngine.ForkchoiceUpdated builds the Fulu
(osaka) / Gloas (amsterdam) ForkchoiceUpdate container — payload_attributes is
an Optional (absent for a pure head update, present when a build is requested,
matching the JSON-RPC null-attrs convention). The returned opaque payload_id is
copied verbatim and never recomputed.
forkchoiceResult maps the restricted status enum onto the same returns and
sentinels as jsonEngine.ForkchoiceUpdated. mapEngineError translates the RFC
7807 problem+json types (invalid-forkchoice -> ErrInvalidForkchoiceState,
invalid-attributes -> ErrInvalidPayloadAttributes, unknown-payload, etc.) onto
the existing JSON-RPC sentinels, keyed on the stable `type` URI. custody_columns
is left absent: Prysm's forkchoice path does not yet manage the EL custody set,
so an omitted field leaves it unchanged.
Flip the skipped lower-half TestForkchoiceUpdated to a real h2c test; add
upper-half TestForkchoiceResult and TestMapEngineError.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Wire GET /engine/v2/{fork}/payloads/{id} (execution-apis#793), the v2
replacement for engine_getPayloadV*. sszEngine.GetPayload selects the fork from
the slot (Fulu/Gloas), hex-encodes the opaque server-assigned id into the path,
and decodes the fork's BuiltPayload. builtPayloadToBundle copies it onto the
existing ExecutionBundle{Fulu,Gloas} proto — a flat field copy, since both
reuse the same v1 inner payload/blobs types — then runs it through
blocks.NewGetPayloadResponse so the GetPayloadResponse is built identically to
the JSON-RPC path. The id is never recomputed and the response is never cached.
Flip the skipped lower-half TestGetPayload to a real h2c test (asserting the
hex path and no-body GET); add an upper-half TestBuiltPayloadToBundle.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
b54a0ae to
ee13ee7
Compare
Wire POST /engine/v2/blobs/v1 and /blobs/v2 (execution-apis#793), the v2
replacements for engine_getBlobsV1/V2. The blob endpoints are version-scoped,
not fork-scoped. sszEngine.GetBlobs / GetBlobsV2 build the shared
List[VersionedHash] request, POST it, and convert the BlobsV1Response /
BlobsV2Response entries into request-aligned []*pb.BlobAndProof{,V2} slices —
one slot per requested hash, nil on per-entry available=false, matching the
JSON-RPC shape consumers already iterate. A 204 (ErrNoContent) means the EL
cannot serve the request (syncing / V2 all-or-nothing miss) and returns an
empty result, matching the JSON-RPC "nothing returned" path.
Each method is gated on the EL's advertised blob revisions (supportsBlob, the
SSZ-native equivalent of jsonEngine's caps.has). supportsBlob reads the probed
v2 capability document under capsLock; GetBlobsV2 honors --disable-get-blobs-v2.
The engineTransport interface has no v3/v4 methods, and the /blobs/v4 bitvector
request container is not defined in proto/engine/v2; v4 cell-range selection is
intentionally left unwired pending that codegen.
Flip the skipped lower-half TestGetBlobs to a real h2c test, add a 204 case,
and add an upper-half test for supportsBlob.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Bodies were the one engine surface still bound to RPCClient directly:
payload_body.go's blindedBlockReconstructor called CallContext for
engine_getPayloadBodiesByHash/ByRangeV1, bypassing the engineTransport seam.
Add PayloadBodyFork, GetPayloadBodiesByHash and GetPayloadBodiesByRange to
engineTransport and refactor the reconstructor to drive it instead of an
RPCClient. The reconstructor now keys its request batches by an opaque key the
transport supplies (PayloadBodyFork) rather than a JSON-RPC method name.
jsonEngine implements the three methods behavior-preserving: PayloadBodyFork
returns a constant so all blocks stay in one V1 batch, and the two getters
relocate the existing CallContext calls verbatim (raw errors, exactly as
before). ReconstructFullBellatrixBlockBatch now dispatches via s.engine().
sszEngine gets stubs returning sszNotImplemented (the SSZ /{fork}/bodies impl
lands in a follow-up PR); the flag-on path is never used in a real run until
all ops land. No behavior change on the JSON-RPC path.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2afa673 to
887a4c1
Compare
The enginehttp client read the EL response with an unbounded io.ReadAll, so a crafted or oversized Content-Length or body could coerce a large allocation. roundtrip now rejects before reading when Content-Length exceeds the cap, and bounds the bytes read in all cases (handling an absent or lying Content-Length) via io.LimitReader plus a length check. The ceiling defaults to 512 MiB and is overridable via Config.MaxResponseBytes; tighter per-endpoint limits.* caps layer on top in a follow-up. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The EL advertises per-request caps in GET /engine/v2/capabilities and rejects anything larger with a 413 request-too-large. The SSZ engine transport now respects them client-side. bodies.max_count: GetPayloadBodiesByHash/ByRange split an over-cap request into in-order sub-batches whose concatenation stays aligned with the requested hashes/range, preserving the reconstructor's invariants. blobs.max_versioned_hashes and payload.max_bytes: blob requests (atomic, all-or-nothing) and a single payload envelope cannot be split, so they are rejected before sending with the same ErrRequestTooLarge sentinel the EL's 413 maps to. A nil capability document or an absent/zero limit imposes no client-side cap, mirroring supportsBlob's defensive default. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The spec replaces the per-method timeout SHOULDs with HTTP-standard request timeouts plus Retry-After semantics on 503; the http.Client timeout already covers the former. roundtrip now retries a 503 that carries a usable Retry-After after the indicated backoff, bounded by maxRetriesOn503 attempts and a per-wait cap (MaxRetryAfter, default 2s), and aborts the wait if the request context ends. A 503 with no Retry-After or one beyond the cap surfaces immediately, leaving longer backoff to the caller. The single-attempt logic is factored into do(); roundtrip is the retry loop. parseRetryAfter handles both RFC 7231 forms (delay-seconds and HTTP-date). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Verified the SSZ-HTTP client already meets the spec's transport SHOULDs, so no functional change is needed: - Flow control: x/net/http2's Transport advertises a 4 MiB stream receive window and a 1 GiB connection window by default, above the spec's >= 1 MiB INITIAL_WINDOW_SIZE SHOULD. The "64 KiB default" is the RFC value, which x/net overrides; the bare Transport exposes no public field to tune it, and our h2c setup does not use http.Transport's HTTP2Config. MAX_FRAME_SIZE / MAX_HEADER_LIST_SIZE stay at HTTP/2 defaults, which the spec leaves unpinned. - Compression: we tolerate uncompressed responses (the only CL MUST) and the Transport negotiates gzip transparently; zstd is optional and would add a dependency, so it is not requested. Documented at the Transport construction so this is not re-investigated. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The REST + SSZ engine API allows only one POST /forkchoice in flight per connection and requires the CL to await the response before issuing the next. Forkchoice updates originate from independent goroutines (the blockchain head-update path and the proposer RPC), which could overlap on the single h2c connection. Add a per-connection sync.Mutex on sszEngine, held across the ForkchoiceUpdated round-trip. All callers funnel through the same connection-scoped sszEngine, so the mutex serializes them regardless of goroutine; a reconnect gets a fresh mutex. JSON-RPC is unchanged. A concurrency test fires 16 concurrent FCUs and asserts max-in-flight == 1. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The REST + SSZ engine API defines the top-level /bodies and /blobs request and
response bodies as bare SSZ List[...] values, not single-field containers. The
proto-generated containers prepended a 4-byte offset, which misframed every
/bodies and /blobs message against execution layers that read them as top-level
lists.
Delete the six wrapper messages (BodiesByHashRequest, BlobsRequest,
BodiesResponse{Fulu,Gloas}, BlobsV1Response, BlobsV2Response) from the proto and
exclude them from ssz-gen, then reimplement them as slice types with hand-written
bare SSZ codecs in engine_v2_req_resp.go that reuse the generated element codecs
item by item (the beacon-chain/p2p/types pattern). Add a codec test covering
round-trips and the bare wire format (no 4-byte container prefix).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This reverts commit 7fa40e8.
Use single-field container framing (a 4-byte offset prefix) for the /bodies and /blobs request and response bodies, matching the go-ethereum reference implementation. Re-add the wrapper messages to the proto and regenerate, and drop the hand-written bare-list codecs. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
# Conflicts: # beacon-chain/execution/engine_client.go # beacon-chain/execution/payload_body.go # beacon-chain/execution/payload_body_test.go
Conflict resolutions: - BUILD.bazel (execution, consensus-types/blocks): union of deps (fastssz + go-bitfield; hexutil + libp2p/peer + pubsub/partialmessages + pkg/errors). - service.go Service struct: union — keep SSZ-over-HTTP transports (sszTransport/jsonTransport) AND upstream's partialColumnsSupported; add a Service-level capabilityCache (shared with the JSON transport). - engine_client.go: took ours for the two moved blocks (JSON-RPC consts/vars and the EngineCaller methods live in engine_jsonrpc.go/engine_transport.go now); kept upstream's auto-merged PeerDAS partial-data-column Reconstructor methods. Added GetBlobsV3/HasBlobs Service methods + missing imports. - rpc_connection.go: took ours (dropped upstream's Service-level capability warning block; capability handling lives in the transport). - specrefs/dataclasses.yml: took ours (NewPayload spec ref points at the relocated jsonEngine.NewPayload). Carried upstream changes through the transport refactor: - NewPayload now takes pb.ExecutionRequester (was *pb.ExecutionRequests) across the engineTransport interface, json/ssz engines and encode helper; uses FlattenRequests() so Gloas requests dispatch polymorphically. - Added GetBlobsV3/HasBlobs methods + consts + fulu endpoints. - Ported upstream's ExchangeCapabilities fix (build the requested method list in a local slice instead of mutating the package-level supportedEngineEndpoints). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
ethereum/execution-apis#793
Note
This work assumes the devnet will start from Fusaka due to reducing boilerplate code. If a devnet starts the fork prior to Fusaka, the engine API won't work.
This PR implements SSZ-REST Engine API, which will replace current JSON-RPC
engine_*methods that require hex-encoded JSON encoding between EL and CL. Adding a flag(--engine-ssz-http) will enable SSZ-REST engine.As the following panels show, this will bring a lot of benefit including:
engine_getPayloadBodiesByHashV1becomes much smaller (4.7x improvement) in average.engine_getBlobsV2in average.Current caveat on implementation:
engine_forkchoiceUpdated*method often shows poor latency. This is because 1) now CL is responsible for serialising fork choice update (See the rationale.) and 2) the current implementation in this PR uses mutex for this. We might expect better latency usingsingleflight.See Appendix for config and scripts.
Design Principal
Files and packages
beacon-chain/execution/enginehttp: Implements h2c client and wires SSZ-REST APIs.engine_client.go: Now only holds interfaces likeReconstructorandEngineCaller. Didn't move reconstructor methods in this file though.engine_transport.go: Defines core interface (engineTransport) with a wrapper for observability (instrumentedEngine).engine_jsonrpc.go: All legacy JSON-RPC method callers are located.jsonrpc_error.gohas util functions for handling error from JSON-RPC Engine.engine_ssz.go: All SSZ-REST method callers are located.execution_block.go: Allethnamespace callers are located.Additional notes
engineTransportinterface: Abstracts the wire transport for theenginenamespace. This enables us to decouple the execution service fromrpcClient. The service now holds two transport:sszTransportandjsonTransport. Note thatjsonTransportis a fallback forsszTransportin case the EL doesn't support SSZ-REST Engine API.beacon-chain/executionpackage.rpcClientand calls it with context.rpcClientcannot be fully removed as methods underethnamespace are still widely used.jsonTransporthas a pointer tos.rpcClientas a primary client.rpcClientforenginemethods is now discouraged: See refactoring for reconstructing block and payload.instrumentedEngineis for observability: It observes the latency for each call.Plan
✅ Implementation Plan
--engine-ssz-httpEngineCaller/Reconstructorinterface. New transport will implement both interfaces, and the caller will call on the interface.Appendix
Kurtosis config (with Erigon#21729)
--engine-ssz-httpenabled + 1 Normal Node without--engine-ssz-http.spamoorsimulates network with transactions and blobs.Build a docker image for Prysm
Grafana Dashboard
See
hack/grafana/engine_transport_dashboard.json.