Skip to content

Prototype for SSZ-REST Engine#16901

Open
syjn99 wants to merge 44 commits into
OffchainLabs:developfrom
syjn99:prototype/ssz-over-http
Open

Prototype for SSZ-REST Engine#16901
syjn99 wants to merge 44 commits into
OffchainLabs:developfrom
syjn99:prototype/ssz-over-http

Conversation

@syjn99

@syjn99 syjn99 commented Jun 5, 2026

Copy link
Copy Markdown
Member

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:

image
  • Payload body size: For example, a request for engine_getPayloadBodiesByHashV1 becomes much smaller (4.7x improvement) in average.
  • Latency: For example, a req-resp roundtrip has improved 17x for engine_getBlobsV2 in 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 using singleflight.

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 like Reconstructor and EngineCaller. 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.go has util functions for handling error from JSON-RPC Engine.
  • engine_ssz.go: All SSZ-REST method callers are located.
  • execution_block.go: All eth namespace callers are located.

Additional notes

  • engineTransport interface: Abstracts the wire transport for the engine namespace. This enables us to decouple the execution service from rpcClient. The service now holds two transport: sszTransport and jsonTransport. Note that jsonTransport is a fallback for sszTransport in case the EL doesn't support SSZ-REST Engine API.
    • Abstraction for Engine transport makes us achieve the goal without much code change outside of beacon-chain/execution package.
  • An execution service originally holds rpcClient and calls it with context. rpcClient cannot be fully removed as methods under eth namespace are still widely used. jsonTransport has a pointer to s.rpcClient as a primary client.
    • Directly calling rpcClient for engine methods is now discouraged: See refactoring for reconstructing block and payload.
  • instrumentedEngine is for observability: It observes the latency for each call.

Plan

✅ Implementation Plan
  • ✅ Build a HTTP/2 client
  • ✅ Wire newly added SSZ containers for communicating with EL
  • ✅ Add a feature flag --engine-ssz-http
  • ✅ Abstraction: leverage EngineCaller/Reconstructor interface. New transport will implement both interfaces, and the caller will call on the interface.
  • ✅ Endpoints implementation
  • ✅ Interop: Currently, it seems Erigon, Nethermind and Ethrex has ongoing implementation. Interop was done with Erigon at this moment.

Appendix

Kurtosis config (with Erigon#21729)

participants:
  - el_type: erigon
    el_image: test/erigon:engine-ssz-793
    cl_type: prysm
    cl_image: prysm-bn-custom-image:engine-ssz
    vc_image: prysm-vc-custom-image:engine-ssz
    supernode: true
    count: 2
    cl_extra_params:
      - --subscribe-all-subnets
      - --verbosity=debug
    vc_extra_params:
      - --verbosity=debug

  - el_type: erigon
    el_image: test/erigon:engine-ssz-793
    cl_type: prysm
    cl_image: prysm-bn-custom-image:engine-ssz
    vc_image: prysm-vc-custom-image:engine-ssz
    supernode: false
    count: 1
    prometheus_config:
      scrape_interval: 15s
      labels:
        variant: ssz-rest
    cl_extra_params:
      - --subscribe-all-subnets
      - --verbosity=debug
      - --engine-ssz-http
    vc_extra_params:
      - --verbosity=debug

  - el_type: erigon
    el_image: test/erigon:engine-ssz-793
    cl_type: prysm
    cl_image: prysm-bn-custom-image:engine-ssz
    vc_image: prysm-vc-custom-image:engine-ssz
    supernode: false
    count: 1
    prometheus_config:
      scrape_interval: 15s
      labels:
        variant: no-ssz-rest
    cl_extra_params:
      - --subscribe-all-subnets
      - --verbosity=debug
    vc_extra_params:
      - --verbosity=debug

network_params:
  fulu_fork_epoch: 0
  genesis_delay: 40

additional_services:
  - dora
  - spamoor
  - prometheus
  - grafana

# spamoor generates load so payloads are non-empty: eoatx for normal EL txs, and blobs for blob transactions
spamoor_params:
  spammers:
    - scenario: eoatx
      config:
        throughput: 10
    - scenario: blobs
      config:
        throughput: 2

global_log_level: debug
  • 2 Supernodes + 1 Normal Node with --engine-ssz-http enabled + 1 Normal Node without --engine-ssz-http.
  • spamoor simulates network with transactions and blobs.
  • This devnet will start from Fusaka, and never fork to Glamsterdam.

Build a docker image for Prysm

#!/usr/bin/env bash
set -euo pipefail

PLATFORM="@io_bazel_rules_go//go/toolchain:linux_arm64_cgo"
CONFIG="release"
TAG="engine-ssz"

echo "Building Prysm beacon-node image..."
bazel build //cmd/beacon-chain:oci_image_tarball \
  --platforms="${PLATFORM}" \
  --config="${CONFIG}"

docker load -i bazel-bin/cmd/beacon-chain/oci_image_tarball/tarball.tar
docker tag gcr.io/offchainlabs/prysm/beacon-chain "prysm-bn-custom-image:${TAG}"

echo "Building Prysm validator image..."
bazel build //cmd/validator:oci_image_tarball \
  --platforms="${PLATFORM}" \
  --config="${CONFIG}"

docker load -i bazel-bin/cmd/validator/oci_image_tarball/tarball.tar
docker tag gcr.io/offchainlabs/prysm/validator "prysm-vc-custom-image:${TAG}"

echo "Built images:"
docker images | grep -E 'prysm-(bn|vc)-custom-image|REPOSITORY'

Grafana Dashboard

See hack/grafana/engine_transport_dashboard.json.

@syjn99 syjn99 changed the title [WIP] Prototype for REST-SSZ Engine [WIP] Prototype for SSZ-REST Engine Jun 10, 2026
@syjn99 syjn99 force-pushed the prototype/ssz-over-http branch from b92d7b1 to b54a0ae Compare June 10, 2026 09:14
syjn99 and others added 13 commits June 11, 2026 13:58
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>
@syjn99 syjn99 force-pushed the prototype/ssz-over-http branch from b54a0ae to ee13ee7 Compare June 11, 2026 05:01
syjn99 and others added 9 commits June 11, 2026 14:26
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>
@syjn99 syjn99 force-pushed the prototype/ssz-over-http branch from 2afa673 to 887a4c1 Compare June 12, 2026 08:04
syjn99 and others added 4 commits June 12, 2026 19:51
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>
syjn99 and others added 7 commits June 12, 2026 20:38
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>
@syjn99 syjn99 changed the title [WIP] Prototype for SSZ-REST Engine Prototype for SSZ-REST Engine Jun 13, 2026
@syjn99 syjn99 marked this pull request as ready for review June 13, 2026 12:40
syjn99 and others added 3 commits June 16, 2026 11:30
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
@syjn99 syjn99 mentioned this pull request Jun 29, 2026
12 tasks
syjn99 and others added 8 commits June 30, 2026 14:53
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant