Skip to content

feat(engine): move SSZ-REST fork selection into Eth-Execution-Version header#12193

Merged
LukaszRozmej merged 9 commits into
masterfrom
feature/engine-ssz-fork-header
Jul 3, 2026
Merged

feat(engine): move SSZ-REST fork selection into Eth-Execution-Version header#12193
LukaszRozmej merged 9 commits into
masterfrom
feature/engine-ssz-fork-header

Conversation

@LukaszRozmej

Copy link
Copy Markdown
Member

Changes

Aligns the SSZ-REST Engine API transport with the latest execution-apis#793 revision (commit d39e9a2, "move fork into header out of path").

  • Fork-scoped endpoints (payloads, forkchoice, bodies) drop the {fork} URL segment. The fork — and thus the SSZ container shape and engine method version — is now negotiated via the Eth-Execution-Version request header (e.g. Eth-Execution-Version: cancun).
  • Routes become POST /engine/v2/payloads, POST /engine/v2/forkchoice, GET /engine/v2/payloads/{payload_id}, POST /engine/v2/bodies/hash, GET /engine/v2/bodies.
  • Blobs remain independently path-versioned (/engine/v2/blobs/v1..v4) and identity/capabilities remain unscoped, per spec.
  • engine_exchangeCapabilities now advertises each distinct fork-less REST path once (OR-ing enablement across the method versions that share it); per-fork availability continues to be expressed via supported_forks in the GET /engine/v2/capabilities body.
  • Error mapping: a missing Eth-Execution-Version header on a fork-scoped endpoint returns 400 invalid-request; an unknown fork returns 400 unsupported-fork.

Notes for reviewers

The relevant spec section is still a working draft and does not pin the URL prefix, whether the header is echoed in responses, or the status codes for a missing/unsupported fork. The choices here — keeping the /engine/v2/ prefix and returning 400 — are pragmatic defaults and easy to revisit. SszRestPaths.GetEngineApiUrlSegment / NamedReleaseSpec.EngineApiUrlSegment keep their names although the value is now a header rather than a URL segment.

Types of changes

What types of changes does your code introduce?

  • Bugfix (non-breaking change that fixes an issue)
  • New feature (non-breaking change that adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • Documentation update
  • Refactoring (no functional changes, no API changes)
  • Build-related changes
  • Other:

Testing

Requires testing

  • Yes
  • No

If yes, did you write tests?

  • Yes
  • No

Nethermind.Merge.Plugin.Test: SszMiddlewareTests (converted to header-based routing, with new missing-header and unknown-fork-in-header cases) and the SszRestPathsAreAdvertised coverage set were updated. 104 SSZ-REST tests and 23 capability tests pass; build is clean with no new warnings.

Documentation

Requires documentation update

  • Yes
  • No

… header

Aligns the SSZ-REST Engine API transport with the latest execution-apis#793
revision (commit d39e9a2, "move fork into header out of path"). Fork-scoped
endpoints (payloads, forkchoice, bodies) drop the {fork} URL segment and instead
negotiate the fork — and thus the SSZ container shape and engine method version —
via the Eth-Execution-Version request header. Blobs remain independently
path-versioned (/blobs/v1..v4) and identity/capabilities remain unscoped.

engine_exchangeCapabilities now advertises each distinct fork-less REST path
once; per-fork availability continues to be expressed via supported_forks in the
GET /engine/v2/capabilities body.

Missing header on a fork-scoped endpoint returns 400 invalid-request; an
unknown fork returns 400 unsupported-fork.

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

claude Bot commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Claude finished @LukaszRozmej's task in 5m 6s —— View job


PR Review — SSZ-REST fork selection via Eth-Execution-Version header

I reviewed the routing (SszMiddleware), path/fork mapping (SszRestPaths), capability advertising (EngineRpcCapabilitiesProvider), all fork-scoped handlers, and the tests against origin/master.

Overall this is a clean, well-tested change. The header-based routing is correct, error mapping is coherent, and the test suite covers the new missing-header / unknown-fork / trailing-slash / BPO-parent cases. No correctness, security, or hot-path performance issues that would block merge. Findings below are all Low.

✅ Verified

  • TryRouteTryResolveForkTryResolveHandler ordering is sound; fork is guaranteed non-null exactly for fork-scoped resources, null for blobs/identity/capabilities.
  • MapForkToVersion version numbers line up with the pre-existing handler registration (unchanged), so header→version yields the same routing the old {fork} path did (e.g. cancun → getPayload V3).
  • bodies/hash remapping, blob path-versioning, and unscoped identity/capabilities are all handled and tested.
  • Capability OR-accumulation is correct: always-on V1 paths stay Enabled; bodies/blobs remain fork-gated; WarnIfMissing is intentionally stripped from SSZ paths.
  • No lingering references to the removed PostV1Payloads/GetV1Payloads/… constants.

🟡 Low / suggestions (non-blocking)

  1. Magic string "SszRouteFork" duplicated in 4 filesSszMiddleware.cs:173 sets ctx.Items["SszRouteFork"]; it's read back in ForkchoiceUpdatedSszHandler.cs:51, GetPayloadBodiesByHashSszHandler.cs:48, GetPayloadBodiesByRangeSszHandler.cs:57. A typo in any reader would silently disable fork filtering / the FCU fork-mismatch check (compiles fine; only some tests would catch it). Suggest hoisting to a shared internal const string. Fix this →

  2. newPayload doesn't validate header-fork vs. payload-timestamp fork, whereas forkchoiceUpdated does (GetForkMismatchMessage). This is safe in practice — a mismatched header selects the wrong wire type (→ ssz-decode-error) and engine_newPayloadV{N} performs its own timestamp/fork validation — but the asymmetry is worth a one-line comment noting it's intentional.

  3. Supported-but-inapplicable fork returns 404 method-not-found — e.g. Eth-Execution-Version: paris on POST /engine/v2/bodies/hash (Paris predates getPayloadBodies). MapForkToVersion returns null → 404, rather than a 400 fork-related error. Debatable; the spec draft doesn't pin this, so fine as-is, but arguably 400 unsupported-fork is more informative.

  4. NamingSszRestPaths.GetEngineApiUrlSegment / NamedReleaseSpec.EngineApiUrlSegment now denote a header value, not a URL segment. Already acknowledged in the PR description; a follow-up rename would improve clarity.

  5. Nitctx.Request.Headers[ForkHeaderName].ToString() (SszMiddleware.cs:319) allocates per request and joins a multi-valued header with commas (→ unsupported-fork). Acceptable given these endpoints aren't per-transaction hot, but worth noting.

None of the above are blocking. 👍

LukaszRozmej and others added 2 commits July 1, 2026 17:37
Addresses PR review: replace the duplicated "SszRouteFork" HttpContext.Items
string literal (set in SszMiddleware, read in three handlers) with a single
internal const, so a typo can't silently disable fork filtering. Also document
why newPayload — unlike forkchoiceUpdated — skips an explicit header-vs-payload
fork check.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- A supported fork that predates a recognized endpoint (e.g. paris + bodies)
  now returns 400 unsupported-fork instead of 404 method-not-found;
  MapForkToVersion reports whether the resource was recognized so the middleware
  can tell "endpoint not on this fork" from "unknown endpoint".
- Rename NamedReleaseSpec.EngineApiUrlSegment -> EngineApiForkName and
  SszRestPaths.GetEngineApiUrlSegment -> GetEngineApiForkName (plus urlFork ->
  requestedFork locals): the value is the Eth-Execution-Version header, not a URL.
- Read the fork header via StringValues indexing rather than .ToString(), avoiding
  the per-request join and rejecting a 0- or multi-valued header as a bad request.

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

Copy link
Copy Markdown
Member Author

Addressed the remaining three in 4f833d5:

  • Netcore #3 (404 vs 400) — a supported fork that predates a recognized endpoint (e.g. paris + bodies) now returns 400 unsupported-fork instead of 404. MapForkToVersion now reports whether the resource was recognized, so the middleware distinguishes "endpoint not available for this fork" from "unknown endpoint". Added regression test Fork_that_predates_endpoint_returns_400_unsupported_fork.
  • Hive #4 (naming) — renamed NamedReleaseSpec.EngineApiUrlSegmentEngineApiForkName and SszRestPaths.GetEngineApiUrlSegmentGetEngineApiForkName (and urlFork locals → requestedFork), since the value is the Eth-Execution-Version header, not a URL segment.
  • Patricia alt 2 #5 (per-request alloc) — the fork header is now read via StringValues indexing instead of .ToString(), avoiding the join; a 0- or multi-valued header is rejected as a bad request rather than silently joined.

Build clean; 115 SSZ-REST + capability tests pass.

LukaszRozmej and others added 3 commits July 2, 2026 10:23
Cleanup from PR review:
- Drop NamedReleaseSpec.EngineApiForkName (it was just Name?.ToLowerInvariant());
  compute the lowercase fork name at the SSZ-REST use sites instead.
- Replace the identity/capabilities/blobs special-cases in TryRoute with a
  data-driven SszRestPaths.GetScoping over the first path segment.
- Rework MapForkToVersion to classify the endpoint (ClassifyMethod) then switch,
  dropping the recognizedResource-set-inline pattern.
- Extract SszEndpointHandlerBase.GetRequestedFork(ctx) used by the fork-aware
  handlers instead of repeating the ctx.Items lookup.
- Tests: drop the static readonly fork fields for lowercase literals and convert
  the object[] TestCaseSources to named TestCaseData.
- Drop a few comments that restated the code.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Its result is only ever compared case-insensitively (string.Equals OrdinalIgnoreCase
and the OrdinalIgnoreCase _forkSpecByUrl lookup), so the per-block/per-FCU
allocation was unnecessary — return the fork name as-is. The one-time lowercasing
when building the supported-fork set stays, since those names are serialized
verbatim into the lowercase supported_forks capabilities list.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Replace the ForkScopedMethod enum + ClassifyMethod if-cascade + switch with two
  resource-keyed FrozenDictionary version-selector tables (POST/GET), looked up via
  cached span alternate-lookups. Presence encodes "recognized"; the selector yields
  the fork's method version (or null when the fork predates the endpoint).
- Remove several comments that restated the code.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
if (int.TryParse(sub[1..], out int blobVer))
case SszRestPaths.ResourceScoping.Unscoped:
// No fork, no version, no extra segments (e.g. /capabilities, /identity).
if (!rest.IsEmpty) return false;

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.

Minor / optional: the !rest.IsEmpty guard only rejects a non-empty trailing segment, so a trailing-slash-only path on an unscoped endpoint now resolves to a handler where it previously 404'd.

For GET /engine/v2/capabilities/: resource = capabilities, rest is empty (the trailing slash produces an empty remainder), the guard passes, pathSegment becomes capabilities/, and in TryResolveHandler extraMem ends up empty → the capabilities handler matches and returns 200. The pre-#793 code rejected /capabilities/ (and /identity/) via the explicit StartsWith("capabilities/") check.

Impact is cosmetic — no security or consensus effect — but it is inconsistent: /capabilities/foo still 404s while /capabilities/ now 200s. If strict rejection is intended, reject a trailing-slash-only remainder here (e.g. if (!rest.IsEmpty || slash >= 0) return false;) and pin it with a test; otherwise feel free to leave it as intentional trailing-slash tolerance.

Co-authored-by: Claude Opus 4.8 (1M context)

Per review: /engine/v2/capabilities/ and /identity/ previously resolved (200)
while /capabilities/foo 404'd. Unscoped endpoints are exact fixed paths, so a
trailing slash is now rejected too — capturing hadTrailingSlash before the
collapse (the collapse otherwise hides it from the resource/rest split).
Fork-scoped paths keep trailing-slash tolerance so a trailing slash never leaks
into a payload id. Updated the trailing-slash test to assert 404 for both
unscoped endpoints.

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

Copy link
Copy Markdown
Member Author

Fixed in 022496b. Unscoped endpoints now reject a trailing slash too, so /engine/v2/capabilities/ and /identity/ 404 consistently with /capabilities/foo.

One thing worth noting: your suggested slash >= 0 guard wouldn't have fired, because the trailing slash is collapsed at the top of TryRoute (so slash is already -1 by the split). I capture hadTrailingSlash before the collapse and reject on it in the unscoped branch instead. Fork-scoped paths keep trailing-slash tolerance (so it never leaks into a payload id — Trailing_slash_on_fork_scoped_path_with_id_does_not_leak_into_extra). The trailing-slash test now asserts 404 for both unscoped endpoints.

@LukaszRozmej LukaszRozmej marked this pull request as ready for review July 2, 2026 11:32
@claude

claude Bot commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Claude finished @LukaszRozmej's task in 3m 22s —— View job


PR Review — SSZ-REST fork selection via Eth-Execution-Version header

Re-reviewed the full change against origin/master (through 022496b), with focus on the routing/trailing-slash fix and the fork→version plumbing.

  • Read SszMiddleware.cs routing + trailing-slash handling
  • Read SszRestPaths.cs fork→version mapping
  • Read EngineRpcCapabilitiesProvider.cs capability OR-accumulation
  • Read fork-scoped handlers + SszEndpointHandlerBase
  • Reviewed tests + NamedReleaseSpec change
  • Post findings

Verdict: no Critical / High / Medium findings — good to merge. The change is coherent, well-tested, and every item from the prior review round (404→400 for predated endpoints, naming, per-request header alloc, trailing-slash on unscoped endpoints) has been addressed. hudem1's approval + optional trailing-slash note is now covered by 022496b.

✅ Verified this round

  • Trailing-slash fix (022496b) is correct and consistent. hadTrailingSlash is captured before the collapse and rejected only in the Unscoped branch (SszMiddleware.cs:299), so /capabilities/ and /identity/ now 404 like /capabilities/foo, while fork-scoped paths keep tolerance and the slash never leaks into extra (bounded by pathLen). Edge cases /engine/v2/, /engine/v2//, and /engine/v2/capabilities all resolve as expected. ClassifySszRequest.IsDiagnosticGetPath still admits /capabilities/ as EngineOk, but TryRoute then 404s it — consistent, and pinned by Trailing_slash_on_unscoped_endpoint_returns_404.
  • Fork→version resolution is sound. MapForkToVersion's recognizedResource out-param cleanly separates "endpoint predates this fork" (400 unsupported-fork) from "unknown resource" (404 method-not-found). Confirmed paris + bodies/hash → 400 via the spec inheritance chain: EngineApiPayloadBodiesByHashVersion is null on Paris (bodies introduced in Shanghai), non-null thereafter through ReplayAncestors. IntroducesEngineApiChange correctly filters BPO forks, and bodies/hash remap only fires on an exact hash extra segment.
  • GetEngineApiForkName returning spec.Name verbatim (no lowercasing) is safe_forkSpecByUrl, SupportedForks, and all downstream string.Equals(...) comparisons are OrdinalIgnoreCase, and header values are validated against SupportedForks before use.
  • Capability OR-accumulation across the now-forkless shared paths is correct (always-on V1 paths stay Enabled; bodies/blobs stay fork-gated; WarnIfMissing stripped for SSZ paths). The SszRestPathsAdvertised test set correctly reflects that Prague/Amsterdam add method versions but no new REST path.
  • No lingering references to the removed EngineApiUrlSegment / PostV1Payloads-style constants; naming rename to EngineApiForkName / GetEngineApiForkName is complete.

🟡 Low / non-blocking

  1. Method-verb comparison uses two different rules. TryResolveHandler selects the route dict via case-insensitive HttpMethods.IsPost/IsGet (SszMiddleware.cs:358-359), but MapForkToVersion matches with string.Equals(httpMethod, "POST"/"GET", StringComparison.Ordinal) (SszRestPaths.cs:139-142). A lowercase verb would pass the first and fail the second (→ spurious 404). No practical impact — Kestrel normalizes standard verbs to uppercase — but the two sites reading the same ctx.Request.Method under different casing rules is a latent footgun; aligning them (both Ordinal or both via HttpMethods) would remove the asymmetry.
  2. newPayload still doesn't cross-check header-fork vs. payload-timestamp fork (unlike forkchoiceUpdated's GetForkMismatchMessage). Already acknowledged as intentional in the prior round — safe because a mismatched header selects the wrong wire type (→ ssz-decode-error) and engine_newPayloadV{N} runs its own timestamp validation. Worth a one-line comment noting the asymmetry is deliberate, but not blocking.

Both are minor and neither blocks merge.
· branch feature/engine-ssz-fork-header

LukaszRozmej and others added 2 commits July 2, 2026 13:36
…dling

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

Both now read ctx.Request.Method via case-insensitive HttpMethods.IsPost/IsGet.
Previously MapForkToVersion used ordinal string.Equals, so a lowercase verb would
pass TryResolveHandler's dict selection but fail here (spurious 404). No practical
impact (Kestrel normalizes verbs) but removes the casing asymmetry.

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

Copy link
Copy Markdown
Member Author

Good catch — agreed it's a latent footgun. Aligned in b26fce8: MapForkToVersion now uses HttpMethods.IsPost/IsGet (case-insensitive), matching TryResolveHandler's dict selection, instead of ordinal string.Equals. So the two sites reading ctx.Request.Method no longer apply different casing rules.

@LukaszRozmej LukaszRozmej mentioned this pull request Jul 2, 2026
16 tasks
@LukaszRozmej LukaszRozmej merged commit 8e28b35 into master Jul 3, 2026
556 checks passed
@LukaszRozmej LukaszRozmej deleted the feature/engine-ssz-fork-header branch July 3, 2026 08:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants