Skip to content

feat: engine API transport SSZ-REST - #9382

Closed
nazarhussain wants to merge 36 commits into
unstablefrom
nh/ssz-engine-api
Closed

nazarhussain wants to merge 36 commits into
unstablefrom
nh/ssz-engine-api

Conversation

@nazarhussain

@nazarhussain nazarhussain commented May 19, 2026

Copy link
Copy Markdown
Contributor

Summary

Implements the SSZ-REST Engine API transport on the consensus-layer side, following
ethereum/execution-apis#793
(src/engine/refactor.md, src/engine/refactor-ssz.md). Supersedes the earlier
#764-based wire format this PR started from.

  • Opt-in via hidden flag --execution.sszRest; default off.
  • One-shot GET /engine/v1/capabilities probe; REST is used for the forks the EL advertises, JSON-RPC for everything else. No per-request fallback (spec § Transition-window behavior).
  • Endpoints: POST /payloads, POST /forkchoice, GET /payloads/{id}, POST /bodies/hash, GET /bodies?from&count, POST /blobs/v1, POST /blobs/v2, GET /identity.
  • Eth-Execution-Version fork header (bellatrix→paris … gloas→amsterdam), X-Engine-Client-Version header, JWT without clv, RFC 7807 application/problem+json errors, 204 No Content on blobs.
  • Per-EL-fork SSZ containers in sszRestEncoding.ts — the only file that knows the wire shape.

Out of scope (follow-ups; need IExecutionEngine changes)

POST /blobs/v3, POST /blobs/v4, forkchoice custody_columns (encoded as absent on Amsterdam), HTTP/2 client.

  • REST-dispatched calls emit no per-route lodestar_execution_engine_http_client_* metrics yet (JSON-RPC metrics unchanged).
  • Pre-existing: getBlobs does not forward caller-provided buffers on the JSON-RPC path; left as-is here, to be fixed separately with its own test.
  • limits.payload.max_bytes from GET /capabilities is parsed but not enforced client-side; an oversize body surfaces as the EL's 413 rather than failing locally.
  • Historical bodies: the spec expects a CL spanning a fork boundary to issue one request per era (and re-issue for the unfilled suffix of a range). Not implemented — getPayloadBodiesByHash/ByRange have no production callers in this repo today.
  • HTTP/1.1 only. The spec requires HTTP/2 support and prefers it; the API is otherwise identical over 1.1, and connections are keep-alive pooled.

Testing

Unit tests only: spec-derived oracle containers and a Fastify fake EL under
packages/beacon-node/test/unit/executionEngine/sszRest*.test.ts and httpSszRest.test.ts.
http.test.ts (JSON-RPC) is unchanged and green.

  • getPayloadBodiesByHash/ByRange have no production callers in this repo today; the REST bodies path is exercised by unit tests only.

No execution client serves #793 yet, so there is no interop test. refactor-ssz.md marks
MAX_BAL_BYTES / MAX_BYTES_PER_EXECUTION_REQUEST as placeholders; they are single constants
at the top of sszRestEncoding.ts.

AI disclosure

Implemented with AI assistance (Claude Code); design, review, and validation by the author.

🤖 Generated with Claude Code

Comment thread packages/beacon-node/src/execution/engine/http.ts Fixed
Comment thread packages/beacon-node/src/execution/engine/sszRestClient.ts Fixed

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request implements EIP-8161, introducing an SSZ-REST transport for the Engine API to improve communication efficiency. It adds a new SszRestClient and specialized encoding/decoding logic, updating the ExecutionEngineHttp class to prioritize SSZ-REST for key methods with a fallback to JSON-RPC. Review feedback identified critical bugs in the SSZ encoding and decoding for getBlobs requests and responses, specifically regarding the incorrect use of offsets for fixed-size lists. Additionally, improvements were suggested to enhance redundancy by supporting multiple engine URLs, refactor duplicated versioning logic into a helper method, and utilize existing utility functions for hex-to-byte conversions.

Comment thread packages/beacon-node/src/execution/engine/sszRestEncoding.ts Outdated
Comment thread packages/beacon-node/src/execution/engine/sszRestEncoding.ts Outdated
Comment thread packages/beacon-node/src/execution/engine/http.ts Outdated
Comment thread packages/beacon-node/src/execution/engine/http.ts Outdated
Comment thread packages/beacon-node/src/execution/engine/sszRestClient.ts Outdated
@github-actions

github-actions Bot commented May 19, 2026

Copy link
Copy Markdown
Contributor

Performance Report

🚀🚀 Significant benchmark improvement detected

Benchmark suite Current: 2d74ef1 Previous: c9d60c8 Ratio
Array.fill - length 1000000 4.7367 ms/op 20.134 ms/op 0.24
Full benchmark results
Benchmark suite Current: 2d74ef1 Previous: c9d60c8 Ratio
getPubkeys - native cache - req 1000 vs - 250000 vc 474.47 us/op 501.12 us/op 0.95
getPubkeys - validatorsArr - req 1000 vs - 250000 vc 39.329 us/op 37.015 us/op 1.06
BLS verify - blst 893.83 us/op 844.21 us/op 1.06
BLS verifyMultipleSignatures 3 - blst 1.3631 ms/op 1.3078 ms/op 1.04
BLS verifyMultipleSignatures 8 - blst 2.2869 ms/op 2.2509 ms/op 1.02
BLS verifyMultipleSignatures 32 - blst 7.4322 ms/op 7.2985 ms/op 1.02
BLS verifyMultipleSignatures 64 - blst 14.313 ms/op 13.598 ms/op 1.05
BLS verifyMultipleSignatures 128 - blst 27.143 ms/op 26.087 ms/op 1.04
BLS deserializing 10000 signatures 656.98 ms/op 638.93 ms/op 1.03
BLS deserializing 100000 signatures 6.3795 s/op 6.4220 s/op 0.99
BLS verifyMultipleSignatures - same message - 3 - blst 920.09 us/op 956.95 us/op 0.96
BLS verifyMultipleSignatures - same message - 8 - blst 1.0455 ms/op 1.0899 ms/op 0.96
BLS verifyMultipleSignatures - same message - 32 - blst 1.6453 ms/op 1.6923 ms/op 0.97
BLS verifyMultipleSignatures - same message - 64 - blst 2.4310 ms/op 2.5386 ms/op 0.96
BLS verifyMultipleSignatures - same message - 128 - blst 4.0054 ms/op 4.1883 ms/op 0.96
BLS aggregatePubkeys 32 - blst 18.724 us/op 19.513 us/op 0.96
BLS aggregatePubkeys 128 - blst 67.372 us/op 70.977 us/op 0.95
getSlashingsAndExits - default max 40.174 us/op 40.543 us/op 0.99
getSlashingsAndExits - 2k 339.27 us/op 344.60 us/op 0.98
proposeBlockBody type=full, size=empty 675.54 us/op 675.78 us/op 1.00
isKnown best case - 1 super set check 155.00 ns/op 179.00 ns/op 0.87
isKnown normal case - 2 super set checks 154.00 ns/op 163.00 ns/op 0.94
isKnown worse case - 16 super set checks 156.00 ns/op 162.00 ns/op 0.96
validate api signedAggregateAndProof - struct 1.4517 ms/op 1.4947 ms/op 0.97
validate gossip signedAggregateAndProof - struct 1.4507 ms/op 1.4923 ms/op 0.97
batch validate gossip attestation - vc 640000 - chunk 32 121.51 us/op 126.06 us/op 0.96
batch validate gossip attestation - vc 640000 - chunk 64 101.31 us/op 107.87 us/op 0.94
batch validate gossip attestation - vc 640000 - chunk 128 92.662 us/op 97.921 us/op 0.95
batch validate gossip attestation - vc 640000 - chunk 256 91.542 us/op 99.088 us/op 0.92
bytes32 toHexString 269.00 ns/op 273.00 ns/op 0.99
bytes32 Buffer.toString(hex) 152.00 ns/op 149.00 ns/op 1.02
bytes32 Buffer.toString(hex) from Uint8Array 217.00 ns/op 225.00 ns/op 0.96
bytes32 Buffer.toString(hex) + 0x 150.00 ns/op 151.00 ns/op 0.99
Return object 10000 times 0.20320 ns/op 0.20420 ns/op 1.00
Throw Error 10000 times 3.1081 us/op 3.1477 us/op 0.99
toHex 90.869 ns/op 88.248 ns/op 1.03
Buffer.from 84.364 ns/op 81.348 ns/op 1.04
shared Buffer 57.269 ns/op 57.022 ns/op 1.00
fastMsgIdFn sha256 / 200 bytes 1.4370 us/op 1.5280 us/op 0.94
fastMsgIdFn h32 xxhash / 200 bytes 148.00 ns/op 154.00 ns/op 0.96
fastMsgIdFn h64 xxhash / 200 bytes 183.00 ns/op 204.00 ns/op 0.90
fastMsgIdFn sha256 / 1000 bytes 4.5290 us/op 4.7570 us/op 0.95
fastMsgIdFn h32 xxhash / 1000 bytes 236.00 ns/op 246.00 ns/op 0.96
fastMsgIdFn h64 xxhash / 1000 bytes 240.00 ns/op 256.00 ns/op 0.94
fastMsgIdFn sha256 / 10000 bytes 39.989 us/op 42.166 us/op 0.95
fastMsgIdFn h32 xxhash / 10000 bytes 1.2450 us/op 1.2640 us/op 0.98
fastMsgIdFn h64 xxhash / 10000 bytes 816.00 ns/op 822.00 ns/op 0.99
send data - 1000 256B messages 4.0919 ms/op 4.0780 ms/op 1.00
send data - 1000 512B messages 4.9770 ms/op 5.0735 ms/op 0.98
send data - 1000 1024B messages 5.2816 ms/op 5.2920 ms/op 1.00
send data - 1000 1200B messages 6.7386 ms/op 6.0574 ms/op 1.11
send data - 1000 2048B messages 11.678 ms/op 8.2419 ms/op 1.42
send data - 1000 4096B messages 35.042 ms/op 53.942 ms/op 0.65
send data - 1000 16384B messages 311.42 ms/op 408.18 ms/op 0.76
send data - 1000 65536B messages 1.5172 s/op 1.6779 s/op 0.90
enrSubnets - fastDeserialize 64 bits 716.00 ns/op 730.00 ns/op 0.98
enrSubnets - ssz BitVector 64 bits 265.00 ns/op 251.00 ns/op 1.06
enrSubnets - fastDeserialize 4 bits 97.000 ns/op 101.00 ns/op 0.96
enrSubnets - ssz BitVector 4 bits 255.00 ns/op 260.00 ns/op 0.98
prioritizePeers score -10:0 att 32-0.1 sync 2-0 197.12 us/op 194.95 us/op 1.01
prioritizePeers score 0:0 att 32-0.25 sync 2-0.25 225.42 us/op 217.39 us/op 1.04
prioritizePeers score 0:0 att 32-0.5 sync 2-0.5 321.45 us/op 310.84 us/op 1.03
prioritizePeers score 0:0 att 64-0.75 sync 4-0.75 574.21 us/op 622.72 us/op 0.92
prioritizePeers score 0:0 att 64-1 sync 4-1 667.24 us/op 657.36 us/op 1.02
archive index / 1 slot / archive root index lookup 16.316 us/op 21.723 us/op 0.75
archive index / 1 slot / archive block lookup, decode and hash 2.8226 ms/op 2.6172 ms/op 1.08
archive index / 1 slots / 1 columns / legacy 23.116 us/op 24.554 us/op 0.94
archive index / 1 slots / 1 columns / flat files 175.20 us/op 179.18 us/op 0.98
archive index / 1 slots / 8 columns / legacy 81.364 us/op 96.962 us/op 0.84
archive index / 1 slots / 8 columns / flat files 365.26 us/op 378.52 us/op 0.96
archive index / 1 slots / 128 columns / legacy 9.4576 ms/op 7.9914 ms/op 1.18
archive index / 1 slots / 128 columns / flat files 4.8027 ms/op 4.4585 ms/op 1.08
archive index / 8 slots / 1 columns / legacy 168.92 us/op 179.44 us/op 0.94
archive index / 8 slots / 1 columns / flat files 1.1514 ms/op 1.3319 ms/op 0.86
archive index / 8 slots / 8 columns / legacy 764.35 us/op 1.6902 ms/op 0.45
archive index / 8 slots / 8 columns / flat files 3.8162 ms/op 3.2953 ms/op 1.16
archive index / 8 slots / 128 columns / legacy 29.234 ms/op 56.151 ms/op 0.52
archive index / 8 slots / 128 columns / flat files 219.34 ms/op 233.15 ms/op 0.94
head state / 1 slot / archive root index lookup 17.596 us/op 20.297 us/op 0.87
head state / 1 slot / archive block lookup, decode and hash 2.8303 ms/op 2.6210 ms/op 1.08
head state / 1 slots / 1 columns / legacy 20.487 us/op 22.594 us/op 0.91
head state / 1 slots / 1 columns / flat files 118.85 us/op 149.06 us/op 0.80
head state / 1 slots / 8 columns / legacy 79.710 us/op 71.863 us/op 1.11
head state / 1 slots / 8 columns / flat files 359.94 us/op 1.0410 ms/op 0.35
head state / 1 slots / 128 columns / legacy 13.270 ms/op 10.256 ms/op 1.29
head state / 1 slots / 128 columns / flat files 5.8696 ms/op 4.1906 ms/op 1.40
head state / 8 slots / 1 columns / legacy 176.82 us/op 197.49 us/op 0.90
head state / 8 slots / 1 columns / flat files 939.20 us/op 1.0643 ms/op 0.88
head state / 8 slots / 8 columns / legacy 5.4249 ms/op 4.8873 ms/op 1.11
head state / 8 slots / 8 columns / flat files 3.7186 ms/op 3.0558 ms/op 1.22
head state / 8 slots / 128 columns / legacy 93.040 ms/op 90.177 ms/op 1.03
head state / 8 slots / 128 columns / flat files 233.11 ms/op 244.85 ms/op 0.95
array of 16000 items push then shift 1.2552 us/op 1.3095 us/op 0.96
LinkedList of 16000 items push then shift 7.7530 ns/op 6.9420 ns/op 1.12
array of 16000 items push then pop 82.355 ns/op 76.837 ns/op 1.07
LinkedList of 16000 items push then pop 6.1420 ns/op 5.9400 ns/op 1.03
array of 24000 items push then shift 1.8541 us/op 1.9289 us/op 0.96
LinkedList of 24000 items push then shift 7.2010 ns/op 6.5700 ns/op 1.10
array of 24000 items push then pop 113.37 ns/op 106.25 ns/op 1.07
LinkedList of 24000 items push then pop 6.0470 ns/op 6.0160 ns/op 1.01
intersect bitArray bitLen 8 3.8150 ns/op 3.9010 ns/op 0.98
intersect array and set length 8 29.321 ns/op 29.574 ns/op 0.99
intersect bitArray bitLen 128 23.568 ns/op 23.459 ns/op 1.00
intersect array and set length 128 491.84 ns/op 502.15 ns/op 0.98
bitArray.getTrueBitIndexes() bitLen 128 934.00 ns/op 925.00 ns/op 1.01
bitArray.getTrueBitIndexes() bitLen 248 1.6980 us/op 1.6890 us/op 1.01
bitArray.getTrueBitIndexes() bitLen 512 3.5090 us/op 3.4840 us/op 1.01
Full columns - reconstruct all 6 blobs 153.33 us/op 123.10 us/op 1.25
Full columns - reconstruct half of the blobs out of 6 75.559 us/op 66.705 us/op 1.13
Full columns - reconstruct single blob out of 6 32.704 us/op 29.111 us/op 1.12
Half columns - reconstruct all 6 blobs 388.27 ms/op 378.77 ms/op 1.03
Half columns - reconstruct half of the blobs out of 6 193.95 ms/op 189.63 ms/op 1.02
Half columns - reconstruct single blob out of 6 69.023 ms/op 67.885 ms/op 1.02
Set add up to 64 items then delete first 1.6114 us/op 1.6693 us/op 0.97
OrderedSet add up to 64 items then delete first 2.4269 us/op 2.5154 us/op 0.96
Set add up to 64 items then delete last 1.8002 us/op 1.8810 us/op 0.96
OrderedSet add up to 64 items then delete last 2.6628 us/op 2.6524 us/op 1.00
Set add up to 64 items then delete middle 1.7810 us/op 1.7196 us/op 1.04
OrderedSet add up to 64 items then delete middle 4.0885 us/op 4.1024 us/op 1.00
Set add up to 128 items then delete first 3.6741 us/op 3.3196 us/op 1.11
OrderedSet add up to 128 items then delete first 5.6995 us/op 4.9973 us/op 1.14
Set add up to 128 items then delete last 3.5482 us/op 3.2992 us/op 1.08
OrderedSet add up to 128 items then delete last 5.3222 us/op 5.1291 us/op 1.04
Set add up to 128 items then delete middle 3.5635 us/op 3.3014 us/op 1.08
OrderedSet add up to 128 items then delete middle 11.388 us/op 10.852 us/op 1.05
Set add up to 256 items then delete first 7.5427 us/op 6.6941 us/op 1.13
OrderedSet add up to 256 items then delete first 12.378 us/op 10.313 us/op 1.20
Set add up to 256 items then delete last 6.9999 us/op 6.6220 us/op 1.06
OrderedSet add up to 256 items then delete last 11.663 us/op 10.693 us/op 1.09
Set add up to 256 items then delete middle 7.1381 us/op 6.6625 us/op 1.07
OrderedSet add up to 256 items then delete middle 35.770 us/op 33.529 us/op 1.07
runFastConfirmationRules vc:100000 bc:96 eq:0 4.7907 ms/op 4.1515 ms/op 1.15
runFastConfirmationRules vc:600000 bc:96 eq:0 36.110 ms/op 34.225 ms/op 1.06
runFastConfirmationRules vc:1000000 bc:96 eq:0 61.438 ms/op 56.740 ms/op 1.08
runFastConfirmationRules vc:600000 bc:320 eq:0 40.935 ms/op 34.183 ms/op 1.20
runFastConfirmationRules vc:100000 bc:96 eq:1000 1.1222 s/op 1.0808 s/op 1.04
pass gossip attestations to forkchoice per slot 2.7079 ms/op 2.6090 ms/op 1.04
forkChoice updateHead vc 100000 bc 64 eq 0 420.34 us/op 497.27 us/op 0.85
forkChoice updateHead vc 600000 bc 64 eq 0 2.5206 ms/op 2.9211 ms/op 0.86
forkChoice updateHead vc 1000000 bc 64 eq 0 4.2106 ms/op 4.9094 ms/op 0.86
forkChoice updateHead vc 600000 bc 320 eq 0 2.5622 ms/op 2.9387 ms/op 0.87
forkChoice updateHead vc 600000 bc 1200 eq 0 2.5960 ms/op 2.9871 ms/op 0.87
forkChoice updateHead vc 600000 bc 7200 eq 0 4.1855 ms/op 3.5160 ms/op 1.19
forkChoice updateHead vc 600000 bc 64 eq 1000 2.5180 ms/op 3.0247 ms/op 0.83
forkChoice updateHead vc 600000 bc 64 eq 10000 2.5418 ms/op 3.0851 ms/op 0.82
forkChoice updateHead vc 600000 bc 64 eq 300000 7.2468 ms/op 7.4305 ms/op 0.98
forkChoice updateHead vc 600000 bc 64 eq 0 gloas boosted 2.5597 ms/op 2.8810 ms/op 0.89
computeDeltas 1400000 validators 0% inactive 12.430 ms/op 12.517 ms/op 0.99
computeDeltas 1400000 validators 10% inactive 11.744 ms/op 11.856 ms/op 0.99
computeDeltas 1400000 validators 20% inactive 11.093 ms/op 11.149 ms/op 0.99
computeDeltas 1400000 validators 50% inactive 9.0932 ms/op 9.1650 ms/op 0.99
computeDeltas 2100000 validators 0% inactive 18.636 ms/op 18.805 ms/op 0.99
computeDeltas 2100000 validators 10% inactive 17.677 ms/op 17.976 ms/op 0.98
computeDeltas 2100000 validators 20% inactive 16.956 ms/op 16.888 ms/op 1.00
computeDeltas 2100000 validators 50% inactive 11.030 ms/op 11.281 ms/op 0.98
altair processAttestation - 250000 vs - 7PWei normalcase 2.7226 ms/op 1.5904 ms/op 1.71
altair processAttestation - 250000 vs - 7PWei worstcase 3.8581 ms/op 2.4213 ms/op 1.59
altair processAttestation - setStatus - 1/6 committees join 115.35 us/op 100.38 us/op 1.15
altair processAttestation - setStatus - 1/3 committees join 212.63 us/op 202.87 us/op 1.05
altair processAttestation - setStatus - 1/2 committees join 311.82 us/op 283.46 us/op 1.10
altair processAttestation - setStatus - 2/3 committees join 396.73 us/op 367.82 us/op 1.08
altair processAttestation - setStatus - 4/5 committees join 575.04 us/op 513.32 us/op 1.12
altair processAttestation - setStatus - 100% committees join 637.47 us/op 603.88 us/op 1.06
altair processBlock - 250000 vs - 7PWei normalcase 7.2075 ms/op 4.4327 ms/op 1.63
altair processBlock - 250000 vs - 7PWei normalcase hashState 28.423 ms/op 24.485 ms/op 1.16
altair processBlock - 250000 vs - 7PWei worstcase 29.547 ms/op 22.151 ms/op 1.33
altair processBlock - 250000 vs - 7PWei worstcase hashState 67.782 ms/op 55.823 ms/op 1.21
phase0 processBlock - 250000 vs - 7PWei normalcase 1.5243 ms/op 1.3409 ms/op 1.14
phase0 processBlock - 250000 vs - 7PWei worstcase 21.793 ms/op 19.681 ms/op 1.11
altair processEth1Data - 250000 vs - 7PWei normalcase 298.87 us/op 273.19 us/op 1.09
getExpectedWithdrawals 250000 eb:1,eth1:1,we:0,wn:0,smpl:16 3.2960 us/op 3.2420 us/op 1.02
getExpectedWithdrawals 250000 eb:0.95,eth1:0.1,we:0.05,wn:0,smpl:220 20.052 us/op 22.102 us/op 0.91
getExpectedWithdrawals 250000 eb:0.95,eth1:0.3,we:0.05,wn:0,smpl:43 5.7350 us/op 7.4310 us/op 0.77
getExpectedWithdrawals 250000 eb:0.95,eth1:0.7,we:0.05,wn:0,smpl:19 3.6950 us/op 3.9870 us/op 0.93
getExpectedWithdrawals 250000 eb:0.1,eth1:0.1,we:0,wn:0,smpl:1021 87.287 us/op 102.82 us/op 0.85
getExpectedWithdrawals 250000 eb:0.03,eth1:0.03,we:0,wn:0,smpl:11778 1.4293 ms/op 1.3693 ms/op 1.04
getExpectedWithdrawals 250000 eb:0.01,eth1:0.01,we:0,wn:0,smpl:16384 1.9886 ms/op 1.7574 ms/op 1.13
getExpectedWithdrawals 250000 eb:0,eth1:0,we:0,wn:0,smpl:16384 2.0192 ms/op 1.7629 ms/op 1.15
getExpectedWithdrawals 250000 eb:0,eth1:0,we:0,wn:0,nocache,smpl:16384 5.0732 ms/op 3.4729 ms/op 1.46
getExpectedWithdrawals 250000 eb:0,eth1:1,we:0,wn:0,smpl:16384 2.2412 ms/op 2.0446 ms/op 1.10
getExpectedWithdrawals 250000 eb:0,eth1:1,we:0,wn:0,nocache,smpl:16384 5.3089 ms/op 4.2043 ms/op 1.26
Tree 40 250000 create 379.42 ms/op 354.42 ms/op 1.07
Tree 40 250000 get(125000) 96.006 ns/op 88.376 ns/op 1.09
Tree 40 250000 set(125000) 1.1599 us/op 944.75 ns/op 1.23
Tree 40 250000 toArray() 23.936 ms/op 16.251 ms/op 1.47
Tree 40 250000 iterate all - toArray() + loop 24.346 ms/op 16.543 ms/op 1.47
Tree 40 250000 iterate all - get(i) 45.353 ms/op 38.717 ms/op 1.17
Array 250000 create 2.7500 ms/op 2.4135 ms/op 1.14
Array 250000 clone - spread 830.49 us/op 775.19 us/op 1.07
Array 250000 get(125000) 0.30300 ns/op 0.29500 ns/op 1.03
Array 250000 set(125000) 0.30600 ns/op 0.29700 ns/op 1.03
Array 250000 iterate all - loop 59.244 us/op 57.311 us/op 1.03
phase0 afterProcessEpoch - 250000 vs - 7PWei 40.145 ms/op 38.619 ms/op 1.04
Array.fill - length 1000000 4.7367 ms/op 20.134 ms/op 0.24
Array push - length 1000000 16.726 ms/op 12.697 ms/op 1.32
Array.get 0.21602 ns/op 0.20153 ns/op 1.07
Uint8Array.get 0.24128 ns/op 0.25433 ns/op 0.95
phase0 beforeProcessEpoch - 250000 vs - 7PWei 20.759 ms/op 13.040 ms/op 1.59
altair processEpoch - mainnet_e81889 280.37 ms/op 238.95 ms/op 1.17
mainnet_e81889 - altair beforeProcessEpoch 39.699 ms/op 34.687 ms/op 1.14
mainnet_e81889 - altair processJustificationAndFinalization 6.0560 us/op 5.1240 us/op 1.18
mainnet_e81889 - altair processInactivityUpdates 6.4953 ms/op 4.0767 ms/op 1.59
mainnet_e81889 - altair processRewardsAndPenalties 22.131 ms/op 16.785 ms/op 1.32
mainnet_e81889 - altair processRegistryUpdates 636.00 ns/op 592.00 ns/op 1.07
mainnet_e81889 - altair processSlashings 147.00 ns/op 140.00 ns/op 1.05
mainnet_e81889 - altair processEth1DataReset 143.00 ns/op 136.00 ns/op 1.05
mainnet_e81889 - altair processEffectiveBalanceUpdates 7.9795 ms/op 1.6681 ms/op 4.78
mainnet_e81889 - altair processSlashingsReset 739.00 ns/op 734.00 ns/op 1.01
mainnet_e81889 - altair processRandaoMixesReset 1.0500 us/op 1.0010 us/op 1.05
mainnet_e81889 - altair processHistoricalRootsUpdate 145.00 ns/op 133.00 ns/op 1.09
mainnet_e81889 - altair processParticipationFlagUpdates 474.00 ns/op 462.00 ns/op 1.03
mainnet_e81889 - altair processSyncCommitteeUpdates 112.00 ns/op 105.00 ns/op 1.07
mainnet_e81889 - altair afterProcessEpoch 43.742 ms/op 39.981 ms/op 1.09
capella processEpoch - mainnet_e217614 794.08 ms/op 820.59 ms/op 0.97
mainnet_e217614 - capella beforeProcessEpoch 71.870 ms/op 61.946 ms/op 1.16
mainnet_e217614 - capella processJustificationAndFinalization 6.5300 us/op 6.0870 us/op 1.07
mainnet_e217614 - capella processInactivityUpdates 17.572 ms/op 11.377 ms/op 1.54
mainnet_e217614 - capella processRewardsAndPenalties 97.096 ms/op 102.96 ms/op 0.94
mainnet_e217614 - capella processRegistryUpdates 4.4910 us/op 4.5280 us/op 0.99
mainnet_e217614 - capella processSlashings 143.00 ns/op 134.00 ns/op 1.07
mainnet_e217614 - capella processEth1DataReset 144.00 ns/op 138.00 ns/op 1.04
mainnet_e217614 - capella processEffectiveBalanceUpdates 18.094 ms/op 18.686 ms/op 0.97
mainnet_e217614 - capella processSlashingsReset 686.00 ns/op 738.00 ns/op 0.93
mainnet_e217614 - capella processRandaoMixesReset 1.0910 us/op 1.1150 us/op 0.98
mainnet_e217614 - capella processHistoricalRootsUpdate 145.00 ns/op 140.00 ns/op 1.04
mainnet_e217614 - capella processParticipationFlagUpdates 471.00 ns/op 488.00 ns/op 0.97
mainnet_e217614 - capella afterProcessEpoch 114.79 ms/op 114.52 ms/op 1.00
phase0 processEpoch - mainnet_e58758 247.29 ms/op 258.27 ms/op 0.96
mainnet_e58758 - phase0 beforeProcessEpoch 48.444 ms/op 59.657 ms/op 0.81
mainnet_e58758 - phase0 processJustificationAndFinalization 4.6840 us/op 5.1840 us/op 0.90
mainnet_e58758 - phase0 processRewardsAndPenalties 16.516 ms/op 17.617 ms/op 0.94
mainnet_e58758 - phase0 processRegistryUpdates 2.1920 us/op 2.3130 us/op 0.95
mainnet_e58758 - phase0 processSlashings 140.00 ns/op 133.00 ns/op 1.05
mainnet_e58758 - phase0 processEth1DataReset 138.00 ns/op 133.00 ns/op 1.04
mainnet_e58758 - phase0 processEffectiveBalanceUpdates 819.36 us/op 795.24 us/op 1.03
mainnet_e58758 - phase0 processSlashingsReset 883.00 ns/op 883.00 ns/op 1.00
mainnet_e58758 - phase0 processRandaoMixesReset 1.0120 us/op 1.1730 us/op 0.86
mainnet_e58758 - phase0 processHistoricalRootsUpdate 147.00 ns/op 135.00 ns/op 1.09
mainnet_e58758 - phase0 processParticipationRecordUpdates 1.0380 us/op 2.4290 us/op 0.43
mainnet_e58758 - phase0 afterProcessEpoch 34.050 ms/op 35.235 ms/op 0.97
phase0 processEffectiveBalanceUpdates - 250000 normalcase 1.0033 ms/op 1.5512 ms/op 0.65
phase0 processEffectiveBalanceUpdates - 250000 worstcase 0.5 2.8923 ms/op 1.7407 ms/op 1.66
gloas processInactivityUpdates - 250000 inactivity leak all eligible missed target 11.488 ms/op 11.091 ms/op 1.04
phase0 processRegistryUpdates - 250000 normalcase 2.4350 us/op 2.3560 us/op 1.03
phase0 processRegistryUpdates - 250000 badcase_full_deposits 143.47 us/op 146.32 us/op 0.98
phase0 processRegistryUpdates - 250000 worstcase 0.5 61.347 ms/op 62.514 ms/op 0.98
altair processRewardsAndPenalties - 250000 normalcase 15.960 ms/op 14.979 ms/op 1.07
altair processRewardsAndPenalties - 250000 worstcase 15.168 ms/op 15.189 ms/op 1.00
phase0 getAttestationDeltas - 250000 normalcase 5.6450 ms/op 5.4228 ms/op 1.04
phase0 getAttestationDeltas - 250000 worstcase 5.6747 ms/op 5.4763 ms/op 1.04
phase0 processSlashings - 250000 worstcase 61.498 us/op 59.899 us/op 1.03
altair processSyncCommitteeUpdates - 250000 12.052 ms/op 10.065 ms/op 1.20
BeaconState.hashTreeRoot - No change 178.00 ns/op 171.00 ns/op 1.04
BeaconState.hashTreeRoot - 1 full validator 75.057 us/op 80.085 us/op 0.94
BeaconState.hashTreeRoot - 32 full validator 896.72 us/op 1.1053 ms/op 0.81
BeaconState.hashTreeRoot - 512 full validator 8.4663 ms/op 8.4124 ms/op 1.01
BeaconState.hashTreeRoot - 1 validator.effectiveBalance 98.581 us/op 112.17 us/op 0.88
BeaconState.hashTreeRoot - 32 validator.effectiveBalance 1.4937 ms/op 1.6403 ms/op 0.91
BeaconState.hashTreeRoot - 512 validator.effectiveBalance 18.597 ms/op 23.493 ms/op 0.79
BeaconState.hashTreeRoot - 1 balances 77.765 us/op 78.798 us/op 0.99
BeaconState.hashTreeRoot - 32 balances 775.54 us/op 1.0202 ms/op 0.76
BeaconState.hashTreeRoot - 512 balances 6.4381 ms/op 5.6219 ms/op 1.15
BeaconState.hashTreeRoot - 250000 balances 133.73 ms/op 139.28 ms/op 0.96
aggregationBits - 2048 els - zipIndexesInBitList 20.527 us/op 19.304 us/op 1.06
regular array get 100000 times 23.600 us/op 23.160 us/op 1.02
wrappedArray get 100000 times 23.648 us/op 23.157 us/op 1.02
arrayWithProxy get 100000 times 9.9871 ms/op 9.1518 ms/op 1.09
ssz.Root.equals 21.785 ns/op 20.829 ns/op 1.05
byteArrayEquals 21.694 ns/op 20.716 ns/op 1.05
Buffer.compare 9.0480 ns/op 8.6380 ns/op 1.05
processSlot - 1 slots 10.915 us/op 10.106 us/op 1.08
processSlot - 32 slots 2.2460 ms/op 2.1342 ms/op 1.05
getEffectiveBalanceIncrementsZeroInactive - 250000 vs - 7PWei 4.4693 ms/op 2.9651 ms/op 1.51
getCommitteeAssignments - req 1 vs - 250000 vc 3.1117 ms/op 1.7072 ms/op 1.82
getCommitteeAssignments - req 100 vs - 250000 vc 3.5046 ms/op 3.4694 ms/op 1.01
getCommitteeAssignments - req 1000 vs - 250000 vc 3.7563 ms/op 3.7424 ms/op 1.00
findModifiedValidators - 10000 modified validators 795.40 ms/op 833.16 ms/op 0.95
findModifiedValidators - 1000 modified validators 532.01 ms/op 474.45 ms/op 1.12
findModifiedValidators - 100 modified validators 411.15 ms/op 310.09 ms/op 1.33
findModifiedValidators - 10 modified validators 340.29 ms/op 290.51 ms/op 1.17
findModifiedValidators - 1 modified validators 215.92 ms/op 255.02 ms/op 0.85
findModifiedValidators - no difference 202.71 ms/op 190.12 ms/op 1.07
migrate state 1500000 validators, 3400 modified, 2000 new 3.2720 s/op 3.6761 s/op 0.89
RootCache.getBlockRootAtSlot - 250000 vs - 7PWei 3.5900 ns/op 3.2600 ns/op 1.10
state getBlockRootAtSlot - 250000 vs - 7PWei 452.38 ns/op 474.12 ns/op 0.95
computeProposerIndex 100000 validators 1.3857 ms/op 1.2921 ms/op 1.07
getNextSyncCommitteeIndices 1000 validators 2.8496 ms/op 2.7234 ms/op 1.05
getNextSyncCommitteeIndices 10000 validators 25.250 ms/op 24.490 ms/op 1.03
getNextSyncCommitteeIndices 100000 validators 86.428 ms/op 83.368 ms/op 1.04
computeProposers - vc 250000 562.39 us/op 525.79 us/op 1.07
computeEpochShuffling - vc 250000 38.487 ms/op 36.742 ms/op 1.05
getNextSyncCommittee - vc 250000 10.367 ms/op 8.9680 ms/op 1.16
nodejs block root to RootHex using toHex 93.877 ns/op 89.488 ns/op 1.05
nodejs block root to RootHex using toRootHex 61.852 ns/op 59.532 ns/op 1.04
nodejs fromHex(blob) 975.93 us/op 955.75 us/op 1.02
nodejs fromHexInto(blob) 608.99 us/op 619.35 us/op 0.98
nodejs block root to RootHex using the deprecated toHexString 619.26 ns/op 605.98 ns/op 1.02
nodejs byteArrayEquals 32 bytes (block root) 25.486 ns/op 25.060 ns/op 1.02
nodejs byteArrayEquals 48 bytes (pubkey) 37.320 ns/op 36.208 ns/op 1.03
nodejs byteArrayEquals 96 bytes (signature) 34.053 ns/op 33.308 ns/op 1.02
nodejs byteArrayEquals 1024 bytes 40.394 ns/op 39.584 ns/op 1.02
nodejs byteArrayEquals 131072 bytes (blob) 1.7398 us/op 1.7150 us/op 1.01
browser block root to RootHex using toHex 142.76 ns/op 143.78 ns/op 0.99
browser block root to RootHex using toRootHex 130.25 ns/op 128.44 ns/op 1.01
browser fromHex(blob) 1.8420 ms/op 1.8909 ms/op 0.97
browser fromHexInto(blob) 628.27 us/op 661.32 us/op 0.95
browser block root to RootHex using the deprecated toHexString 407.63 ns/op 642.23 ns/op 0.63
browser byteArrayEquals 32 bytes (block root) 27.538 ns/op 28.414 ns/op 0.97
browser byteArrayEquals 48 bytes (pubkey) 38.918 ns/op 40.092 ns/op 0.97
browser byteArrayEquals 96 bytes (signature) 73.251 ns/op 75.653 ns/op 0.97
browser byteArrayEquals 1024 bytes 746.77 ns/op 765.27 ns/op 0.98
browser byteArrayEquals 131072 bytes (blob) 94.758 us/op 96.664 us/op 0.98

by benchmarkbot/action

@nazarhussain nazarhussain changed the title feat: ssz engine API transport (#8994) feat: ssz engine API transport May 19, 2026
Giulio2002 and others added 12 commits May 20, 2026 18:11
## Summary

Implements SSZ-REST Engine API transport on the consensus layer (client
side), as specified in
[ethereum/execution-apis#764](ethereum/execution-apis#764).

- New CLI flag `--execution.sszRestUrl` to configure SSZ-REST endpoint
- SSZ-encoded request/response bodies for all Engine API methods
- Automatic fallback to JSON-RPC on network errors
- Supports: `new_payload` (v1-v5), `forkchoice_updated` (v1-v3),
`get_payload` (v1-v5), `exchange_capabilities`
- Proper fork-based version selection for Deneb/Electra/Fulu

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Replace the hand-rolled byte-level encoders/decoders in sszRestEncoding.ts
with @chainsafe/ssz ContainerType definitions for every Engine API request
and response. This fixes a number of wire-format defects from #8994:

- NewPayloadV1/V2 now carry the required Container offset prefix.
- PayloadAttributes encoding matches the per-fork shape (V1 lacks
  withdrawals, V2 lacks parentBeaconBlockRoot, etc.) instead of always
  writing the V3 layout.
- execution_requests is encoded as the spec's flat List[ByteList, 256]
  with proper SSZ list framing, not a flat concatenation of typed blobs.
- GetPayloadResponse V2 (Shanghai) and the V5/V6 Osaka/Amsterdam shapes
  are now decodable.
- GetBlobs V2 cell proofs (List[Bytes48, CELLS_PER_EXT_BLOB]) decode
  correctly; the previous fixed-stride scan only worked for V1.
- Fork → version mapping is centralized in newPayloadVersion,
  getPayloadVersion, forkchoiceUpdatedVersion, and getBlobsVersion,
  fixing the prior fulu→v5 mismapping for newPayload and the v4 gap for
  forkchoiceUpdated.

PayloadAttributes containers are redefined locally because
ssz.{fork}.PayloadAttributes from @lodestar/types declares
suggestedFeeRecipient with a JSON-only stringType that throws on SSZ
serialize.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…sszRest

The SSZ-REST Engine API transport from #8994 was constructed unconditionally
and probed on every Engine call, then silently fell back to JSON-RPC on
network errors. Until ethereum/execution-apis#764 stabilises and the ELs we
test against advertise support consistently, this probing is wasted traffic
against vanilla EL deployments and can mask transient infra issues.

Add a `sszRest` flag to ExecutionEngineHttpOpts and a hidden
`--execution.sszRest` CLI flag. The SszRestClient is only constructed when
the flag is set; otherwise the JSON-RPC path is used exclusively.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Drop the local hexToBytes helper in favour of fromHex from @lodestar/utils,
matching the convention used elsewhere in the package. Addresses
gemini-code-assist feedback on #8994.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…baseUrl

CodeQL (js/polynomial-redos) flagged engineUrl.replace(/\/+$/, "") at the
SszRestClient init site. The regex is O(N^2) on N trailing slashes due to
greedy + backtracking against the `$` anchor. The input is operator-supplied
(--execution.urls), so the alert is not exploitable in our threat model, but
the fix is trivial and clears the security alert.

Use a linear charCode scan instead, and drop the redundant duplicate strip
inside SszRestClient (the caller already normalises).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add SSZ containers for ExecutionPayloadBodyV1, PayloadBodiesV1Response, and
the two request shapes from execution-apis#764, plus encoder/decoder helpers
and the two HTTP call sites.

Advertise the new endpoints in supportedSszRestEndpoints so the EL knows we
support them; both methods negotiate via engine_exchangeCapabilities and
fall back to JSON-RPC on network errors. Payload bodies can be sizeable
(transactions + withdrawals), so binary SSZ avoids the hex-encoding bloat
of the JSON-RPC equivalent.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add SSZ containers for ClientVersionV1, GetClientVersionV1Request, and
GetClientVersionV1Response from execution-apis#764, plus the call site in
the existing getClientVersion path.

Split the response handling into fetchClientVersions (raw transport) and
the surrounding code mapping (ClientCode enum + strip 0x prefix). Advertise
POST /engine/v1/client/version in supportedSszRestEndpoints; the call
negotiates via engine_exchangeCapabilities and falls back to JSON-RPC on
network errors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Re-add POST /engine/v1/blobs to supportedSszRestEndpoints (removed in
e4d5d11) and flip the v1 SSZ-REST test to assert the new behaviour.

Spec v1 returns List[BlobAndProofV1, MAX_BLOB_HASHES_REQUEST] with no
per-element nullability, while the JSON-RPC v1 contract returns a
same-length array with null for missing blobs. Map the gap by padding
the SSZ response up to the request length with null, assuming the EL
returns results in request order with any trailing positions missing.

This is a Lodestar-side assumption since the spec is silent on response
ordering for v1; revisit if interop testing surfaces ELs that return
out-of-order results.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Osaka SSZ spec defines both v2 (all-or-nothing) and v3 (per-element
nullable) blob endpoints. Lodestar wires only v2; record the four reasons
inline on getBlobsVersion so a future reader doesn't have to reconstruct
them:

  - IExecutionEngine.getBlobs post-Fulu is all-or-nothing by design
  - Transport-symmetric with the existing JSON-RPC v2 path
  - Matches the spec's own guidance for all-or-nothing consumers
  - Buffer-reuse optimisation in block production assumes all-or-nothing

Plus a note on when to revisit (if a granular blob-fetch consumer appears)
and that picking v2 has no interop cost since the major ELs (Nethermind,
Erigon) serve both.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@nazarhussain
nazarhussain marked this pull request as ready for review May 22, 2026 05:55
@nazarhussain
nazarhussain requested a review from a team as a code owner May 22, 2026 05:55
@codecov

codecov Bot commented May 22, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 52.54%. Comparing base (1d0e0b9) to head (3534a7a).
⚠️ Report is 5 commits behind head on unstable.

Additional details and impacted files
@@             Coverage Diff              @@
##           unstable    #9382      +/-   ##
============================================
- Coverage     52.55%   52.54%   -0.01%     
============================================
  Files           848      848              
  Lines         60950    60927      -23     
  Branches       4487     4486       -1     
============================================
- Hits          32034    32016      -18     
+ Misses        28854    28849       -5     
  Partials         62       62              
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3534a7aa12

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +56 to +58
const PayloadStatusV1 = new ContainerType(
{status: Uint8, latestValidHash: NullableHash, validationError: ValidationErrorBytes},
{typeName: "PayloadStatusV1"}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Encode PayloadStatus with spec field shapes

This container uses latestValidHash as List[Bytes32, 1], but the SSZ-REST Engine schema defines PayloadStatusV1.latest_valid_hash as fixed Bytes32 (zero-hash sentinel for absence), and ForkchoiceUpdatedResponseV1.payload_id similarly as fixed Bytes8. Using nullable-list wrappers here changes the wire layout, so a compliant EL response will fail deserialization (or be misdecoded) and interop will break for newPayload/forkchoiceUpdated.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Current head follows merged execution-apis#793, where PayloadStatus.latest_valid_hash and ForkchoiceUpdateResponse.payload_id are Optional[...] (List[T, 1]). The older fixed-field #764 shape this comment was based on no longer applies.

Comment on lines +469 to +474
case 2:
return ExecutionPayloadStatus.SYNCING;
case 3:
return ExecutionPayloadStatus.ACCEPTED;
default:
throw Error(`Unknown payload status byte=${byte}`);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Handle INVALID_BLOCK_HASH status byte

statusByteToEnum maps only values 0-3 and throws on any other byte, but PayloadStatusV1 also includes INVALID_BLOCK_HASH (value 4). When an EL returns that valid status for newPayload, the SSZ path will throw instead of returning a structured ExecutePayloadResponse, turning a protocol-level response into an unexpected exception path during block verification.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

execution-apis#793 removes INVALID_BLOCK_HASH from the REST PayloadStatus enum; valid REST status bytes are 0..3. Current head intentionally rejects byte 4 and has coverage for that, so this stale JSON-RPC status concern no longer applies to the REST path.

@lodekeeper lodekeeper left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I found two additional blockers in the SSZ-REST transport. I also agree with the existing unresolved comments on the PayloadStatusV1 / ForkchoiceUpdatedResponseV1 wire shape and the missing INVALID_BLOCK_HASH status byte; those need to be fixed before this can interop with the current EIP-8178 shape.

// (matches the JSON-RPC v1 contract). This assumes ELs return
// results in request order with trailing missing entries.
const found = decodeGetBlobsV1Response(resp);
return versionedHashes.map((_, i) => found[i] ?? null);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 This loses the request-to-response mapping for partial pre-Fulu blob responses.

The existing JSON-RPC engine_getBlobsV1 contract returns one entry per requested hash, with null in the exact missing positions; Lodestar relies on that at the caller by indexing the response back into blobMeta. This SSZ v1 response is a compact List[BlobAndProofV1], so after a request like [A, B, C] where the EL only has A and C, found is [A, C] and this maps it to [A, C, null]. The blob/proof for C is then attached to B's blob index and commitment.

Because BlobAndProofV1 does not carry the requested versioned hash, the client cannot reconstruct arbitrary missing positions from this wire shape. Please either keep v1 blobs on JSON-RPC / fall back when the SSZ v1 length differs from the request length, or use a response shape with per-element nullability before adapting it to Lodestar's indexed (BlobAndProof | null)[] API.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This blocker is addressed on the current head. The #793 /blobs/v1 and /blobs/v2 responses now use per-request-entry availability (BlobEntry {available, contents}), and the decoder preserves the requested-index mapping by returning null at unavailable positions. There is also regression coverage for v1: available=false -> null at that index and the HTTP path's partial-response behavior.

I do not have permission to resolve the thread in GitHub, but from my side this stale blocker is closed.

parentBlockRoot,
executionRequests
);
const resp = await this.rpcFetchQueue.push<Uint8Array>(async () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 The fallback is outside the serialized queue item, so a failed SSZ request can let later Engine calls overtake its JSON-RPC retry.

JobFnQueue rejects this job and immediately starts the next queued job before the awaiting caller reaches this catch and enqueues the JSON-RPC fallback below. If newPayload is in flight over SSZ, a later FCU is already queued, and the SSZ request times out, the queue can run the later FCU before the earlier newPayload JSON-RPC fallback. That breaks the ordering invariant described above this queue, exactly in the sync path where newPayload/FCU ordering matters.

Please queue the whole logical Engine call as one unit: inside the queued function, try SSZ and perform the JSON-RPC fallback before resolving/rejecting, so no later queued Engine call can observe or overtake the retry.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This blocker is addressed on the current head. The old per-request SSZ-then-JSON retry path is gone; REST newPayload / forkchoiceUpdated calls are pushed directly through the existing serialized rpcFetchQueue, while unsupported forks or unavailable capabilities route before dispatch. That removes the queue-rejection-then-later-JSON-retry overtake path this comment described.

I do not have permission to resolve the thread in GitHub, but from my side this stale blocker is closed.

nazarhussain and others added 4 commits September 22, 2026 13:43
…#793

Fork header map (CL fork -> EL fork name), spec MAX_* constants, and the
fork-invariant PayloadStatus / ForkchoiceUpdateResponse containers with
Optional[String] validation_error. Removes the #764 versioned containers.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Drops expected_blob_versioned_hashes; folds parent_beacon_block_root and
execution_requests into the envelope in spec field order.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Amsterdam carries custody_columns as an absent Optional[Bitvector[128]].

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… order

execution_requests now precedes should_override_builder (was reversed).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
nazarhussain and others added 17 commits September 22, 2026 14:07
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Eth-Execution-Version and X-Engine-Client-Version headers, JWT without clv,
204 -> null, RFC 7807 problem+json errors as SszRestError extends HttpRpcError.
Removes the per-request network-error fallback classifier.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…n SszRestClient

toSszRestError no longer discards the raw response text as `detail` when a JSON
error body parses but isn't RFC 7807-shaped (e.g. legacy {code,message} bodies).

send() now reads the response body inside the same try/finally as fetch(), so
the abort timer also bounds a stalled 200/204 body read, not just the initial
request; a DOMException abort from that read is normalized to the same
FetchError/ERR_ABORTED shape callers already see for request-level timeouts.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…robe event policy

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…paths

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ntics

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…test

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Probe /engine/v1/capabilities once; route forks it advertises to REST and
everything else to JSON-RPC. Removes engine_exchangeCapabilities negotiation
and the per-request JSON-RPC fallback.

Also fixes pre-existing tsgo errors in sszRestEncoding.test.ts (payloadFor
overloads; entry/response generic-bound simplification) blocking a clean
repo-wide check-types.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ntics

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Commit 21c173a accidentally started forwarding caller-provided
buffers to the JSON-RPC engine_getBlobsV2 call. At merge-base with
unstable, buffers were silently dropped on that path. Restore that
behaviour so nodes that never enable sszRest see no change; the REST
transport legitimately forwards buffers via rest.blobsV2().

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
produceBlockBody.ts carried a pre-existing branch commit adding a
getTargetGasLimit() helper and an unconditional targetGasLimit
override for gloas payload attributes. unstable already sets
targetGasLimit inside preparePayloadAttributes via
getProposerTargetGasLimit, so the override was redundant and diverged
from the SSE payload-attributes path. Restore the merge-base file.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The REST branch of notifyNewPayload dispatched before validating
versionedHashes/parentBlockRoot/executionRequests, so a caller bug
would throw inside the queued REST closure, get caught by the
.catch() that maps errors to UNAVAILABLE, and silently mark a healthy
EL OFFLINE. Move the same checks (same conditions/messages as the
JSON-RPC branch) to the top of the method so both transports fail
synchronously before any request, and drop the now-duplicate checks
from the JSON-RPC branch.

Add a regression test asserting the REST route is never hit and
engine.state stays ONLINE when preconditions fail.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The REST transport only ever talks to opts.urls[0]. When more than
one execution URL is configured, log a warning at startup so
operators relying on JSON-RPC-style fallback URLs notice the REST
transport doesn't provide it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- sszRestEngine.ts: drop the unused `ready` field, make `ssz()`/`json()`
  private (no subclasses reference them as protected).
- sszRestEncoding.ts: EXECUTION_PAYLOAD_BY_EL_FORK is only used within
  the file, drop the export.
- sszRestEngine.ts getPayload: validate payloadId against
  /^0x[0-9a-f]{16}$/i before building the URL, with a regression test
  asserting no request is made for a malformed id.
- httpSszRest.test.ts: replace the console logger with a silent local
  stub instead of leaking test noise to the console.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@nazarhussain nazarhussain changed the title feat: ssz engine API transport feat: SSZ-REST Engine API transport (execution-apis#793) Sep 22, 2026
@nazarhussain nazarhussain changed the title feat: SSZ-REST Engine API transport (execution-apis#793) feat: SSZ-REST Engine API transport Sep 22, 2026
Resolve http.ts conflict: serializeExecutionRequests now takes the fork.
Adapt the SSZ-REST codec to unstable's gloas changes: builder deposit /
exit requests (0x03 / 0x04) in the execution_requests list, and
targetGasLimit as UintBn64 to match PayloadAttributes.targetGasLimit: bigint.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@nazarhussain nazarhussain changed the title feat: SSZ-REST Engine API transport feat: engine API transport SSZ-REST Sep 22, 2026
nazarhussain and others added 2 commits September 22, 2026 16:51
The codec reuses ssz.gloas.ExecutionPayload, and the codec tests built their
oracle from the same symbol — so a consensus-specs change moved implementation
and oracle in lockstep and the suite stayed green while the Engine API wire
format drifted from the spec. This nearly bit when gloas became an EIP-7688
ProgressiveContainer: the bytes still matched, but nothing checked that.

Pin the spec's field list literally, and assert the progressive framing stays
serialization-identical to a plain SSZ container.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
blobsRevision() was derived from ForkSeq alone, so a fork with no
Eth-Execution-Version mapping (heze, added on unstable) would still take
/engine/v1/blobs/v2 while every other method correctly fell back to JSON-RPC.
/blobs/vN is unscoped so nothing in the request needed the mapping, but a fork
the transport cannot name is one whose blob semantics it cannot vouch for.

Return null for such forks so blobs behave like the rest of the surface.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@nazarhussain

Copy link
Copy Markdown
Contributor Author

Superseded by #10155.

This PR was opened against execution-apis#764, which was closed without merging in favour of #793. #793 changes the wire format substantially — versioning moves from per-method URLs (/engine/v{N}/payloads) to a stable path plus an Eth-Execution-Version fork header, expected_blob_versioned_hashes is dropped from the payload envelope, BuiltPayload field order is normative and differs from the legacy JSON envelope, errors become application/problem+json, and capability negotiation becomes GET /engine/v1/capabilities instead of engine_exchangeCapabilities — so almost none of the encoding here carries over.

Rather than keep rewriting in place, #10155 is a fresh branch off unstable with the implementation rebuilt against #793, so the new design gets its own review.

Both blockers raised here are resolved there:

  • blobs v1 losing the request-to-response index mapping/blobs/v1 now returns per-entry BlobEntry {available, contents}, so unavailable blobs come back as null at their requested position.
  • fallback outside the serialized queue letting later calls overtake a retry — there is no per-request fallback in Must match target to match head #793's model at all; transport is negotiated once at startup, and REST newPayload/forkchoiceUpdated go through the same serialized queue as JSON-RPC.

The stale review comments above refer to the #764 encoding and no longer apply; thanks for the reviews regardless — the two blockers were real and shaped the replacement.

@github-project-automation github-project-automation Bot moved this from Awaiting Author to Done in Lodestar Team Coordination Sep 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

6 participants