From 2ecf2662804d980f18881193f91b69befab9c0b0 Mon Sep 17 00:00:00 2001 From: Dyslex7c Date: Wed, 3 Jun 2026 08:14:36 +0530 Subject: [PATCH 01/35] feat(engine): implement Engine API v2 REST+SSZ surface per spec --- .../Nethermind.Consensus/EngineApiVersions.cs | 3 +- .../Producers/PayloadAttributes.cs | 20 +- .../KzgPolynomialCommitments.cs | 5 + .../EngineModuleTests.V1.cs | 54 ++-- .../EngineModuleTests.V3.cs | 3 +- .../EngineModuleTests.V6.cs | 12 +- .../SszRest/SszCodecTests.cs | 10 - .../SszRest/SszMiddlewareTests.cs | 166 +++++++--- .../SszRest/SszMultiSegmentDecodeTests.cs | 10 +- .../Data/BlobCellsAndProofs.cs | 15 + .../EngineRpcModule.Amsterdam.cs | 10 +- .../EngineRpcModule.Paris.cs | 31 +- .../EngineRpcModule.cs | 9 +- .../Handlers/EngineRpcCapabilitiesProvider.cs | 5 +- .../Handlers/GetBlobsHandlerV4.cs | 88 +++++ .../IEngineRpcModule.Amsterdam.cs | 9 +- .../MergeErrorCodes.cs | 7 +- .../Nethermind.Merge.Plugin/MergePlugin.cs | 1 + .../Handlers/CapabilitiesSszHandler.cs | 28 +- .../Handlers/ClientVersionSszHandler.cs | 21 +- .../Handlers/ForkchoiceUpdatedSszHandler.cs | 18 +- .../SszRest/Handlers/GetBlobsSszHandler.cs | 14 + .../GetPayloadBodiesByRangeSszHandler.cs | 24 +- .../Handlers/SszEndpointHandlerBase.cs | 59 +++- .../SszRest/Handlers/SszRestPaths.cs | 55 ++-- .../SszRest/Handlers/SszVersionDescriptors.cs | 35 +- .../SszRest/SszBlobCell.cs | 33 ++ .../SszRest/SszBlobCellVectorTypeConverter.cs | 41 +++ .../SszRest/SszCodec.cs | 137 ++++++-- .../SszRest/SszMiddleware.cs | 302 +++++++++++++----- .../SszRest/SszMiddlewareConfigurer.cs | 3 + .../SszRest/SszRestErrorCodes.cs | 42 +++ .../SszRest/SszWireTypes.cs | 111 +++++-- .../SszRest/WireConversionExtensions.cs | 4 +- .../Nethermind.Runner/JsonRpc/Startup.cs | 24 +- .../Rpc/TaikoEngineRpcModule.cs | 2 + 36 files changed, 1120 insertions(+), 291 deletions(-) create mode 100644 src/Nethermind/Nethermind.Merge.Plugin/Data/BlobCellsAndProofs.cs create mode 100644 src/Nethermind/Nethermind.Merge.Plugin/Handlers/GetBlobsHandlerV4.cs create mode 100644 src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszBlobCell.cs create mode 100644 src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszBlobCellVectorTypeConverter.cs create mode 100644 src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszRestErrorCodes.cs diff --git a/src/Nethermind/Nethermind.Consensus/EngineApiVersions.cs b/src/Nethermind/Nethermind.Consensus/EngineApiVersions.cs index 99e1eb667a0d..68fd4bc253ed 100644 --- a/src/Nethermind/Nethermind.Consensus/EngineApiVersions.cs +++ b/src/Nethermind/Nethermind.Consensus/EngineApiVersions.cs @@ -50,7 +50,8 @@ public static class GetBlobs public const int V1 = 1; // Cancun public const int V2 = 2; // Osaka public const int V3 = 3; // Osaka (allowPartialReturn = true) - public const int Latest = V3; + public const int V4 = 4; // Amsterdam (cell retrieval) + public const int Latest = V4; } /// engine_getPayloadBodiesByHash method versions. diff --git a/src/Nethermind/Nethermind.Consensus/Producers/PayloadAttributes.cs b/src/Nethermind/Nethermind.Consensus/Producers/PayloadAttributes.cs index 76a49e928601..6ad0bad2d228 100644 --- a/src/Nethermind/Nethermind.Consensus/Producers/PayloadAttributes.cs +++ b/src/Nethermind/Nethermind.Consensus/Producers/PayloadAttributes.cs @@ -28,7 +28,9 @@ public class PayloadAttributes public ulong? SlotNumber { get; set; } - public virtual long? GetGasLimit() => null; + public ulong? TargetGasLimit { get; set; } + + public virtual long? GetGasLimit() => TargetGasLimit is { } limit ? (long)limit : null; public override string ToString() => ToString(string.Empty); @@ -54,6 +56,11 @@ public string ToString(string indentation) sb.Append($", {nameof(SlotNumber)}: {SlotNumber}"); } + if (TargetGasLimit is not null) + { + sb.Append($", {nameof(TargetGasLimit)}: {TargetGasLimit}"); + } + sb.Append('}'); return sb.ToString(); @@ -83,7 +90,8 @@ protected virtual int ComputePayloadIdMembersSize() => + Address.Size // suggested fee recipient + (Withdrawals is null ? 0 : Keccak.Size) // withdrawals root hash + (ParentBeaconBlockRoot is null ? 0 : Keccak.Size) // parent beacon block root - + (SlotNumber is null ? 0 : sizeof(ulong)); // slot number + + (SlotNumber is null ? 0 : sizeof(ulong)) // slot number + + (TargetGasLimit is null ? 0 : sizeof(ulong)); // target gas limit protected static string ComputePayloadId(Span inputSpan) { @@ -128,6 +136,12 @@ protected virtual int WritePayloadIdMembers(BlockHeader parentHeader, Span position += sizeof(ulong); } + if (TargetGasLimit is not null) + { + BinaryPrimitives.WriteUInt64BigEndian(inputSpan.Slice(position, sizeof(ulong)), TargetGasLimit.Value); + position += sizeof(ulong); + } + return position; } @@ -214,6 +228,7 @@ public virtual PayloadAttributesValidationResult Validate( >= PayloadAttributesVersions.V2 when Withdrawals is null => $"{nameof(Withdrawals)} must be provided", >= PayloadAttributesVersions.V3 when ParentBeaconBlockRoot is null => $"{nameof(ParentBeaconBlockRoot)} must be provided", >= PayloadAttributesVersions.V4 when SlotNumber is null => $"{nameof(SlotNumber)} must be provided", + >= PayloadAttributesVersions.V4 when TargetGasLimit is null => $"{nameof(TargetGasLimit)} must be provided", _ => null }; } @@ -226,6 +241,7 @@ public static class PayloadAttributesExtensions public static int GetVersion(this PayloadAttributes executionPayload) => executionPayload switch { + { TargetGasLimit: not null } => PayloadAttributesVersions.V4, { SlotNumber: not null } => PayloadAttributesVersions.V4, { ParentBeaconBlockRoot: not null } => PayloadAttributesVersions.V3, { Withdrawals: not null } => PayloadAttributesVersions.V2, diff --git a/src/Nethermind/Nethermind.Crypto/KzgPolynomialCommitments.cs b/src/Nethermind/Nethermind.Crypto/KzgPolynomialCommitments.cs index bd04cd3b90c5..48e9c0196ccc 100644 --- a/src/Nethermind/Nethermind.Crypto/KzgPolynomialCommitments.cs +++ b/src/Nethermind/Nethermind.Crypto/KzgPolynomialCommitments.cs @@ -58,4 +58,9 @@ ReadOnlySpan proof public static void ComputeCellProofs(ReadOnlySpan blob, Span cellProofs) => Ckzg.ComputeCellsAndKzgProofs(new byte[Ckzg.CellsPerExtBlob * Ckzg.BytesPerCell], cellProofs, blob, _ckzgSetup); + + /// The input blob data. + /// The output span of size CELLS_PER_EXT_BLOB * BYTES_PER_CELL (131072 bytes) where cells will be written. + public static void ComputeCells(ReadOnlySpan blob, Span cells) => + Ckzg.ComputeCells(cells, blob, _ckzgSetup); } diff --git a/src/Nethermind/Nethermind.Merge.Plugin.Test/EngineModuleTests.V1.cs b/src/Nethermind/Nethermind.Merge.Plugin.Test/EngineModuleTests.V1.cs index 032603fcd06b..662192af77e0 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin.Test/EngineModuleTests.V1.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin.Test/EngineModuleTests.V1.cs @@ -1882,7 +1882,7 @@ public async Task Should_return_ClientVersionV1() { using MergeTestBlockchain chain = await CreateBlockchain(); IEngineRpcModule rpcModule = chain.EngineRpcModule; - ResultWrapper result = rpcModule.engine_getClientVersionV1(new ClientVersionV1()); + ResultWrapper result = rpcModule.engine_getClientVersionV1(default); Assert.That(result.Data, Is.EqualTo([new ClientVersionV1()])); } @@ -1940,7 +1940,8 @@ public void Should_return_expected_capabilities_for_mainnet() nameof(IEngineRpcModule.engine_getPayloadV5), nameof(IEngineRpcModule.engine_getBlobsV2), - nameof(IEngineRpcModule.engine_getBlobsV3) + nameof(IEngineRpcModule.engine_getBlobsV3), + nameof(IEngineRpcModule.engine_getBlobsV4) ]; Assert.That(result, Is.EquivalentTo(expectedMethods)); } @@ -1977,50 +1978,51 @@ public async Task Should_warn_for_missing_capabilities() private static readonly string[] SszRestPathsParis = [ - "POST /engine/v1/payloads", - "GET /engine/v1/payloads/{payload_id}", - "POST /engine/v1/forkchoice", - "POST /engine/v1/capabilities", - "POST /engine/v1/client/version", + "POST /engine/v2/paris/payloads", + "GET /engine/v2/paris/payloads/{payload_id}", + "POST /engine/v2/paris/forkchoice", + "GET /engine/v2/capabilities", + "GET /engine/v2/identity", ]; private static readonly string[] SszRestPathsShanghai = [ - "POST /engine/v2/payloads", - "GET /engine/v2/payloads/{payload_id}", - "POST /engine/v2/forkchoice", - "POST /engine/v1/payloads/bodies/by-hash", - "POST /engine/v1/payloads/bodies/by-range", + "POST /engine/v2/shanghai/payloads", + "GET /engine/v2/shanghai/payloads/{payload_id}", + "POST /engine/v2/shanghai/forkchoice", + "POST /engine/v2/shanghai/bodies/hash", + "GET /engine/v2/shanghai/bodies", ]; private static readonly string[] SszRestPathsCancun = [ - "POST /engine/v3/payloads", - "GET /engine/v3/payloads/{payload_id}", - "POST /engine/v3/forkchoice", - "POST /engine/v1/blobs", + "POST /engine/v2/cancun/payloads", + "GET /engine/v2/cancun/payloads/{payload_id}", + "POST /engine/v2/cancun/forkchoice", + "POST /engine/v2/blobs/v1", ]; private static readonly string[] SszRestPathsPrague = [ - "POST /engine/v4/payloads", - "GET /engine/v4/payloads/{payload_id}", + "POST /engine/v2/prague/payloads", + "GET /engine/v2/prague/payloads/{payload_id}", ]; private static readonly string[] SszRestPathsOsaka = [ - "GET /engine/v5/payloads/{payload_id}", - "POST /engine/v2/blobs", - "POST /engine/v3/blobs", + "GET /engine/v2/osaka/payloads/{payload_id}", + "POST /engine/v2/blobs/v2", + "POST /engine/v2/blobs/v3", + "POST /engine/v2/blobs/v4", ]; private static readonly string[] SszRestPathsAmsterdam = [ - "POST /engine/v5/payloads", - "GET /engine/v6/payloads/{payload_id}", - "POST /engine/v4/forkchoice", - "POST /engine/v2/payloads/bodies/by-hash", - "POST /engine/v2/payloads/bodies/by-range", + "POST /engine/v2/amsterdam/payloads", + "GET /engine/v2/amsterdam/payloads/{payload_id}", + "POST /engine/v2/amsterdam/forkchoice", + "POST /engine/v2/amsterdam/bodies/hash", + "GET /engine/v2/amsterdam/bodies", ]; public static IEnumerable SszRestPathsAdvertisedCases() diff --git a/src/Nethermind/Nethermind.Merge.Plugin.Test/EngineModuleTests.V3.cs b/src/Nethermind/Nethermind.Merge.Plugin.Test/EngineModuleTests.V3.cs index 7f7ca3fb8220..29fd86afee2f 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin.Test/EngineModuleTests.V3.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin.Test/EngineModuleTests.V3.cs @@ -399,9 +399,10 @@ public async Task NewPayloadV3_should_verify_blob_versioned_hashes_again Substitute.For, IReadOnlyList>>(), Substitute.For(), Substitute.For>(), - Substitute.For, IReadOnlyList>>(), + Substitute.For, IReadOnlyList>>(), Substitute.For>>(), Substitute.For?>>(), + Substitute.For?>>(), Substitute.For, IReadOnlyList>>(), Substitute.For(), Substitute.For(), diff --git a/src/Nethermind/Nethermind.Merge.Plugin.Test/EngineModuleTests.V6.cs b/src/Nethermind/Nethermind.Merge.Plugin.Test/EngineModuleTests.V6.cs index b2c74c8407a3..52b3d606bf1f 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin.Test/EngineModuleTests.V6.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin.Test/EngineModuleTests.V6.cs @@ -42,7 +42,7 @@ public enum BalErrorKind SurplusReads, } - [TestCase("0xb54389c226c76c61de0a8ebea2fe74cb0119295d34b8c01d0897901867c41c63", "0x14c38ed94cf91d5323eb3aaa7ff6c64c4c059a0a898658fcbc37f9723c25e6b3", "0x8a792f3d13211724decede460a451cdac669b5aaae37a01c2110d9f3114bc8a2", "0xfe420b1626a1f16d")] + [TestCase("0xb54389c226c76c61de0a8ebea2fe74cb0119295d34b8c01d0897901867c41c63", "0x14c38ed94cf91d5323eb3aaa7ff6c64c4c059a0a898658fcbc37f9723c25e6b3", "0x8a792f3d13211724decede460a451cdac669b5aaae37a01c2110d9f3114bc8a2", "0x2dc87ccb57a65b07")] public virtual async Task Should_process_block_as_expected_V6( string latestValidHash, string blockHash, @@ -72,6 +72,7 @@ public virtual async Task Should_process_block_as_expected_V6( withdrawals, parentBeaconBLockRoot = Keccak.Zero, slotNumber = slotNumber.ToHexString(true), + targetGasLimit = chain.BlockTree.Head!.GasLimit.ToHexString(true), }; object?[] parameters = [chain.JsonSerializer.Serialize(fcuState), chain.JsonSerializer.Serialize(payloadAttrs)]; @@ -367,7 +368,8 @@ public virtual async Task GetPayloadV6_builds_block_with_BAL(string? customWithd SuggestedFeeRecipient = Address.Zero, ParentBeaconBlockRoot = Keccak.Zero, Withdrawals = [], - SlotNumber = 1 + SlotNumber = 1, + TargetGasLimit = (ulong)genesis.Header.GasLimit }; Transaction tx = Build.A.Transaction @@ -510,7 +512,8 @@ private async Task AddNewBlockV6(IEngineRpcModule rpcModule, SuggestedFeeRecipient = TestItem.AddressF, Withdrawals = [], ParentBeaconBlockRoot = TestItem.KeccakE, - SlotNumber = chain.BlockTree.Head!.SlotNumber + 1 + SlotNumber = chain.BlockTree.Head!.SlotNumber + 1, + TargetGasLimit = (ulong)chain.BlockTree.Head!.GasLimit }; Hash256 currentHeadHash = chain.BlockTree.HeadHash; ForkchoiceStateV1 forkchoiceState = new(currentHeadHash, currentHeadHash, currentHeadHash); @@ -849,7 +852,8 @@ private static (Transaction tx, Transaction tx2, Transaction tx3, Withdrawal wit SuggestedFeeRecipient = TestItem.AddressE, ParentBeaconBlockRoot = Keccak.Zero, Withdrawals = [withdrawal], - SlotNumber = slotNumber + SlotNumber = slotNumber, + TargetGasLimit = (ulong)chain.BlockTree.Head!.GasLimit }; ForkchoiceStateV1 fcuState = new(parentHash, parentHash, parentHash); diff --git a/src/Nethermind/Nethermind.Merge.Plugin.Test/SszRest/SszCodecTests.cs b/src/Nethermind/Nethermind.Merge.Plugin.Test/SszRest/SszCodecTests.cs index 9f0f9358d6ca..df61eee10995 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin.Test/SszRest/SszCodecTests.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin.Test/SszRest/SszCodecTests.cs @@ -4,7 +4,6 @@ using System; using System.Buffers; using System.Collections.Generic; -using System.Linq; using Nethermind.Core; using Nethermind.Core.Crypto; using Nethermind.Core.Test.Builders; @@ -176,12 +175,9 @@ public void Encoded_buffer_is_non_empty(Action> encode) } private static void AssertCommonNewPayloadFields( - byte[]?[] hashes, Hash256[] expectedHashes, Hash256? parentBeaconBlockRoot, Hash256 expectedParentRoot, byte[][]? requests, byte[] expectedRequest) { - Assert.That(hashes, Is.EqualTo(expectedHashes.Select(static hash => hash.Bytes.ToArray()).ToArray())); - Assert.That(parentBeaconBlockRoot, Is.Not.Null); Assert.That(parentBeaconBlockRoot, Is.EqualTo(expectedParentRoot)); @@ -197,7 +193,6 @@ public void DecodeNewPayload_v4_roundtrip_preserves_all_fields() NewPayloadV4RequestWire wire = new() { ExecutionPayload = new SszExecutionPayloadV3(SszTestData.MakeV3Payload()), - ExpectedBlobVersionedHashes = [TestItem.KeccakA, TestItem.KeccakB], ParentBeaconBlockRoot = TestItem.KeccakC, ExecutionRequests = [new SszTransaction { Bytes = executionRequest }] }; @@ -206,7 +201,6 @@ public void DecodeNewPayload_v4_roundtrip_preserves_all_fields() NewPayloadV4RequestWire.Decode(encoded, out NewPayloadV4RequestWire decoded); ExecutionPayloadV3 payload = decoded.ExecutionPayload.AsExecutionPayload(); - byte[]?[] hashes = decoded.ExpectedBlobVersionedHashes.ToBytesArrays(); byte[][]? requests = decoded.ExecutionRequests.ToExecutionRequests(); Assert.That(payload.BlockNumber, Is.EqualTo(100)); @@ -217,7 +211,6 @@ public void DecodeNewPayload_v4_roundtrip_preserves_all_fields() Assert.That(payload.ExcessBlobGas, Is.EqualTo(0x40000UL)); AssertCommonNewPayloadFields( - hashes, [TestItem.KeccakA, TestItem.KeccakB], decoded.ParentBeaconBlockRoot, TestItem.KeccakC, requests, executionRequest); } @@ -232,7 +225,6 @@ public void DecodeNewPayload_v5_roundtrip_preserves_v4_payload_fields() NewPayloadV5RequestWire wire = new() { ExecutionPayload = new SszExecutionPayloadV4(SszTestData.MakeV4Payload(blockAccessList, slotNumber)), - ExpectedBlobVersionedHashes = [TestItem.KeccakA], ParentBeaconBlockRoot = TestItem.KeccakD, ExecutionRequests = [new SszTransaction { Bytes = executionRequest }] }; @@ -241,7 +233,6 @@ public void DecodeNewPayload_v5_roundtrip_preserves_v4_payload_fields() NewPayloadV5RequestWire.Decode(encoded, out NewPayloadV5RequestWire decoded); ExecutionPayloadV4 payload = decoded.ExecutionPayload.AsExecutionPayload(); - byte[]?[] hashes = decoded.ExpectedBlobVersionedHashes.ToBytesArrays(); byte[][]? requests = decoded.ExecutionRequests.ToExecutionRequests(); Assert.That(payload.BlockNumber, Is.EqualTo(100)); @@ -255,7 +246,6 @@ public void DecodeNewPayload_v5_roundtrip_preserves_v4_payload_fields() Assert.That(payload.ExcessBlobGas, Is.EqualTo(0x40000UL)); AssertCommonNewPayloadFields( - hashes, [TestItem.KeccakA], decoded.ParentBeaconBlockRoot, TestItem.KeccakD, requests, executionRequest); } diff --git a/src/Nethermind/Nethermind.Merge.Plugin.Test/SszRest/SszMiddlewareTests.cs b/src/Nethermind/Nethermind.Merge.Plugin.Test/SszRest/SszMiddlewareTests.cs index e9e23fec0d78..f9aff336837d 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin.Test/SszRest/SszMiddlewareTests.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin.Test/SszRest/SszMiddlewareTests.cs @@ -13,6 +13,7 @@ using Nethermind.Core; using Nethermind.Core.Authentication; using Nethermind.Core.Crypto; +using Nethermind.Core.Specs; using Nethermind.Core.Test.Builders; using Nethermind.Int256; using Nethermind.JsonRpc; @@ -21,7 +22,9 @@ using Nethermind.Merge.Plugin.Data; using Nethermind.Merge.Plugin.SszRest; using Nethermind.Merge.Plugin.SszRest.Handlers; +using System.Linq; using NSubstitute; +using NSubstitute.Core; using NUnit.Framework; namespace Nethermind.Merge.Plugin.Test.SszRest; @@ -30,6 +33,7 @@ namespace Nethermind.Merge.Plugin.Test.SszRest; public class SszMiddlewareTests { private IEngineRpcModule _engineModule = null!; + private ISpecProvider _specProvider = null!; private IJsonRpcUrlCollection _urlCollection = null!; private IRpcAuthentication _auth = null!; @@ -47,6 +51,7 @@ public class SszMiddlewareTests public void SetUp() { _engineModule = Substitute.For(); + _specProvider = Substitute.For(); _urlCollection = Substitute.For(); _auth = Substitute.For(); @@ -73,10 +78,10 @@ private SszMiddleware BuildMiddleware(RequestDelegate? next = null) new NewPayloadSszHandler(_engineModule), new NewPayloadSszHandler(_engineModule), - new ForkchoiceUpdatedSszHandler(_engineModule), - new ForkchoiceUpdatedSszHandler(_engineModule), - new ForkchoiceUpdatedSszHandler(_engineModule), - new ForkchoiceUpdatedSszHandler(_engineModule), + new ForkchoiceUpdatedSszHandler(_engineModule, _specProvider), + new ForkchoiceUpdatedSszHandler(_engineModule, _specProvider), + new ForkchoiceUpdatedSszHandler(_engineModule, _specProvider), + new ForkchoiceUpdatedSszHandler(_engineModule, _specProvider), new GetPayloadSszHandler(_engineModule), new GetPayloadSszHandler(_engineModule), @@ -96,7 +101,7 @@ private SszMiddleware BuildMiddleware(RequestDelegate? next = null) new GetPayloadBodiesByRangeSszHandler(_engineModule), new ClientVersionSszHandler(_engineModule), - new CapabilitiesSszHandler(_engineModule), + new CapabilitiesSszHandler(), ]; return new SszMiddleware( @@ -151,8 +156,8 @@ private static byte[] EncodeToBytes(T value, Func, int return w.WrittenSpan.ToArray(); } - [TestCase(1, "/engine/v1/payloads")] - [TestCase(2, "/engine/v2/payloads")] + [TestCase(1, "/engine/v2/paris/payloads")] + [TestCase(2, "/engine/v2/shanghai/payloads")] public async Task NewPayload_routes_to_correct_engine_module_version(int version, string path) { PayloadStatusV1 status = new() { Status = PayloadStatus.Valid, LatestValidHash = TestItem.KeccakA }; @@ -172,8 +177,8 @@ public async Task NewPayload_routes_to_correct_engine_module_version(int version await _engineModule.Received(version == 2 ? 1 : 0).engine_newPayloadV2(Arg.Any()); } - [TestCase(1, "/engine/v1/payloads/0x0102030405060708")] - [TestCase(2, "/engine/v2/payloads/0x0102030405060708")] + [TestCase(1, "/engine/v2/paris/payloads/0x0102030405060708")] + [TestCase(2, "/engine/v2/shanghai/payloads/0x0102030405060708")] public async Task GetPayload_routes_to_correct_handler_with_no_store_header(int version, string path) { _engineModule.engine_getPayloadV1(Arg.Any()) @@ -191,10 +196,10 @@ public async Task GetPayload_routes_to_correct_handler_with_no_store_header(int await _engineModule.Received(version == 2 ? 1 : 0).engine_getPayloadV2(Arg.Any()); } - [TestCase("/engine/v1/forkchoice", 1)] - [TestCase("/engine/v2/forkchoice", 2)] - [TestCase("/engine/v3/forkchoice", 3)] - [TestCase("/engine/v4/forkchoice", 4)] + [TestCase("/engine/v2/paris/forkchoice", 1)] + [TestCase("/engine/v2/shanghai/forkchoice", 2)] + [TestCase("/engine/v2/cancun/forkchoice", 3)] + [TestCase("/engine/v2/amsterdam/forkchoice", 4)] public async Task Forkchoice_calls_correct_engine_module_version(string path, int version) { ForkchoiceUpdatedV1Result fcuResult = new() @@ -210,7 +215,7 @@ public async Task Forkchoice_calls_correct_engine_module_version(string path, in _engineModule.engine_forkchoiceUpdatedV4(Arg.Any(), Arg.Any()) .Returns(ResultWrapper.Success(fcuResult)); - byte[] body = BuildForkchoiceRequest(); + byte[] body = version == 4 ? BuildForkchoiceV4Request() : BuildForkchoiceRequest(); DefaultHttpContext ctx = MakePostContext(path, body); await _middleware.InvokeAsync(ctx); @@ -237,7 +242,7 @@ public async Task GetBlobsV1_returns_200_when_all_blobs_present() .Returns(ResultWrapper>.Success([bap])); byte[] body = BuildHashListRequest([TestItem.KeccakA.Bytes.ToArray()]); - DefaultHttpContext ctx = MakePostContext("/engine/v1/blobs", body); + DefaultHttpContext ctx = MakePostContext("/engine/v2/blobs/v1", body); await _middleware.InvokeAsync(ctx); @@ -247,8 +252,8 @@ public async Task GetBlobsV1_returns_200_when_all_blobs_present() await _engineModule.DidNotReceive().engine_getBlobsV3(Arg.Any()); } - [TestCase("/engine/v2/blobs", false)] - [TestCase("/engine/v3/blobs", true)] + [TestCase("/engine/v2/blobs/v2", false)] + [TestCase("/engine/v2/blobs/v3", true)] public async Task GetBlobsV2V3_routes_to_correct_engine_method(string path, bool isV3) { _engineModule.engine_getBlobsV2(Arg.Any()) @@ -265,8 +270,8 @@ public async Task GetBlobsV2V3_routes_to_correct_engine_method(string path, bool await _engineModule.Received(isV3 ? 1 : 0).engine_getBlobsV3(Arg.Any()); } - [TestCase(1, "/engine/v1/payloads/bodies/by-hash")] - [TestCase(2, "/engine/v2/payloads/bodies/by-hash")] + [TestCase(1, "/engine/v2/shanghai/bodies/hash")] + [TestCase(2, "/engine/v2/amsterdam/bodies/hash")] public async Task GetPayloadBodiesByHash_routes_to_correct_engine_method(int version, string path) { _engineModule.engine_getPayloadBodiesByHashV1(Arg.Any>()) @@ -286,8 +291,8 @@ public async Task GetPayloadBodiesByHash_routes_to_correct_engine_method(int ver await _engineModule.Received(version == 2 ? 1 : 0).engine_getPayloadBodiesByHashV2(Arg.Any>()); } - [TestCase(1, "/engine/v1/payloads/bodies/by-range")] - [TestCase(2, "/engine/v2/payloads/bodies/by-range")] + [TestCase(1, "/engine/v2/shanghai/bodies")] + [TestCase(2, "/engine/v2/amsterdam/bodies")] public async Task GetPayloadBodiesByRange_routes_to_correct_engine_method_with_correct_args(int version, string path) { const long expectedStart = 7; @@ -302,8 +307,9 @@ public async Task GetPayloadBodiesByRange_routes_to_correct_engine_method_with_c .engine_getPayloadBodiesByRangeV2(Arg.Do(s => v2Start = s), Arg.Do(c => v2Count = c)) .Returns(ResultWrapper>.Success([])); - byte[] body = BuildPayloadBodiesByRangeRequest((ulong)expectedStart, (ulong)expectedCount); - DefaultHttpContext ctx = MakePostContext(path, body); + // The range endpoint is now GET with from/count as query parameters. + DefaultHttpContext ctx = MakeGetContext(path); + ctx.Request.QueryString = new QueryString($"?from={expectedStart}&count={expectedCount}"); await _middleware.InvokeAsync(ctx); @@ -319,17 +325,12 @@ public async Task GetPayloadBodiesByRange_routes_to_correct_engine_method_with_c [Test] public async Task Capabilities_returns_intersection_of_supported_methods() { - string[] returned = ["POST /engine/v5/payloads"]; - _engineModule.engine_exchangeCapabilities(Arg.Any>()) - .Returns(ResultWrapper>.Success(returned)); - - byte[] body = BuildCapabilitiesRequest(["POST /engine/v5/payloads", "POST /engine/v4/forkchoice"]); - DefaultHttpContext ctx = MakePostContext("/engine/v1/capabilities", body); + DefaultHttpContext ctx = MakeGetContext("/engine/v2/capabilities"); await _middleware.InvokeAsync(ctx); Assert.That(ctx.Response.StatusCode, Is.EqualTo(StatusCodes.Status200OK)); - Assert.That(ctx.Response.ContentType, Does.Contain(OctetStream)); + Assert.That(ctx.Response.ContentType, Does.Contain("application/json")); } [Test] @@ -339,13 +340,12 @@ public async Task ClientVersion_returns_non_empty_response() _engineModule.engine_getClientVersionV1(default) .ReturnsForAnyArgs(ResultWrapper.Success(response)); - byte[] body = BuildClientVersionRequest(); - DefaultHttpContext ctx = MakePostContext("/engine/v1/client/version", body); + DefaultHttpContext ctx = MakeGetContext("/engine/v2/identity"); await _middleware.InvokeAsync(ctx); Assert.That(ctx.Response.StatusCode, Is.EqualTo(StatusCodes.Status200OK)); - Assert.That(ctx.Response.ContentType, Does.Contain(OctetStream)); + Assert.That(ctx.Response.ContentType, Does.Contain("application/json")); Assert.That(ResponseBytes(ctx).Length, Is.GreaterThan(0)); } @@ -366,7 +366,7 @@ public async Task Authentication_failure_returns_401_and_does_not_call_engine_mo [Test] public async Task Oversized_body_returns_413_without_calling_engine_module() { - DefaultHttpContext ctx = MakePostContext("/engine/v1/payloads", []); + DefaultHttpContext ctx = MakePostContext("/engine/v2/paris/payloads", []); ctx.Request.ContentLength = SszMiddleware.MaxBodySize + 1; ctx.Request.Body = new MemoryStream(new byte[1]); @@ -419,7 +419,7 @@ public async Task Malformed_ssz_body_returns_400_without_propagating_exception() byte[] garbage = new byte[64]; new Random(42).NextBytes(garbage); - DefaultHttpContext ctx = MakePostContext("/engine/v1/payloads", garbage); + DefaultHttpContext ctx = MakePostContext("/engine/v2/paris/payloads", garbage); Func act = () => _middleware.InvokeAsync(ctx); @@ -432,7 +432,7 @@ public async Task Malformed_ssz_body_returns_400_without_propagating_exception() public async Task Truncated_body_with_overstated_content_length_returns_400() { byte[] body = new byte[16]; - DefaultHttpContext ctx = MakePostContext("/engine/v1/payloads", body); + DefaultHttpContext ctx = MakePostContext("/engine/v2/paris/payloads", body); // Declare more bytes than the stream will deliver — ReadAtLeastAsync returns short. ctx.Request.ContentLength = body.Length + 64; @@ -449,7 +449,7 @@ public async Task GetBlobsV1_null_result_data_returns_204_no_content() .Returns(ResultWrapper>.Success(null!)); byte[] body = BuildHashListRequest([TestItem.KeccakA.Bytes.ToArray()]); - DefaultHttpContext ctx = MakePostContext("/engine/v1/blobs", body); + DefaultHttpContext ctx = MakePostContext("/engine/v2/blobs/v1", body); await _middleware.InvokeAsync(ctx); @@ -478,7 +478,7 @@ public async Task Server_error_skips_WriteError_when_request_already_aborted() _engineModule.engine_newPayloadV1(Arg.Any()) .Returns>>(_ => throw new InvalidOperationException("simulated server error")); - DefaultHttpContext ctx = MakePostContext("/engine/v1/payloads", BuildMinimalV1NewPayloadRequest()); + DefaultHttpContext ctx = MakePostContext("/engine/v2/paris/payloads", BuildMinimalV1NewPayloadRequest()); // Simulate the encode-failure → ctx.Abort() effect by pre-cancelling RequestAborted. // DefaultHttpContext's Abort() is a no-op without a real lifetime feature, so we @@ -506,7 +506,7 @@ public async Task Encoder_returning_zero_length_for_non_null_data_yields_204() SszMiddleware middleware = new( _ => Task.CompletedTask, _urlCollection, _auth, [handler], _processExitSource, LimboLogs.Instance); - DefaultHttpContext ctx = MakePostContext($"/engine/v1/{ZeroLengthEncodeHandler.ResourceName}", []); + DefaultHttpContext ctx = MakePostContext($"/engine/v2/paris/{ZeroLengthEncodeHandler.ResourceName}", []); await middleware.InvokeAsync(ctx); @@ -516,7 +516,8 @@ public async Task Encoder_returning_zero_length_for_non_null_data_yields_204() private sealed class ZeroLengthEncodeHandler : SszEndpointHandlerBase { - public const string ResourceName = "zero-length-encode"; + // Route under a known fork-scoped resource so TryRoute can map it to a version. + public const string ResourceName = "payloads"; public override string HttpMethod => "POST"; public override string Resource => ResourceName; public override int? Version => 1; @@ -570,6 +571,20 @@ private static byte[] BuildForkchoiceRequest() return body; } + // V4 wire adds CustodyColumns (a second variable list), so the fixed section is + // 96 (ForkchoiceState) + 4 (PayloadAttributes offset) + 4 (CustodyColumns offset) = 104 bytes. + private static byte[] BuildForkchoiceV4Request() + { + byte[] body = new byte[104]; + Buffer.BlockCopy(TestItem.KeccakA.Bytes.ToArray(), 0, body, 0, 32); + Buffer.BlockCopy(TestItem.KeccakB.Bytes.ToArray(), 0, body, 32, 32); + Buffer.BlockCopy(Keccak.Zero.Bytes.ToArray(), 0, body, 64, 32); + // Both lists are empty; both offsets point just past the fixed section. + BitConverter.TryWriteBytes(body.AsSpan(96, 4), (uint)104); + BitConverter.TryWriteBytes(body.AsSpan(100, 4), (uint)104); + return body; + } + private static byte[] BuildHashListRequest(byte[][] hashes) { byte[] result = new byte[4 + hashes.Length * 32]; @@ -607,4 +622,75 @@ private static byte[] BuildClientVersionRequest() return request; } + [Test] + public async Task ClientVersion_reads_X_Engine_Client_Version_header() + { + ClientVersionV1 clVersion = new() + { + Code = "NB", + Name = "Nimbus", + Version = "v26.5.0", + Commit = "0df2a74" + }; + + string jsonHeader = System.Text.Json.JsonSerializer.Serialize(clVersion); + + ClientVersionV1[] response = [new(), clVersion]; + _engineModule.engine_getClientVersionV1(default) + .ReturnsForAnyArgs(ResultWrapper.Success(response)); + + DefaultHttpContext ctx = MakeGetContext("/engine/v2/identity"); + ctx.Request.Headers["X-Engine-Client-Version"] = jsonHeader; + + await _middleware.InvokeAsync(ctx); + + ICall[] calls = _engineModule.ReceivedCalls() + .Where(c => c.GetMethodInfo().Name == nameof(IEngineRpcModule.engine_getClientVersionV1)) + .ToArray(); + Assert.That(calls.Length, Is.EqualTo(1)); + ClientVersionV1 arg = (ClientVersionV1)calls[0].GetArguments()[0]!; + Assert.That(arg.Code, Is.EqualTo("NB")); + + Assert.That(ctx.Response.StatusCode, Is.EqualTo(StatusCodes.Status200OK)); + Assert.That(ctx.Response.ContentType, Does.Contain("application/json")); + + byte[] bytes = ResponseBytes(ctx); + string responseStr = System.Text.Json.JsonSerializer.Deserialize(bytes).ToString(); + Assert.That(responseStr, Does.Contain("Nimbus")); + } + + [Test] + public async Task Forkchoice_unsupported_fork_returns_400() + { + IReleaseSpec mockSpec = Substitute.For(); + mockSpec.Name.Returns("Shanghai"); + _specProvider.GetSpec(Arg.Any()).Returns(mockSpec); + + ForkchoiceUpdatedV3RequestWire request = new() + { + ForkchoiceState = new ForkchoiceStateWire + { + HeadBlockHash = TestItem.KeccakA, + SafeBlockHash = TestItem.KeccakB, + FinalizedBlockHash = Keccak.Zero + }, + PayloadAttributes = [new PayloadAttributesV3Wire + { + Timestamp = 12345, + SuggestedFeeRecipient = TestItem.AddressA, + PrevRandao = Keccak.Zero, + Withdrawals = [], + ParentBeaconBlockRoot = Keccak.Zero + }] + }; + byte[] body = ForkchoiceUpdatedV3RequestWire.Encode(request); + + DefaultHttpContext ctx = MakePostContext("/engine/v2/cancun/forkchoice", body); + + await _middleware.InvokeAsync(ctx); + + Assert.That(ctx.Response.StatusCode, Is.EqualTo(StatusCodes.Status400BadRequest)); + string respBody = System.Text.Encoding.UTF8.GetString(ResponseBytes(ctx)); + Assert.That(respBody, Does.Contain("unsupported-fork")); + } } diff --git a/src/Nethermind/Nethermind.Merge.Plugin.Test/SszRest/SszMultiSegmentDecodeTests.cs b/src/Nethermind/Nethermind.Merge.Plugin.Test/SszRest/SszMultiSegmentDecodeTests.cs index e7225b780338..3e6479244edb 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin.Test/SszRest/SszMultiSegmentDecodeTests.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin.Test/SszRest/SszMultiSegmentDecodeTests.cs @@ -118,13 +118,11 @@ public void GetPayloadBodiesByRange_decodes_correctly_across_segments(int segSiz public void NewPayloadV3RequestWire_decodes_correctly_across_segments(int segSize) { // The most schema-rich path: nested struct (SszExecutionPayloadV3 with - // variable transactions/withdrawals lists) + a list of fixed Hash256 + a - // trailing fixed Hash256. Exercises offset reads, recursive Decode, and - // multi-segment primitive reads in roughly that order. + // variable transactions/withdrawals lists) + a trailing fixed Hash256. + // Exercises offset reads, recursive Decode, and multi-segment primitive reads. NewPayloadV3RequestWire wire = new() { ExecutionPayload = new SszExecutionPayloadV3(SszTestData.MakeV3Payload()), - ExpectedBlobVersionedHashes = [TestItem.KeccakA, TestItem.KeccakB], ParentBeaconBlockRoot = TestItem.KeccakC, }; byte[] encoded = NewPayloadV3RequestWire.Encode(wire); @@ -132,10 +130,6 @@ public void NewPayloadV3RequestWire_decodes_correctly_across_segments(int segSiz NewPayloadV3RequestWire.Decode(Multi(encoded, segSize), out NewPayloadV3RequestWire decoded); Assert.That(decoded.ParentBeaconBlockRoot, Is.EqualTo(TestItem.KeccakC)); - Assert.That(decoded.ExpectedBlobVersionedHashes, Is.Not.Null); - Assert.That(decoded.ExpectedBlobVersionedHashes!.Length, Is.EqualTo(2)); - Assert.That(decoded.ExpectedBlobVersionedHashes![0], Is.EqualTo(TestItem.KeccakA)); - Assert.That(decoded.ExpectedBlobVersionedHashes[1], Is.EqualTo(TestItem.KeccakB)); ExecutionPayloadV3 payload = decoded.ExecutionPayload.AsExecutionPayload(); Assert.That(payload.BlockNumber, Is.EqualTo(100)); Assert.That(payload.BlockHash, Is.EqualTo(TestItem.KeccakE)); diff --git a/src/Nethermind/Nethermind.Merge.Plugin/Data/BlobCellsAndProofs.cs b/src/Nethermind/Nethermind.Merge.Plugin/Data/BlobCellsAndProofs.cs new file mode 100644 index 000000000000..7b37fd32452c --- /dev/null +++ b/src/Nethermind/Nethermind.Merge.Plugin/Data/BlobCellsAndProofs.cs @@ -0,0 +1,15 @@ +// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited +// SPDX-License-Identifier: LGPL-3.0-only + +namespace Nethermind.Merge.Plugin.Data; + +public class BlobCellsAndProofs +{ + public const int CellsPerExtBlob = 128; + public const int BytesPerCell = 1024; + public const int BytesPerProof = 48; + public bool Available { get; init; } + public byte[]?[]? BlobCells { get; init; } + public byte[]?[]? Proofs { get; init; } + public static BlobCellsAndProofs Unavailable { get; } = new() { Available = false }; +} diff --git a/src/Nethermind/Nethermind.Merge.Plugin/EngineRpcModule.Amsterdam.cs b/src/Nethermind/Nethermind.Merge.Plugin/EngineRpcModule.Amsterdam.cs index f88284f27687..a6c9a9ffdd61 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/EngineRpcModule.Amsterdam.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/EngineRpcModule.Amsterdam.cs @@ -1,6 +1,7 @@ // SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited // SPDX-License-Identifier: LGPL-3.0-only +using System.Collections; using System.Collections.Generic; using System.Threading.Tasks; using Nethermind.Consensus; @@ -18,18 +19,23 @@ public partial class EngineRpcModule : IEngineRpcModule private readonly IHandler, IReadOnlyList> _executionGetPayloadBodiesByHashV2Handler = getPayloadBodiesByHashV2Handler; private readonly IGetPayloadBodiesByRangeV2Handler _executionGetPayloadBodiesByRangeV2Handler = getPayloadBodiesByRangeV2Handler; + private readonly IAsyncHandler?> _getBlobsHandlerV4 = getBlobsHandlerV4; + public Task> engine_getPayloadV6(byte[] payloadId) => _getPayloadHandlerV6.HandleAsync(payloadId); public Task> engine_newPayloadV5(ExecutionPayloadV4 executionPayload, Hash256?[] blobVersionedHashes, Hash256? parentBeaconBlockRoot, byte[][]? executionRequests) => NewPayload(new ExecutionPayloadParams(executionPayload, blobVersionedHashes, parentBeaconBlockRoot, executionRequests), EngineApiVersions.NewPayload.V5); - public Task> engine_forkchoiceUpdatedV4(ForkchoiceStateV1 forkchoiceState, PayloadAttributes? payloadAttributes = null) - => ForkchoiceUpdated(forkchoiceState, payloadAttributes, EngineApiVersions.Fcu.V4); + public Task> engine_forkchoiceUpdatedV4(ForkchoiceStateV1 forkchoiceState, PayloadAttributes? payloadAttributes = null, BitArray? custodyColumns = null) + => ForkchoiceUpdated(forkchoiceState, payloadAttributes, EngineApiVersions.Fcu.V4, custodyColumns); public Task>> engine_getPayloadBodiesByHashV2(IReadOnlyList blockHashes) => _executionGetPayloadBodiesByHashV2Handler.Handle(blockHashes); public Task>> engine_getPayloadBodiesByRangeV2(long start, long count) => _executionGetPayloadBodiesByRangeV2Handler.Handle(start, count); + + public Task?>> engine_getBlobsV4(byte[][] blobVersionedHashes, System.Collections.BitArray indicesBitarray) + => _getBlobsHandlerV4.HandleAsync(new(blobVersionedHashes, indicesBitarray)); } diff --git a/src/Nethermind/Nethermind.Merge.Plugin/EngineRpcModule.Paris.cs b/src/Nethermind/Nethermind.Merge.Plugin/EngineRpcModule.Paris.cs index e76e56000f2c..bcae3f58ab96 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/EngineRpcModule.Paris.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/EngineRpcModule.Paris.cs @@ -2,6 +2,7 @@ // SPDX-License-Identifier: LGPL-3.0-only using System; +using System.Collections; using System.Diagnostics; using System.Threading; using System.Threading.Tasks; @@ -41,7 +42,12 @@ public Task> engine_forkchoiceUpdatedV1 public Task> engine_newPayloadV1(ExecutionPayload executionPayload) => NewPayload(executionPayload, EngineApiVersions.NewPayload.V1); - protected async Task> ForkchoiceUpdated(ForkchoiceStateV1 forkchoiceState, PayloadAttributes? payloadAttributes, int version) + protected Task> ForkchoiceUpdated( + ForkchoiceStateV1 forkchoiceState, PayloadAttributes? payloadAttributes, int version) + => ForkchoiceUpdated(forkchoiceState, payloadAttributes, version, custodyColumns: null); + + protected async Task> ForkchoiceUpdated( + ForkchoiceStateV1 forkchoiceState, PayloadAttributes? payloadAttributes, int version, BitArray? custodyColumns) { _engineRequestsTracker.OnForkchoiceUpdatedCalled(); if (await _locker.WaitAsync(_timeout)) @@ -49,7 +55,26 @@ protected async Task> ForkchoiceUpdated long startTime = Stopwatch.GetTimestamp(); try { - return await _forkchoiceUpdatedV1Handler.Handle(forkchoiceState, payloadAttributes, version); + ResultWrapper result = + await _forkchoiceUpdatedV1Handler.Handle(forkchoiceState, payloadAttributes, version); + + // Apply custody-column update independently — spec requires errors here to NOT affect + // the payload_status already captured above. + if (custodyColumns is not null) + { + try + { + ApplyCustodyColumns(custodyColumns); + } + catch (Exception ex) + { + // Log but swallow: custody errors must not affect the forkchoice result. + if (_logger.IsWarn) + _logger.Warn($"engine_forkchoiceUpdatedV{version}: custody-column update failed (ignored per spec): {ex.Message}"); + } + } + + return result; } finally { @@ -64,6 +89,8 @@ protected async Task> ForkchoiceUpdated } } + partial void ApplyCustodyColumns(BitArray custodyColumns); + protected async Task> NewPayload(IExecutionPayloadParams executionPayloadParams, int version) { _engineRequestsTracker.OnNewPayloadCalled(); diff --git a/src/Nethermind/Nethermind.Merge.Plugin/EngineRpcModule.cs b/src/Nethermind/Nethermind.Merge.Plugin/EngineRpcModule.cs index 2bde522688e7..540cc28a9a8b 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/EngineRpcModule.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/EngineRpcModule.cs @@ -29,6 +29,7 @@ public partial class EngineRpcModule( IHandler, IReadOnlyList> capabilitiesHandler, IAsyncHandler> getBlobsHandler, IAsyncHandler?> getBlobsHandlerV2, + IAsyncHandler?> getBlobsHandlerV4, IHandler, IReadOnlyList> getPayloadBodiesByHashV2Handler, IGetPayloadBodiesByRangeV2Handler getPayloadBodiesByRangeV2Handler, IEngineRequestsTracker engineRequestsTracker, @@ -44,5 +45,11 @@ public partial class EngineRpcModule( public ResultWrapper> engine_exchangeCapabilities(IEnumerable methods) => _capabilitiesHandler.Handle(methods as HashSet ?? [.. methods]); - public ResultWrapper engine_getClientVersionV1(ClientVersionV1 clientVersionV1) => ResultWrapper.Success([new ClientVersionV1()]); + public ResultWrapper engine_getClientVersionV1(ClientVersionV1 clientVersionV1) + { + ClientVersionV1 elVersion = new(); + return string.IsNullOrEmpty(clientVersionV1.Code) + ? ResultWrapper.Success([elVersion]) + : ResultWrapper.Success([elVersion, clientVersionV1]); + } } diff --git a/src/Nethermind/Nethermind.Merge.Plugin/Handlers/EngineRpcCapabilitiesProvider.cs b/src/Nethermind/Nethermind.Merge.Plugin/Handlers/EngineRpcCapabilitiesProvider.cs index d8c860e7ef72..6766e91c8b61 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/Handlers/EngineRpcCapabilitiesProvider.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/Handlers/EngineRpcCapabilitiesProvider.cs @@ -95,7 +95,7 @@ void Configure(string method, string path, RpcCapabilityOptions options) Configure(nameof(IEngineRpcModule.engine_getPayloadV2), SszRestPaths.GetV2Payloads, Gate(spec.WithdrawalsEnabled)); Configure(nameof(IEngineRpcModule.engine_newPayloadV2), SszRestPaths.PostV2Payloads, Gate(spec.WithdrawalsEnabled)); Configure(nameof(IEngineRpcModule.engine_getPayloadBodiesByHashV1), SszRestPaths.PostV1PayloadBodiesByHash, Gate(spec.WithdrawalsEnabled)); - Configure(nameof(IEngineRpcModule.engine_getPayloadBodiesByRangeV1), SszRestPaths.PostV1PayloadBodiesByRange, Gate(spec.WithdrawalsEnabled)); + Configure(nameof(IEngineRpcModule.engine_getPayloadBodiesByRangeV1), SszRestPaths.GetV1PayloadBodiesByRange, Gate(spec.WithdrawalsEnabled)); // Cancun Configure(nameof(IEngineRpcModule.engine_getPayloadV3), SszRestPaths.GetV3Payloads, GateWithWarn(spec.IsEip4844Enabled)); @@ -117,7 +117,8 @@ void Configure(string method, string path, RpcCapabilityOptions options) Configure(nameof(IEngineRpcModule.engine_newPayloadV5), SszRestPaths.PostV5Payloads, GateWithWarn(spec.IsEip7928Enabled)); Configure(nameof(IEngineRpcModule.engine_forkchoiceUpdatedV4), SszRestPaths.PostV4Forkchoice, GateWithWarn(spec.IsEip7843Enabled)); Configure(nameof(IEngineRpcModule.engine_getPayloadBodiesByHashV2), SszRestPaths.PostV2PayloadBodiesByHash, GateWithWarn(spec.IsEip7928Enabled)); - Configure(nameof(IEngineRpcModule.engine_getPayloadBodiesByRangeV2), SszRestPaths.PostV2PayloadBodiesByRange, GateWithWarn(spec.IsEip7928Enabled)); + Configure(nameof(IEngineRpcModule.engine_getPayloadBodiesByRangeV2), SszRestPaths.GetV2PayloadBodiesByRange, GateWithWarn(spec.IsEip7928Enabled)); + Configure(nameof(IEngineRpcModule.engine_getBlobsV4), SszRestPaths.PostV4Blobs, Gate(spec.IsEip7594Enabled)); json = jsonLocal; ssz = sszLocal; diff --git a/src/Nethermind/Nethermind.Merge.Plugin/Handlers/GetBlobsHandlerV4.cs b/src/Nethermind/Nethermind.Merge.Plugin/Handlers/GetBlobsHandlerV4.cs new file mode 100644 index 000000000000..a480a2394423 --- /dev/null +++ b/src/Nethermind/Nethermind.Merge.Plugin/Handlers/GetBlobsHandlerV4.cs @@ -0,0 +1,88 @@ +// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited +// SPDX-License-Identifier: LGPL-3.0-only + +using System; +using System.Collections; +using System.Collections.Generic; +using System.Threading.Tasks; +using CkzgLib; +using Nethermind.Crypto; +using Nethermind.JsonRpc; +using Nethermind.Merge.Plugin.Data; +using Nethermind.TxPool; + +namespace Nethermind.Merge.Plugin.Handlers; + +public class GetBlobsHandlerV4(ITxPool txPool) : IAsyncHandler?> +{ + private const int MaxRequest = 128; + + private static readonly Task?>> NotFound = + Task.FromResult(ResultWrapper?>.Success(null)); + + public Task?>> HandleAsync(GetBlobsHandlerV4Request request) + { + if (request.BlobVersionedHashes.Length > MaxRequest) + { + string error = $"The number of requested blobs must not exceed {MaxRequest}"; + return ResultWrapper?>.Fail(error, MergeErrorCodes.TooLargeRequest); + } + + Metrics.GetBlobsRequestsTotal += request.BlobVersionedHashes.Length; + + int n = request.BlobVersionedHashes.Length; + byte[]?[] blobs = new byte[n][]; + ReadOnlyMemory[] proofs = new ReadOnlyMemory[n]; + int count = txPool.TryGetBlobsAndProofsV1(request.BlobVersionedHashes, blobs, proofs); + + Metrics.GetBlobsRequestsInBlobpoolTotal += count; + + BlobCellsAndProofs?[] response = new BlobCellsAndProofs?[n]; + + for (int i = 0; i < n; i++) + { + byte[]? blob = blobs[i]; + if (blob is null) + { + response[i] = null; + continue; + } + + // We have the blob and proofs for this blob. + // Let's compute all 128 cells. + byte[] cellsBuffer = new byte[Ckzg.CellsPerExtBlob * Ckzg.BytesPerCell]; + KzgPolynomialCommitments.ComputeCells(blob, cellsBuffer); + + byte[]?[] blobCells = new byte[Ckzg.CellsPerExtBlob][]; + byte[]?[] cellProofs = new byte[Ckzg.CellsPerExtBlob][]; + + ReadOnlySpan blobProofs = proofs[i].Span; + + for (int cellIdx = 0; cellIdx < Ckzg.CellsPerExtBlob; cellIdx++) + { + if (request.IndicesBitarray.Get(cellIdx)) + { + byte[] cell = new byte[Ckzg.BytesPerCell]; + Buffer.BlockCopy(cellsBuffer, cellIdx * Ckzg.BytesPerCell, cell, 0, Ckzg.BytesPerCell); + blobCells[cellIdx] = cell; + + byte[] cellProof = new byte[Ckzg.BytesPerProof]; + Buffer.BlockCopy(blobProofs[cellIdx], 0, cellProof, 0, Ckzg.BytesPerProof); + cellProofs[cellIdx] = cellProof; + } + } + + response[i] = new BlobCellsAndProofs + { + Available = true, + BlobCells = blobCells, + Proofs = cellProofs + }; + } + + Metrics.GetBlobsRequestsSuccessTotal++; + return ResultWrapper?>.Success(response); + } +} + +public readonly record struct GetBlobsHandlerV4Request(byte[][] BlobVersionedHashes, BitArray IndicesBitarray); diff --git a/src/Nethermind/Nethermind.Merge.Plugin/IEngineRpcModule.Amsterdam.cs b/src/Nethermind/Nethermind.Merge.Plugin/IEngineRpcModule.Amsterdam.cs index 2350a86ef76a..2efd15d28668 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/IEngineRpcModule.Amsterdam.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/IEngineRpcModule.Amsterdam.cs @@ -1,6 +1,7 @@ // SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited // SPDX-License-Identifier: LGPL-3.0-only +using System.Collections; using System.Collections.Generic; using System.Threading.Tasks; using Nethermind.Consensus.Producers; @@ -29,7 +30,7 @@ public partial interface IEngineRpcModule : IRpcModule Description = "Applies fork choice and starts building a new block if payload attributes are present.", IsSharable = true, IsImplemented = true)] - Task> engine_forkchoiceUpdatedV4(ForkchoiceStateV1 forkchoiceState, PayloadAttributes? payloadAttributes = null); + Task> engine_forkchoiceUpdatedV4(ForkchoiceStateV1 forkchoiceState, PayloadAttributes? payloadAttributes = null, BitArray? custodyColumns = null); [JsonRpcMethod( Description = "Returns an array of execution payload bodies for the list of provided block hashes.", @@ -42,4 +43,10 @@ public partial interface IEngineRpcModule : IRpcModule IsSharable = true, IsImplemented = true)] Task>> engine_getPayloadBodiesByRangeV2(long start, long count); + + [JsonRpcMethod( + Description = "Returns requested blob cells and proofs.", + IsSharable = true, + IsImplemented = true)] + Task?>> engine_getBlobsV4(byte[][] blobVersionedHashes, BitArray indicesBitarray); } diff --git a/src/Nethermind/Nethermind.Merge.Plugin/MergeErrorCodes.cs b/src/Nethermind/Nethermind.Merge.Plugin/MergeErrorCodes.cs index f47dfa4df2ae..d8903728e17b 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/MergeErrorCodes.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/MergeErrorCodes.cs @@ -31,7 +31,12 @@ public static class MergeErrorCodes public const int TooLargeRequest = -38004; /// - /// Payload attributes are invalid or inconsistent. + /// Requested fork is not supported by this EL. /// public const int UnsupportedFork = -38005; + + /// + /// Reorg depth exceeds the EL's limit. + /// + public const int ReorgTooDeep = -38006; } diff --git a/src/Nethermind/Nethermind.Merge.Plugin/MergePlugin.cs b/src/Nethermind/Nethermind.Merge.Plugin/MergePlugin.cs index 2d8af51cc728..9c6145415499 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/MergePlugin.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/MergePlugin.cs @@ -326,6 +326,7 @@ protected override void Load(ContainerBuilder builder) => builder .AddSingleton() .AddSingleton>, GetBlobsHandler>() .AddSingleton?>, GetBlobsHandlerV2>() + .AddSingleton?>, GetBlobsHandlerV4>() .AddSingleton, IReadOnlyList>, GetPayloadBodiesByHashV2Handler>() .AddSingleton() diff --git a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/CapabilitiesSszHandler.cs b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/CapabilitiesSszHandler.cs index 3e39810f24d5..1aa19d842fc5 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/CapabilitiesSszHandler.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/CapabilitiesSszHandler.cs @@ -9,19 +9,33 @@ namespace Nethermind.Merge.Plugin.SszRest.Handlers; /// -/// Handles POST /engine/v{N}/capabilities, the SSZ-REST equivalent of +/// Handles GET /engine/v2/capabilities, the HTTP/REST equivalent of /// engine_exchangeCapabilities. /// -public sealed class CapabilitiesSszHandler(IEngineRpcModule engineModule) : SszEndpointHandlerBase +public sealed class CapabilitiesSszHandler : SszEndpointHandlerBase { - public override string HttpMethod => "POST"; + public override string HttpMethod => "GET"; public override string Resource => SszRestPaths.Capabilities; - public override int? Version => 1; + public override int? Version => null; public override async Task HandleAsync(HttpContext ctx, int version, ReadOnlyMemory extra, ReadOnlySequence body) { - string[] caps = SszCodec.DecodeCapabilitiesRequest(body); - await WriteSszResultAsync(ctx, engineModule.engine_exchangeCapabilities(caps), - SszCodec.EncodeCapabilitiesResponse); + ctx.Response.ContentType = "application/json"; + ctx.Response.StatusCode = StatusCodes.Status200OK; + await ctx.Response.WriteAsync(""" + { + "supported_forks": ["paris", "shanghai", "cancun", "prague", "osaka", "amsterdam"], + "fork_scoped_endpoints": ["payloads", "forkchoice", "bodies"], + "independently_versioned": { + "blobs": ["v1", "v2", "v3", "v4"] + }, + "unscoped_endpoints": ["capabilities", "identity"], + "limits": { + "bodies.max_count": 32, + "blobs.max_versioned_hashes": 128, + "payload.max_bytes": 134217728 + } + } + """, ctx.RequestAborted); } } diff --git a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/ClientVersionSszHandler.cs b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/ClientVersionSszHandler.cs index d1c1f66a9805..3e6df90eda79 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/ClientVersionSszHandler.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/ClientVersionSszHandler.cs @@ -11,21 +11,30 @@ namespace Nethermind.Merge.Plugin.SszRest.Handlers; /// -/// Handles POST /engine/v{N}/client/version, the SSZ-REST equivalent of +/// Handles GET /engine/v2/identity, the HTTP/REST equivalent of /// engine_getClientVersionV1. /// public sealed class ClientVersionSszHandler(IEngineRpcModule engineModule) : SszEndpointHandlerBase { private readonly IEngineRpcModule _engineModule = engineModule; - public override string HttpMethod => "POST"; + public override string HttpMethod => "GET"; public override string Resource => SszRestPaths.ClientVersion; - public override int? Version => 1; + public override int? Version => null; public override async Task HandleAsync(HttpContext ctx, int version, ReadOnlyMemory extra, ReadOnlySequence body) { - ClientVersionV1 caller = SszCodec.DecodeClientVersionRequest(body); - ResultWrapper result = _engineModule.engine_getClientVersionV1(caller); - await WriteSszResultAsync(ctx, result, SszCodec.EncodeClientVersionResponse); + ClientVersionV1 clientVersion = ctx.Items.TryGetValue("X-Engine-Client-Version", out object? clvObj) && clvObj is ClientVersionV1 clv + ? clv + : default; + ResultWrapper result = _engineModule.engine_getClientVersionV1(clientVersion); + + ctx.Response.ContentType = "application/json"; + ctx.Response.StatusCode = StatusCodes.Status200OK; + string json = System.Text.Json.JsonSerializer.Serialize(result.Data, new System.Text.Json.JsonSerializerOptions + { + PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase + }); + await ctx.Response.WriteAsync(json, ctx.RequestAborted); } } diff --git a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/ForkchoiceUpdatedSszHandler.cs b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/ForkchoiceUpdatedSszHandler.cs index a68994525f6c..5509996035ca 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/ForkchoiceUpdatedSszHandler.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/ForkchoiceUpdatedSszHandler.cs @@ -5,6 +5,7 @@ using System.Buffers; using System.Threading.Tasks; using Microsoft.AspNetCore.Http; +using Nethermind.Core.Specs; using Nethermind.JsonRpc; using Nethermind.Merge.Plugin.Data; using Nethermind.Serialization.Ssz; @@ -16,7 +17,7 @@ namespace Nethermind.Merge.Plugin.SszRest.Handlers; /// engine_forkchoiceUpdatedV{N}. Generic over a per-version descriptor /// so adding V5 is one new descriptor struct + one DI line — no version switch. /// -public sealed class ForkchoiceUpdatedSszHandler(IEngineRpcModule engineModule) : SszEndpointHandlerBase +public sealed class ForkchoiceUpdatedSszHandler(IEngineRpcModule engineModule, ISpecProvider specProvider) : SszEndpointHandlerBase where TVersion : struct, IForkchoiceUpdatedVersion where TWire : struct, ISszCodec { @@ -27,6 +28,21 @@ public sealed class ForkchoiceUpdatedSszHandler(IEngineRpcModul public override async Task HandleAsync(HttpContext ctx, int version, ReadOnlyMemory extra, ReadOnlySequence body) { TWire.Decode(body, out TWire wire); + + ulong? timestamp = TVersion.GetTimestamp(wire); + if (timestamp.HasValue) + { + if (ctx.Items.TryGetValue("SszRouteFork", out object? forkObj) && forkObj is string urlFork) + { + IReleaseSpec timestampSpec = specProvider.GetSpec(new ForkActivation(long.MaxValue - 1, timestamp.Value)); + if (!timestampSpec.Name.Equals(urlFork, StringComparison.OrdinalIgnoreCase)) + { + await WriteErrorAsync(ctx, StatusCodes.Status400BadRequest, "Unsupported fork", MergeErrorCodes.UnsupportedFork); + return; + } + } + } + ResultWrapper result = await TVersion.Call(engineModule, wire); await WriteSszResultAsync(ctx, result, SszCodec.EncodeForkchoiceUpdatedResponse); } diff --git a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/GetBlobsSszHandler.cs b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/GetBlobsSszHandler.cs index c898748996dc..b8fb09aacf90 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/GetBlobsSszHandler.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/GetBlobsSszHandler.cs @@ -41,3 +41,17 @@ public override async Task HandleAsync(HttpContext ctx, int v, ReadOnlyMemory TVersion.Encode(d!, w)); } } + +public sealed class GetBlobsV4SszHandler(IEngineRpcModule engineModule) : SszEndpointHandlerBase +{ + public override string HttpMethod => "POST"; + public override string Resource => SszRestPaths.Blobs; + public override int? Version => EngineApiVersions.GetBlobs.V4; + + public override async Task HandleAsync(HttpContext ctx, int version, ReadOnlyMemory extra, ReadOnlySequence body) + { + (byte[][] hashes, System.Collections.BitArray indices) = SszCodec.DecodeGetBlobsV4Request(body); + ResultWrapper?> result = await engineModule.engine_getBlobsV4(hashes, indices); + await WriteSszResultAsync(ctx, result, static (d, w) => SszCodec.EncodeGetBlobsV4Response(d!, w)); + } +} diff --git a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/GetPayloadBodiesByRangeSszHandler.cs b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/GetPayloadBodiesByRangeSszHandler.cs index ab41a753ee2f..13a0b7c2cbae 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/GetPayloadBodiesByRangeSszHandler.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/GetPayloadBodiesByRangeSszHandler.cs @@ -11,7 +11,7 @@ namespace Nethermind.Merge.Plugin.SszRest.Handlers; /// -/// Handles POST /engine/v{N}/payloads/bodies/by-range, the SSZ-REST equivalent +/// Handles GET /engine/v2/{fork}/bodies?from=N&count=M, the SSZ-REST equivalent /// of engine_getPayloadBodiesByRangeV{N}. Generic over a per-version descriptor /// so adding a Vn+1 endpoint is one new descriptor + one DI line. /// @@ -20,19 +20,35 @@ public sealed class GetPayloadBodiesByRangeSszHandler(IEngine where TVersion : struct, IPayloadBodiesByRangeVersion where TResult : class { + // per spec: MAX_BODIES_REQUEST = 2**5 = 32. The previous value of 128 matched MAX_BLOBS_REQUEST but contradicted the bodies spec. private const int MaxPayloadBodiesRequest = 32; - public override string HttpMethod => "POST"; + public override string HttpMethod => "GET"; public override string Resource => SszRestPaths.PayloadBodiesByRange; public override int? Version => TVersion.VersionNumber; public override async Task HandleAsync(HttpContext ctx, int v, ReadOnlyMemory extra, ReadOnlySequence body) { - (long start, long count) = SszCodec.DecodeGetPayloadBodiesByRangeRequest(body); + // body is empty for GET; parameters come from the query string. + if (!long.TryParse(ctx.Request.Query["from"], out long start) || start <= 0) + { + await WriteErrorAsync(ctx, StatusCodes.Status400BadRequest, + "Missing or invalid 'from' query parameter: must be a positive integer block number", + SszRestErrorCodes.InvalidRequest); + return; + } + if (!long.TryParse(ctx.Request.Query["count"], out long count) || count <= 0) + { + await WriteErrorAsync(ctx, StatusCodes.Status400BadRequest, + "Missing or invalid 'count' query parameter: must be a positive integer", + SszRestErrorCodes.InvalidRequest); + return; + } if (count > MaxPayloadBodiesRequest) { await WriteErrorAsync(ctx, StatusCodes.Status400BadRequest, - $"count {count} exceeds the SSZ limit of {MaxPayloadBodiesRequest}"); + $"count {count} exceeds the limit of {MaxPayloadBodiesRequest}", + SszRestErrorCodes.InvalidRequest); return; } ResultWrapper> result = await TVersion.Call(engineModule, start, count); diff --git a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/SszEndpointHandlerBase.cs b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/SszEndpointHandlerBase.cs index 502442448a89..4708d1a18a97 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/SszEndpointHandlerBase.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/SszEndpointHandlerBase.cs @@ -5,6 +5,7 @@ using System.Buffers; using System.Diagnostics; using System.IO.Pipelines; +using System.Text.Json; using System.Threading.Tasks; using Microsoft.AspNetCore.Http; using Nethermind.Core; @@ -74,7 +75,7 @@ protected static async Task WriteSszResultAsync(HttpContext ctx, ResultWrappe { await (result switch { - { Result.ResultType: not ResultType.Success } => WriteErrorAsync(ctx, ErrorCodeToHttpStatus(result.ErrorCode), result.Result.Error ?? "Unknown error"), + { Result.ResultType: not ResultType.Success } => WriteErrorAsync(ctx, ErrorCodeToHttpStatus(result.ErrorCode), result.Result.Error ?? "Unknown error", result.ErrorCode), { Data: null } => SetNoContent(ctx), { Data: var data } => WriteSszAsync(ctx, data, encode) }); @@ -87,19 +88,69 @@ private static Task SetNoContent(HttpContext ctx) return Task.CompletedTask; } - public static async Task WriteErrorAsync(HttpContext ctx, int status, string message) + public static async Task WriteErrorAsync(HttpContext ctx, int status, string message, int? errorCode = null) { ctx.Response.StatusCode = status; - ctx.Response.ContentType = "text/plain"; - await ctx.Response.WriteAsync(message, ctx.RequestAborted); + ctx.Response.ContentType = "application/problem+json"; + + // Derive the RFC 7807 type URI. Error-code takes precedence over HTTP status so + // that two distinct engine errors sharing the same status emit different type URIs. + string type = errorCode switch + { + // Engine-API extension codes (-38xxx) + MergeErrorCodes.UnknownPayload => "/engine-api/errors/unknown-payload", + MergeErrorCodes.InvalidForkchoiceState => "/engine-api/errors/invalid-forkchoice", + MergeErrorCodes.InvalidPayloadAttributes => "/engine-api/errors/invalid-attributes", + MergeErrorCodes.TooLargeRequest => "/engine-api/errors/request-too-large", + MergeErrorCodes.UnsupportedFork => "/engine-api/errors/unsupported-fork", + MergeErrorCodes.ReorgTooDeep => "/engine-api/errors/reorg-too-deep", + + // SSZ-REST-specific internal codes (-39xxx) + SszRestErrorCodes.SszDecodeError => "/engine-api/errors/ssz-decode-error", + SszRestErrorCodes.InvalidRequest => "/engine-api/errors/invalid-request", + SszRestErrorCodes.MethodNotFound => "/engine-api/errors/method-not-found", + SszRestErrorCodes.UnsupportedMediaType => "/engine-api/errors/unsupported-media-type", + SszRestErrorCodes.InvalidBody => "/engine-api/errors/invalid-body", + + _ => status switch + { + // Fallback mapping for callers that pass no error code. + // 400: map to invalid-request as the generic fallback (covers malformed + // query parameters, structural field errors, etc.) + StatusCodes.Status400BadRequest => "/engine-api/errors/invalid-request", + StatusCodes.Status401Unauthorized => "/engine-api/errors/unauthorized", + // 404: default to method-not-found; unknown-payload is covered above via + // MergeErrorCodes.UnknownPayload. + StatusCodes.Status404NotFound => "/engine-api/errors/method-not-found", + StatusCodes.Status409Conflict => "/engine-api/errors/invalid-forkchoice", + StatusCodes.Status413PayloadTooLarge => "/engine-api/errors/request-too-large", + StatusCodes.Status415UnsupportedMediaType => "/engine-api/errors/unsupported-media-type", + StatusCodes.Status422UnprocessableEntity => "/engine-api/errors/invalid-attributes", + StatusCodes.Status500InternalServerError => "/engine-api/errors/internal", + StatusCodes.Status503ServiceUnavailable => "/engine-api/errors/service-unavailable", + _ => "/engine-api/errors/error" + } + }; + + bool omitDetail = type is "/engine-api/errors/ssz-decode-error" + or "/engine-api/errors/unauthorized" + || string.IsNullOrEmpty(message); + + string body = omitDetail + ? $"{{\"type\":{JsonSerializer.Serialize(type)}}}" + : $"{{\"type\":{JsonSerializer.Serialize(type)},\"detail\":{JsonSerializer.Serialize(message)}}}"; + + await ctx.Response.WriteAsync(body, ctx.RequestAborted); } private static int ErrorCodeToHttpStatus(int errorCode) => errorCode switch { MergeErrorCodes.UnknownPayload => StatusCodes.Status404NotFound, MergeErrorCodes.InvalidForkchoiceState => StatusCodes.Status409Conflict, + MergeErrorCodes.ReorgTooDeep => StatusCodes.Status409Conflict, MergeErrorCodes.InvalidPayloadAttributes => StatusCodes.Status422UnprocessableEntity, MergeErrorCodes.TooLargeRequest => StatusCodes.Status413PayloadTooLarge, + MergeErrorCodes.UnsupportedFork => StatusCodes.Status400BadRequest, ErrorCodes.MethodNotFound => StatusCodes.Status404NotFound, ErrorCodes.InternalError => StatusCodes.Status500InternalServerError, _ => StatusCodes.Status400BadRequest diff --git a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/SszRestPaths.cs b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/SszRestPaths.cs index 4b33e46aed60..399dc5ebafa9 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/SszRestPaths.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/SszRestPaths.cs @@ -11,46 +11,47 @@ public static class SszRestPaths public const string Capabilities = "capabilities"; - public const string ClientVersion = "client/version"; + public const string ClientVersion = "identity"; - public const string PayloadBodiesByHash = "payloads/bodies/by-hash"; + public const string PayloadBodiesByHash = "bodies/hash"; - public const string PayloadBodiesByRange = "payloads/bodies/by-range"; + public const string PayloadBodiesByRange = "bodies"; public const string Blobs = "blobs"; - public const string PostV1Payloads = "POST /engine/v1/payloads"; - public const string GetV1Payloads = "GET /engine/v1/payloads/{payload_id}"; - public const string PostV1Forkchoice = "POST /engine/v1/forkchoice"; - public const string PostV1Capabilities = "POST /engine/v1/capabilities"; - public const string PostV1ClientVersion = "POST /engine/v1/client/version"; + public const string PostV1Payloads = "POST /engine/v2/paris/payloads"; + public const string GetV1Payloads = "GET /engine/v2/paris/payloads/{payload_id}"; + public const string PostV1Forkchoice = "POST /engine/v2/paris/forkchoice"; + public const string PostV1Capabilities = "GET /engine/v2/capabilities"; + public const string PostV1ClientVersion = "GET /engine/v2/identity"; // Shanghai - public const string PostV2Payloads = "POST /engine/v2/payloads"; - public const string PostV2Forkchoice = "POST /engine/v2/forkchoice"; - public const string GetV2Payloads = "GET /engine/v2/payloads/{payload_id}"; - public const string PostV1PayloadBodiesByHash = "POST /engine/v1/payloads/bodies/by-hash"; - public const string PostV1PayloadBodiesByRange = "POST /engine/v1/payloads/bodies/by-range"; + public const string PostV2Payloads = "POST /engine/v2/shanghai/payloads"; + public const string PostV2Forkchoice = "POST /engine/v2/shanghai/forkchoice"; + public const string GetV2Payloads = "GET /engine/v2/shanghai/payloads/{payload_id}"; + public const string PostV1PayloadBodiesByHash = "POST /engine/v2/shanghai/bodies/hash"; + public const string GetV1PayloadBodiesByRange = "GET /engine/v2/shanghai/bodies"; // Cancun - public const string PostV3Payloads = "POST /engine/v3/payloads"; - public const string PostV3Forkchoice = "POST /engine/v3/forkchoice"; - public const string GetV3Payloads = "GET /engine/v3/payloads/{payload_id}"; - public const string PostV1Blobs = "POST /engine/v1/blobs"; + public const string PostV3Payloads = "POST /engine/v2/cancun/payloads"; + public const string PostV3Forkchoice = "POST /engine/v2/cancun/forkchoice"; + public const string GetV3Payloads = "GET /engine/v2/cancun/payloads/{payload_id}"; + public const string PostV1Blobs = "POST /engine/v2/blobs/v1"; // Prague - public const string PostV4Payloads = "POST /engine/v4/payloads"; - public const string GetV4Payloads = "GET /engine/v4/payloads/{payload_id}"; + public const string PostV4Payloads = "POST /engine/v2/prague/payloads"; + public const string GetV4Payloads = "GET /engine/v2/prague/payloads/{payload_id}"; // Osaka - public const string GetV5Payloads = "GET /engine/v5/payloads/{payload_id}"; - public const string PostV2Blobs = "POST /engine/v2/blobs"; - public const string PostV3Blobs = "POST /engine/v3/blobs"; + public const string GetV5Payloads = "GET /engine/v2/osaka/payloads/{payload_id}"; + public const string PostV2Blobs = "POST /engine/v2/blobs/v2"; + public const string PostV3Blobs = "POST /engine/v2/blobs/v3"; // Amsterdam - public const string PostV5Payloads = "POST /engine/v5/payloads"; - public const string GetV6Payloads = "GET /engine/v6/payloads/{payload_id}"; - public const string PostV4Forkchoice = "POST /engine/v4/forkchoice"; - public const string PostV2PayloadBodiesByHash = "POST /engine/v2/payloads/bodies/by-hash"; - public const string PostV2PayloadBodiesByRange = "POST /engine/v2/payloads/bodies/by-range"; + public const string PostV5Payloads = "POST /engine/v2/amsterdam/payloads"; + public const string GetV6Payloads = "GET /engine/v2/amsterdam/payloads/{payload_id}"; + public const string PostV4Forkchoice = "POST /engine/v2/amsterdam/forkchoice"; + public const string PostV2PayloadBodiesByHash = "POST /engine/v2/amsterdam/bodies/hash"; + public const string GetV2PayloadBodiesByRange = "GET /engine/v2/amsterdam/bodies"; + public const string PostV4Blobs = "POST /engine/v2/blobs/v4"; } diff --git a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/SszVersionDescriptors.cs b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/SszVersionDescriptors.cs index 7ff1ac99cdb7..c5e9a03adfe4 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/SszVersionDescriptors.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/SszVersionDescriptors.cs @@ -2,6 +2,7 @@ // SPDX-License-Identifier: LGPL-3.0-only using System.Buffers; +using System.Collections; using System.Collections.Generic; using System.Threading.Tasks; using Nethermind.Consensus; @@ -46,7 +47,7 @@ public static Task> Call(IEngineRpcModule engine, public static Task> Call(IEngineRpcModule engine, in NewPayloadV3RequestWire wire) => engine.engine_newPayloadV3( wire.ExecutionPayload.AsExecutionPayload(), - wire.ExpectedBlobVersionedHashes ?? [], + SszCodec.GetBlobVersionedHashes(wire.ExecutionPayload.AsExecutionPayload()), wire.ParentBeaconBlockRoot); } @@ -56,7 +57,7 @@ public static Task> Call(IEngineRpcModule engine, public static Task> Call(IEngineRpcModule engine, in NewPayloadV4RequestWire wire) => engine.engine_newPayloadV4( wire.ExecutionPayload.AsExecutionPayload(), - wire.ExpectedBlobVersionedHashes ?? [], + SszCodec.GetBlobVersionedHashes(wire.ExecutionPayload.AsExecutionPayload()), wire.ParentBeaconBlockRoot, wire.ExecutionRequests.ToExecutionRequests()); } @@ -67,7 +68,7 @@ public static Task> Call(IEngineRpcModule engine, public static Task> Call(IEngineRpcModule engine, in NewPayloadV5RequestWire wire) => engine.engine_newPayloadV5( wire.ExecutionPayload.AsExecutionPayload(), - wire.ExpectedBlobVersionedHashes ?? [], + SszCodec.GetBlobVersionedHashes(wire.ExecutionPayload.AsExecutionPayload()), wire.ParentBeaconBlockRoot, wire.ExecutionRequests.ToExecutionRequests()); } @@ -76,6 +77,7 @@ public interface IForkchoiceUpdatedVersion where TWire : struct, ISszCode { static abstract int VersionNumber { get; } static abstract Task> Call(IEngineRpcModule engine, in TWire wire); + static abstract ulong? GetTimestamp(in TWire wire); } public readonly struct ForkchoiceUpdatedDescriptorV1 : IForkchoiceUpdatedVersion @@ -87,6 +89,8 @@ public static Task> Call(IEngineRpcModu PayloadAttributes? attrs = wire.PayloadAttributes is { Length: > 0 } a ? SszCodec.PayloadAttributesFromWire(a[0]) : null; return engine.engine_forkchoiceUpdatedV1(state, attrs); } + public static ulong? GetTimestamp(in ForkchoiceUpdatedV1RequestWire wire) => + wire.PayloadAttributes is { Length: > 0 } a ? a[0].Timestamp : null; } public readonly struct ForkchoiceUpdatedDescriptorV2 : IForkchoiceUpdatedVersion @@ -98,6 +102,8 @@ public static Task> Call(IEngineRpcModu PayloadAttributes? attrs = wire.PayloadAttributes is { Length: > 0 } a ? SszCodec.PayloadAttributesFromWire(a[0]) : null; return engine.engine_forkchoiceUpdatedV2(state, attrs); } + public static ulong? GetTimestamp(in ForkchoiceUpdatedV2RequestWire wire) => + wire.PayloadAttributes is { Length: > 0 } a ? a[0].Timestamp : null; } public readonly struct ForkchoiceUpdatedDescriptorV3 : IForkchoiceUpdatedVersion @@ -109,6 +115,8 @@ public static Task> Call(IEngineRpcModu PayloadAttributes? attrs = wire.PayloadAttributes is { Length: > 0 } a ? SszCodec.PayloadAttributesFromWire(a[0]) : null; return engine.engine_forkchoiceUpdatedV3(state, attrs); } + public static ulong? GetTimestamp(in ForkchoiceUpdatedV3RequestWire wire) => + wire.PayloadAttributes is { Length: > 0 } a ? a[0].Timestamp : null; } public readonly struct ForkchoiceUpdatedDescriptorV4 : IForkchoiceUpdatedVersion @@ -118,8 +126,11 @@ public static Task> Call(IEngineRpcModu { ForkchoiceStateV1 state = SszCodec.ForkchoiceStateV1FromWire(wire.ForkchoiceState); PayloadAttributes? attrs = wire.PayloadAttributes is { Length: > 0 } a ? SszCodec.PayloadAttributesFromWire(a[0]) : null; - return engine.engine_forkchoiceUpdatedV4(state, attrs); + BitArray? custody = wire.CustodyColumns is { Length: > 0 } c ? c[0].Bits : null; + return engine.engine_forkchoiceUpdatedV4(state, attrs, custody); } + public static ulong? GetTimestamp(in ForkchoiceUpdatedRequestWire wire) => + wire.PayloadAttributes is { Length: > 0 } a ? a[0].Timestamp : null; } public readonly struct GetPayloadDescriptorV1 : IGetPayloadVersion @@ -250,3 +261,19 @@ public static int Encode(IReadOnlyList blobs, IBufferWriter blobs, IBufferWriter writer) => SszCodec.EncodeGetBlobsV3Response(blobs, writer); } + +public interface IGetBlobsV4Version +{ + static abstract int VersionNumber { get; } + static abstract Task?>> Call(IEngineRpcModule engine, byte[][] hashes, System.Collections.BitArray indicesBitarray); + static abstract int Encode(IReadOnlyList blobs, IBufferWriter writer); +} + +public readonly struct GetBlobsDescriptorV4 : IGetBlobsV4Version +{ + public static int VersionNumber => EngineApiVersions.GetBlobs.V4; + public static Task?>> Call(IEngineRpcModule engine, byte[][] hashes, System.Collections.BitArray indicesBitarray) + => engine.engine_getBlobsV4(hashes, indicesBitarray); + public static int Encode(IReadOnlyList blobs, IBufferWriter writer) + => SszCodec.EncodeGetBlobsV4Response(blobs, writer); +} diff --git a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszBlobCell.cs b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszBlobCell.cs new file mode 100644 index 000000000000..bb691a465902 --- /dev/null +++ b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszBlobCell.cs @@ -0,0 +1,33 @@ +// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited +// SPDX-License-Identifier: LGPL-3.0-only + +using System; +using System.IO; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +namespace Nethermind.Merge.Plugin.SszRest; + +/// +/// Inline 1024-byte Blob Cell representation used by Engine API SSZ wire types. +/// +[StructLayout(LayoutKind.Sequential, Size = 1024)] +public struct SszBlobCell +{ + public const int BlobCellLength = 1024; + + public static SszBlobCell FromSpan(ReadOnlySpan span) + { + if (span.Length != BlobCellLength) + { + throw new InvalidDataException($"{nameof(SszBlobCell)} expects input of length {BlobCellLength} and received {span.Length}"); + } + + SszBlobCell result = default; + span.CopyTo(MemoryMarshal.CreateSpan(ref Unsafe.As(ref result), BlobCellLength)); + return result; + } + + public ReadOnlySpan AsSpan() => + MemoryMarshal.CreateReadOnlySpan(ref Unsafe.As(ref Unsafe.AsRef(in this)), BlobCellLength); +} diff --git a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszBlobCellVectorTypeConverter.cs b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszBlobCellVectorTypeConverter.cs new file mode 100644 index 000000000000..dd75a4984884 --- /dev/null +++ b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszBlobCellVectorTypeConverter.cs @@ -0,0 +1,41 @@ +// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited +// SPDX-License-Identifier: LGPL-3.0-only + +using System; +using Nethermind.Int256; +using Nethermind.Serialization.Ssz.Merkleization; +using Nethermind.Serialization.Ssz; + +namespace Nethermind.Merge.Plugin.SszRest; + +[SszVectorTypeConverter] +public static class SszBlobCellVectorTypeConverter +{ + public const int Length = SszBlobCell.BlobCellLength; + + public static SszBlobCell FromSpan(ReadOnlySpan span) => SszBlobCell.FromSpan(span); + + public static void FromSpan(ReadOnlySpan span, Span values) + { + for (int i = 0; i < values.Length; i++) + { + values[i] = FromSpan(span.Slice(i * Length, Length)); + } + } + + public static void ToSpan(Span span, SszBlobCell value) => value.AsSpan().CopyTo(span); + + public static void ToSpan(Span span, ReadOnlySpan values) + { + for (int i = 0; i < values.Length; i++) + { + ToSpan(span.Slice(i * Length, Length), values[i]); + } + } + + public static void Feed(ref Merkleizer merkleizer, SszBlobCell value) + { + Merkle.Merkleize(out UInt256 root, value.AsSpan()); + merkleizer.Feed(root); + } +} diff --git a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszCodec.cs b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszCodec.cs index ccd14906a4bf..afff20c63eb8 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszCodec.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszCodec.cs @@ -3,7 +3,6 @@ using System; using System.Buffers; -using System.Buffers.Binary; using System.Collections.Generic; using System.Text; using Nethermind.Core; @@ -35,16 +34,18 @@ public static int EncodePayloadStatus(PayloadStatusV1 ps, IBufferWriter wr public static int EncodeForkchoiceUpdatedResponse(ForkchoiceUpdatedV1Result resp, IBufferWriter writer) { - ulong[]? pidList = null; + SszPayloadId[]? pidList = null; if (resp.PayloadId is not null) { ReadOnlySpan hex = resp.PayloadId.AsSpan(); if (hex.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) hex = hex[2..]; if (hex.Length != 16) throw new InvalidOperationException($"Invalid payload id '{resp.PayloadId}': expected 16 hex chars, got {hex.Length}"); - Span stack = stackalloc byte[8]; - Bytes.FromHexString(hex, stack); - pidList = [BinaryPrimitives.ReadUInt64LittleEndian(stack)]; + byte[] idBytes = new byte[8]; + Bytes.FromHexString(hex, idBytes); + // ByteVector[8]: transmitted as-is (no LE flip — the bytes are already the + // opaque token; the spec says treat payload_id as opaque bytes, not a uint64). + pidList = [new SszPayloadId { Bytes = idBytes }]; } return EncodeToWriter(new ForkchoiceUpdatedResponseWire @@ -121,42 +122,91 @@ public static byte[][] DecodeGetBlobsRequest(ReadOnlySequence buf) public static int EncodeGetBlobsV1Response(IReadOnlyList blobs, IBufferWriter writer) { - // V1 SSZ has no nullable wrapper, nulls (unknown hashes) are dropped and the CL - // infers misses by comparing response length to request length. int count = blobs.Count; - int filled = 0; - for (int i = 0; i < count; i++) if (blobs[i] is not null) filled++; - BlobAndProofV1Wire[] arr = new BlobAndProofV1Wire[filled]; - int j = 0; + BlobV1EntryWire[] arr = new BlobV1EntryWire[count]; for (int i = 0; i < count; i++) - if (blobs[i] is { } b) arr[j++] = new() { Blob = b.Blob, Proof = b.Proof }; - return EncodeToWriter(new GetBlobsV1ResponseWire { BlobsAndProofs = arr }, writer); + { + BlobAndProofV1? b = blobs[i]; + arr[i] = b is null + ? new BlobV1EntryWire { Available = false, Contents = default } + : new BlobV1EntryWire { Available = true, Contents = new() { Blob = b.Blob, Proof = b.Proof } }; + } + return EncodeToWriter(new GetBlobsV1ResponseWire { Entries = arr }, writer); } public static int EncodeGetBlobsV2Response(IReadOnlyList blobs, IBufferWriter writer) { int count = blobs.Count; - int filled = 0; - for (int i = 0; i < count; i++) if (blobs[i] is not null) filled++; - BlobAndProofV2Wire[] arr = new BlobAndProofV2Wire[filled]; - int j = 0; + BlobV2EntryWire[] arr = new BlobV2EntryWire[count]; for (int i = 0; i < count; i++) - if (blobs[i] is { } b) arr[j++] = new() { Blob = b.Blob, Proofs = b.Proofs.ToKzgWire() }; - return EncodeToWriter(new GetBlobsV2ResponseWire { BlobsAndProofs = arr }, writer); + { + BlobAndProofV2? b = blobs[i]; + arr[i] = b is null + ? new BlobV2EntryWire { Available = false, Contents = default } + : new BlobV2EntryWire { Available = true, Contents = new() { Blob = b.Blob, Proofs = b.Proofs.ToKzgWire() } }; + } + return EncodeToWriter(new GetBlobsV2ResponseWire { Entries = arr }, writer); } public static int EncodeGetBlobsV3Response(IReadOnlyList blobs, IBufferWriter writer) { int count = blobs.Count; - NullableBlobAndProofV2Wire[] arr = new NullableBlobAndProofV2Wire[count]; + BlobV3EntryWire[] arr = new BlobV3EntryWire[count]; for (int i = 0; i < count; i++) { BlobAndProofV2? b = blobs[i]; arr[i] = b is null - ? new() { BlobAndProof = [] } - : new() { BlobAndProof = [new() { Blob = b.Blob, Proofs = b.Proofs.ToKzgWire() }] }; + ? new BlobV3EntryWire { Available = false, Contents = default } + : new BlobV3EntryWire { Available = true, Contents = new() { Blob = b.Blob, Proofs = b.Proofs.ToKzgWire() } }; + } + return EncodeToWriter(new GetBlobsV3ResponseWire { Entries = arr }, writer); + } + + public static (byte[][] hashes, System.Collections.BitArray indices) DecodeGetBlobsV4Request(ReadOnlySequence buf) + { + GetBlobsV4RequestWire.Decode(buf, out GetBlobsV4RequestWire wire); + if (wire.BlobVersionedHashes is null) return ([], new System.Collections.BitArray(128)); + byte[][] hashes = new byte[wire.BlobVersionedHashes.Length][]; + for (int i = 0; i < hashes.Length; i++) + hashes[i] = wire.BlobVersionedHashes[i].Bytes.ToArray(); + return (hashes, wire.IndicesBitarray ?? new System.Collections.BitArray(128)); + } + + public static int EncodeGetBlobsV4Response(IReadOnlyList blobs, IBufferWriter writer) + { + int count = blobs.Count; + BlobV4EntryWire[] arr = new BlobV4EntryWire[count]; + for (int i = 0; i < count; i++) + { + BlobCellsAndProofs? b = blobs[i]; + if (b is null || !b.Available) + { + arr[i] = new BlobV4EntryWire { Available = false, Contents = default }; + } + else + { + NullableBlobCellWire[] cells = new NullableBlobCellWire[128]; + NullableKzgProofWire[] proofs = new NullableKzgProofWire[128]; + for (int j = 0; j < 128; j++) + { + byte[]? cell = b.BlobCells?[j]; + byte[]? proof = b.Proofs?[j]; + cells[j] = cell is null + ? new() { Cell = [] } + : new() { Cell = [SszBlobCell.FromSpan(cell)] }; + proofs[j] = proof is null + ? new() { Proof = [] } + : new() { Proof = [SszKzgCommitment.FromSpan(proof)] }; + } + + arr[i] = new BlobV4EntryWire + { + Available = true, + Contents = new BlobCellsAndProofsWire { BlobCells = cells, Proofs = proofs } + }; + } } - return EncodeToWriter(new GetBlobsV3ResponseWire { BlobsAndProofs = arr }, writer); + return EncodeToWriter(new GetBlobsV4ResponseWire { Entries = arr }, writer); } public static Hash256[] DecodeGetPayloadBodiesByHashRequest(ReadOnlySequence buf) @@ -174,25 +224,29 @@ public static (long start, long count) DecodeGetPayloadBodiesByRangeRequest(Read public static int EncodePayloadBodiesV1Response(IReadOnlyList bodies, IBufferWriter writer) { int count = bodies.Count; - NullablePayloadBodyV1Wire[] arr = new NullablePayloadBodyV1Wire[count]; + BodyEntryV1Wire[] arr = new BodyEntryV1Wire[count]; for (int i = 0; i < count; i++) { ExecutionPayloadBodyV1Result? b = bodies[i]; - arr[i] = new() { Body = b is null ? [] : [b.ToBodyWire()] }; + arr[i] = b is null + ? new BodyEntryV1Wire { Available = false, Body = default } + : new BodyEntryV1Wire { Available = true, Body = b.ToBodyWire() }; } - return EncodeToWriter(new PayloadBodiesV1ResponseWire { PayloadBodies = arr }, writer); + return EncodeToWriter(new PayloadBodiesV1ResponseWire { Entries = arr }, writer); } public static int EncodePayloadBodiesV2Response(IReadOnlyList bodies, IBufferWriter writer) { int count = bodies.Count; - NullablePayloadBodyV2Wire[] arr = new NullablePayloadBodyV2Wire[count]; + BodyEntryV2Wire[] arr = new BodyEntryV2Wire[count]; for (int i = 0; i < count; i++) { ExecutionPayloadBodyV2Result? b = bodies[i]; - arr[i] = new() { Body = b is null ? [] : [b.ToBodyWire()] }; + arr[i] = b is null + ? new BodyEntryV2Wire { Available = false, Body = default } + : new BodyEntryV2Wire { Available = true, Body = b.ToBodyWire() }; } - return EncodeToWriter(new PayloadBodiesV2ResponseWire { PayloadBodies = arr }, writer); + return EncodeToWriter(new PayloadBodiesV2ResponseWire { Entries = arr }, writer); } public static int EncodeCapabilitiesResponse(IReadOnlyList caps, IBufferWriter writer) @@ -289,7 +343,8 @@ internal static PayloadAttributes PayloadAttributesFromWire(PayloadAttributesWir BuildPayloadAttributes(pa.Timestamp, pa.PrevRandao, pa.SuggestedFeeRecipient, withdrawals: pa.Withdrawals.ToDomain(), parentBeaconBlockRoot: pa.ParentBeaconBlockRoot, - slotNumber: pa.SlotNumber); + slotNumber: pa.SlotNumber, + targetGasLimit: pa.TargetGasLimit); private static PayloadAttributes BuildPayloadAttributes( ulong timestamp, @@ -297,14 +352,32 @@ private static PayloadAttributes BuildPayloadAttributes( Address suggestedFeeRecipient, Withdrawal[]? withdrawals = null, Hash256? parentBeaconBlockRoot = null, - ulong? slotNumber = null) => new() + ulong? slotNumber = null, + ulong? targetGasLimit = null) => new() { Timestamp = timestamp, PrevRandao = prevRandao, SuggestedFeeRecipient = suggestedFeeRecipient, Withdrawals = withdrawals, ParentBeaconBlockRoot = parentBeaconBlockRoot, - SlotNumber = slotNumber + SlotNumber = slotNumber, + TargetGasLimit = targetGasLimit }; + public static Hash256[] GetBlobVersionedHashes(ExecutionPayload payload) + { + Result decoded = payload.TryGetTransactions(); + if (decoded.IsError) return []; + List list = []; + foreach (Transaction tx in decoded.Data) + { + byte[]?[]? hashes = tx.BlobVersionedHashes; + if (hashes is null) continue; + foreach (byte[]? h in hashes) + { + if (h is not null) list.Add(new Hash256(h)); + } + } + return list.ToArray(); + } } diff --git a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszMiddleware.cs b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszMiddleware.cs index b5ad49c667ce..665fd6acd35a 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszMiddleware.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszMiddleware.cs @@ -17,6 +17,7 @@ using Nethermind.JsonRpc.Modules; using Nethermind.Logging; using Nethermind.Merge.Plugin.SszRest.Handlers; +using Nethermind.Merge.Plugin.Data; namespace Nethermind.Merge.Plugin.SszRest; @@ -36,11 +37,11 @@ public sealed class SszMiddleware private const string EnginePrefix = "/engine/v"; /// - /// Maximum allowed request body size in bytes (16 MiB). - /// Corresponds to MAX_REQUEST_BODY_SIZE defined in the Engine API SSZ-REST spec - /// (see https://github.com/ethereum/execution-apis/pull/764) + /// Maximum allowed request body size in bytes (128 MiB). + /// Matches MAX_REQUEST_BODY_SIZE in the Engine API SSZ-REST spec + /// (see https://github.com/ethereum/execution-apis/pull/764). /// - public const int MaxBodySize = 0x1000000; + public const int MaxBodySize = 0x8000000; private readonly FrozenDictionary> _postRoutes; private readonly FrozenDictionary> _getRoutes; @@ -50,6 +51,8 @@ public sealed class SszMiddleware private readonly (string Resource, List Handlers)[] _postPrefixRoutes; private readonly (string Resource, List Handlers)[] _getPrefixRoutes; + private enum SszRequestKind { NotEngine, EngineWrongMediaType, EngineOk } + public SszMiddleware( RequestDelegate next, IJsonRpcUrlCollection urlCollection, @@ -111,10 +114,10 @@ private static (FrozenDictionary> post, public Task InvokeAsync(HttpContext ctx) { - if (!IsSszRequest(ctx)) - { + SszRequestKind kind = ClassifySszRequest(ctx); + + if (kind == SszRequestKind.NotEngine) return _next(ctx); - } if (_processExitToken.IsCancellationRequested) { @@ -127,12 +130,40 @@ public Task InvokeAsync(HttpContext ctx) return _next(ctx); } + if (kind == SszRequestKind.EngineWrongMediaType) + { + Metrics.SszRestRequestsClientErrorTotal++; + return SszEndpointHandlerBase.WriteErrorAsync( + ctx, + StatusCodes.Status415UnsupportedMediaType, + "Engine API hot-path endpoints require Content-Type: application/octet-stream (POST) " + + "or Accept: application/octet-stream (GET)", + SszRestErrorCodes.UnsupportedMediaType); + } + Metrics.SszRestRequestsTotal++; return ProcessSszRequestAsync(ctx); } private async Task ProcessSszRequestAsync(HttpContext ctx) { + if (ctx.Request.Headers.TryGetValue("X-Engine-Client-Version", out Microsoft.Extensions.Primitives.StringValues headerValues) && headerValues.Count > 0) + { + string? headerVal = headerValues[0]; + if (!string.IsNullOrWhiteSpace(headerVal)) + { + try + { + ClientVersionV1 clVer = System.Text.Json.JsonSerializer.Deserialize(headerVal, new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + ctx.Items["X-Engine-Client-Version"] = clVer; + } + catch + { + // Ignore malformed header values + } + } + } + string? authHeader = ctx.Request.Headers.Authorization; if (authHeader is null || !await _auth.Authenticate(authHeader)) { @@ -140,22 +171,38 @@ private async Task ProcessSszRequestAsync(HttpContext ctx) await SszEndpointHandlerBase.WriteErrorAsync(ctx, StatusCodes.Status401Unauthorized, "Authentication error"); } - else if (!TryRoute(ctx.Request.Path.Value ?? string.Empty, out int version, out ReadOnlyMemory pathSegment)) + else if (!TryRoute(ctx.Request.Path.Value ?? string.Empty, out int version, out string? fork, + out ReadOnlyMemory pathSegment, out bool unsupportedFork)) { Metrics.SszRestRequestsClientErrorTotal++; - await SszEndpointHandlerBase.WriteErrorAsync(ctx, StatusCodes.Status404NotFound, - "Unknown SSZ endpoint"); + if (unsupportedFork) + { + await SszEndpointHandlerBase.WriteErrorAsync(ctx, StatusCodes.Status400BadRequest, + $"Fork '{fork}' is not supported by this EL", + MergeErrorCodes.UnsupportedFork); + } + else + { + await SszEndpointHandlerBase.WriteErrorAsync(ctx, StatusCodes.Status404NotFound, + "Unknown SSZ endpoint", SszRestErrorCodes.MethodNotFound); + } } - else if (!TryResolveHandler(ctx.Request.Method, pathSegment, version, out ISszEndpointHandler? handler, out ReadOnlyMemory extra)) + else if (!TryResolveHandler(ctx.Request.Method, pathSegment, version, fork, out ISszEndpointHandler? handler, out ReadOnlyMemory extra)) { Metrics.SszRestRequestsClientErrorTotal++; // Use .Span in the interpolation: ROM.ToString() would allocate a separate // intermediate string; appending the span goes straight into the format buffer. await SszEndpointHandlerBase.WriteErrorAsync(ctx, StatusCodes.Status404NotFound, - $"Unknown method: {ctx.Request.Method} /engine/v{version}/{pathSegment.Span}"); + $"Unknown method: {ctx.Request.Method} /engine/v{version}/{pathSegment.Span}", + SszRestErrorCodes.MethodNotFound); } else { + if (fork is not null) + { + ctx.Items["SszRouteFork"] = fork; + } + if (_logger.IsTrace) { _logger.Trace(extra.IsEmpty @@ -201,13 +248,15 @@ await SszEndpointHandlerBase.WriteErrorAsync(ctx, StatusCodes.Status404NotFound, catch (Exception ex) when (ex is InvalidDataException or IndexOutOfRangeException or EndOfStreamException) { // Per execution-apis #764 (Engine API SSZ Transport spec, "HTTP status codes" section): - // malformed SSZ encoding is 400 Bad Request. 422 Unprocessable Entity is reserved - // for "Invalid payload attributes" and is emitted by the handler chain via + // malformed SSZ encoding is 400 Bad Request with type=ssz-decode-error: canned error, + // no detail (spec verbatim). 422 Unprocessable Entity is reserved for + // "Invalid payload attributes" and is emitted by the handler chain via // ErrorCodeToHttpStatus when the engine module returns InvalidPayloadAttributes. Metrics.SszRestDecodeFailuresTotal++; Metrics.SszRestRequestsClientErrorTotal++; if (_logger.IsDebug) _logger.Debug($"SSZ-REST malformed body at {ctx.Request.Path.Value}: {ex.Message}"); - await SszEndpointHandlerBase.WriteErrorAsync(ctx, StatusCodes.Status400BadRequest, "Malformed SSZ body"); + await SszEndpointHandlerBase.WriteErrorAsync(ctx, StatusCodes.Status400BadRequest, + string.Empty, SszRestErrorCodes.SszDecodeError); } catch (Exception ex) { @@ -227,10 +276,13 @@ await SszEndpointHandlerBase.WriteErrorAsync(ctx, StatusCodes.Status404NotFound, } } - private static bool TryRoute(string path, out int version, out ReadOnlyMemory pathSegment) + private static bool TryRoute(string path, out int version, out string? fork, + out ReadOnlyMemory pathSegment, out bool unsupportedFork) { - version = 0; + version = 2; + fork = null; pathSegment = default; + unsupportedFork = false; ReadOnlySpan span = path.AsSpan(); if (!span.StartsWith(EnginePrefix.AsSpan(), StringComparison.OrdinalIgnoreCase)) @@ -242,37 +294,72 @@ private static bool TryRoute(string path, out int version, out ReadOnlyMemory sub = span["blobs/".Length..]; + if (sub.StartsWith("v", StringComparison.OrdinalIgnoreCase)) { - if (prevSlash) return false; - prevSlash = true; - continue; + if (int.TryParse(sub[1..], out int blobVer)) + { + version = blobVer; + pathSegment = path.AsMemory(offset, "blobs".Length); + return true; + } } - if (!char.IsAsciiLetterOrDigit(c) && c != '-') - return false; - prevSlash = false; + return false; + } + + // Everything remaining should be a fork-scoped path: /{fork}/{resource}[/{extra}] + int nextSlash = span.IndexOf('/'); + if (nextSlash <= 0) + { + // E.g. bodies query or payloads query without extra path segment + nextSlash = span.Length; } - // Slice into the original path string — zero-allocation; the memory stays valid - // for the lifetime of the request because Path.Value is held by ctx.Request. - pathSegment = path.AsMemory(offset); + ReadOnlySpan forkSpan = span[..nextSlash]; + string forkStr = forkSpan.ToString().ToLowerInvariant(); + if (forkStr is not ("paris" or "shanghai" or "cancun" or "prague" or "osaka" or "amsterdam")) + { + fork = forkStr; + unsupportedFork = true; + return false; + } + + fork = forkStr; + if (nextSlash < span.Length) + { + offset += nextSlash + 1; + pathSegment = path.AsMemory(offset); + } + else + { + // Recognised fork but missing resource segment, e.g. /engine/v2/cancun — not + // a valid endpoint; leave unsupportedFork = false so the caller uses 404. + return false; + } return true; } - private bool TryResolveHandler(string method, ReadOnlyMemory pathSegment, int version, + private bool TryResolveHandler(string method, ReadOnlyMemory pathSegment, int version, string? fork, out ISszEndpointHandler? handler, out ReadOnlyMemory extra) { handler = null; @@ -281,6 +368,29 @@ private bool TryResolveHandler(string method, ReadOnlyMemory pathSegment, bool isPost = HttpMethods.IsPost(method); bool isGet = !isPost && HttpMethods.IsGet(method); + string resourceStr = pathSegment.ToString(); + string extraStr = string.Empty; + + int firstSlash = resourceStr.IndexOf('/'); + if (firstSlash > 0) + { + extraStr = resourceStr[(firstSlash + 1)..]; + resourceStr = resourceStr[..firstSlash]; + } + + if (resourceStr.Equals("bodies", StringComparison.OrdinalIgnoreCase) && extraStr.Equals("hash", StringComparison.OrdinalIgnoreCase)) + { + resourceStr = "bodies/hash"; + extraStr = string.Empty; + } + + if (fork is not null) + { + int? mappedVersion = MapForkToVersion(fork, resourceStr, method); + if (mappedVersion is null) return false; + version = mappedVersion.Value; + } + FrozenDictionary>? exactDict = isPost ? _postRoutes : isGet ? _getRoutes : null; if (exactDict is not null) @@ -288,85 +398,115 @@ private bool TryResolveHandler(string method, ReadOnlyMemory pathSegment, FrozenDictionary>.AlternateLookup> lookup = isPost ? _postLookup : _getLookup; - if (lookup.TryGetValue(pathSegment.Span, out List? exactList)) + if (lookup.TryGetValue(resourceStr.AsSpan(), out List? exactList)) { ISszEndpointHandler? fallback = null; foreach (ISszEndpointHandler candidate in exactList) { - if (candidate.Version == version) { handler = candidate; extra = default; return true; } + if (candidate.Version == version) + { + handler = candidate; + extra = extraStr.AsMemory(); + return true; + } if (candidate.Version is null) fallback = candidate; } - if (fallback is not null) { handler = fallback; extra = default; return true; } + if (fallback is not null) + { + handler = fallback; + extra = extraStr.AsMemory(); + return true; + } } } - ISszEndpointHandler? prefixFallback = null; - ReadOnlyMemory prefixFallbackExtra = default; + return false; + } - (string Resource, List Handlers)[] prefixRoutes = - isPost ? _postPrefixRoutes : isGet ? _getPrefixRoutes : []; + public static int? MapForkToVersion(string fork, string resource, string httpMethod) + { + fork = fork.ToLowerInvariant(); + resource = resource.ToLowerInvariant(); - ReadOnlySpan pathSpan = pathSegment.Span; - foreach ((string routeResource, List candidates) in prefixRoutes) + if (resource == "payloads") { - ReadOnlySpan resourceSpan = routeResource.AsSpan(); - - if (pathSpan.Length <= resourceSpan.Length || pathSpan[resourceSpan.Length] != '/') - continue; - if (!pathSpan.StartsWith(resourceSpan, StringComparison.OrdinalIgnoreCase)) - continue; - - ReadOnlyMemory tail = pathSegment[(resourceSpan.Length + 1)..]; - - foreach (ISszEndpointHandler candidate in candidates) + if (httpMethod == "POST") { - if (candidate.Version == version) + return fork switch { - handler = candidate; - extra = tail; - return true; - } - - if (candidate.Version is null) + "paris" => 1, + "shanghai" => 2, + "cancun" => 3, + "prague" or "osaka" => 4, + "amsterdam" => 5, + _ => null + }; + } + else if (httpMethod == "GET") + { + return fork switch { - prefixFallback = candidate; - prefixFallbackExtra = tail; - } + "paris" => 1, + "shanghai" => 2, + "cancun" => 3, + "prague" => 4, + "osaka" => 5, + "amsterdam" => 6, + _ => null + }; } } - - if (prefixFallback is not null) + else if (resource == "forkchoice") { - handler = prefixFallback; - extra = prefixFallbackExtra; - return true; + return fork switch + { + "paris" => 1, + "shanghai" => 2, + "cancun" or "prague" or "osaka" => 3, + "amsterdam" => 4, + _ => null + }; + } + else if (resource == "bodies/hash" || resource == "bodies") + { + return fork switch + { + "paris" or "shanghai" or "cancun" or "prague" or "osaka" => 1, + "amsterdam" => 2, + _ => null + }; } - return false; + return null; } - private static bool IsSszRequest(HttpContext ctx) + private static SszRequestKind ClassifySszRequest(HttpContext ctx) { string path = ctx.Request.Path.Value ?? string.Empty; - if (!path.StartsWith("/engine/", StringComparison.OrdinalIgnoreCase)) - return false; + + if (!path.StartsWith(EnginePrefix, StringComparison.OrdinalIgnoreCase)) + return SszRequestKind.NotEngine; switch (ctx.Request.Method) { case "POST": - return ctx.Request.ContentType?.Contains(MediaTypeNames.Application.Octet, StringComparison.OrdinalIgnoreCase) == true; + return ctx.Request.ContentType?.Contains( + MediaTypeNames.Application.Octet, StringComparison.OrdinalIgnoreCase) == true + ? SszRequestKind.EngineOk + : SszRequestKind.EngineWrongMediaType; + case "GET": + foreach (string? v in ctx.Request.Headers.Accept) { - foreach (string? v in ctx.Request.Headers.Accept) - { - if (v is not null && v.Contains(MediaTypeNames.Application.Octet, StringComparison.OrdinalIgnoreCase)) - return true; - } - - return false; + if (v is not null && v.Contains( + MediaTypeNames.Application.Octet, StringComparison.OrdinalIgnoreCase)) + return SszRequestKind.EngineOk; } + + return SszRequestKind.NotEngine; + default: - return false; + return SszRequestKind.NotEngine; } } diff --git a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszMiddlewareConfigurer.cs b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszMiddlewareConfigurer.cs index 19f0bff63afb..744ac7a3d8c2 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszMiddlewareConfigurer.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszMiddlewareConfigurer.cs @@ -10,6 +10,7 @@ using Nethermind.Config; using Nethermind.Core.Authentication; using Nethermind.Logging; +using Nethermind.Core.Specs; using Nethermind.Merge.Plugin.Data; using Nethermind.Merge.Plugin.SszRest.Handlers; @@ -40,6 +41,7 @@ public void Configure(IServiceCollection services) services.Bridge(ctx); services.Bridge(ctx); services.Bridge(ctx); + services.Bridge(ctx); services.Bridge(ctx); services.AddSingleton>(); @@ -64,6 +66,7 @@ public void Configure(IServiceCollection services) services.AddSingleton>(); services.AddSingleton>(); + services.AddSingleton(); services.AddSingleton>(); diff --git a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszRestErrorCodes.cs b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszRestErrorCodes.cs new file mode 100644 index 000000000000..5f78f5986419 --- /dev/null +++ b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszRestErrorCodes.cs @@ -0,0 +1,42 @@ +// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited +// SPDX-License-Identifier: LGPL-3.0-only + +namespace Nethermind.Merge.Plugin.SszRest; + +/// +/// Internal sentinel error codes used by +/// to produce the correct RFC 7807 type URI for SSZ-REST-specific error conditions +/// that share the same HTTP status as other, unrelated engine-API errors. +/// +/// These are not exposed through the JSON-RPC layer. They live in the range +/// -39000 to -39999 to avoid collision with the JSON-RPC standard codes +/// (-32xxx) and the engine-API extension codes (-38xxx). +/// +/// +internal static class SszRestErrorCodes +{ + /// + /// SSZ body could not be decoded at all (structural parse failure). + /// + public const int SszDecodeError = -39000; + + /// + /// Request shape is wrong (missing or malformed query parameter, wrong field count, etc.). + /// + public const int InvalidRequest = -39001; + + /// + /// URL does not match any registered endpoint. + /// + public const int MethodNotFound = -39002; + + /// + /// Content-Type (POST) or Accept (GET) header is missing or wrong for an engine hot-path endpoint. + /// + public const int UnsupportedMediaType = -39003; + + /// + /// SSZ body decoded successfully but contains semantically invalid values (e.g. a uint field that overflows its domain range). + /// + public const int InvalidBody = -39004; +} diff --git a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszWireTypes.cs b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszWireTypes.cs index 7ccb36c31bc3..7d141cae3574 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszWireTypes.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszWireTypes.cs @@ -1,6 +1,7 @@ // SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited // SPDX-License-Identifier: LGPL-3.0-only +using System.Collections; using Nethermind.Core; using Nethermind.Core.Crypto; using Nethermind.Int256; @@ -80,6 +81,7 @@ public partial struct PayloadAttributesWire [SszList(16)] public SszWithdrawal[]? Withdrawals { get; set; } public Hash256 ParentBeaconBlockRoot { get; set; } public ulong SlotNumber { get; set; } + public ulong TargetGasLimit { get; set; } } [SszContainer] @@ -108,13 +110,26 @@ public partial struct ForkchoiceUpdatedRequestWire { public ForkchoiceStateWire ForkchoiceState { get; set; } [SszList(1)] public PayloadAttributesWire[]? PayloadAttributes { get; set; } + [SszList(1)] public SszCustodyColumns[]? CustodyColumns { get; set; } +} + +[SszContainer(isCollectionItself: true)] +public partial struct SszCustodyColumns +{ + [SszVector(128)] public BitArray? Bits { get; set; } +} + +[SszContainer(isCollectionItself: true)] +public partial struct SszPayloadId +{ + [SszVector(8)] public byte[]? Bytes { get; set; } } [SszContainer] public partial struct ForkchoiceUpdatedResponseWire { public PayloadStatusWire PayloadStatus { get; set; } - [SszList(1)] public ulong[]? PayloadId { get; set; } + [SszList(1)] public SszPayloadId[]? PayloadId { get; set; } } [SszContainer] @@ -133,7 +148,6 @@ public partial struct NewPayloadV2RequestWire public partial struct NewPayloadV3RequestWire { public SszExecutionPayloadV3 ExecutionPayload { get; set; } - [SszList(4096)] public Hash256[]? ExpectedBlobVersionedHashes { get; set; } public Hash256 ParentBeaconBlockRoot { get; set; } } @@ -141,7 +155,6 @@ public partial struct NewPayloadV3RequestWire public partial struct NewPayloadV4RequestWire { public SszExecutionPayloadV3 ExecutionPayload { get; set; } - [SszList(4096)] public Hash256[]? ExpectedBlobVersionedHashes { get; set; } public Hash256 ParentBeaconBlockRoot { get; set; } [SszList(256)] public SszTransaction[]? ExecutionRequests { get; set; } } @@ -150,7 +163,6 @@ public partial struct NewPayloadV4RequestWire public partial struct NewPayloadV5RequestWire { public SszExecutionPayloadV4 ExecutionPayload { get; set; } - [SszList(4096)] public Hash256[]? ExpectedBlobVersionedHashes { get; set; } public Hash256 ParentBeaconBlockRoot { get; set; } [SszList(256)] public SszTransaction[]? ExecutionRequests { get; set; } } @@ -236,10 +248,17 @@ public partial struct BlobAndProofV1Wire [SszVector(48)] public byte[]? Proof { get; set; } } +[SszContainer] +public partial struct BlobV1EntryWire +{ + public bool Available { get; set; } + public BlobAndProofV1Wire Contents { get; set; } +} + [SszContainer] public partial struct GetBlobsV1ResponseWire { - [SszList(128)] public BlobAndProofV1Wire[]? BlobsAndProofs { get; set; } + [SszList(128)] public BlobV1EntryWire[]? Entries { get; set; } } [SszContainer(isCollectionItself: true)] @@ -293,7 +312,7 @@ public partial struct ExecutionPayloadBodyV2Wire { [SszList(0x10_0000)] public SszTransaction[]? Transactions { get; set; } [SszList(16)] public SszWithdrawal[]? Withdrawals { get; set; } - [SszList(1)] public SszTransaction[]? BlockAccessList { get; set; } + [SszList(0x4000_0000)] public byte[]? BlockAccessList { get; set; } } [SszContainer] @@ -310,33 +329,35 @@ public partial struct GetPayloadBodiesByRangeRequestWire } /// -/// Each inner list has 0 elements for unknown blocks, 1 element for known blocks. +/// BodyEntry { available: Boolean; body: ExecutionPayloadBody } per spec. /// [SszContainer] -public partial struct NullablePayloadBodyV1Wire +public partial struct BodyEntryV1Wire { - [SszList(1)] public ExecutionPayloadBodyV1Wire[]? Body { get; set; } + public bool Available { get; set; } + public ExecutionPayloadBodyV1Wire Body { get; set; } } [SszContainer] public partial struct PayloadBodiesV1ResponseWire { - [SszList(32)] public NullablePayloadBodyV1Wire[]? PayloadBodies { get; set; } + [SszList(32)] public BodyEntryV1Wire[]? Entries { get; set; } } /// -/// Each inner list has 0 elements for unknown blocks, 1 element for known blocks. +/// BodyEntry { available: Boolean; body: ExecutionPayloadBodyAmsterdam } per spec. /// [SszContainer] -public partial struct NullablePayloadBodyV2Wire +public partial struct BodyEntryV2Wire { - [SszList(1)] public ExecutionPayloadBodyV2Wire[]? Body { get; set; } + public bool Available { get; set; } + public ExecutionPayloadBodyV2Wire Body { get; set; } } [SszContainer] public partial struct PayloadBodiesV2ResponseWire { - [SszList(32)] public NullablePayloadBodyV2Wire[]? PayloadBodies { get; set; } + [SszList(32)] public BodyEntryV2Wire[]? Entries { get; set; } } [SszContainer] @@ -347,23 +368,75 @@ public partial struct BlobAndProofV2Wire [SszList(128)] public SszKzgCommitment[]? Proofs { get; set; } } +[SszContainer] +public partial struct BlobV2EntryWire +{ + public bool Available { get; set; } + public BlobAndProofV2Wire Contents { get; set; } +} + [SszContainer] public partial struct GetBlobsV2ResponseWire { - [SszList(128)] public BlobAndProofV2Wire[]? BlobsAndProofs { get; set; } + [SszList(128)] public BlobV2EntryWire[]? Entries { get; set; } } /// -/// V3 uses nullable per-element encoding: List[BlobAndProofV2, 1] where 0 = missing, 1 = present. +/// BlobV3Entry = BlobV2Entry { available: Boolean; contents: BlobAndProofV2 } per spec. +/// Unlike V2 (all-or-nothing), V3 supports partial responses: missing blobs have +/// Available = false; only a full EL outage returns 204. /// [SszContainer] -public partial struct NullableBlobAndProofV2Wire +public partial struct BlobV3EntryWire { - [SszList(1)] public BlobAndProofV2Wire[]? BlobAndProof { get; set; } + public bool Available { get; set; } + public BlobAndProofV2Wire Contents { get; set; } } [SszContainer] public partial struct GetBlobsV3ResponseWire { - [SszList(128)] public NullableBlobAndProofV2Wire[]? BlobsAndProofs { get; set; } + [SszList(128)] public BlobV3EntryWire[]? Entries { get; set; } +} + +[SszContainer] +public partial struct GetBlobsV4RequestWire +{ + [SszList(128)] public Hash256[]? BlobVersionedHashes { get; set; } + [SszVector(128)] public BitArray? IndicesBitarray { get; set; } +} + +[SszContainer] +public partial struct NullableBlobCellWire +{ + [SszList(1)] public SszBlobCell[]? Cell { get; set; } +} + +[SszContainer] +public partial struct NullableKzgProofWire +{ + [SszList(1)] public SszKzgCommitment[]? Proof { get; set; } +} + +[SszContainer] +public partial struct BlobCellsAndProofsWire +{ + [SszList(128)] public NullableBlobCellWire[]? BlobCells { get; set; } + [SszList(128)] public NullableKzgProofWire[]? Proofs { get; set; } +} + +/// +/// BlobV4Entry { available: Boolean; contents: BlobCellsAndProofs } per spec. +/// +[SszContainer] +public partial struct BlobV4EntryWire +{ + public bool Available { get; set; } + public BlobCellsAndProofsWire Contents { get; set; } +} + +[SszContainer] +public partial struct GetBlobsV4ResponseWire +{ + [SszList(128)] public BlobV4EntryWire[]? Entries { get; set; } } diff --git a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/WireConversionExtensions.cs b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/WireConversionExtensions.cs index eed775522997..b38acf86f274 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/WireConversionExtensions.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/WireConversionExtensions.cs @@ -129,9 +129,7 @@ public static ExecutionPayloadBodyV2Wire ToBodyWire(this ExecutionPayloadBodyV2R { Transactions = body.Transactions.ToTxsWire(), Withdrawals = body.Withdrawals.ToWire(), - BlockAccessList = body.BlockAccessList is not null - ? [new SszTransaction { Bytes = body.BlockAccessList }] - : [] + BlockAccessList = body.BlockAccessList ?? [] }; private static SszBlob[] ToBlobsWire(byte[][] blobs) diff --git a/src/Nethermind/Nethermind.Runner/JsonRpc/Startup.cs b/src/Nethermind/Nethermind.Runner/JsonRpc/Startup.cs index dd223c97a7b1..9f4cdcc455e3 100644 --- a/src/Nethermind/Nethermind.Runner/JsonRpc/Startup.cs +++ b/src/Nethermind/Nethermind.Runner/JsonRpc/Startup.cs @@ -86,14 +86,34 @@ public void ConfigureServices(IServiceCollection services) IConfigProvider? configProvider = sp.GetService() ?? throw new ApplicationException($"{nameof(IConfigProvider)} could not be resolved"); IJsonRpcConfig jsonRpcConfig = configProvider.GetConfig(); + IJsonRpcUrlCollection? urlCollection = sp.GetService(); + HashSet engineApiPorts = urlCollection is null + ? [] + : urlCollection.Values + .Where(static u => u.IsAuthenticated) + .Select(static u => u.Port) + .ToHashSet(); + services.Configure(options => { options.Limits.MaxRequestBodySize = jsonRpcConfig.MaxRequestBodySize; options.ConfigureHttpsDefaults(co => co.SslProtocols |= SslProtocols.Tls13); + + options.Limits.Http2.InitialConnectionWindowSize = 1 * 1024 * 1024; + options.Limits.Http2.InitialStreamWindowSize = 1 * 1024 * 1024; + options.ConfigureEndpointDefaults(listenOptions => { - listenOptions.Protocols = HttpProtocols.Http1; - listenOptions.DisableAltSvcHeader = true; + int port = (listenOptions.EndPoint as System.Net.IPEndPoint)?.Port ?? 0; + if (engineApiPorts.Contains(port)) + { + listenOptions.Protocols = HttpProtocols.Http2; + } + else + { + listenOptions.Protocols = HttpProtocols.Http1; + listenOptions.DisableAltSvcHeader = true; + } }); }); Bootstrap.Instance.RegisterJsonRpcServices(services); diff --git a/src/Nethermind/Nethermind.Taiko/Rpc/TaikoEngineRpcModule.cs b/src/Nethermind/Nethermind.Taiko/Rpc/TaikoEngineRpcModule.cs index 29f0bc392d6c..a52774f7cce5 100644 --- a/src/Nethermind/Nethermind.Taiko/Rpc/TaikoEngineRpcModule.cs +++ b/src/Nethermind/Nethermind.Taiko/Rpc/TaikoEngineRpcModule.cs @@ -50,6 +50,7 @@ public class TaikoEngineRpcModule(IAsyncHandler getPa IHandler, IReadOnlyList> capabilitiesHandler, IAsyncHandler> getBlobsHandler, IAsyncHandler?> getBlobsHandlerV2, + IAsyncHandler?> getBlobsHandlerV4, IHandler, IReadOnlyList> getPayloadBodiesByHashV2Handler, IGetPayloadBodiesByRangeV2Handler getPayloadBodiesByRangeV2Handler, IEngineRequestsTracker engineRequestsTracker, @@ -76,6 +77,7 @@ public class TaikoEngineRpcModule(IAsyncHandler getPa capabilitiesHandler, getBlobsHandler, getBlobsHandlerV2, + getBlobsHandlerV4, getPayloadBodiesByHashV2Handler, getPayloadBodiesByRangeV2Handler, engineRequestsTracker, From 78c78d746dfa1c4ea0434dd18dae6dd53cfda081 Mon Sep 17 00:00:00 2001 From: Dyslex7c Date: Wed, 3 Jun 2026 08:52:38 +0530 Subject: [PATCH 02/35] fix build errors --- src/Nethermind/Nethermind.Taiko.Test/CertainBatchLookupTests.cs | 1 + src/Nethermind/Nethermind.Taiko.Test/TxPoolContentListsTests.cs | 1 + 2 files changed, 2 insertions(+) diff --git a/src/Nethermind/Nethermind.Taiko.Test/CertainBatchLookupTests.cs b/src/Nethermind/Nethermind.Taiko.Test/CertainBatchLookupTests.cs index 8fea5d8002a5..0969a252eae0 100644 --- a/src/Nethermind/Nethermind.Taiko.Test/CertainBatchLookupTests.cs +++ b/src/Nethermind/Nethermind.Taiko.Test/CertainBatchLookupTests.cs @@ -318,6 +318,7 @@ private static TaikoEngineRpcModule CreateRpcModule(IL1OriginStore l1OriginStore Substitute.For, IReadOnlyList>>(), Substitute.For>>(), Substitute.For?>>(), + Substitute.For?>>(), Substitute.For, IReadOnlyList>>(), Substitute.For(), Substitute.For(), diff --git a/src/Nethermind/Nethermind.Taiko.Test/TxPoolContentListsTests.cs b/src/Nethermind/Nethermind.Taiko.Test/TxPoolContentListsTests.cs index c9cb92adefc5..61f21c949ab3 100644 --- a/src/Nethermind/Nethermind.Taiko.Test/TxPoolContentListsTests.cs +++ b/src/Nethermind/Nethermind.Taiko.Test/TxPoolContentListsTests.cs @@ -256,6 +256,7 @@ private static TaikoEngineRpcModule CreateRpcModule( Substitute.For, IReadOnlyList>>(), Substitute.For>>(), Substitute.For?>>(), + Substitute.For?>>(), Substitute.For, IReadOnlyList>>(), Substitute.For(), Substitute.For(), From aba2a6abd5c24e10e6d6742d4f5b3a9ab9c8e6b9 Mon Sep 17 00:00:00 2001 From: Dyslex7c Date: Wed, 3 Jun 2026 10:05:38 +0530 Subject: [PATCH 03/35] fix benchmarks build error --- .../NewPayloadSerializationBenchmarks.cs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/Nethermind/Nethermind.Merge.Plugin.Benchmark/NewPayloadSerializationBenchmarks.cs b/src/Nethermind/Nethermind.Merge.Plugin.Benchmark/NewPayloadSerializationBenchmarks.cs index b404c782da2e..d1f51e171c1e 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin.Benchmark/NewPayloadSerializationBenchmarks.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin.Benchmark/NewPayloadSerializationBenchmarks.cs @@ -64,7 +64,7 @@ public void GlobalSetup() Hash256[] blobHashes = BuildBlobVersionedHashes(Blobs); Hash256 parentRoot = TestItem.KeccakA; - _sszBody = EncodeSszBody(payload, blobHashes, parentRoot); + _sszBody = EncodeSszBody(payload, parentRoot); _jsonBody = EncodeJsonBody(payload, blobHashes, parentRoot); _jsonPayloadOnly = JsonSerializer.SerializeToUtf8Bytes(payload, EthereumJsonSerializer.JsonOptions); @@ -187,12 +187,11 @@ private static Hash256[] BuildBlobVersionedHashes(int count) return hashes; } - private static byte[] EncodeSszBody(ExecutionPayloadV3 payload, Hash256[] blobs, Hash256 parentRoot) + private static byte[] EncodeSszBody(ExecutionPayloadV3 payload, Hash256 parentRoot) { NewPayloadV3RequestWire wire = new() { ExecutionPayload = new SszExecutionPayloadV3(payload), - ExpectedBlobVersionedHashes = blobs, ParentBeaconBlockRoot = parentRoot, }; From 7e93834f13f35276c38fba1c35173fbdfed60e41 Mon Sep 17 00:00:00 2001 From: Dyslex7c Date: Wed, 3 Jun 2026 12:40:56 +0530 Subject: [PATCH 04/35] fix stateless build and AuRa test --- .../Nethermind.Merge.AuRa.Test/AuRaMergeEngineModuleTests.cs | 2 +- .../Nethermind.Stateless.Executor.csproj | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Nethermind/Nethermind.Merge.AuRa.Test/AuRaMergeEngineModuleTests.cs b/src/Nethermind/Nethermind.Merge.AuRa.Test/AuRaMergeEngineModuleTests.cs index 038c46fbe12e..09d64464cc88 100644 --- a/src/Nethermind/Nethermind.Merge.AuRa.Test/AuRaMergeEngineModuleTests.cs +++ b/src/Nethermind/Nethermind.Merge.AuRa.Test/AuRaMergeEngineModuleTests.cs @@ -78,7 +78,7 @@ public override Task processing_block_should_serialize_valid_responses(string bl "0xec6f5611ce3652fefd669e8d7e6d63bd8cdefdcdfe9a0a44eb61355084831da4", "0xf382f220de54b57ac9355d4eeb114f9e6bc4d25e307cdac0347b43d5534ac68e", "0xb8a1a0780980ab4e20a46237a3c533af8cd0386cf4c74d05c8ec5e9bf5cbc482", - "0x2802e8a8c34cd1ea", + "0xb6ea0a60ac692530", _auraWithdrawalContractAddress)] public override async Task Should_process_block_as_expected_V6(string latestValidHash, string blockHash, string stateRoot, string payloadId, string? customWithdrawalContractAddress) => await base.Should_process_block_as_expected_V6(latestValidHash, blockHash, stateRoot, payloadId, customWithdrawalContractAddress); diff --git a/src/Nethermind/Nethermind.Stateless.Executor/Nethermind.Stateless.Executor.csproj b/src/Nethermind/Nethermind.Stateless.Executor/Nethermind.Stateless.Executor.csproj index 002a1cbe4e9e..93748a84e393 100644 --- a/src/Nethermind/Nethermind.Stateless.Executor/Nethermind.Stateless.Executor.csproj +++ b/src/Nethermind/Nethermind.Stateless.Executor/Nethermind.Stateless.Executor.csproj @@ -23,6 +23,8 @@ + + From 0eadda19ac82fceeabf556a0ca98f63a2df0f520 Mon Sep 17 00:00:00 2001 From: Dyslex7c Date: Wed, 3 Jun 2026 13:08:18 +0530 Subject: [PATCH 05/35] address claude review --- .../SszRest/SszMiddlewareTests.cs | 1 + .../SszRest/Handlers/CapabilitiesSszHandler.cs | 10 +++++++--- .../SszRest/Handlers/ClientVersionSszHandler.cs | 8 ++++---- .../Handlers/ForkchoiceUpdatedSszHandler.cs | 2 +- .../SszRest/Handlers/SszRestPaths.cs | 12 ++++++++++++ .../Nethermind.Merge.Plugin/SszRest/SszCodec.cs | 14 +++++++++----- .../SszRest/SszMiddleware.cs | 14 +++++++++----- .../Nethermind.Runner/JsonRpc/Startup.cs | 2 +- 8 files changed, 44 insertions(+), 19 deletions(-) diff --git a/src/Nethermind/Nethermind.Merge.Plugin.Test/SszRest/SszMiddlewareTests.cs b/src/Nethermind/Nethermind.Merge.Plugin.Test/SszRest/SszMiddlewareTests.cs index f9aff336837d..390fff30616b 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin.Test/SszRest/SszMiddlewareTests.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin.Test/SszRest/SszMiddlewareTests.cs @@ -93,6 +93,7 @@ private SszMiddleware BuildMiddleware(RequestDelegate? next = null) new GetBlobsV1SszHandler(_engineModule), new GetBlobsV2SszHandler(_engineModule), new GetBlobsV2SszHandler(_engineModule), + new GetBlobsV4SszHandler(_engineModule), new GetPayloadBodiesByHashSszHandler(_engineModule), new GetPayloadBodiesByHashSszHandler(_engineModule), diff --git a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/CapabilitiesSszHandler.cs b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/CapabilitiesSszHandler.cs index 1aa19d842fc5..19ccd4b864c3 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/CapabilitiesSszHandler.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/CapabilitiesSszHandler.cs @@ -3,6 +3,7 @@ using System; using System.Buffers; +using System.Text.Json; using System.Threading.Tasks; using Microsoft.AspNetCore.Http; @@ -18,13 +19,16 @@ public sealed class CapabilitiesSszHandler : SszEndpointHandlerBase public override string Resource => SszRestPaths.Capabilities; public override int? Version => null; + private static readonly string _supportedForksJson = + JsonSerializer.Serialize(SszRestPaths.SupportedForksOrdered); + public override async Task HandleAsync(HttpContext ctx, int version, ReadOnlyMemory extra, ReadOnlySequence body) { ctx.Response.ContentType = "application/json"; ctx.Response.StatusCode = StatusCodes.Status200OK; - await ctx.Response.WriteAsync(""" + await ctx.Response.WriteAsync($$""" { - "supported_forks": ["paris", "shanghai", "cancun", "prague", "osaka", "amsterdam"], + "supported_forks": {{_supportedForksJson}}, "fork_scoped_endpoints": ["payloads", "forkchoice", "bodies"], "independently_versioned": { "blobs": ["v1", "v2", "v3", "v4"] @@ -33,7 +37,7 @@ await ctx.Response.WriteAsync(""" "limits": { "bodies.max_count": 32, "blobs.max_versioned_hashes": 128, - "payload.max_bytes": 134217728 + "payload.max_bytes": {{SszMiddleware.MaxBodySize}} } } """, ctx.RequestAborted); diff --git a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/ClientVersionSszHandler.cs b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/ClientVersionSszHandler.cs index 3e6df90eda79..b101222d5463 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/ClientVersionSszHandler.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/ClientVersionSszHandler.cs @@ -18,6 +18,9 @@ public sealed class ClientVersionSszHandler(IEngineRpcModule engineModule) : Ssz { private readonly IEngineRpcModule _engineModule = engineModule; + private static readonly System.Text.Json.JsonSerializerOptions _jsonOptions = + new() { PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase }; + public override string HttpMethod => "GET"; public override string Resource => SszRestPaths.ClientVersion; public override int? Version => null; @@ -31,10 +34,7 @@ public override async Task HandleAsync(HttpContext ctx, int version, ReadOnlyMem ctx.Response.ContentType = "application/json"; ctx.Response.StatusCode = StatusCodes.Status200OK; - string json = System.Text.Json.JsonSerializer.Serialize(result.Data, new System.Text.Json.JsonSerializerOptions - { - PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase - }); + string json = System.Text.Json.JsonSerializer.Serialize(result.Data, _jsonOptions); await ctx.Response.WriteAsync(json, ctx.RequestAborted); } } diff --git a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/ForkchoiceUpdatedSszHandler.cs b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/ForkchoiceUpdatedSszHandler.cs index 5509996035ca..506e43dfb33c 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/ForkchoiceUpdatedSszHandler.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/ForkchoiceUpdatedSszHandler.cs @@ -34,7 +34,7 @@ public override async Task HandleAsync(HttpContext ctx, int version, ReadOnlyMem { if (ctx.Items.TryGetValue("SszRouteFork", out object? forkObj) && forkObj is string urlFork) { - IReleaseSpec timestampSpec = specProvider.GetSpec(new ForkActivation(long.MaxValue - 1, timestamp.Value)); + IReleaseSpec timestampSpec = specProvider.GetSpec(ForkActivation.TimestampOnly(timestamp.Value)); if (!timestampSpec.Name.Equals(urlFork, StringComparison.OrdinalIgnoreCase)) { await WriteErrorAsync(ctx, StatusCodes.Status400BadRequest, "Unsupported fork", MergeErrorCodes.UnsupportedFork); diff --git a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/SszRestPaths.cs b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/SszRestPaths.cs index 399dc5ebafa9..269cc1102fc6 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/SszRestPaths.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/SszRestPaths.cs @@ -1,10 +1,22 @@ // SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited // SPDX-License-Identifier: LGPL-3.0-only +using System.Collections.Frozen; +using System.Collections.Generic; + namespace Nethermind.Merge.Plugin.SszRest.Handlers; public static class SszRestPaths { + public static readonly FrozenSet SupportedForks = + new HashSet(System.StringComparer.OrdinalIgnoreCase) + { + "paris", "shanghai", "cancun", "prague", "osaka", "amsterdam" + }.ToFrozenSet(System.StringComparer.OrdinalIgnoreCase); + + public static readonly IReadOnlyList SupportedForksOrdered = + ["paris", "shanghai", "cancun", "prague", "osaka", "amsterdam"]; + public const string Payloads = "payloads"; public const string Forkchoice = "forkchoice"; diff --git a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszCodec.cs b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszCodec.cs index afff20c63eb8..c695b19ec86f 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszCodec.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszCodec.cs @@ -41,11 +41,11 @@ public static int EncodeForkchoiceUpdatedResponse(ForkchoiceUpdatedV1Result resp if (hex.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) hex = hex[2..]; if (hex.Length != 16) throw new InvalidOperationException($"Invalid payload id '{resp.PayloadId}': expected 16 hex chars, got {hex.Length}"); - byte[] idBytes = new byte[8]; - Bytes.FromHexString(hex, idBytes); + Span idSpan = stackalloc byte[8]; + Bytes.FromHexString(hex, idSpan); // ByteVector[8]: transmitted as-is (no LE flip — the bytes are already the // opaque token; the spec says treat payload_id as opaque bytes, not a uint64). - pidList = [new SszPayloadId { Bytes = idBytes }]; + pidList = [new SszPayloadId { Bytes = idSpan.ToArray() }]; } return EncodeToWriter(new ForkchoiceUpdatedResponseWire @@ -368,8 +368,12 @@ public static Hash256[] GetBlobVersionedHashes(ExecutionPayload payload) { Result decoded = payload.TryGetTransactions(); if (decoded.IsError) return []; - List list = []; - foreach (Transaction tx in decoded.Data) + Transaction[] txs = decoded.Data; + int totalHashes = 0; + foreach (Transaction tx in txs) + if (tx.BlobVersionedHashes is { } h) totalHashes += h.Length; + List list = new(totalHashes); + foreach (Transaction tx in txs) { byte[]?[]? hashes = tx.BlobVersionedHashes; if (hashes is null) continue; diff --git a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszMiddleware.cs b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszMiddleware.cs index 665fd6acd35a..7c535eae8dbe 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszMiddleware.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszMiddleware.cs @@ -51,6 +51,9 @@ public sealed class SszMiddleware private readonly (string Resource, List Handlers)[] _postPrefixRoutes; private readonly (string Resource, List Handlers)[] _getPrefixRoutes; + private static readonly System.Text.Json.JsonSerializerOptions _headerJsonOptions = + new() { PropertyNameCaseInsensitive = true }; + private enum SszRequestKind { NotEngine, EngineWrongMediaType, EngineOk } public SszMiddleware( @@ -154,12 +157,12 @@ private async Task ProcessSszRequestAsync(HttpContext ctx) { try { - ClientVersionV1 clVer = System.Text.Json.JsonSerializer.Deserialize(headerVal, new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + ClientVersionV1 clVer = System.Text.Json.JsonSerializer.Deserialize(headerVal, _headerJsonOptions); ctx.Items["X-Engine-Client-Version"] = clVer; } - catch + catch (Exception ex) { - // Ignore malformed header values + if (_logger.IsTrace) _logger.Trace($"SSZ-REST: ignoring malformed X-Engine-Client-Version header: {ex.Message}"); } } } @@ -245,7 +248,7 @@ await SszEndpointHandlerBase.WriteErrorAsync(ctx, StatusCodes.Status404NotFound, Metrics.SszRestRequestsClientErrorTotal++; await SszEndpointHandlerBase.WriteErrorAsync(ctx, StatusCodes.Status413PayloadTooLarge, ex.Message); } - catch (Exception ex) when (ex is InvalidDataException or IndexOutOfRangeException or EndOfStreamException) + catch (Exception ex) when (ex is InvalidDataException or EndOfStreamException) { // Per execution-apis #764 (Engine API SSZ Transport spec, "HTTP status codes" section): // malformed SSZ encoding is 400 Bad Request with type=ssz-decode-error: canned error, @@ -337,7 +340,8 @@ private static bool TryRoute(string path, out int version, out string? fork, ReadOnlySpan forkSpan = span[..nextSlash]; string forkStr = forkSpan.ToString().ToLowerInvariant(); - if (forkStr is not ("paris" or "shanghai" or "cancun" or "prague" or "osaka" or "amsterdam")) + // SszRestPaths.SupportedForks is the single source of truth for recognised fork names. + if (!SszRestPaths.SupportedForks.Contains(forkStr)) { fork = forkStr; unsupportedFork = true; diff --git a/src/Nethermind/Nethermind.Runner/JsonRpc/Startup.cs b/src/Nethermind/Nethermind.Runner/JsonRpc/Startup.cs index 9f4cdcc455e3..39e200089890 100644 --- a/src/Nethermind/Nethermind.Runner/JsonRpc/Startup.cs +++ b/src/Nethermind/Nethermind.Runner/JsonRpc/Startup.cs @@ -107,7 +107,7 @@ public void ConfigureServices(IServiceCollection services) int port = (listenOptions.EndPoint as System.Net.IPEndPoint)?.Port ?? 0; if (engineApiPorts.Contains(port)) { - listenOptions.Protocols = HttpProtocols.Http2; + listenOptions.Protocols = HttpProtocols.Http1AndHttp2; } else { From 73c30193fa692e40aff5acf19fdee818a88b37db Mon Sep 17 00:00:00 2001 From: Dyslex7c Date: Wed, 3 Jun 2026 17:19:07 +0530 Subject: [PATCH 06/35] address deep review comments --- .../PayloadAttributesValidateTests.cs | 113 ++++++++ .../Producers/PayloadAttributes.cs | 14 +- .../ForkchoiceUpdatedCustodyGuardTests.cs | 182 ++++++++++++ .../SszRest/SszMiddlewareTests.cs | 260 +++++++++++++++++- .../EngineRpcModule.Paris.cs | 10 +- .../Handlers/CapabilitiesSszHandler.cs | 42 ++- .../Handlers/ForkchoiceUpdatedSszHandler.cs | 42 ++- .../GetPayloadBodiesByRangeSszHandler.cs | 8 +- .../SszRest/Handlers/SszRestPaths.cs | 10 + .../SszRest/SszMiddleware.cs | 49 ++-- .../Nethermind.Runner/JsonRpc/Startup.cs | 2 + 11 files changed, 683 insertions(+), 49 deletions(-) create mode 100644 src/Nethermind/Nethermind.Consensus.Test/PayloadAttributesValidateTests.cs create mode 100644 src/Nethermind/Nethermind.Merge.Plugin.Test/ForkchoiceUpdatedCustodyGuardTests.cs diff --git a/src/Nethermind/Nethermind.Consensus.Test/PayloadAttributesValidateTests.cs b/src/Nethermind/Nethermind.Consensus.Test/PayloadAttributesValidateTests.cs new file mode 100644 index 000000000000..5809c6609c5c --- /dev/null +++ b/src/Nethermind/Nethermind.Consensus.Test/PayloadAttributesValidateTests.cs @@ -0,0 +1,113 @@ +// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited +// SPDX-License-Identifier: LGPL-3.0-only + +using Nethermind.Consensus.Producers; +using Nethermind.Core; +using Nethermind.Core.Crypto; +using Nethermind.Core.Specs; +using NSubstitute; +using NUnit.Framework; + +namespace Nethermind.Consensus.Test; + +public class PayloadAttributesValidateTests +{ + private static PayloadAttributes ValidV3Attributes(ulong timestamp = 1_000UL) => new() + { + Timestamp = timestamp, + PrevRandao = Keccak.Zero, + SuggestedFeeRecipient = Address.Zero, + Withdrawals = [], + ParentBeaconBlockRoot = Keccak.Zero, + SlotNumber = null, + TargetGasLimit = null, + }; + + private static ISpecProvider MakeSpecProvider(bool isAmsterdam) + { + ISpecProvider sp = Substitute.For(); + IReleaseSpec spec = Substitute.For(); + + spec.IsEip7843Enabled.Returns(isAmsterdam); + spec.IsEip4844Enabled.Returns(true); + spec.WithdrawalsEnabled.Returns(true); + + sp.GetSpec(Arg.Any()).Returns(spec); + return sp; + } + + [Test] + public void Validate_returns_specific_field_error_when_V4_fields_absent_on_Amsterdam_timestamp() + { + ISpecProvider sp = MakeSpecProvider(isAmsterdam: true); + PayloadAttributes attrs = ValidV3Attributes(); + + PayloadAttributesValidationResult result = attrs.Validate(sp, fcuVersion: PayloadAttributesVersions.V4, out string error); + + Assert.That(result, Is.EqualTo(PayloadAttributesValidationResult.InvalidPayloadAttributes)); + Assert.That(error, Is.Not.Null); + Assert.That(error, Does.Contain("must be provided"), + "Error should identify which V4 field is missing, not emit a generic version-mismatch message."); + Assert.That(error, Does.Not.Contain("expected"), + "A 'V4 expected' version-mismatch error would obscure the real problem (missing field)."); + } + + [Test] + public void Validate_reports_missing_SlotNumber_when_only_TargetGasLimit_present() + { + ISpecProvider sp = MakeSpecProvider(isAmsterdam: true); + PayloadAttributes attrs = ValidV3Attributes(); + attrs.TargetGasLimit = 30_000_000UL; + + PayloadAttributesValidationResult result = attrs.Validate(sp, fcuVersion: PayloadAttributesVersions.V4, out string error); + + Assert.That(result, Is.EqualTo(PayloadAttributesValidationResult.InvalidPayloadAttributes)); + Assert.That(error, Does.Contain(nameof(PayloadAttributes.SlotNumber)), + "SlotNumber is the first unset V4 field checked after TargetGasLimit is present."); + } + + [Test] + public void Validate_succeeds_for_complete_V4_attributes_on_Amsterdam_timestamp() + { + ISpecProvider sp = MakeSpecProvider(isAmsterdam: true); + PayloadAttributes attrs = ValidV3Attributes(); + attrs.SlotNumber = 42UL; + attrs.TargetGasLimit = 30_000_000UL; + + PayloadAttributesValidationResult result = attrs.Validate(sp, fcuVersion: PayloadAttributesVersions.V4, out string error); + + Assert.That(result, Is.EqualTo(PayloadAttributesValidationResult.Success)); + Assert.That(error, Is.Null); + } + + [Test] + public void Validate_returns_UnsupportedFork_when_V4_attrs_sent_to_V3_spec() + { + ISpecProvider sp = MakeSpecProvider(isAmsterdam: false); + PayloadAttributes attrs = ValidV3Attributes(); + attrs.SlotNumber = 42UL; + attrs.TargetGasLimit = 30_000_000UL; + + PayloadAttributesValidationResult result = attrs.Validate(sp, fcuVersion: PayloadAttributesVersions.V3, out string error); + + Assert.That(result, Is.EqualTo(PayloadAttributesValidationResult.UnsupportedFork)); + Assert.That(error, Is.Not.Null); + } + + [TestCase(false, false, PayloadAttributesVersions.V1)] + [TestCase(true, false, PayloadAttributesVersions.V4)] + [TestCase(false, true, PayloadAttributesVersions.V4)] + [TestCase(true, true, PayloadAttributesVersions.V4)] + public void GetVersion_infers_correct_version_from_present_fields( + bool hasTargetGasLimit, bool hasSlotNumber, int expectedVersion) + { + PayloadAttributes attrs = new() + { + Timestamp = 1_000UL, + TargetGasLimit = hasTargetGasLimit ? 30_000_000UL : null, + SlotNumber = hasSlotNumber ? 1UL : null, + }; + + Assert.That(attrs.GetVersion(), Is.EqualTo(expectedVersion)); + } +} diff --git a/src/Nethermind/Nethermind.Consensus/Producers/PayloadAttributes.cs b/src/Nethermind/Nethermind.Consensus/Producers/PayloadAttributes.cs index 6ad0bad2d228..f313a0c26890 100644 --- a/src/Nethermind/Nethermind.Consensus/Producers/PayloadAttributes.cs +++ b/src/Nethermind/Nethermind.Consensus/Producers/PayloadAttributes.cs @@ -199,10 +199,22 @@ public virtual PayloadAttributesValidationResult Validate( [NotNullWhen(false)] out string? error) { int actualVersion = this.GetVersion(); + int timestampVersion = specProvider.GetSpec(ForkActivation.TimestampOnly(Timestamp)).ExpectedPayloadAttributesVersion(); + + if (actualVersion < timestampVersion && !IsSupportedFcuForkCombination(fcuVersion, actualVersion)) + { + string? fieldError = ValidateFields(timestampVersion); + if (fieldError is not null) + { + error = fieldError; + return PayloadAttributesValidationResult.InvalidPayloadAttributes; + } + } + PayloadAttributesValidationResult result = ValidateVersion( fcuVersion, actualVersion, - timestampVersion: specProvider.GetSpec(ForkActivation.TimestampOnly(Timestamp)).ExpectedPayloadAttributesVersion(), + timestampVersion, "PayloadAttributesV", out error); diff --git a/src/Nethermind/Nethermind.Merge.Plugin.Test/ForkchoiceUpdatedCustodyGuardTests.cs b/src/Nethermind/Nethermind.Merge.Plugin.Test/ForkchoiceUpdatedCustodyGuardTests.cs new file mode 100644 index 000000000000..7680e8ba0b1d --- /dev/null +++ b/src/Nethermind/Nethermind.Merge.Plugin.Test/ForkchoiceUpdatedCustodyGuardTests.cs @@ -0,0 +1,182 @@ +// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited +// SPDX-License-Identifier: LGPL-3.0-only + +using System.Collections; +using System.Collections.Generic; +using System.Threading.Tasks; +using Nethermind.Api; +using Nethermind.Consensus.Producers; +using Nethermind.Core.Crypto; +using Nethermind.Core.Specs; +using Nethermind.Core.Test.Builders; +using Nethermind.JsonRpc; +using Nethermind.Logging; +using Nethermind.Merge.Plugin.Data; +using Nethermind.Merge.Plugin.GC; +using Nethermind.Merge.Plugin.Handlers; +using NSubstitute; +using NUnit.Framework; + +namespace Nethermind.Merge.Plugin.Test; + +[TestFixture] +public class ForkchoiceUpdatedCustodyGuardTests +{ + private static ForkchoiceStateV1 AnyForkchoiceState() => + new(TestItem.KeccakA, TestItem.KeccakB, TestItem.KeccakC); + + private static (CustodyInterceptingModule module, IForkchoiceUpdatedHandler fcuHandler) + BuildModule() + { + IForkchoiceUpdatedHandler fcuHandler = Substitute.For(); + + IEngineRequestsTracker tracker = Substitute.For(); + tracker.OnForkchoiceUpdatedCalled(); + + GCKeeper gcKeeper = new(NoGCStrategy.Instance, LimboLogs.Instance); + + CustodyInterceptingModule module = new( + fcuHandler: fcuHandler, + engineRequestsTracker: tracker, + gcKeeper: gcKeeper); + + return (module, fcuHandler); + } + + private static ResultWrapper FcuResult(string payloadStatus) => + ResultWrapper.Success(new ForkchoiceUpdatedV1Result + { + PayloadStatus = new PayloadStatusV1 { Status = payloadStatus, LatestValidHash = TestItem.KeccakA } + }); + + private static ResultWrapper FcuError() => + ResultWrapper.Fail("forced error", -32000); + + [Test] + public async Task CustodyColumns_applied_when_payload_status_is_VALID() + { + (CustodyInterceptingModule? module, IForkchoiceUpdatedHandler? fcuHandler) = BuildModule(); + fcuHandler.Handle(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(FcuResult(PayloadStatus.Valid)); + + await module.engine_forkchoiceUpdatedV4( + AnyForkchoiceState(), + payloadAttributes: null, + custodyColumns: new BitArray(64, false)); + + Assert.That(module.ApplyCustodyColumnsCalled, Is.True, + "ApplyCustodyColumns must be called when FCU returns VALID"); + } + + [Test] + public async Task CustodyColumns_suppressed_when_payload_status_is_INVALID() + { + (CustodyInterceptingModule? module, IForkchoiceUpdatedHandler? fcuHandler) = BuildModule(); + fcuHandler.Handle(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(FcuResult(PayloadStatus.Invalid)); + + await module.engine_forkchoiceUpdatedV4( + AnyForkchoiceState(), + payloadAttributes: null, + custodyColumns: new BitArray(64, false)); + + Assert.That(module.ApplyCustodyColumnsCalled, Is.False, + "ApplyCustodyColumns must NOT be called when FCU returns INVALID"); + } + + [Test] + public async Task CustodyColumns_suppressed_when_payload_status_is_SYNCING() + { + (CustodyInterceptingModule? module, IForkchoiceUpdatedHandler? fcuHandler) = BuildModule(); + fcuHandler.Handle(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(FcuResult(PayloadStatus.Syncing)); + + await module.engine_forkchoiceUpdatedV4( + AnyForkchoiceState(), + payloadAttributes: null, + custodyColumns: new BitArray(64, false)); + + Assert.That(module.ApplyCustodyColumnsCalled, Is.False, + "ApplyCustodyColumns must NOT be called when FCU returns SYNCING — " + + "the head was not updated so no custody change should be applied (spec §FCU)"); + } + + [Test] + public async Task CustodyColumns_suppressed_when_payload_status_is_ACCEPTED() + { + (CustodyInterceptingModule? module, IForkchoiceUpdatedHandler? fcuHandler) = BuildModule(); + fcuHandler.Handle(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(FcuResult(PayloadStatus.Accepted)); + + await module.engine_forkchoiceUpdatedV4( + AnyForkchoiceState(), + payloadAttributes: null, + custodyColumns: new BitArray(64, false)); + + Assert.That(module.ApplyCustodyColumnsCalled, Is.False, + "ApplyCustodyColumns must NOT be called when FCU returns ACCEPTED"); + } + + [Test] + public async Task CustodyColumns_suppressed_when_handler_returns_error() + { + (CustodyInterceptingModule? module, IForkchoiceUpdatedHandler? fcuHandler) = BuildModule(); + fcuHandler.Handle(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(FcuError()); + + await module.engine_forkchoiceUpdatedV4( + AnyForkchoiceState(), + payloadAttributes: null, + custodyColumns: new BitArray(64, false)); + + Assert.That(module.ApplyCustodyColumnsCalled, Is.False, + "ApplyCustodyColumns must NOT be called when the FCU handler returns an error result"); + } + + [Test] + public async Task CustodyColumns_not_called_when_null_even_on_VALID() + { + (CustodyInterceptingModule? module, IForkchoiceUpdatedHandler? fcuHandler) = BuildModule(); + fcuHandler.Handle(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(FcuResult(PayloadStatus.Valid)); + + await module.engine_forkchoiceUpdatedV4( + AnyForkchoiceState(), + payloadAttributes: null, + custodyColumns: null); + + Assert.That(module.ApplyCustodyColumnsCalled, Is.False, + "ApplyCustodyColumns must not be called when custodyColumns is null"); + } + + private sealed partial class CustodyInterceptingModule( + IForkchoiceUpdatedHandler fcuHandler, + IEngineRequestsTracker engineRequestsTracker, + GCKeeper gcKeeper) : EngineRpcModule( + getPayloadHandlerV1: Substitute.For>(), + getPayloadHandlerV2: Substitute.For>(), + getPayloadHandlerV3: Substitute.For>(), + getPayloadHandlerV4: Substitute.For>(), + getPayloadHandlerV5: Substitute.For>(), + getPayloadHandlerV6: Substitute.For>(), + newPayloadV1Handler: Substitute.For>(), + forkchoiceUpdatedV1Handler: fcuHandler, + executionGetPayloadBodiesByHashV1Handler: Substitute.For, IReadOnlyList>>(), + executionGetPayloadBodiesByRangeV1Handler: Substitute.For(), + transitionConfigurationHandler: Substitute.For>(), + capabilitiesHandler: Substitute.For, IReadOnlyList>>(), + getBlobsHandler: Substitute.For>>(), + getBlobsHandlerV2: Substitute.For?>>(), + getBlobsHandlerV4: Substitute.For?>>(), + getPayloadBodiesByHashV2Handler: Substitute.For, IReadOnlyList>>(), + getPayloadBodiesByRangeV2Handler: Substitute.For(), + engineRequestsTracker: engineRequestsTracker, + specProvider: Substitute.For(), + gcKeeper: gcKeeper, + logManager: LimboLogs.Instance) + { + public bool ApplyCustodyColumnsCalled { get; private set; } + + protected override void ApplyCustodyColumns(BitArray custodyColumns) => ApplyCustodyColumnsCalled = true; + } +} diff --git a/src/Nethermind/Nethermind.Merge.Plugin.Test/SszRest/SszMiddlewareTests.cs b/src/Nethermind/Nethermind.Merge.Plugin.Test/SszRest/SszMiddlewareTests.cs index 390fff30616b..e624078192ed 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin.Test/SszRest/SszMiddlewareTests.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin.Test/SszRest/SszMiddlewareTests.cs @@ -102,7 +102,7 @@ private SszMiddleware BuildMiddleware(RequestDelegate? next = null) new GetPayloadBodiesByRangeSszHandler(_engineModule), new ClientVersionSszHandler(_engineModule), - new CapabilitiesSszHandler(), + new CapabilitiesSszHandler(_specProvider), ]; return new SszMiddleware( @@ -326,6 +326,8 @@ public async Task GetPayloadBodiesByRange_routes_to_correct_engine_method_with_c [Test] public async Task Capabilities_returns_intersection_of_supported_methods() { + _specProvider.TransitionActivations.Returns([]); + DefaultHttpContext ctx = MakeGetContext("/engine/v2/capabilities"); await _middleware.InvokeAsync(ctx); @@ -334,6 +336,40 @@ public async Task Capabilities_returns_intersection_of_supported_methods() Assert.That(ctx.Response.ContentType, Does.Contain("application/json")); } + [Test] + public async Task Capabilities_supported_forks_are_gated_by_spec_provider() + { + // Two distinct spec objects for Shanghai and Cancun, identified purely by reference + // equality — no Name property is involved. + IReleaseSpec shanghaiSpec = Substitute.For(); + IReleaseSpec cancunSpec = Substitute.For(); + + ForkActivation[] transitions = + [ + ForkActivation.TimestampOnly(1_000UL), + ForkActivation.TimestampOnly(2_000UL), + ]; + _specProvider.TransitionActivations.Returns(transitions); + _specProvider.GetSpec(Arg.Is(fa => fa.Timestamp == 1_000UL)).Returns(shanghaiSpec); + _specProvider.GetSpec(Arg.Is(fa => fa.Timestamp == 2_000UL)).Returns(cancunSpec); + + // Rebuild middleware so it picks up the now-configured spec provider. + SszMiddleware mw = BuildMiddleware(); + DefaultHttpContext ctx = MakeGetContext("/engine/v2/capabilities"); + await mw.InvokeAsync(ctx); + + Assert.That(ctx.Response.StatusCode, Is.EqualTo(StatusCodes.Status200OK)); + string body = System.Text.Encoding.UTF8.GetString(ResponseBytes(ctx)); + + Assert.That(body, Does.Contain("\"paris\"")); + Assert.That(body, Does.Contain("\"shanghai\"")); + Assert.That(body, Does.Contain("\"cancun\"")); + + Assert.That(body, Does.Not.Contain("\"prague\"")); + Assert.That(body, Does.Not.Contain("\"osaka\"")); + Assert.That(body, Does.Not.Contain("\"amsterdam\"")); + } + [Test] public async Task ClientVersion_returns_non_empty_response() { @@ -356,7 +392,7 @@ public async Task Authentication_failure_returns_401_and_does_not_call_engine_mo _auth.Authenticate(Arg.Any()).Returns(false); byte[] body = BuildMinimalV1NewPayloadRequest(); - DefaultHttpContext ctx = MakePostContext("/engine/v1/payloads", body); + DefaultHttpContext ctx = MakePostContext("/engine/v2/paris/payloads", body); await _middleware.InvokeAsync(ctx); @@ -382,7 +418,7 @@ public async Task Unknown_engine_path_returns_404() { bool nextInvoked = false; SszMiddleware mw = BuildMiddleware(_ => { nextInvoked = true; return Task.CompletedTask; }); - DefaultHttpContext ctx = MakePostContext("/engine/v1/unknown-resource", []); + DefaultHttpContext ctx = MakePostContext("/engine/v2/paris/unknown-resource", []); await mw.InvokeAsync(ctx); @@ -393,7 +429,8 @@ public async Task Unknown_engine_path_returns_404() [Test] public async Task Post_payloads_with_unknown_extra_returns_404_not_500() { - DefaultHttpContext ctx = MakePostContext("/engine/v1/payloads/foo/bar", []); + // Extra segments on a non-AcceptsPathExtra resource must be 404, not silently dispatched. + DefaultHttpContext ctx = MakePostContext("/engine/v2/paris/payloads/foo/bar", []); await _middleware.InvokeAsync(ctx); @@ -406,7 +443,7 @@ public async Task Path_with_consecutive_slashes_returns_404() { // TryRoute must reject runs of '/' so that //abc does not reach // the payload-id parser and produce a confusing parse error. - DefaultHttpContext ctx = MakePostContext("/engine/v1/payloads//abc", []); + DefaultHttpContext ctx = MakePostContext("/engine/v2/paris/payloads//abc", []); await _middleware.InvokeAsync(ctx); @@ -425,7 +462,7 @@ public async Task Malformed_ssz_body_returns_400_without_propagating_exception() Func act = () => _middleware.InvokeAsync(ctx); Assert.That(async () => await act(), Throws.Nothing); - // Per execution-apis #764: malformed SSZ encoding maps to 400 Bad Request. + // Per execution-apis #793: malformed SSZ encoding maps to 400 Bad Request. Assert.That(ctx.Response.StatusCode, Is.EqualTo(StatusCodes.Status400BadRequest)); } @@ -663,9 +700,22 @@ public async Task ClientVersion_reads_X_Engine_Client_Version_header() [Test] public async Task Forkchoice_unsupported_fork_returns_400() { - IReleaseSpec mockSpec = Substitute.For(); - mockSpec.Name.Returns("Shanghai"); - _specProvider.GetSpec(Arg.Any()).Returns(mockSpec); + IReleaseSpec shanghaiSpec = Substitute.For(); + IReleaseSpec cancunSpec = Substitute.For(); + + const ulong shanghaiTs = 1_000UL; + const ulong cancunTs = 2_000UL; + const ulong payloadTs = 1_500UL; + + ForkActivation[] transitions = + [ + ForkActivation.TimestampOnly(shanghaiTs), + ForkActivation.TimestampOnly(cancunTs), + ]; + _specProvider.TransitionActivations.Returns(transitions); + _specProvider.GetSpec(Arg.Is(fa => fa.Timestamp == shanghaiTs)).Returns(shanghaiSpec); + _specProvider.GetSpec(Arg.Is(fa => fa.Timestamp == cancunTs)).Returns(cancunSpec); + _specProvider.GetSpec(Arg.Is(fa => fa.Timestamp == payloadTs)).Returns(shanghaiSpec); ForkchoiceUpdatedV3RequestWire request = new() { @@ -677,7 +727,7 @@ public async Task Forkchoice_unsupported_fork_returns_400() }, PayloadAttributes = [new PayloadAttributesV3Wire { - Timestamp = 12345, + Timestamp = payloadTs, SuggestedFeeRecipient = TestItem.AddressA, PrevRandao = Keccak.Zero, Withdrawals = [], @@ -694,4 +744,194 @@ public async Task Forkchoice_unsupported_fork_returns_400() string respBody = System.Text.Encoding.UTF8.GetString(ResponseBytes(ctx)); Assert.That(respBody, Does.Contain("unsupported-fork")); } + + [Test] + public async Task Forkchoice_stale_fork_url_without_attributes_is_allowed() + { + ForkchoiceUpdatedV1Result fcuResult = new() + { + PayloadStatus = new PayloadStatusV1 { Status = PayloadStatus.Valid, LatestValidHash = TestItem.KeccakA } + }; + _engineModule.engine_forkchoiceUpdatedV3(Arg.Any(), Arg.Any()) + .Returns(ResultWrapper.Success(fcuResult)); + + ForkchoiceUpdatedV3RequestWire request = new() + { + ForkchoiceState = new ForkchoiceStateWire + { + HeadBlockHash = TestItem.KeccakA, + SafeBlockHash = TestItem.KeccakB, + FinalizedBlockHash = Keccak.Zero + }, + PayloadAttributes = [] + }; + byte[] body = ForkchoiceUpdatedV3RequestWire.Encode(request); + + DefaultHttpContext ctx = MakePostContext("/engine/v2/cancun/forkchoice", body); + + await _middleware.InvokeAsync(ctx); + + Assert.That(ctx.Response.StatusCode, Is.EqualTo(StatusCodes.Status200OK)); + await _engineModule.Received(1).engine_forkchoiceUpdatedV3(Arg.Any(), Arg.Any()); + // ISpecProvider must NOT have been consulted — no timestamp to validate. + _specProvider.DidNotReceive().GetSpec(Arg.Any()); + } + + [TestCase("application/json")] + [TestCase("*/*")] + [TestCase("text/html, application/json;q=0.9, */*;q=0.8")] + public async Task Capabilities_returns_200_json_regardless_of_Accept_header(string accept) + { + DefaultHttpContext ctx = MakeBaseContext("GET", "/engine/v2/capabilities", AuthenticatedPort); + ctx.Request.Headers.Accept = accept; + ctx.Request.Body = Stream.Null; + + await _middleware.InvokeAsync(ctx); + + Assert.That(ctx.Response.StatusCode, Is.EqualTo(StatusCodes.Status200OK)); + Assert.That(ctx.Response.ContentType, Does.Contain("application/json")); + string body = System.Text.Encoding.UTF8.GetString(ResponseBytes(ctx)); + Assert.That(body, Does.Contain("supported_forks")); + } + + [TestCase("application/json")] + [TestCase("*/*")] + public async Task Identity_returns_200_json_regardless_of_Accept_header(string accept) + { + ClientVersionV1[] response = [new ClientVersionV1()]; + _engineModule.engine_getClientVersionV1(default) + .ReturnsForAnyArgs(ResultWrapper.Success(response)); + + DefaultHttpContext ctx = MakeBaseContext("GET", "/engine/v2/identity", AuthenticatedPort); + ctx.Request.Headers.Accept = accept; + ctx.Request.Body = Stream.Null; + + await _middleware.InvokeAsync(ctx); + + Assert.That(ctx.Response.StatusCode, Is.EqualTo(StatusCodes.Status200OK)); + Assert.That(ctx.Response.ContentType, Does.Contain("application/json")); + } + + [Test] + public async Task Trailing_slash_on_fork_scoped_path_returns_404() + { + DefaultHttpContext ctx = MakePostContext("/engine/v2/cancun/forkchoice/", []); + await _middleware.InvokeAsync(ctx); + Assert.That(ctx.Response.StatusCode, Is.EqualTo(StatusCodes.Status404NotFound)); + string body = System.Text.Encoding.UTF8.GetString(ResponseBytes(ctx)); + Assert.That(body, Does.Contain("method-not-found")); + } + + [Test] + public async Task Trailing_slash_on_get_path_returns_404() + { + DefaultHttpContext ctx = MakeBaseContext("GET", "/engine/v2/capabilities/", AuthenticatedPort); + ctx.Request.Headers.Accept = "application/json"; + ctx.Request.Body = Stream.Null; + await _middleware.InvokeAsync(ctx); + Assert.That(ctx.Response.StatusCode, Is.EqualTo(StatusCodes.Status404NotFound)); + } + + [TestCase("/engine/v2/cancun/forkchoice/whatever")] + [TestCase("/engine/v2/paris/payloads/foo/bar")] + public async Task Extra_path_segment_on_non_accepting_handler_returns_404(string path) + { + DefaultHttpContext ctx = MakePostContext(path, []); + + await _middleware.InvokeAsync(ctx); + + Assert.That(ctx.Response.StatusCode, Is.EqualTo(StatusCodes.Status404NotFound)); + } + + [Test] + public async Task Unknown_fork_in_path_returns_400_unsupported_fork() + { + DefaultHttpContext ctx = MakePostContext("/engine/v2/atlantis/payloads", []); + + await _middleware.InvokeAsync(ctx); + + Assert.That(ctx.Response.StatusCode, Is.EqualTo(StatusCodes.Status400BadRequest)); + string body = System.Text.Encoding.UTF8.GetString(ResponseBytes(ctx)); + Assert.That(body, Does.Contain("unsupported-fork")); + } + + [Test] + public async Task Unknown_blob_version_returns_404() + { + DefaultHttpContext ctx = MakePostContext("/engine/v2/blobs/v99", []); + + await _middleware.InvokeAsync(ctx); + + Assert.That(ctx.Response.StatusCode, Is.EqualTo(StatusCodes.Status404NotFound)); + } + + [Test] + public async Task Invalid_payload_id_in_path_returns_400() + { + DefaultHttpContext ctx = MakeGetContext("/engine/v2/paris/payloads/0xZZZZZZZZZZZZZZZZ"); + + await _middleware.InvokeAsync(ctx); + + Assert.That(ctx.Response.StatusCode, Is.EqualTo(StatusCodes.Status400BadRequest)); + } + + [Test] + public async Task GetPayloadBodiesByRange_over_limit_returns_413_request_too_large() + { + DefaultHttpContext ctx = MakeGetContext("/engine/v2/shanghai/bodies"); + ctx.Request.QueryString = new QueryString("?from=1&count=1000"); + + await _middleware.InvokeAsync(ctx); + + Assert.That(ctx.Response.StatusCode, Is.EqualTo(StatusCodes.Status413PayloadTooLarge)); + string body = System.Text.Encoding.UTF8.GetString(ResponseBytes(ctx)); + Assert.That(body, Does.Contain("request-too-large")); + } + + [Test] + public async Task GetPayloadBodiesByRange_from_zero_is_valid() + { + _engineModule.engine_getPayloadBodiesByRangeV1(Arg.Any(), Arg.Any()) + .Returns(ResultWrapper>.Success([])); + + DefaultHttpContext ctx = MakeGetContext("/engine/v2/shanghai/bodies"); + ctx.Request.QueryString = new QueryString("?from=0&count=1"); + + await _middleware.InvokeAsync(ctx); + + Assert.That(ctx.Response.StatusCode, Is.Not.EqualTo(StatusCodes.Status400BadRequest), + "from=0 (genesis block) must be accepted"); + } + + [Test] + public async Task Error_response_has_correct_RFC7807_shape_type_only_for_canned_errors() + { + byte[] garbage = new byte[64]; + new Random(42).NextBytes(garbage); + DefaultHttpContext ctx = MakePostContext("/engine/v2/paris/payloads", garbage); + + await _middleware.InvokeAsync(ctx); + + string body = System.Text.Encoding.UTF8.GetString(ResponseBytes(ctx)); + using System.Text.Json.JsonDocument doc = System.Text.Json.JsonDocument.Parse(body); + System.Text.Json.JsonElement root = doc.RootElement; + Assert.That(root.TryGetProperty("type", out _), Is.True, "RFC 7807 body must contain 'type'"); + Assert.That(root.TryGetProperty("detail", out _), Is.False, "ssz-decode-error must NOT include 'detail'"); + Assert.That(root.EnumerateObject().Count(), Is.EqualTo(1), "ssz-decode-error body must have exactly one key"); + } + + [Test] + public async Task Error_response_has_correct_RFC7807_shape_with_detail_for_non_canned_errors() + { + DefaultHttpContext ctx = MakePostContext("/engine/v2/atlantis/payloads", []); + + await _middleware.InvokeAsync(ctx); + + string body = System.Text.Encoding.UTF8.GetString(ResponseBytes(ctx)); + using System.Text.Json.JsonDocument doc = System.Text.Json.JsonDocument.Parse(body); + System.Text.Json.JsonElement root = doc.RootElement; + Assert.That(root.TryGetProperty("type", out _), Is.True); + Assert.That(root.TryGetProperty("detail", out _), Is.True, "unsupported-fork must include 'detail'"); + Assert.That(root.EnumerateObject().Count(), Is.EqualTo(2), "error body must have exactly two keys: type + detail"); + } } diff --git a/src/Nethermind/Nethermind.Merge.Plugin/EngineRpcModule.Paris.cs b/src/Nethermind/Nethermind.Merge.Plugin/EngineRpcModule.Paris.cs index bcae3f58ab96..682195515a61 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/EngineRpcModule.Paris.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/EngineRpcModule.Paris.cs @@ -9,6 +9,7 @@ using Nethermind.Api; using Nethermind.Consensus; using Nethermind.Consensus.Producers; +using Nethermind.Core; using Nethermind.Core.Exceptions; using Nethermind.Core.Specs; using Nethermind.JsonRpc; @@ -58,9 +59,10 @@ protected async Task> ForkchoiceUpdated ResultWrapper result = await _forkchoiceUpdatedV1Handler.Handle(forkchoiceState, payloadAttributes, version); - // Apply custody-column update independently — spec requires errors here to NOT affect - // the payload_status already captured above. - if (custodyColumns is not null) + bool fcuSucceeded = result.Result.ResultType == ResultType.Success + && result.Data?.PayloadStatus?.Status == PayloadStatus.Valid; + + if (custodyColumns is not null && fcuSucceeded) { try { @@ -89,7 +91,7 @@ protected async Task> ForkchoiceUpdated } } - partial void ApplyCustodyColumns(BitArray custodyColumns); + protected virtual void ApplyCustodyColumns(BitArray custodyColumns) { } protected async Task> NewPayload(IExecutionPayloadParams executionPayloadParams, int version) { diff --git a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/CapabilitiesSszHandler.cs b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/CapabilitiesSszHandler.cs index 19ccd4b864c3..d8007f22b668 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/CapabilitiesSszHandler.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/CapabilitiesSszHandler.cs @@ -3,9 +3,12 @@ using System; using System.Buffers; +using System.Collections.Generic; +using System.Linq; using System.Text.Json; using System.Threading.Tasks; using Microsoft.AspNetCore.Http; +using Nethermind.Core.Specs; namespace Nethermind.Merge.Plugin.SszRest.Handlers; @@ -13,22 +16,27 @@ namespace Nethermind.Merge.Plugin.SszRest.Handlers; /// Handles GET /engine/v2/capabilities, the HTTP/REST equivalent of /// engine_exchangeCapabilities. /// -public sealed class CapabilitiesSszHandler : SszEndpointHandlerBase +public sealed class CapabilitiesSszHandler(ISpecProvider specProvider) : SszEndpointHandlerBase { public override string HttpMethod => "GET"; public override string Resource => SszRestPaths.Capabilities; public override int? Version => null; - private static readonly string _supportedForksJson = - JsonSerializer.Serialize(SszRestPaths.SupportedForksOrdered); - public override async Task HandleAsync(HttpContext ctx, int version, ReadOnlyMemory extra, ReadOnlySequence body) { + int timestampForkCount = ComputeTimestampForkCount(specProvider); + + IEnumerable supportedForks = timestampForkCount == 0 + ? SszRestPaths.SupportedForksOrdered + : SszRestPaths.SupportedForksOrdered.Take(timestampForkCount + 1); + + string supportedForksJson = JsonSerializer.Serialize(supportedForks); + ctx.Response.ContentType = "application/json"; ctx.Response.StatusCode = StatusCodes.Status200OK; await ctx.Response.WriteAsync($$""" { - "supported_forks": {{_supportedForksJson}}, + "supported_forks": {{supportedForksJson}}, "fork_scoped_endpoints": ["payloads", "forkchoice", "bodies"], "independently_versioned": { "blobs": ["v1", "v2", "v3", "v4"] @@ -42,4 +50,28 @@ await ctx.Response.WriteAsync($$""" } """, ctx.RequestAborted); } + + private static int ComputeTimestampForkCount(ISpecProvider specProvider) + { + int count = 0; + IReleaseSpec? lastSeen = null; + + foreach (ForkActivation fa in specProvider.TransitionActivations) + { + if (fa.Timestamp is null) + continue; + + IReleaseSpec s = specProvider.GetSpec(fa); + if (ReferenceEquals(s, lastSeen)) + continue; + + count++; + lastSeen = s; + + if (count >= SszRestPaths.SupportedForksOrdered.Count - 1) + break; + } + + return count; + } } diff --git a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/ForkchoiceUpdatedSszHandler.cs b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/ForkchoiceUpdatedSszHandler.cs index 506e43dfb33c..6d5e55add361 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/ForkchoiceUpdatedSszHandler.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/ForkchoiceUpdatedSszHandler.cs @@ -34,10 +34,11 @@ public override async Task HandleAsync(HttpContext ctx, int version, ReadOnlyMem { if (ctx.Items.TryGetValue("SszRouteFork", out object? forkObj) && forkObj is string urlFork) { - IReleaseSpec timestampSpec = specProvider.GetSpec(ForkActivation.TimestampOnly(timestamp.Value)); - if (!timestampSpec.Name.Equals(urlFork, StringComparison.OrdinalIgnoreCase)) + if (!TimestampMatchesForkOrdinal(timestamp.Value, SszRestPaths.ForkOrdinal(urlFork))) { - await WriteErrorAsync(ctx, StatusCodes.Status400BadRequest, "Unsupported fork", MergeErrorCodes.UnsupportedFork); + await WriteErrorAsync(ctx, StatusCodes.Status400BadRequest, + $"URL fork '{urlFork}' does not match the fork for timestamp {timestamp.Value}", + MergeErrorCodes.UnsupportedFork); return; } } @@ -46,4 +47,39 @@ public override async Task HandleAsync(HttpContext ctx, int version, ReadOnlyMem ResultWrapper result = await TVersion.Call(engineModule, wire); await WriteSszResultAsync(ctx, result, SszCodec.EncodeForkchoiceUpdatedResponse); } + + private bool TimestampMatchesForkOrdinal(ulong timestamp, int urlForkOrdinal) + { + if (urlForkOrdinal < 0) + return false; + + IReleaseSpec payloadSpec = specProvider.GetSpec(ForkActivation.TimestampOnly(timestamp)); + + // Count distinct spec objects produced by consecutive timestamp-based TransitionActivations. + // The count at which payloadSpec first appears is the payload's fork ordinal in SupportedForksOrdered. + int payloadForkOrdinal = -1; + int ordinal = 0; + IReleaseSpec? lastSeen = null; + foreach (ForkActivation fa in specProvider.TransitionActivations) + { + // Skip block-number-only activations (pre-Merge); post-Merge forks use timestamps. + if (fa.Timestamp is null) + continue; + + IReleaseSpec s = specProvider.GetSpec(fa); + if (ReferenceEquals(s, lastSeen)) + continue; + + if (ReferenceEquals(s, payloadSpec)) + { + payloadForkOrdinal = ordinal; + break; + } + + lastSeen = s; + ordinal++; + } + + return payloadForkOrdinal == urlForkOrdinal; + } } diff --git a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/GetPayloadBodiesByRangeSszHandler.cs b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/GetPayloadBodiesByRangeSszHandler.cs index 13a0b7c2cbae..a58dbbaf57c1 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/GetPayloadBodiesByRangeSszHandler.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/GetPayloadBodiesByRangeSszHandler.cs @@ -30,10 +30,10 @@ public sealed class GetPayloadBodiesByRangeSszHandler(IEngine public override async Task HandleAsync(HttpContext ctx, int v, ReadOnlyMemory extra, ReadOnlySequence body) { // body is empty for GET; parameters come from the query string. - if (!long.TryParse(ctx.Request.Query["from"], out long start) || start <= 0) + if (!long.TryParse(ctx.Request.Query["from"], out long start) || start < 0) { await WriteErrorAsync(ctx, StatusCodes.Status400BadRequest, - "Missing or invalid 'from' query parameter: must be a positive integer block number", + "Missing or invalid 'from' query parameter: must be a non-negative integer block number", SszRestErrorCodes.InvalidRequest); return; } @@ -46,9 +46,9 @@ await WriteErrorAsync(ctx, StatusCodes.Status400BadRequest, } if (count > MaxPayloadBodiesRequest) { - await WriteErrorAsync(ctx, StatusCodes.Status400BadRequest, + await WriteErrorAsync(ctx, StatusCodes.Status413PayloadTooLarge, $"count {count} exceeds the limit of {MaxPayloadBodiesRequest}", - SszRestErrorCodes.InvalidRequest); + MergeErrorCodes.TooLargeRequest); return; } ResultWrapper> result = await TVersion.Call(engineModule, start, count); diff --git a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/SszRestPaths.cs b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/SszRestPaths.cs index 269cc1102fc6..d7060d252b08 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/SszRestPaths.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/SszRestPaths.cs @@ -17,6 +17,16 @@ public static class SszRestPaths public static readonly IReadOnlyList SupportedForksOrdered = ["paris", "shanghai", "cancun", "prague", "osaka", "amsterdam"]; + public static int ForkOrdinal(string forkUrl) + { + for (int i = 0; i < SupportedForksOrdered.Count; i++) + { + if (string.Equals(SupportedForksOrdered[i], forkUrl, System.StringComparison.OrdinalIgnoreCase)) + return i; + } + return -1; + } + public const string Payloads = "payloads"; public const string Forkchoice = "forkchoice"; diff --git a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszMiddleware.cs b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszMiddleware.cs index 7c535eae8dbe..b8f55577b171 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszMiddleware.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszMiddleware.cs @@ -39,7 +39,7 @@ public sealed class SszMiddleware /// /// Maximum allowed request body size in bytes (128 MiB). /// Matches MAX_REQUEST_BODY_SIZE in the Engine API SSZ-REST spec - /// (see https://github.com/ethereum/execution-apis/pull/764). + /// (see https://github.com/ethereum/execution-apis/pull/793). /// public const int MaxBodySize = 0x8000000; @@ -48,9 +48,6 @@ public sealed class SszMiddleware private readonly FrozenDictionary>.AlternateLookup> _postLookup; private readonly FrozenDictionary>.AlternateLookup> _getLookup; - private readonly (string Resource, List Handlers)[] _postPrefixRoutes; - private readonly (string Resource, List Handlers)[] _getPrefixRoutes; - private static readonly System.Text.Json.JsonSerializerOptions _headerJsonOptions = new() { PropertyNameCaseInsensitive = true }; @@ -69,15 +66,13 @@ public SszMiddleware( _auth = auth; _logger = logManager.GetClassLogger(); _processExitToken = processExitSource.Token; - (_postRoutes, _getRoutes, _postPrefixRoutes, _getPrefixRoutes) = BuildRoutes(handlers); + (_postRoutes, _getRoutes) = BuildRoutes(handlers); _postLookup = _postRoutes.GetAlternateLookup>(); _getLookup = _getRoutes.GetAlternateLookup>(); } private static (FrozenDictionary> post, - FrozenDictionary> get, - (string, List)[] postPrefix, - (string, List)[] getPrefix) + FrozenDictionary> get) BuildRoutes(IEnumerable handlers) { Dictionary> postDict = []; @@ -100,19 +95,7 @@ private static (FrozenDictionary> post, FrozenDictionary> post = postDict.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase); FrozenDictionary> get = getDict.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase); - return (post, get, BuildPrefix(postDict), BuildPrefix(getDict)); - - static (string Resource, List Handlers)[] BuildPrefix( - Dictionary> source) - { - List<(string, List)> prefix = []; - foreach ((string r, List list) in source) - { - List accepting = list.FindAll(static c => c.AcceptsPathExtra); - if (accepting.Count > 0) prefix.Add((r, accepting)); - } - return prefix.ToArray(); - } + return (post, get); } public Task InvokeAsync(HttpContext ctx) @@ -250,7 +233,7 @@ await SszEndpointHandlerBase.WriteErrorAsync(ctx, StatusCodes.Status404NotFound, } catch (Exception ex) when (ex is InvalidDataException or EndOfStreamException) { - // Per execution-apis #764 (Engine API SSZ Transport spec, "HTTP status codes" section): + // Per execution-apis #793 (Engine API SSZ Transport spec, "HTTP status codes" section): // malformed SSZ encoding is 400 Bad Request with type=ssz-decode-error: canned error, // no detail (spec verbatim). 422 Unprocessable Entity is reserved for // "Invalid payload attributes" and is emitted by the handler chain via @@ -291,6 +274,9 @@ private static bool TryRoute(string path, out int version, out string? fork, if (!span.StartsWith(EnginePrefix.AsSpan(), StringComparison.OrdinalIgnoreCase)) return false; + if (span.EndsWith("/")) + return false; + int offset = EnginePrefix.Length; span = span[offset..]; @@ -409,6 +395,9 @@ private bool TryResolveHandler(string method, ReadOnlyMemory pathSegment, { if (candidate.Version == version) { + if (!string.IsNullOrEmpty(extraStr) && !candidate.AcceptsPathExtra) + return false; + handler = candidate; extra = extraStr.AsMemory(); return true; @@ -417,6 +406,9 @@ private bool TryResolveHandler(string method, ReadOnlyMemory pathSegment, } if (fallback is not null) { + if (!string.IsNullOrEmpty(extraStr) && !fallback.AcceptsPathExtra) + return false; + handler = fallback; extra = extraStr.AsMemory(); return true; @@ -500,6 +492,10 @@ private static SszRequestKind ClassifySszRequest(HttpContext ctx) : SszRequestKind.EngineWrongMediaType; case "GET": + if (IsDiagnosticGetPath(path)) + return SszRequestKind.EngineOk; + + // Hot-path SSZ GET endpoints require Accept: application/octet-stream. foreach (string? v in ctx.Request.Headers.Accept) { if (v is not null && v.Contains( @@ -514,6 +510,15 @@ private static SszRequestKind ClassifySszRequest(HttpContext ctx) } } + private static bool IsDiagnosticGetPath(string path) + { + ReadOnlySpan span = path.AsSpan(); + const string capabilitiesPath = "/engine/v2/capabilities"; + const string identityPath = "/engine/v2/identity"; + return span.StartsWith(capabilitiesPath.AsSpan(), StringComparison.OrdinalIgnoreCase) + || span.StartsWith(identityPath.AsSpan(), StringComparison.OrdinalIgnoreCase); + } + /// /// Returns the request body as a over the PipeReader's /// pooled segments. The caller MUST call diff --git a/src/Nethermind/Nethermind.Runner/JsonRpc/Startup.cs b/src/Nethermind/Nethermind.Runner/JsonRpc/Startup.cs index 39e200089890..6130a4ea1468 100644 --- a/src/Nethermind/Nethermind.Runner/JsonRpc/Startup.cs +++ b/src/Nethermind/Nethermind.Runner/JsonRpc/Startup.cs @@ -107,6 +107,8 @@ public void ConfigureServices(IServiceCollection services) int port = (listenOptions.EndPoint as System.Net.IPEndPoint)?.Port ?? 0; if (engineApiPorts.Contains(port)) { + // Keep HTTP/1.1 + HTTP/2 on the engine port: SSZ-REST uses HTTP/2, while legacy + // Engine API JSON-RPC still relies on HTTP/1.1 and shares the same listener. listenOptions.Protocols = HttpProtocols.Http1AndHttp2; } else From 43309fe409663ce7dfd1cdc4ce64ecd3de913f5e Mon Sep 17 00:00:00 2001 From: Dyslex7c Date: Wed, 3 Jun 2026 17:43:08 +0530 Subject: [PATCH 07/35] fix consensus test --- .../Producers/PayloadAttributes.cs | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/Nethermind/Nethermind.Consensus/Producers/PayloadAttributes.cs b/src/Nethermind/Nethermind.Consensus/Producers/PayloadAttributes.cs index f313a0c26890..8e293b724015 100644 --- a/src/Nethermind/Nethermind.Consensus/Producers/PayloadAttributes.cs +++ b/src/Nethermind/Nethermind.Consensus/Producers/PayloadAttributes.cs @@ -172,23 +172,23 @@ private static PayloadAttributesValidationResult ValidateVersion( string methodName, [NotNullWhen(false)] out string? error) { - // This FCU version doesn't support this fork at all (e.g. V3 attrs sent to FCUv2). - if (!IsSupportedFcuForkCombination(fcuVersion, actualVersion)) - { - error = $"{methodName}{fcuVersion} expected"; - return PayloadAttributesValidationResult.InvalidPayloadAttributes; - } - - // Attributes structure doesn't match what the fork expects (e.g. V3 attrs sent to when FCUv3 not yet activated in spec). + // Attributes structure doesn't match what the fork expects. if (actualVersion != timestampVersion) { error = $"{methodName}{timestampVersion} expected"; - // FCU also doesn't support this fork → UnsupportedFork (post-Paris only) - return fcuVersion != timestampVersion && timestampVersion >= PayloadAttributesVersions.V2 + bool unsupportedFork = timestampVersion >= PayloadAttributesVersions.V2 && + (actualVersion > timestampVersion || fcuVersion != timestampVersion); + return unsupportedFork ? PayloadAttributesValidationResult.UnsupportedFork : PayloadAttributesValidationResult.InvalidPayloadAttributes; } + if (!IsSupportedFcuForkCombination(fcuVersion, actualVersion)) + { + error = $"{methodName}{fcuVersion} expected"; + return PayloadAttributesValidationResult.InvalidPayloadAttributes; + } + error = null; return PayloadAttributesValidationResult.Success; } From 244739406d1be9dd39b0d464894b5d7a70e3ea6b Mon Sep 17 00:00:00 2001 From: Dyslex7c Date: Wed, 3 Jun 2026 20:07:25 +0530 Subject: [PATCH 08/35] centralize fork names --- .../EngineModuleTests.V1.cs | 51 ++++----- .../EngineModuleTests.V3.cs | 2 +- .../SszRest/SszMiddlewareTests.cs | 60 +++++----- .../SszRest/Handlers/SszRestPaths.cs | 106 ++++++++++++++---- .../SszRest/SszMiddleware.cs | 57 +--------- 5 files changed, 141 insertions(+), 135 deletions(-) diff --git a/src/Nethermind/Nethermind.Merge.Plugin.Test/EngineModuleTests.V1.cs b/src/Nethermind/Nethermind.Merge.Plugin.Test/EngineModuleTests.V1.cs index c5b76f385a9e..2812b8b4cba9 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin.Test/EngineModuleTests.V1.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin.Test/EngineModuleTests.V1.cs @@ -34,6 +34,7 @@ using Nethermind.Logging; using Nethermind.Merge.Plugin.Data; using Nethermind.Merge.Plugin.Handlers; +using Nethermind.Merge.Plugin.SszRest.Handlers; using Nethermind.Serialization.Json; using Nethermind.Specs; using Nethermind.Specs.ChainSpecStyle; @@ -2055,51 +2056,51 @@ public async Task Should_warn_for_missing_capabilities() private static readonly string[] SszRestPathsParis = [ - "POST /engine/v2/paris/payloads", - "GET /engine/v2/paris/payloads/{payload_id}", - "POST /engine/v2/paris/forkchoice", - "GET /engine/v2/capabilities", - "GET /engine/v2/identity", + SszRestPaths.PostV1Payloads, + SszRestPaths.GetV1Payloads, + SszRestPaths.PostV1Forkchoice, + SszRestPaths.PostV1Capabilities, + SszRestPaths.PostV1ClientVersion, ]; private static readonly string[] SszRestPathsShanghai = [ - "POST /engine/v2/shanghai/payloads", - "GET /engine/v2/shanghai/payloads/{payload_id}", - "POST /engine/v2/shanghai/forkchoice", - "POST /engine/v2/shanghai/bodies/hash", - "GET /engine/v2/shanghai/bodies", + SszRestPaths.PostV2Payloads, + SszRestPaths.GetV2Payloads, + SszRestPaths.PostV2Forkchoice, + SszRestPaths.PostV1PayloadBodiesByHash, + SszRestPaths.GetV1PayloadBodiesByRange, ]; private static readonly string[] SszRestPathsCancun = [ - "POST /engine/v2/cancun/payloads", - "GET /engine/v2/cancun/payloads/{payload_id}", - "POST /engine/v2/cancun/forkchoice", - "POST /engine/v2/blobs/v1", + SszRestPaths.PostV3Payloads, + SszRestPaths.GetV3Payloads, + SszRestPaths.PostV3Forkchoice, + SszRestPaths.PostV1Blobs, ]; private static readonly string[] SszRestPathsPrague = [ - "POST /engine/v2/prague/payloads", - "GET /engine/v2/prague/payloads/{payload_id}", + SszRestPaths.PostV4Payloads, + SszRestPaths.GetV4Payloads, ]; private static readonly string[] SszRestPathsOsaka = [ - "GET /engine/v2/osaka/payloads/{payload_id}", - "POST /engine/v2/blobs/v2", - "POST /engine/v2/blobs/v3", - "POST /engine/v2/blobs/v4", + SszRestPaths.GetV5Payloads, + SszRestPaths.PostV2Blobs, + SszRestPaths.PostV3Blobs, + SszRestPaths.PostV4Blobs, ]; private static readonly string[] SszRestPathsAmsterdam = [ - "POST /engine/v2/amsterdam/payloads", - "GET /engine/v2/amsterdam/payloads/{payload_id}", - "POST /engine/v2/amsterdam/forkchoice", - "POST /engine/v2/amsterdam/bodies/hash", - "GET /engine/v2/amsterdam/bodies", + SszRestPaths.PostV5Payloads, + SszRestPaths.GetV6Payloads, + SszRestPaths.PostV4Forkchoice, + SszRestPaths.PostV2PayloadBodiesByHash, + SszRestPaths.GetV2PayloadBodiesByRange, ]; public static IEnumerable SszRestPathsAdvertisedCases() diff --git a/src/Nethermind/Nethermind.Merge.Plugin.Test/EngineModuleTests.V3.cs b/src/Nethermind/Nethermind.Merge.Plugin.Test/EngineModuleTests.V3.cs index 29fd86afee2f..dc5e4dc42847 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin.Test/EngineModuleTests.V3.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin.Test/EngineModuleTests.V3.cs @@ -854,7 +854,7 @@ public static IEnumerable ForkchoiceUpdatedV3DeclinedTestCaseSourc yield return new TestCaseData(Shanghai.Instance, nameof(IEngineRpcModule.engine_forkchoiceUpdatedV2), true) { TestName = "ForkchoiceUpdatedV2 To Request Shanghai Payload, Zero Beacon Root", - ExpectedResult = MergeErrorCodes.InvalidPayloadAttributes, + ExpectedResult = MergeErrorCodes.UnsupportedFork, }; yield return new TestCaseData(Cancun.Instance, nameof(IEngineRpcModule.engine_forkchoiceUpdatedV2), true) { diff --git a/src/Nethermind/Nethermind.Merge.Plugin.Test/SszRest/SszMiddlewareTests.cs b/src/Nethermind/Nethermind.Merge.Plugin.Test/SszRest/SszMiddlewareTests.cs index e624078192ed..1eec349143d6 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin.Test/SszRest/SszMiddlewareTests.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin.Test/SszRest/SszMiddlewareTests.cs @@ -157,8 +157,8 @@ private static byte[] EncodeToBytes(T value, Func, int return w.WrittenSpan.ToArray(); } - [TestCase(1, "/engine/v2/paris/payloads")] - [TestCase(2, "/engine/v2/shanghai/payloads")] + [TestCase(1, "/engine/v2/" + SszRestPaths.Paris + "/payloads")] + [TestCase(2, "/engine/v2/" + SszRestPaths.Shanghai + "/payloads")] public async Task NewPayload_routes_to_correct_engine_module_version(int version, string path) { PayloadStatusV1 status = new() { Status = PayloadStatus.Valid, LatestValidHash = TestItem.KeccakA }; @@ -178,8 +178,8 @@ public async Task NewPayload_routes_to_correct_engine_module_version(int version await _engineModule.Received(version == 2 ? 1 : 0).engine_newPayloadV2(Arg.Any()); } - [TestCase(1, "/engine/v2/paris/payloads/0x0102030405060708")] - [TestCase(2, "/engine/v2/shanghai/payloads/0x0102030405060708")] + [TestCase(1, "/engine/v2/" + SszRestPaths.Paris + "/payloads/0x0102030405060708")] + [TestCase(2, "/engine/v2/" + SszRestPaths.Shanghai + "/payloads/0x0102030405060708")] public async Task GetPayload_routes_to_correct_handler_with_no_store_header(int version, string path) { _engineModule.engine_getPayloadV1(Arg.Any()) @@ -197,10 +197,10 @@ public async Task GetPayload_routes_to_correct_handler_with_no_store_header(int await _engineModule.Received(version == 2 ? 1 : 0).engine_getPayloadV2(Arg.Any()); } - [TestCase("/engine/v2/paris/forkchoice", 1)] - [TestCase("/engine/v2/shanghai/forkchoice", 2)] - [TestCase("/engine/v2/cancun/forkchoice", 3)] - [TestCase("/engine/v2/amsterdam/forkchoice", 4)] + [TestCase("/engine/v2/" + SszRestPaths.Paris + "/forkchoice", 1)] + [TestCase("/engine/v2/" + SszRestPaths.Shanghai + "/forkchoice", 2)] + [TestCase("/engine/v2/" + SszRestPaths.Cancun + "/forkchoice", 3)] + [TestCase("/engine/v2/" + SszRestPaths.Amsterdam + "/forkchoice", 4)] public async Task Forkchoice_calls_correct_engine_module_version(string path, int version) { ForkchoiceUpdatedV1Result fcuResult = new() @@ -271,8 +271,8 @@ public async Task GetBlobsV2V3_routes_to_correct_engine_method(string path, bool await _engineModule.Received(isV3 ? 1 : 0).engine_getBlobsV3(Arg.Any()); } - [TestCase(1, "/engine/v2/shanghai/bodies/hash")] - [TestCase(2, "/engine/v2/amsterdam/bodies/hash")] + [TestCase(1, "/engine/v2/" + SszRestPaths.Shanghai + "/bodies/hash")] + [TestCase(2, "/engine/v2/" + SszRestPaths.Amsterdam + "/bodies/hash")] public async Task GetPayloadBodiesByHash_routes_to_correct_engine_method(int version, string path) { _engineModule.engine_getPayloadBodiesByHashV1(Arg.Any>()) @@ -292,8 +292,8 @@ public async Task GetPayloadBodiesByHash_routes_to_correct_engine_method(int ver await _engineModule.Received(version == 2 ? 1 : 0).engine_getPayloadBodiesByHashV2(Arg.Any>()); } - [TestCase(1, "/engine/v2/shanghai/bodies")] - [TestCase(2, "/engine/v2/amsterdam/bodies")] + [TestCase(1, "/engine/v2/" + SszRestPaths.Shanghai + "/bodies")] + [TestCase(2, "/engine/v2/" + SszRestPaths.Amsterdam + "/bodies")] public async Task GetPayloadBodiesByRange_routes_to_correct_engine_method_with_correct_args(int version, string path) { const long expectedStart = 7; @@ -392,7 +392,7 @@ public async Task Authentication_failure_returns_401_and_does_not_call_engine_mo _auth.Authenticate(Arg.Any()).Returns(false); byte[] body = BuildMinimalV1NewPayloadRequest(); - DefaultHttpContext ctx = MakePostContext("/engine/v2/paris/payloads", body); + DefaultHttpContext ctx = MakePostContext($"/engine/v2/{SszRestPaths.Paris}/payloads", body); await _middleware.InvokeAsync(ctx); @@ -403,7 +403,7 @@ public async Task Authentication_failure_returns_401_and_does_not_call_engine_mo [Test] public async Task Oversized_body_returns_413_without_calling_engine_module() { - DefaultHttpContext ctx = MakePostContext("/engine/v2/paris/payloads", []); + DefaultHttpContext ctx = MakePostContext($"/engine/v2/{SszRestPaths.Paris}/payloads", []); ctx.Request.ContentLength = SszMiddleware.MaxBodySize + 1; ctx.Request.Body = new MemoryStream(new byte[1]); @@ -418,7 +418,7 @@ public async Task Unknown_engine_path_returns_404() { bool nextInvoked = false; SszMiddleware mw = BuildMiddleware(_ => { nextInvoked = true; return Task.CompletedTask; }); - DefaultHttpContext ctx = MakePostContext("/engine/v2/paris/unknown-resource", []); + DefaultHttpContext ctx = MakePostContext($"/engine/v2/{SszRestPaths.Paris}/unknown-resource", []); await mw.InvokeAsync(ctx); @@ -430,7 +430,7 @@ public async Task Unknown_engine_path_returns_404() public async Task Post_payloads_with_unknown_extra_returns_404_not_500() { // Extra segments on a non-AcceptsPathExtra resource must be 404, not silently dispatched. - DefaultHttpContext ctx = MakePostContext("/engine/v2/paris/payloads/foo/bar", []); + DefaultHttpContext ctx = MakePostContext($"/engine/v2/{SszRestPaths.Paris}/payloads/foo/bar", []); await _middleware.InvokeAsync(ctx); @@ -443,7 +443,7 @@ public async Task Path_with_consecutive_slashes_returns_404() { // TryRoute must reject runs of '/' so that //abc does not reach // the payload-id parser and produce a confusing parse error. - DefaultHttpContext ctx = MakePostContext("/engine/v2/paris/payloads//abc", []); + DefaultHttpContext ctx = MakePostContext($"/engine/v2/{SszRestPaths.Paris}/payloads//abc", []); await _middleware.InvokeAsync(ctx); @@ -457,7 +457,7 @@ public async Task Malformed_ssz_body_returns_400_without_propagating_exception() byte[] garbage = new byte[64]; new Random(42).NextBytes(garbage); - DefaultHttpContext ctx = MakePostContext("/engine/v2/paris/payloads", garbage); + DefaultHttpContext ctx = MakePostContext($"/engine/v2/{SszRestPaths.Paris}/payloads", garbage); Func act = () => _middleware.InvokeAsync(ctx); @@ -470,7 +470,7 @@ public async Task Malformed_ssz_body_returns_400_without_propagating_exception() public async Task Truncated_body_with_overstated_content_length_returns_400() { byte[] body = new byte[16]; - DefaultHttpContext ctx = MakePostContext("/engine/v2/paris/payloads", body); + DefaultHttpContext ctx = MakePostContext($"/engine/v2/{SszRestPaths.Paris}/payloads", body); // Declare more bytes than the stream will deliver — ReadAtLeastAsync returns short. ctx.Request.ContentLength = body.Length + 64; @@ -516,7 +516,7 @@ public async Task Server_error_skips_WriteError_when_request_already_aborted() _engineModule.engine_newPayloadV1(Arg.Any()) .Returns>>(_ => throw new InvalidOperationException("simulated server error")); - DefaultHttpContext ctx = MakePostContext("/engine/v2/paris/payloads", BuildMinimalV1NewPayloadRequest()); + DefaultHttpContext ctx = MakePostContext($"/engine/v2/{SszRestPaths.Paris}/payloads", BuildMinimalV1NewPayloadRequest()); // Simulate the encode-failure → ctx.Abort() effect by pre-cancelling RequestAborted. // DefaultHttpContext's Abort() is a no-op without a real lifetime feature, so we @@ -544,7 +544,7 @@ public async Task Encoder_returning_zero_length_for_non_null_data_yields_204() SszMiddleware middleware = new( _ => Task.CompletedTask, _urlCollection, _auth, [handler], _processExitSource, LimboLogs.Instance); - DefaultHttpContext ctx = MakePostContext($"/engine/v2/paris/{ZeroLengthEncodeHandler.ResourceName}", []); + DefaultHttpContext ctx = MakePostContext($"/engine/v2/{SszRestPaths.Paris}/{ZeroLengthEncodeHandler.ResourceName}", []); await middleware.InvokeAsync(ctx); @@ -736,7 +736,7 @@ public async Task Forkchoice_unsupported_fork_returns_400() }; byte[] body = ForkchoiceUpdatedV3RequestWire.Encode(request); - DefaultHttpContext ctx = MakePostContext("/engine/v2/cancun/forkchoice", body); + DefaultHttpContext ctx = MakePostContext($"/engine/v2/{SszRestPaths.Cancun}/forkchoice", body); await _middleware.InvokeAsync(ctx); @@ -767,7 +767,7 @@ public async Task Forkchoice_stale_fork_url_without_attributes_is_allowed() }; byte[] body = ForkchoiceUpdatedV3RequestWire.Encode(request); - DefaultHttpContext ctx = MakePostContext("/engine/v2/cancun/forkchoice", body); + DefaultHttpContext ctx = MakePostContext($"/engine/v2/{SszRestPaths.Cancun}/forkchoice", body); await _middleware.InvokeAsync(ctx); @@ -815,7 +815,7 @@ public async Task Identity_returns_200_json_regardless_of_Accept_header(string a [Test] public async Task Trailing_slash_on_fork_scoped_path_returns_404() { - DefaultHttpContext ctx = MakePostContext("/engine/v2/cancun/forkchoice/", []); + DefaultHttpContext ctx = MakePostContext($"/engine/v2/{SszRestPaths.Cancun}/forkchoice/", []); await _middleware.InvokeAsync(ctx); Assert.That(ctx.Response.StatusCode, Is.EqualTo(StatusCodes.Status404NotFound)); string body = System.Text.Encoding.UTF8.GetString(ResponseBytes(ctx)); @@ -832,8 +832,8 @@ public async Task Trailing_slash_on_get_path_returns_404() Assert.That(ctx.Response.StatusCode, Is.EqualTo(StatusCodes.Status404NotFound)); } - [TestCase("/engine/v2/cancun/forkchoice/whatever")] - [TestCase("/engine/v2/paris/payloads/foo/bar")] + [TestCase("/engine/v2/" + SszRestPaths.Cancun + "/forkchoice/whatever")] + [TestCase("/engine/v2/" + SszRestPaths.Paris + "/payloads/foo/bar")] public async Task Extra_path_segment_on_non_accepting_handler_returns_404(string path) { DefaultHttpContext ctx = MakePostContext(path, []); @@ -868,7 +868,7 @@ public async Task Unknown_blob_version_returns_404() [Test] public async Task Invalid_payload_id_in_path_returns_400() { - DefaultHttpContext ctx = MakeGetContext("/engine/v2/paris/payloads/0xZZZZZZZZZZZZZZZZ"); + DefaultHttpContext ctx = MakeGetContext($"/engine/v2/{SszRestPaths.Paris}/payloads/0xZZZZZZZZZZZZZZZZ"); await _middleware.InvokeAsync(ctx); @@ -878,7 +878,7 @@ public async Task Invalid_payload_id_in_path_returns_400() [Test] public async Task GetPayloadBodiesByRange_over_limit_returns_413_request_too_large() { - DefaultHttpContext ctx = MakeGetContext("/engine/v2/shanghai/bodies"); + DefaultHttpContext ctx = MakeGetContext($"/engine/v2/{SszRestPaths.Shanghai}/bodies"); ctx.Request.QueryString = new QueryString("?from=1&count=1000"); await _middleware.InvokeAsync(ctx); @@ -894,7 +894,7 @@ public async Task GetPayloadBodiesByRange_from_zero_is_valid() _engineModule.engine_getPayloadBodiesByRangeV1(Arg.Any(), Arg.Any()) .Returns(ResultWrapper>.Success([])); - DefaultHttpContext ctx = MakeGetContext("/engine/v2/shanghai/bodies"); + DefaultHttpContext ctx = MakeGetContext($"/engine/v2/{SszRestPaths.Shanghai}/bodies"); ctx.Request.QueryString = new QueryString("?from=0&count=1"); await _middleware.InvokeAsync(ctx); @@ -908,7 +908,7 @@ public async Task Error_response_has_correct_RFC7807_shape_type_only_for_canned_ { byte[] garbage = new byte[64]; new Random(42).NextBytes(garbage); - DefaultHttpContext ctx = MakePostContext("/engine/v2/paris/payloads", garbage); + DefaultHttpContext ctx = MakePostContext($"/engine/v2/{SszRestPaths.Paris}/payloads", garbage); await _middleware.InvokeAsync(ctx); diff --git a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/SszRestPaths.cs b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/SszRestPaths.cs index d7060d252b08..110b116adc78 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/SszRestPaths.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/SszRestPaths.cs @@ -1,6 +1,7 @@ // SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited // SPDX-License-Identifier: LGPL-3.0-only +using System; using System.Collections.Frozen; using System.Collections.Generic; @@ -8,20 +9,27 @@ namespace Nethermind.Merge.Plugin.SszRest.Handlers; public static class SszRestPaths { + public const string Paris = "paris"; + public const string Shanghai = "shanghai"; + public const string Cancun = "cancun"; + public const string Prague = "prague"; + public const string Osaka = "osaka"; + public const string Amsterdam = "amsterdam"; + public static readonly FrozenSet SupportedForks = - new HashSet(System.StringComparer.OrdinalIgnoreCase) + new HashSet(StringComparer.OrdinalIgnoreCase) { - "paris", "shanghai", "cancun", "prague", "osaka", "amsterdam" - }.ToFrozenSet(System.StringComparer.OrdinalIgnoreCase); + Paris, Shanghai, Cancun, Prague, Osaka, Amsterdam + }.ToFrozenSet(StringComparer.OrdinalIgnoreCase); public static readonly IReadOnlyList SupportedForksOrdered = - ["paris", "shanghai", "cancun", "prague", "osaka", "amsterdam"]; + [Paris, Shanghai, Cancun, Prague, Osaka, Amsterdam]; public static int ForkOrdinal(string forkUrl) { for (int i = 0; i < SupportedForksOrdered.Count; i++) { - if (string.Equals(SupportedForksOrdered[i], forkUrl, System.StringComparison.OrdinalIgnoreCase)) + if (string.Equals(SupportedForksOrdered[i], forkUrl, StringComparison.OrdinalIgnoreCase)) return i; } return -1; @@ -41,39 +49,89 @@ public static int ForkOrdinal(string forkUrl) public const string Blobs = "blobs"; - public const string PostV1Payloads = "POST /engine/v2/paris/payloads"; - public const string GetV1Payloads = "GET /engine/v2/paris/payloads/{payload_id}"; - public const string PostV1Forkchoice = "POST /engine/v2/paris/forkchoice"; + // Paris + public const string PostV1Payloads = "POST /engine/v2/" + Paris + "/payloads"; + public const string GetV1Payloads = "GET /engine/v2/" + Paris + "/payloads/{payload_id}"; + public const string PostV1Forkchoice = "POST /engine/v2/" + Paris + "/forkchoice"; public const string PostV1Capabilities = "GET /engine/v2/capabilities"; public const string PostV1ClientVersion = "GET /engine/v2/identity"; // Shanghai - public const string PostV2Payloads = "POST /engine/v2/shanghai/payloads"; - public const string PostV2Forkchoice = "POST /engine/v2/shanghai/forkchoice"; - public const string GetV2Payloads = "GET /engine/v2/shanghai/payloads/{payload_id}"; - public const string PostV1PayloadBodiesByHash = "POST /engine/v2/shanghai/bodies/hash"; - public const string GetV1PayloadBodiesByRange = "GET /engine/v2/shanghai/bodies"; + public const string PostV2Payloads = "POST /engine/v2/" + Shanghai + "/payloads"; + public const string PostV2Forkchoice = "POST /engine/v2/" + Shanghai + "/forkchoice"; + public const string GetV2Payloads = "GET /engine/v2/" + Shanghai + "/payloads/{payload_id}"; + public const string PostV1PayloadBodiesByHash = "POST /engine/v2/" + Shanghai + "/bodies/hash"; + public const string GetV1PayloadBodiesByRange = "GET /engine/v2/" + Shanghai + "/bodies"; // Cancun - public const string PostV3Payloads = "POST /engine/v2/cancun/payloads"; - public const string PostV3Forkchoice = "POST /engine/v2/cancun/forkchoice"; - public const string GetV3Payloads = "GET /engine/v2/cancun/payloads/{payload_id}"; + public const string PostV3Payloads = "POST /engine/v2/" + Cancun + "/payloads"; + public const string PostV3Forkchoice = "POST /engine/v2/" + Cancun + "/forkchoice"; + public const string GetV3Payloads = "GET /engine/v2/" + Cancun + "/payloads/{payload_id}"; public const string PostV1Blobs = "POST /engine/v2/blobs/v1"; // Prague - public const string PostV4Payloads = "POST /engine/v2/prague/payloads"; - public const string GetV4Payloads = "GET /engine/v2/prague/payloads/{payload_id}"; + public const string PostV4Payloads = "POST /engine/v2/" + Prague + "/payloads"; + public const string GetV4Payloads = "GET /engine/v2/" + Prague + "/payloads/{payload_id}"; // Osaka - public const string GetV5Payloads = "GET /engine/v2/osaka/payloads/{payload_id}"; + public const string GetV5Payloads = "GET /engine/v2/" + Osaka + "/payloads/{payload_id}"; public const string PostV2Blobs = "POST /engine/v2/blobs/v2"; public const string PostV3Blobs = "POST /engine/v2/blobs/v3"; // Amsterdam - public const string PostV5Payloads = "POST /engine/v2/amsterdam/payloads"; - public const string GetV6Payloads = "GET /engine/v2/amsterdam/payloads/{payload_id}"; - public const string PostV4Forkchoice = "POST /engine/v2/amsterdam/forkchoice"; - public const string PostV2PayloadBodiesByHash = "POST /engine/v2/amsterdam/bodies/hash"; - public const string GetV2PayloadBodiesByRange = "GET /engine/v2/amsterdam/bodies"; + public const string PostV5Payloads = "POST /engine/v2/" + Amsterdam + "/payloads"; + public const string GetV6Payloads = "GET /engine/v2/" + Amsterdam + "/payloads/{payload_id}"; + public const string PostV4Forkchoice = "POST /engine/v2/" + Amsterdam + "/forkchoice"; + public const string PostV2PayloadBodiesByHash = "POST /engine/v2/" + Amsterdam + "/bodies/hash"; + public const string GetV2PayloadBodiesByRange = "GET /engine/v2/" + Amsterdam + "/bodies"; public const string PostV4Blobs = "POST /engine/v2/blobs/v4"; + + private static readonly FrozenDictionary<(string Fork, string Resource, string Method), int> s_forkVersionMap = + new Dictionary<(string, string, string), int> + { + // newPayload (POST payloads) + [(Paris, Payloads, "POST")] = 1, + [(Shanghai, Payloads, "POST")] = 2, + [(Cancun, Payloads, "POST")] = 3, + [(Prague, Payloads, "POST")] = 4, + [(Osaka, Payloads, "POST")] = 4, + [(Amsterdam, Payloads, "POST")] = 5, + + // getPayload (GET payloads) + [(Paris, Payloads, "GET")] = 1, + [(Shanghai, Payloads, "GET")] = 2, + [(Cancun, Payloads, "GET")] = 3, + [(Prague, Payloads, "GET")] = 4, + [(Osaka, Payloads, "GET")] = 5, + [(Amsterdam, Payloads, "GET")] = 6, + + // forkchoiceUpdated (POST forkchoice) + [(Paris, Forkchoice, "POST")] = 1, + [(Shanghai, Forkchoice, "POST")] = 2, + [(Cancun, Forkchoice, "POST")] = 3, + [(Prague, Forkchoice, "POST")] = 3, + [(Osaka, Forkchoice, "POST")] = 3, + [(Amsterdam, Forkchoice, "POST")] = 4, + + // bodies/hash (POST) + [(Paris, PayloadBodiesByHash, "POST")] = 1, + [(Shanghai, PayloadBodiesByHash, "POST")] = 1, + [(Cancun, PayloadBodiesByHash, "POST")] = 1, + [(Prague, PayloadBodiesByHash, "POST")] = 1, + [(Osaka, PayloadBodiesByHash, "POST")] = 1, + [(Amsterdam, PayloadBodiesByHash, "POST")] = 2, + + // bodies (GET) + [(Paris, PayloadBodiesByRange, "GET")] = 1, + [(Shanghai, PayloadBodiesByRange, "GET")] = 1, + [(Cancun, PayloadBodiesByRange, "GET")] = 1, + [(Prague, PayloadBodiesByRange, "GET")] = 1, + [(Osaka, PayloadBodiesByRange, "GET")] = 1, + [(Amsterdam, PayloadBodiesByRange, "GET")] = 2, + }.ToFrozenDictionary(); + + public static int? MapForkToVersion(string fork, string resource, string httpMethod) => + s_forkVersionMap.TryGetValue((fork.ToLowerInvariant(), resource.ToLowerInvariant(), httpMethod), out int version) + ? version + : null; } diff --git a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszMiddleware.cs b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszMiddleware.cs index b8f55577b171..868b9103c688 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszMiddleware.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszMiddleware.cs @@ -419,62 +419,9 @@ private bool TryResolveHandler(string method, ReadOnlyMemory pathSegment, return false; } - public static int? MapForkToVersion(string fork, string resource, string httpMethod) - { - fork = fork.ToLowerInvariant(); - resource = resource.ToLowerInvariant(); + public static int? MapForkToVersion(string fork, string resource, string httpMethod) => + SszRestPaths.MapForkToVersion(fork, resource, httpMethod); - if (resource == "payloads") - { - if (httpMethod == "POST") - { - return fork switch - { - "paris" => 1, - "shanghai" => 2, - "cancun" => 3, - "prague" or "osaka" => 4, - "amsterdam" => 5, - _ => null - }; - } - else if (httpMethod == "GET") - { - return fork switch - { - "paris" => 1, - "shanghai" => 2, - "cancun" => 3, - "prague" => 4, - "osaka" => 5, - "amsterdam" => 6, - _ => null - }; - } - } - else if (resource == "forkchoice") - { - return fork switch - { - "paris" => 1, - "shanghai" => 2, - "cancun" or "prague" or "osaka" => 3, - "amsterdam" => 4, - _ => null - }; - } - else if (resource == "bodies/hash" || resource == "bodies") - { - return fork switch - { - "paris" or "shanghai" or "cancun" or "prague" or "osaka" => 1, - "amsterdam" => 2, - _ => null - }; - } - - return null; - } private static SszRequestKind ClassifySszRequest(HttpContext ctx) { From 4b93d02b0bf5d7e72eec27f94e1f67808247ed47 Mon Sep 17 00:00:00 2001 From: Dyslex7c Date: Wed, 3 Jun 2026 23:53:11 +0530 Subject: [PATCH 09/35] address other review comments by @flcl42 --- .../Producers/PayloadAttributes.cs | 2 +- .../SszRest/SszMiddlewareTests.cs | 8 +++ .../SszRest/Handlers/SszRestPaths.cs | 50 +++++++++---------- .../SszRest/SszMiddleware.cs | 32 ++++++------ 4 files changed, 48 insertions(+), 44 deletions(-) diff --git a/src/Nethermind/Nethermind.Consensus/Producers/PayloadAttributes.cs b/src/Nethermind/Nethermind.Consensus/Producers/PayloadAttributes.cs index 8e293b724015..8327495900c3 100644 --- a/src/Nethermind/Nethermind.Consensus/Producers/PayloadAttributes.cs +++ b/src/Nethermind/Nethermind.Consensus/Producers/PayloadAttributes.cs @@ -30,7 +30,7 @@ public class PayloadAttributes public ulong? TargetGasLimit { get; set; } - public virtual long? GetGasLimit() => TargetGasLimit is { } limit ? (long)limit : null; + public virtual long? GetGasLimit() => (long?)TargetGasLimit; public override string ToString() => ToString(string.Empty); diff --git a/src/Nethermind/Nethermind.Merge.Plugin.Test/SszRest/SszMiddlewareTests.cs b/src/Nethermind/Nethermind.Merge.Plugin.Test/SszRest/SszMiddlewareTests.cs index 1eec349143d6..88e9b40527ea 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin.Test/SszRest/SszMiddlewareTests.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin.Test/SszRest/SszMiddlewareTests.cs @@ -116,6 +116,14 @@ private SszMiddleware BuildMiddleware(RequestDelegate? next = null) private static DefaultHttpContext MakeBaseContext(string method, string path, int port) { + if (path.StartsWith("/engine/v2/")) + { + path = "/engine/" + path["/engine/v2/".Length..]; + } + else if (path.StartsWith("/engine/v2")) + { + path = "/engine" + path["/engine/v2".Length..]; + } DefaultHttpContext ctx = new(); ctx.Request.Method = method; ctx.Request.Path = path; diff --git a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/SszRestPaths.cs b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/SszRestPaths.cs index 110b116adc78..57ce4f1b8536 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/SszRestPaths.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/SszRestPaths.cs @@ -50,41 +50,41 @@ public static int ForkOrdinal(string forkUrl) public const string Blobs = "blobs"; // Paris - public const string PostV1Payloads = "POST /engine/v2/" + Paris + "/payloads"; - public const string GetV1Payloads = "GET /engine/v2/" + Paris + "/payloads/{payload_id}"; - public const string PostV1Forkchoice = "POST /engine/v2/" + Paris + "/forkchoice"; - public const string PostV1Capabilities = "GET /engine/v2/capabilities"; - public const string PostV1ClientVersion = "GET /engine/v2/identity"; + public const string PostV1Payloads = "POST /engine/" + Paris + "/payloads"; + public const string GetV1Payloads = "GET /engine/" + Paris + "/payloads/{payload_id}"; + public const string PostV1Forkchoice = "POST /engine/" + Paris + "/forkchoice"; + public const string PostV1Capabilities = "GET /engine/capabilities"; + public const string PostV1ClientVersion = "GET /engine/identity"; // Shanghai - public const string PostV2Payloads = "POST /engine/v2/" + Shanghai + "/payloads"; - public const string PostV2Forkchoice = "POST /engine/v2/" + Shanghai + "/forkchoice"; - public const string GetV2Payloads = "GET /engine/v2/" + Shanghai + "/payloads/{payload_id}"; - public const string PostV1PayloadBodiesByHash = "POST /engine/v2/" + Shanghai + "/bodies/hash"; - public const string GetV1PayloadBodiesByRange = "GET /engine/v2/" + Shanghai + "/bodies"; + public const string PostV2Payloads = "POST /engine/" + Shanghai + "/payloads"; + public const string PostV2Forkchoice = "POST /engine/" + Shanghai + "/forkchoice"; + public const string GetV2Payloads = "GET /engine/" + Shanghai + "/payloads/{payload_id}"; + public const string PostV1PayloadBodiesByHash = "POST /engine/" + Shanghai + "/bodies/hash"; + public const string GetV1PayloadBodiesByRange = "GET /engine/" + Shanghai + "/bodies"; // Cancun - public const string PostV3Payloads = "POST /engine/v2/" + Cancun + "/payloads"; - public const string PostV3Forkchoice = "POST /engine/v2/" + Cancun + "/forkchoice"; - public const string GetV3Payloads = "GET /engine/v2/" + Cancun + "/payloads/{payload_id}"; - public const string PostV1Blobs = "POST /engine/v2/blobs/v1"; + public const string PostV3Payloads = "POST /engine/" + Cancun + "/payloads"; + public const string PostV3Forkchoice = "POST /engine/" + Cancun + "/forkchoice"; + public const string GetV3Payloads = "GET /engine/" + Cancun + "/payloads/{payload_id}"; + public const string PostV1Blobs = "POST /engine/blobs/v1"; // Prague - public const string PostV4Payloads = "POST /engine/v2/" + Prague + "/payloads"; - public const string GetV4Payloads = "GET /engine/v2/" + Prague + "/payloads/{payload_id}"; + public const string PostV4Payloads = "POST /engine/" + Prague + "/payloads"; + public const string GetV4Payloads = "GET /engine/" + Prague + "/payloads/{payload_id}"; // Osaka - public const string GetV5Payloads = "GET /engine/v2/" + Osaka + "/payloads/{payload_id}"; - public const string PostV2Blobs = "POST /engine/v2/blobs/v2"; - public const string PostV3Blobs = "POST /engine/v2/blobs/v3"; + public const string GetV5Payloads = "GET /engine/" + Osaka + "/payloads/{payload_id}"; + public const string PostV2Blobs = "POST /engine/blobs/v2"; + public const string PostV3Blobs = "POST /engine/blobs/v3"; // Amsterdam - public const string PostV5Payloads = "POST /engine/v2/" + Amsterdam + "/payloads"; - public const string GetV6Payloads = "GET /engine/v2/" + Amsterdam + "/payloads/{payload_id}"; - public const string PostV4Forkchoice = "POST /engine/v2/" + Amsterdam + "/forkchoice"; - public const string PostV2PayloadBodiesByHash = "POST /engine/v2/" + Amsterdam + "/bodies/hash"; - public const string GetV2PayloadBodiesByRange = "GET /engine/v2/" + Amsterdam + "/bodies"; - public const string PostV4Blobs = "POST /engine/v2/blobs/v4"; + public const string PostV5Payloads = "POST /engine/" + Amsterdam + "/payloads"; + public const string GetV6Payloads = "GET /engine/" + Amsterdam + "/payloads/{payload_id}"; + public const string PostV4Forkchoice = "POST /engine/" + Amsterdam + "/forkchoice"; + public const string PostV2PayloadBodiesByHash = "POST /engine/" + Amsterdam + "/bodies/hash"; + public const string GetV2PayloadBodiesByRange = "GET /engine/" + Amsterdam + "/bodies"; + public const string PostV4Blobs = "POST /engine/blobs/v4"; private static readonly FrozenDictionary<(string Fork, string Resource, string Method), int> s_forkVersionMap = new Dictionary<(string, string, string), int> diff --git a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszMiddleware.cs b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszMiddleware.cs index 868b9103c688..c0940e1e23e8 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszMiddleware.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszMiddleware.cs @@ -33,8 +33,8 @@ public sealed class SszMiddleware private readonly ILogger _logger; private readonly CancellationToken _processExitToken; - // Path: /engine/v{N}/{resource}[/{extra}] - private const string EnginePrefix = "/engine/v"; + // Path: /engine/{fork}/{resource}[/{extra}] + private const string EnginePrefix = "/engine/"; /// /// Maximum allowed request body size in bytes (128 MiB). @@ -179,7 +179,7 @@ await SszEndpointHandlerBase.WriteErrorAsync(ctx, StatusCodes.Status404NotFound, // Use .Span in the interpolation: ROM.ToString() would allocate a separate // intermediate string; appending the span goes straight into the format buffer. await SszEndpointHandlerBase.WriteErrorAsync(ctx, StatusCodes.Status404NotFound, - $"Unknown method: {ctx.Request.Method} /engine/v{version}/{pathSegment.Span}", + $"Unknown method: {ctx.Request.Method} /engine/{pathSegment.Span}", SszRestErrorCodes.MethodNotFound); } else @@ -192,8 +192,8 @@ await SszEndpointHandlerBase.WriteErrorAsync(ctx, StatusCodes.Status404NotFound, if (_logger.IsTrace) { _logger.Trace(extra.IsEmpty - ? $"SSZ-REST {ctx.Request.Method} /engine/v{version}/{handler!.Resource}" - : $"SSZ-REST {ctx.Request.Method} /engine/v{version}/{handler!.Resource}/{extra.Span}"); + ? $"SSZ-REST {ctx.Request.Method} /engine/{pathSegment.Span}" + : $"SSZ-REST {ctx.Request.Method} /engine/{pathSegment.Span}/{extra.Span}"); } // Read directly from PipeReader: the buffer is a ReadOnlySequence over Kestrel's @@ -265,7 +265,7 @@ await SszEndpointHandlerBase.WriteErrorAsync(ctx, StatusCodes.Status400BadReques private static bool TryRoute(string path, out int version, out string? fork, out ReadOnlyMemory pathSegment, out bool unsupportedFork) { - version = 2; + version = 1; fork = null; pathSegment = default; unsupportedFork = false; @@ -279,15 +279,6 @@ private static bool TryRoute(string path, out int version, out string? fork, int offset = EnginePrefix.Length; span = span[offset..]; - - int slashPos = span.IndexOf('/'); - if (slashPos <= 0) return false; - - if (!int.TryParse(span[..slashPos], out version) || version != 2) - return false; - - offset += slashPos + 1; - span = span[(slashPos + 1)..]; if (span.IsEmpty) return false; if (span.Equals("identity".AsSpan(), StringComparison.OrdinalIgnoreCase)) @@ -329,6 +320,11 @@ private static bool TryRoute(string path, out int version, out string? fork, // SszRestPaths.SupportedForks is the single source of truth for recognised fork names. if (!SszRestPaths.SupportedForks.Contains(forkStr)) { + if (forkStr.StartsWith("v") && forkStr.Length > 1 && int.TryParse(forkStr.AsSpan(1), out _)) + { + return false; + } + fork = forkStr; unsupportedFork = true; return false; @@ -342,7 +338,7 @@ private static bool TryRoute(string path, out int version, out string? fork, } else { - // Recognised fork but missing resource segment, e.g. /engine/v2/cancun — not + // Recognised fork but missing resource segment, e.g. /engine/cancun — not // a valid endpoint; leave unsupportedFork = false so the caller uses 404. return false; } @@ -460,8 +456,8 @@ private static SszRequestKind ClassifySszRequest(HttpContext ctx) private static bool IsDiagnosticGetPath(string path) { ReadOnlySpan span = path.AsSpan(); - const string capabilitiesPath = "/engine/v2/capabilities"; - const string identityPath = "/engine/v2/identity"; + const string capabilitiesPath = "/engine/capabilities"; + const string identityPath = "/engine/identity"; return span.StartsWith(capabilitiesPath.AsSpan(), StringComparison.OrdinalIgnoreCase) || span.StartsWith(identityPath.AsSpan(), StringComparison.OrdinalIgnoreCase); } From 00b4b6db3b1d869b9869578360d9fa1fc9a9e560 Mon Sep 17 00:00:00 2001 From: Dyslex7c Date: Thu, 4 Jun 2026 00:25:35 +0530 Subject: [PATCH 10/35] address claude review comments --- .../SszRest/SszMiddlewareTests.cs | 20 ++++++++++++++++++ .../Handlers/CapabilitiesSszHandler.cs | 21 +++++++++++++------ .../Handlers/ClientVersionSszHandler.cs | 7 +++++++ .../SszRest/SszMiddleware.cs | 7 +++++-- 4 files changed, 47 insertions(+), 8 deletions(-) diff --git a/src/Nethermind/Nethermind.Merge.Plugin.Test/SszRest/SszMiddlewareTests.cs b/src/Nethermind/Nethermind.Merge.Plugin.Test/SszRest/SszMiddlewareTests.cs index 88e9b40527ea..8b6c1a60fc3a 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin.Test/SszRest/SszMiddlewareTests.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin.Test/SszRest/SszMiddlewareTests.cs @@ -279,6 +279,26 @@ public async Task GetBlobsV2V3_routes_to_correct_engine_method(string path, bool await _engineModule.Received(isV3 ? 1 : 0).engine_getBlobsV3(Arg.Any()); } + [Test] + public async Task GetBlobsV4_routes_to_engine_getBlobsV4() + { + _engineModule.engine_getBlobsV4(Arg.Any(), Arg.Any()) + .Returns(ResultWrapper?>.Success(null)); + + GetBlobsV4RequestWire request = new() + { + BlobVersionedHashes = [], + IndicesBitarray = new System.Collections.BitArray(128) + }; + byte[] body = GetBlobsV4RequestWire.Encode(request); + DefaultHttpContext ctx = MakePostContext("/engine/v2/blobs/v4", body); + + await _middleware.InvokeAsync(ctx); + + Assert.That(ctx.Response.StatusCode, Is.EqualTo(StatusCodes.Status204NoContent)); + await _engineModule.Received(1).engine_getBlobsV4(Arg.Any(), Arg.Any()); + } + [TestCase(1, "/engine/v2/" + SszRestPaths.Shanghai + "/bodies/hash")] [TestCase(2, "/engine/v2/" + SszRestPaths.Amsterdam + "/bodies/hash")] public async Task GetPayloadBodiesByHash_routes_to_correct_engine_method(int version, string path) diff --git a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/CapabilitiesSszHandler.cs b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/CapabilitiesSszHandler.cs index d8007f22b668..400fdfc7abe2 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/CapabilitiesSszHandler.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/CapabilitiesSszHandler.cs @@ -4,7 +4,6 @@ using System; using System.Buffers; using System.Collections.Generic; -using System.Linq; using System.Text.Json; using System.Threading.Tasks; using Microsoft.AspNetCore.Http; @@ -26,11 +25,21 @@ public override async Task HandleAsync(HttpContext ctx, int version, ReadOnlyMem { int timestampForkCount = ComputeTimestampForkCount(specProvider); - IEnumerable supportedForks = timestampForkCount == 0 - ? SszRestPaths.SupportedForksOrdered - : SszRestPaths.SupportedForksOrdered.Take(timestampForkCount + 1); - - string supportedForksJson = JsonSerializer.Serialize(supportedForks); + string supportedForksJson; + if (timestampForkCount == 0) + { + supportedForksJson = JsonSerializer.Serialize(SszRestPaths.SupportedForksOrdered); + } + else + { + int limit = Math.Min(timestampForkCount + 1, SszRestPaths.SupportedForksOrdered.Count); + List forkSlice = new(limit); + for (int i = 0; i < limit; i++) + { + forkSlice.Add(SszRestPaths.SupportedForksOrdered[i]); + } + supportedForksJson = JsonSerializer.Serialize(forkSlice); + } ctx.Response.ContentType = "application/json"; ctx.Response.StatusCode = StatusCodes.Status200OK; diff --git a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/ClientVersionSszHandler.cs b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/ClientVersionSszHandler.cs index b101222d5463..2f8689a40770 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/ClientVersionSszHandler.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/ClientVersionSszHandler.cs @@ -5,6 +5,7 @@ using System.Buffers; using System.Threading.Tasks; using Microsoft.AspNetCore.Http; +using Nethermind.Core; using Nethermind.JsonRpc; using Nethermind.Merge.Plugin.Data; @@ -32,6 +33,12 @@ public override async Task HandleAsync(HttpContext ctx, int version, ReadOnlyMem : default; ResultWrapper result = _engineModule.engine_getClientVersionV1(clientVersion); + if (result.Result.ResultType != ResultType.Success) + { + await WriteErrorAsync(ctx, StatusCodes.Status500InternalServerError, result.Result.Error ?? "engine_getClientVersionV1 failed"); + return; + } + ctx.Response.ContentType = "application/json"; ctx.Response.StatusCode = StatusCodes.Status200OK; string json = System.Text.Json.JsonSerializer.Serialize(result.Data, _jsonOptions); diff --git a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszMiddleware.cs b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszMiddleware.cs index c0940e1e23e8..575bf1ccc6dc 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszMiddleware.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszMiddleware.cs @@ -458,8 +458,11 @@ private static bool IsDiagnosticGetPath(string path) ReadOnlySpan span = path.AsSpan(); const string capabilitiesPath = "/engine/capabilities"; const string identityPath = "/engine/identity"; - return span.StartsWith(capabilitiesPath.AsSpan(), StringComparison.OrdinalIgnoreCase) - || span.StartsWith(identityPath.AsSpan(), StringComparison.OrdinalIgnoreCase); + + return span.Equals(capabilitiesPath.AsSpan(), StringComparison.OrdinalIgnoreCase) + || (span.StartsWith(capabilitiesPath.AsSpan(), StringComparison.OrdinalIgnoreCase) && span.Length > capabilitiesPath.Length && span[capabilitiesPath.Length] == '/') + || span.Equals(identityPath.AsSpan(), StringComparison.OrdinalIgnoreCase) + || (span.StartsWith(identityPath.AsSpan(), StringComparison.OrdinalIgnoreCase) && span.Length > identityPath.Length && span[identityPath.Length] == '/'); } /// From 7610306b11f11a3eff0b35eafb6d8e2366f9d6e6 Mon Sep 17 00:00:00 2001 From: Dyslex7c Date: Thu, 4 Jun 2026 18:21:33 +0530 Subject: [PATCH 11/35] clean fcu handler and remove `custodyColumns`, add v2 to urls --- .../ForkchoiceUpdatedCustodyGuardTests.cs | 182 ------------------ .../SszRest/SszMiddlewareTests.cs | 8 - .../EngineRpcModule.Paris.cs | 24 +-- .../SszRest/Handlers/SszRestPaths.cs | 50 ++--- .../SszRest/SszMiddleware.cs | 35 +++- 5 files changed, 51 insertions(+), 248 deletions(-) delete mode 100644 src/Nethermind/Nethermind.Merge.Plugin.Test/ForkchoiceUpdatedCustodyGuardTests.cs diff --git a/src/Nethermind/Nethermind.Merge.Plugin.Test/ForkchoiceUpdatedCustodyGuardTests.cs b/src/Nethermind/Nethermind.Merge.Plugin.Test/ForkchoiceUpdatedCustodyGuardTests.cs deleted file mode 100644 index 7680e8ba0b1d..000000000000 --- a/src/Nethermind/Nethermind.Merge.Plugin.Test/ForkchoiceUpdatedCustodyGuardTests.cs +++ /dev/null @@ -1,182 +0,0 @@ -// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited -// SPDX-License-Identifier: LGPL-3.0-only - -using System.Collections; -using System.Collections.Generic; -using System.Threading.Tasks; -using Nethermind.Api; -using Nethermind.Consensus.Producers; -using Nethermind.Core.Crypto; -using Nethermind.Core.Specs; -using Nethermind.Core.Test.Builders; -using Nethermind.JsonRpc; -using Nethermind.Logging; -using Nethermind.Merge.Plugin.Data; -using Nethermind.Merge.Plugin.GC; -using Nethermind.Merge.Plugin.Handlers; -using NSubstitute; -using NUnit.Framework; - -namespace Nethermind.Merge.Plugin.Test; - -[TestFixture] -public class ForkchoiceUpdatedCustodyGuardTests -{ - private static ForkchoiceStateV1 AnyForkchoiceState() => - new(TestItem.KeccakA, TestItem.KeccakB, TestItem.KeccakC); - - private static (CustodyInterceptingModule module, IForkchoiceUpdatedHandler fcuHandler) - BuildModule() - { - IForkchoiceUpdatedHandler fcuHandler = Substitute.For(); - - IEngineRequestsTracker tracker = Substitute.For(); - tracker.OnForkchoiceUpdatedCalled(); - - GCKeeper gcKeeper = new(NoGCStrategy.Instance, LimboLogs.Instance); - - CustodyInterceptingModule module = new( - fcuHandler: fcuHandler, - engineRequestsTracker: tracker, - gcKeeper: gcKeeper); - - return (module, fcuHandler); - } - - private static ResultWrapper FcuResult(string payloadStatus) => - ResultWrapper.Success(new ForkchoiceUpdatedV1Result - { - PayloadStatus = new PayloadStatusV1 { Status = payloadStatus, LatestValidHash = TestItem.KeccakA } - }); - - private static ResultWrapper FcuError() => - ResultWrapper.Fail("forced error", -32000); - - [Test] - public async Task CustodyColumns_applied_when_payload_status_is_VALID() - { - (CustodyInterceptingModule? module, IForkchoiceUpdatedHandler? fcuHandler) = BuildModule(); - fcuHandler.Handle(Arg.Any(), Arg.Any(), Arg.Any()) - .Returns(FcuResult(PayloadStatus.Valid)); - - await module.engine_forkchoiceUpdatedV4( - AnyForkchoiceState(), - payloadAttributes: null, - custodyColumns: new BitArray(64, false)); - - Assert.That(module.ApplyCustodyColumnsCalled, Is.True, - "ApplyCustodyColumns must be called when FCU returns VALID"); - } - - [Test] - public async Task CustodyColumns_suppressed_when_payload_status_is_INVALID() - { - (CustodyInterceptingModule? module, IForkchoiceUpdatedHandler? fcuHandler) = BuildModule(); - fcuHandler.Handle(Arg.Any(), Arg.Any(), Arg.Any()) - .Returns(FcuResult(PayloadStatus.Invalid)); - - await module.engine_forkchoiceUpdatedV4( - AnyForkchoiceState(), - payloadAttributes: null, - custodyColumns: new BitArray(64, false)); - - Assert.That(module.ApplyCustodyColumnsCalled, Is.False, - "ApplyCustodyColumns must NOT be called when FCU returns INVALID"); - } - - [Test] - public async Task CustodyColumns_suppressed_when_payload_status_is_SYNCING() - { - (CustodyInterceptingModule? module, IForkchoiceUpdatedHandler? fcuHandler) = BuildModule(); - fcuHandler.Handle(Arg.Any(), Arg.Any(), Arg.Any()) - .Returns(FcuResult(PayloadStatus.Syncing)); - - await module.engine_forkchoiceUpdatedV4( - AnyForkchoiceState(), - payloadAttributes: null, - custodyColumns: new BitArray(64, false)); - - Assert.That(module.ApplyCustodyColumnsCalled, Is.False, - "ApplyCustodyColumns must NOT be called when FCU returns SYNCING — " + - "the head was not updated so no custody change should be applied (spec §FCU)"); - } - - [Test] - public async Task CustodyColumns_suppressed_when_payload_status_is_ACCEPTED() - { - (CustodyInterceptingModule? module, IForkchoiceUpdatedHandler? fcuHandler) = BuildModule(); - fcuHandler.Handle(Arg.Any(), Arg.Any(), Arg.Any()) - .Returns(FcuResult(PayloadStatus.Accepted)); - - await module.engine_forkchoiceUpdatedV4( - AnyForkchoiceState(), - payloadAttributes: null, - custodyColumns: new BitArray(64, false)); - - Assert.That(module.ApplyCustodyColumnsCalled, Is.False, - "ApplyCustodyColumns must NOT be called when FCU returns ACCEPTED"); - } - - [Test] - public async Task CustodyColumns_suppressed_when_handler_returns_error() - { - (CustodyInterceptingModule? module, IForkchoiceUpdatedHandler? fcuHandler) = BuildModule(); - fcuHandler.Handle(Arg.Any(), Arg.Any(), Arg.Any()) - .Returns(FcuError()); - - await module.engine_forkchoiceUpdatedV4( - AnyForkchoiceState(), - payloadAttributes: null, - custodyColumns: new BitArray(64, false)); - - Assert.That(module.ApplyCustodyColumnsCalled, Is.False, - "ApplyCustodyColumns must NOT be called when the FCU handler returns an error result"); - } - - [Test] - public async Task CustodyColumns_not_called_when_null_even_on_VALID() - { - (CustodyInterceptingModule? module, IForkchoiceUpdatedHandler? fcuHandler) = BuildModule(); - fcuHandler.Handle(Arg.Any(), Arg.Any(), Arg.Any()) - .Returns(FcuResult(PayloadStatus.Valid)); - - await module.engine_forkchoiceUpdatedV4( - AnyForkchoiceState(), - payloadAttributes: null, - custodyColumns: null); - - Assert.That(module.ApplyCustodyColumnsCalled, Is.False, - "ApplyCustodyColumns must not be called when custodyColumns is null"); - } - - private sealed partial class CustodyInterceptingModule( - IForkchoiceUpdatedHandler fcuHandler, - IEngineRequestsTracker engineRequestsTracker, - GCKeeper gcKeeper) : EngineRpcModule( - getPayloadHandlerV1: Substitute.For>(), - getPayloadHandlerV2: Substitute.For>(), - getPayloadHandlerV3: Substitute.For>(), - getPayloadHandlerV4: Substitute.For>(), - getPayloadHandlerV5: Substitute.For>(), - getPayloadHandlerV6: Substitute.For>(), - newPayloadV1Handler: Substitute.For>(), - forkchoiceUpdatedV1Handler: fcuHandler, - executionGetPayloadBodiesByHashV1Handler: Substitute.For, IReadOnlyList>>(), - executionGetPayloadBodiesByRangeV1Handler: Substitute.For(), - transitionConfigurationHandler: Substitute.For>(), - capabilitiesHandler: Substitute.For, IReadOnlyList>>(), - getBlobsHandler: Substitute.For>>(), - getBlobsHandlerV2: Substitute.For?>>(), - getBlobsHandlerV4: Substitute.For?>>(), - getPayloadBodiesByHashV2Handler: Substitute.For, IReadOnlyList>>(), - getPayloadBodiesByRangeV2Handler: Substitute.For(), - engineRequestsTracker: engineRequestsTracker, - specProvider: Substitute.For(), - gcKeeper: gcKeeper, - logManager: LimboLogs.Instance) - { - public bool ApplyCustodyColumnsCalled { get; private set; } - - protected override void ApplyCustodyColumns(BitArray custodyColumns) => ApplyCustodyColumnsCalled = true; - } -} diff --git a/src/Nethermind/Nethermind.Merge.Plugin.Test/SszRest/SszMiddlewareTests.cs b/src/Nethermind/Nethermind.Merge.Plugin.Test/SszRest/SszMiddlewareTests.cs index 8b6c1a60fc3a..356700360a9f 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin.Test/SszRest/SszMiddlewareTests.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin.Test/SszRest/SszMiddlewareTests.cs @@ -116,14 +116,6 @@ private SszMiddleware BuildMiddleware(RequestDelegate? next = null) private static DefaultHttpContext MakeBaseContext(string method, string path, int port) { - if (path.StartsWith("/engine/v2/")) - { - path = "/engine/" + path["/engine/v2/".Length..]; - } - else if (path.StartsWith("/engine/v2")) - { - path = "/engine" + path["/engine/v2".Length..]; - } DefaultHttpContext ctx = new(); ctx.Request.Method = method; ctx.Request.Path = path; diff --git a/src/Nethermind/Nethermind.Merge.Plugin/EngineRpcModule.Paris.cs b/src/Nethermind/Nethermind.Merge.Plugin/EngineRpcModule.Paris.cs index 682195515a61..b47a5d13bf3b 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/EngineRpcModule.Paris.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/EngineRpcModule.Paris.cs @@ -9,7 +9,6 @@ using Nethermind.Api; using Nethermind.Consensus; using Nethermind.Consensus.Producers; -using Nethermind.Core; using Nethermind.Core.Exceptions; using Nethermind.Core.Specs; using Nethermind.JsonRpc; @@ -56,27 +55,7 @@ protected async Task> ForkchoiceUpdated long startTime = Stopwatch.GetTimestamp(); try { - ResultWrapper result = - await _forkchoiceUpdatedV1Handler.Handle(forkchoiceState, payloadAttributes, version); - - bool fcuSucceeded = result.Result.ResultType == ResultType.Success - && result.Data?.PayloadStatus?.Status == PayloadStatus.Valid; - - if (custodyColumns is not null && fcuSucceeded) - { - try - { - ApplyCustodyColumns(custodyColumns); - } - catch (Exception ex) - { - // Log but swallow: custody errors must not affect the forkchoice result. - if (_logger.IsWarn) - _logger.Warn($"engine_forkchoiceUpdatedV{version}: custody-column update failed (ignored per spec): {ex.Message}"); - } - } - - return result; + return await _forkchoiceUpdatedV1Handler.Handle(forkchoiceState, payloadAttributes, version); } finally { @@ -91,7 +70,6 @@ protected async Task> ForkchoiceUpdated } } - protected virtual void ApplyCustodyColumns(BitArray custodyColumns) { } protected async Task> NewPayload(IExecutionPayloadParams executionPayloadParams, int version) { diff --git a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/SszRestPaths.cs b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/SszRestPaths.cs index 57ce4f1b8536..110b116adc78 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/SszRestPaths.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/SszRestPaths.cs @@ -50,41 +50,41 @@ public static int ForkOrdinal(string forkUrl) public const string Blobs = "blobs"; // Paris - public const string PostV1Payloads = "POST /engine/" + Paris + "/payloads"; - public const string GetV1Payloads = "GET /engine/" + Paris + "/payloads/{payload_id}"; - public const string PostV1Forkchoice = "POST /engine/" + Paris + "/forkchoice"; - public const string PostV1Capabilities = "GET /engine/capabilities"; - public const string PostV1ClientVersion = "GET /engine/identity"; + public const string PostV1Payloads = "POST /engine/v2/" + Paris + "/payloads"; + public const string GetV1Payloads = "GET /engine/v2/" + Paris + "/payloads/{payload_id}"; + public const string PostV1Forkchoice = "POST /engine/v2/" + Paris + "/forkchoice"; + public const string PostV1Capabilities = "GET /engine/v2/capabilities"; + public const string PostV1ClientVersion = "GET /engine/v2/identity"; // Shanghai - public const string PostV2Payloads = "POST /engine/" + Shanghai + "/payloads"; - public const string PostV2Forkchoice = "POST /engine/" + Shanghai + "/forkchoice"; - public const string GetV2Payloads = "GET /engine/" + Shanghai + "/payloads/{payload_id}"; - public const string PostV1PayloadBodiesByHash = "POST /engine/" + Shanghai + "/bodies/hash"; - public const string GetV1PayloadBodiesByRange = "GET /engine/" + Shanghai + "/bodies"; + public const string PostV2Payloads = "POST /engine/v2/" + Shanghai + "/payloads"; + public const string PostV2Forkchoice = "POST /engine/v2/" + Shanghai + "/forkchoice"; + public const string GetV2Payloads = "GET /engine/v2/" + Shanghai + "/payloads/{payload_id}"; + public const string PostV1PayloadBodiesByHash = "POST /engine/v2/" + Shanghai + "/bodies/hash"; + public const string GetV1PayloadBodiesByRange = "GET /engine/v2/" + Shanghai + "/bodies"; // Cancun - public const string PostV3Payloads = "POST /engine/" + Cancun + "/payloads"; - public const string PostV3Forkchoice = "POST /engine/" + Cancun + "/forkchoice"; - public const string GetV3Payloads = "GET /engine/" + Cancun + "/payloads/{payload_id}"; - public const string PostV1Blobs = "POST /engine/blobs/v1"; + public const string PostV3Payloads = "POST /engine/v2/" + Cancun + "/payloads"; + public const string PostV3Forkchoice = "POST /engine/v2/" + Cancun + "/forkchoice"; + public const string GetV3Payloads = "GET /engine/v2/" + Cancun + "/payloads/{payload_id}"; + public const string PostV1Blobs = "POST /engine/v2/blobs/v1"; // Prague - public const string PostV4Payloads = "POST /engine/" + Prague + "/payloads"; - public const string GetV4Payloads = "GET /engine/" + Prague + "/payloads/{payload_id}"; + public const string PostV4Payloads = "POST /engine/v2/" + Prague + "/payloads"; + public const string GetV4Payloads = "GET /engine/v2/" + Prague + "/payloads/{payload_id}"; // Osaka - public const string GetV5Payloads = "GET /engine/" + Osaka + "/payloads/{payload_id}"; - public const string PostV2Blobs = "POST /engine/blobs/v2"; - public const string PostV3Blobs = "POST /engine/blobs/v3"; + public const string GetV5Payloads = "GET /engine/v2/" + Osaka + "/payloads/{payload_id}"; + public const string PostV2Blobs = "POST /engine/v2/blobs/v2"; + public const string PostV3Blobs = "POST /engine/v2/blobs/v3"; // Amsterdam - public const string PostV5Payloads = "POST /engine/" + Amsterdam + "/payloads"; - public const string GetV6Payloads = "GET /engine/" + Amsterdam + "/payloads/{payload_id}"; - public const string PostV4Forkchoice = "POST /engine/" + Amsterdam + "/forkchoice"; - public const string PostV2PayloadBodiesByHash = "POST /engine/" + Amsterdam + "/bodies/hash"; - public const string GetV2PayloadBodiesByRange = "GET /engine/" + Amsterdam + "/bodies"; - public const string PostV4Blobs = "POST /engine/blobs/v4"; + public const string PostV5Payloads = "POST /engine/v2/" + Amsterdam + "/payloads"; + public const string GetV6Payloads = "GET /engine/v2/" + Amsterdam + "/payloads/{payload_id}"; + public const string PostV4Forkchoice = "POST /engine/v2/" + Amsterdam + "/forkchoice"; + public const string PostV2PayloadBodiesByHash = "POST /engine/v2/" + Amsterdam + "/bodies/hash"; + public const string GetV2PayloadBodiesByRange = "GET /engine/v2/" + Amsterdam + "/bodies"; + public const string PostV4Blobs = "POST /engine/v2/blobs/v4"; private static readonly FrozenDictionary<(string Fork, string Resource, string Method), int> s_forkVersionMap = new Dictionary<(string, string, string), int> diff --git a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszMiddleware.cs b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszMiddleware.cs index 575bf1ccc6dc..92f7fc62c784 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszMiddleware.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszMiddleware.cs @@ -33,8 +33,8 @@ public sealed class SszMiddleware private readonly ILogger _logger; private readonly CancellationToken _processExitToken; - // Path: /engine/{fork}/{resource}[/{extra}] - private const string EnginePrefix = "/engine/"; + // Path: /engine/v2/{fork}/{resource}[/{extra}] + private const string EnginePrefix = "/engine/v2/"; /// /// Maximum allowed request body size in bytes (128 MiB). @@ -179,7 +179,7 @@ await SszEndpointHandlerBase.WriteErrorAsync(ctx, StatusCodes.Status404NotFound, // Use .Span in the interpolation: ROM.ToString() would allocate a separate // intermediate string; appending the span goes straight into the format buffer. await SszEndpointHandlerBase.WriteErrorAsync(ctx, StatusCodes.Status404NotFound, - $"Unknown method: {ctx.Request.Method} /engine/{pathSegment.Span}", + $"Unknown method: {ctx.Request.Method} /engine/v2/{pathSegment.Span}", SszRestErrorCodes.MethodNotFound); } else @@ -192,8 +192,8 @@ await SszEndpointHandlerBase.WriteErrorAsync(ctx, StatusCodes.Status404NotFound, if (_logger.IsTrace) { _logger.Trace(extra.IsEmpty - ? $"SSZ-REST {ctx.Request.Method} /engine/{pathSegment.Span}" - : $"SSZ-REST {ctx.Request.Method} /engine/{pathSegment.Span}/{extra.Span}"); + ? $"SSZ-REST {ctx.Request.Method} /engine/v2/{pathSegment.Span}" + : $"SSZ-REST {ctx.Request.Method} /engine/v2/{pathSegment.Span}/{extra.Span}"); } // Read directly from PipeReader: the buffer is a ReadOnlySequence over Kestrel's @@ -338,7 +338,7 @@ private static bool TryRoute(string path, out int version, out string? fork, } else { - // Recognised fork but missing resource segment, e.g. /engine/cancun — not + // Recognised fork but missing resource segment, e.g. /engine/v2/cancun — not // a valid endpoint; leave unsupportedFork = false so the caller uses 404. return false; } @@ -423,18 +423,33 @@ private static SszRequestKind ClassifySszRequest(HttpContext ctx) { string path = ctx.Request.Path.Value ?? string.Empty; - if (!path.StartsWith(EnginePrefix, StringComparison.OrdinalIgnoreCase)) + if (!path.StartsWith("/engine/", StringComparison.OrdinalIgnoreCase)) return SszRequestKind.NotEngine; + ReadOnlySpan span = path.AsSpan("/engine/".Length); + int nextSlash = span.IndexOf('/'); + ReadOnlySpan versionSegment = nextSlash < 0 ? span : span[..nextSlash]; + + bool isVersioned = versionSegment.StartsWith("v", StringComparison.OrdinalIgnoreCase) + && versionSegment.Length > 1 + && int.TryParse(versionSegment[1..], out _); + + if (!isVersioned) + return SszRequestKind.NotEngine; + + bool isEnginePrefix = path.StartsWith(EnginePrefix, StringComparison.OrdinalIgnoreCase); + switch (ctx.Request.Method) { case "POST": + if (!isEnginePrefix) return SszRequestKind.EngineOk; return ctx.Request.ContentType?.Contains( MediaTypeNames.Application.Octet, StringComparison.OrdinalIgnoreCase) == true ? SszRequestKind.EngineOk : SszRequestKind.EngineWrongMediaType; case "GET": + if (!isEnginePrefix) return SszRequestKind.EngineOk; if (IsDiagnosticGetPath(path)) return SszRequestKind.EngineOk; @@ -456,9 +471,9 @@ private static SszRequestKind ClassifySszRequest(HttpContext ctx) private static bool IsDiagnosticGetPath(string path) { ReadOnlySpan span = path.AsSpan(); - const string capabilitiesPath = "/engine/capabilities"; - const string identityPath = "/engine/identity"; - + const string capabilitiesPath = "/engine/v2/capabilities"; + const string identityPath = "/engine/v2/identity"; + return span.Equals(capabilitiesPath.AsSpan(), StringComparison.OrdinalIgnoreCase) || (span.StartsWith(capabilitiesPath.AsSpan(), StringComparison.OrdinalIgnoreCase) && span.Length > capabilitiesPath.Length && span[capabilitiesPath.Length] == '/') || span.Equals(identityPath.AsSpan(), StringComparison.OrdinalIgnoreCase) From ac868b2faeebbdd64efe16d35a7bd4f4a0b7e61b Mon Sep 17 00:00:00 2001 From: Dyslex7c Date: Thu, 11 Jun 2026 03:03:50 +0530 Subject: [PATCH 12/35] remove `TargetGasLimit` from `PayloadAttributes` --- .../PayloadAttributesValidateTests.cs | 26 +++---------------- .../Producers/PayloadAttributes.cs | 19 ++------------ .../EngineModuleTests.V6.cs | 6 +---- .../SszRest/SszCodec.cs | 9 +++---- .../SszRest/SszWireTypes.cs | 1 - 5 files changed, 9 insertions(+), 52 deletions(-) diff --git a/src/Nethermind/Nethermind.Consensus.Test/PayloadAttributesValidateTests.cs b/src/Nethermind/Nethermind.Consensus.Test/PayloadAttributesValidateTests.cs index 5809c6609c5c..2c03b69df2b0 100644 --- a/src/Nethermind/Nethermind.Consensus.Test/PayloadAttributesValidateTests.cs +++ b/src/Nethermind/Nethermind.Consensus.Test/PayloadAttributesValidateTests.cs @@ -20,7 +20,6 @@ public class PayloadAttributesValidateTests Withdrawals = [], ParentBeaconBlockRoot = Keccak.Zero, SlotNumber = null, - TargetGasLimit = null, }; private static ISpecProvider MakeSpecProvider(bool isAmsterdam) @@ -52,27 +51,12 @@ public void Validate_returns_specific_field_error_when_V4_fields_absent_on_Amste "A 'V4 expected' version-mismatch error would obscure the real problem (missing field)."); } - [Test] - public void Validate_reports_missing_SlotNumber_when_only_TargetGasLimit_present() - { - ISpecProvider sp = MakeSpecProvider(isAmsterdam: true); - PayloadAttributes attrs = ValidV3Attributes(); - attrs.TargetGasLimit = 30_000_000UL; - - PayloadAttributesValidationResult result = attrs.Validate(sp, fcuVersion: PayloadAttributesVersions.V4, out string error); - - Assert.That(result, Is.EqualTo(PayloadAttributesValidationResult.InvalidPayloadAttributes)); - Assert.That(error, Does.Contain(nameof(PayloadAttributes.SlotNumber)), - "SlotNumber is the first unset V4 field checked after TargetGasLimit is present."); - } - [Test] public void Validate_succeeds_for_complete_V4_attributes_on_Amsterdam_timestamp() { ISpecProvider sp = MakeSpecProvider(isAmsterdam: true); PayloadAttributes attrs = ValidV3Attributes(); attrs.SlotNumber = 42UL; - attrs.TargetGasLimit = 30_000_000UL; PayloadAttributesValidationResult result = attrs.Validate(sp, fcuVersion: PayloadAttributesVersions.V4, out string error); @@ -86,7 +70,6 @@ public void Validate_returns_UnsupportedFork_when_V4_attrs_sent_to_V3_spec() ISpecProvider sp = MakeSpecProvider(isAmsterdam: false); PayloadAttributes attrs = ValidV3Attributes(); attrs.SlotNumber = 42UL; - attrs.TargetGasLimit = 30_000_000UL; PayloadAttributesValidationResult result = attrs.Validate(sp, fcuVersion: PayloadAttributesVersions.V3, out string error); @@ -94,17 +77,14 @@ public void Validate_returns_UnsupportedFork_when_V4_attrs_sent_to_V3_spec() Assert.That(error, Is.Not.Null); } - [TestCase(false, false, PayloadAttributesVersions.V1)] - [TestCase(true, false, PayloadAttributesVersions.V4)] - [TestCase(false, true, PayloadAttributesVersions.V4)] - [TestCase(true, true, PayloadAttributesVersions.V4)] + [TestCase(false, PayloadAttributesVersions.V1)] + [TestCase(true, PayloadAttributesVersions.V4)] public void GetVersion_infers_correct_version_from_present_fields( - bool hasTargetGasLimit, bool hasSlotNumber, int expectedVersion) + bool hasSlotNumber, int expectedVersion) { PayloadAttributes attrs = new() { Timestamp = 1_000UL, - TargetGasLimit = hasTargetGasLimit ? 30_000_000UL : null, SlotNumber = hasSlotNumber ? 1UL : null, }; diff --git a/src/Nethermind/Nethermind.Consensus/Producers/PayloadAttributes.cs b/src/Nethermind/Nethermind.Consensus/Producers/PayloadAttributes.cs index 8327495900c3..ed57acc50b05 100644 --- a/src/Nethermind/Nethermind.Consensus/Producers/PayloadAttributes.cs +++ b/src/Nethermind/Nethermind.Consensus/Producers/PayloadAttributes.cs @@ -28,9 +28,7 @@ public class PayloadAttributes public ulong? SlotNumber { get; set; } - public ulong? TargetGasLimit { get; set; } - - public virtual long? GetGasLimit() => (long?)TargetGasLimit; + public virtual long? GetGasLimit() => null; public override string ToString() => ToString(string.Empty); @@ -56,11 +54,6 @@ public string ToString(string indentation) sb.Append($", {nameof(SlotNumber)}: {SlotNumber}"); } - if (TargetGasLimit is not null) - { - sb.Append($", {nameof(TargetGasLimit)}: {TargetGasLimit}"); - } - sb.Append('}'); return sb.ToString(); @@ -91,7 +84,7 @@ protected virtual int ComputePayloadIdMembersSize() => + (Withdrawals is null ? 0 : Keccak.Size) // withdrawals root hash + (ParentBeaconBlockRoot is null ? 0 : Keccak.Size) // parent beacon block root + (SlotNumber is null ? 0 : sizeof(ulong)) // slot number - + (TargetGasLimit is null ? 0 : sizeof(ulong)); // target gas limit + ; protected static string ComputePayloadId(Span inputSpan) { @@ -136,12 +129,6 @@ protected virtual int WritePayloadIdMembers(BlockHeader parentHeader, Span position += sizeof(ulong); } - if (TargetGasLimit is not null) - { - BinaryPrimitives.WriteUInt64BigEndian(inputSpan.Slice(position, sizeof(ulong)), TargetGasLimit.Value); - position += sizeof(ulong); - } - return position; } @@ -240,7 +227,6 @@ public virtual PayloadAttributesValidationResult Validate( >= PayloadAttributesVersions.V2 when Withdrawals is null => $"{nameof(Withdrawals)} must be provided", >= PayloadAttributesVersions.V3 when ParentBeaconBlockRoot is null => $"{nameof(ParentBeaconBlockRoot)} must be provided", >= PayloadAttributesVersions.V4 when SlotNumber is null => $"{nameof(SlotNumber)} must be provided", - >= PayloadAttributesVersions.V4 when TargetGasLimit is null => $"{nameof(TargetGasLimit)} must be provided", _ => null }; } @@ -253,7 +239,6 @@ public static class PayloadAttributesExtensions public static int GetVersion(this PayloadAttributes executionPayload) => executionPayload switch { - { TargetGasLimit: not null } => PayloadAttributesVersions.V4, { SlotNumber: not null } => PayloadAttributesVersions.V4, { ParentBeaconBlockRoot: not null } => PayloadAttributesVersions.V3, { Withdrawals: not null } => PayloadAttributesVersions.V2, diff --git a/src/Nethermind/Nethermind.Merge.Plugin.Test/EngineModuleTests.V6.cs b/src/Nethermind/Nethermind.Merge.Plugin.Test/EngineModuleTests.V6.cs index 52b3d606bf1f..93c04af2cd07 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin.Test/EngineModuleTests.V6.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin.Test/EngineModuleTests.V6.cs @@ -42,7 +42,7 @@ public enum BalErrorKind SurplusReads, } - [TestCase("0xb54389c226c76c61de0a8ebea2fe74cb0119295d34b8c01d0897901867c41c63", "0x14c38ed94cf91d5323eb3aaa7ff6c64c4c059a0a898658fcbc37f9723c25e6b3", "0x8a792f3d13211724decede460a451cdac669b5aaae37a01c2110d9f3114bc8a2", "0x2dc87ccb57a65b07")] + [TestCase("0xb54389c226c76c61de0a8ebea2fe74cb0119295d34b8c01d0897901867c41c63", "0x14c38ed94cf91d5323eb3aaa7ff6c64c4c059a0a898658fcbc37f9723c25e6b3", "0x8a792f3d13211724decede460a451cdac669b5aaae37a01c2110d9f3114bc8a2", "0xfe420b1626a1f16d")] public virtual async Task Should_process_block_as_expected_V6( string latestValidHash, string blockHash, @@ -72,7 +72,6 @@ public virtual async Task Should_process_block_as_expected_V6( withdrawals, parentBeaconBLockRoot = Keccak.Zero, slotNumber = slotNumber.ToHexString(true), - targetGasLimit = chain.BlockTree.Head!.GasLimit.ToHexString(true), }; object?[] parameters = [chain.JsonSerializer.Serialize(fcuState), chain.JsonSerializer.Serialize(payloadAttrs)]; @@ -369,7 +368,6 @@ public virtual async Task GetPayloadV6_builds_block_with_BAL(string? customWithd ParentBeaconBlockRoot = Keccak.Zero, Withdrawals = [], SlotNumber = 1, - TargetGasLimit = (ulong)genesis.Header.GasLimit }; Transaction tx = Build.A.Transaction @@ -513,7 +511,6 @@ private async Task AddNewBlockV6(IEngineRpcModule rpcModule, Withdrawals = [], ParentBeaconBlockRoot = TestItem.KeccakE, SlotNumber = chain.BlockTree.Head!.SlotNumber + 1, - TargetGasLimit = (ulong)chain.BlockTree.Head!.GasLimit }; Hash256 currentHeadHash = chain.BlockTree.HeadHash; ForkchoiceStateV1 forkchoiceState = new(currentHeadHash, currentHeadHash, currentHeadHash); @@ -853,7 +850,6 @@ private static (Transaction tx, Transaction tx2, Transaction tx3, Withdrawal wit ParentBeaconBlockRoot = Keccak.Zero, Withdrawals = [withdrawal], SlotNumber = slotNumber, - TargetGasLimit = (ulong)chain.BlockTree.Head!.GasLimit }; ForkchoiceStateV1 fcuState = new(parentHash, parentHash, parentHash); diff --git a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszCodec.cs b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszCodec.cs index c695b19ec86f..f2098209fe4d 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszCodec.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszCodec.cs @@ -343,8 +343,7 @@ internal static PayloadAttributes PayloadAttributesFromWire(PayloadAttributesWir BuildPayloadAttributes(pa.Timestamp, pa.PrevRandao, pa.SuggestedFeeRecipient, withdrawals: pa.Withdrawals.ToDomain(), parentBeaconBlockRoot: pa.ParentBeaconBlockRoot, - slotNumber: pa.SlotNumber, - targetGasLimit: pa.TargetGasLimit); + slotNumber: pa.SlotNumber); private static PayloadAttributes BuildPayloadAttributes( ulong timestamp, @@ -352,16 +351,14 @@ private static PayloadAttributes BuildPayloadAttributes( Address suggestedFeeRecipient, Withdrawal[]? withdrawals = null, Hash256? parentBeaconBlockRoot = null, - ulong? slotNumber = null, - ulong? targetGasLimit = null) => new() + ulong? slotNumber = null) => new() { Timestamp = timestamp, PrevRandao = prevRandao, SuggestedFeeRecipient = suggestedFeeRecipient, Withdrawals = withdrawals, ParentBeaconBlockRoot = parentBeaconBlockRoot, - SlotNumber = slotNumber, - TargetGasLimit = targetGasLimit + SlotNumber = slotNumber }; public static Hash256[] GetBlobVersionedHashes(ExecutionPayload payload) diff --git a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszWireTypes.cs b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszWireTypes.cs index 7d141cae3574..5d2421e17ad8 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszWireTypes.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszWireTypes.cs @@ -81,7 +81,6 @@ public partial struct PayloadAttributesWire [SszList(16)] public SszWithdrawal[]? Withdrawals { get; set; } public Hash256 ParentBeaconBlockRoot { get; set; } public ulong SlotNumber { get; set; } - public ulong TargetGasLimit { get; set; } } [SszContainer] From 0f80d5626778952d1e3e90f6e0d581b6af0281ed Mon Sep 17 00:00:00 2001 From: Dyslex7c Date: Thu, 11 Jun 2026 13:30:34 +0530 Subject: [PATCH 13/35] fix AuRa merge tests --- .../Nethermind.Merge.AuRa.Test/AuRaMergeEngineModuleTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Nethermind/Nethermind.Merge.AuRa.Test/AuRaMergeEngineModuleTests.cs b/src/Nethermind/Nethermind.Merge.AuRa.Test/AuRaMergeEngineModuleTests.cs index 09d64464cc88..038c46fbe12e 100644 --- a/src/Nethermind/Nethermind.Merge.AuRa.Test/AuRaMergeEngineModuleTests.cs +++ b/src/Nethermind/Nethermind.Merge.AuRa.Test/AuRaMergeEngineModuleTests.cs @@ -78,7 +78,7 @@ public override Task processing_block_should_serialize_valid_responses(string bl "0xec6f5611ce3652fefd669e8d7e6d63bd8cdefdcdfe9a0a44eb61355084831da4", "0xf382f220de54b57ac9355d4eeb114f9e6bc4d25e307cdac0347b43d5534ac68e", "0xb8a1a0780980ab4e20a46237a3c533af8cd0386cf4c74d05c8ec5e9bf5cbc482", - "0xb6ea0a60ac692530", + "0x2802e8a8c34cd1ea", _auraWithdrawalContractAddress)] public override async Task Should_process_block_as_expected_V6(string latestValidHash, string blockHash, string stateRoot, string payloadId, string? customWithdrawalContractAddress) => await base.Should_process_block_as_expected_V6(latestValidHash, blockHash, stateRoot, payloadId, customWithdrawalContractAddress); From 3894b82450fd831e67618c962e401b8d384c7c19 Mon Sep 17 00:00:00 2001 From: Dyslex7c Date: Thu, 11 Jun 2026 17:39:35 +0530 Subject: [PATCH 14/35] use modern transport during startup --- src/Nethermind/Nethermind.Runner/JsonRpc/Startup.cs | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/src/Nethermind/Nethermind.Runner/JsonRpc/Startup.cs b/src/Nethermind/Nethermind.Runner/JsonRpc/Startup.cs index cb7a12d8b07b..34d5d57c2c3d 100644 --- a/src/Nethermind/Nethermind.Runner/JsonRpc/Startup.cs +++ b/src/Nethermind/Nethermind.Runner/JsonRpc/Startup.cs @@ -33,6 +33,7 @@ using Nethermind.Api; using Nethermind.Config; using Nethermind.Core.Authentication; +using Nethermind.Core.Extensions; using Nethermind.Facade.Eth; using Nethermind.HealthChecks; using Nethermind.JsonRpc; @@ -100,23 +101,18 @@ public void ConfigureServices(IServiceCollection services) options.Limits.MaxRequestBodySize = jsonRpcConfig.MaxRequestBodySize; options.ConfigureHttpsDefaults(co => co.SslProtocols |= SslProtocols.Tls13); - options.Limits.Http2.InitialConnectionWindowSize = 1 * 1024 * 1024; - options.Limits.Http2.InitialStreamWindowSize = 1 * 1024 * 1024; + options.Limits.Http2.InitialConnectionWindowSize = (int)1.MiB; + options.Limits.Http2.InitialStreamWindowSize = (int)1.MiB; options.ConfigureEndpointDefaults(listenOptions => { - int port = (listenOptions.EndPoint as System.Net.IPEndPoint)?.Port ?? 0; + int port = (listenOptions.EndPoint as IPEndPoint)?.Port ?? 0; if (engineApiPorts.Contains(port)) { // Keep HTTP/1.1 + HTTP/2 on the engine port: SSZ-REST uses HTTP/2, while legacy // Engine API JSON-RPC still relies on HTTP/1.1 and shares the same listener. listenOptions.Protocols = HttpProtocols.Http1AndHttp2; } - else - { - listenOptions.Protocols = HttpProtocols.Http1; - listenOptions.DisableAltSvcHeader = true; - } }); }); Bootstrap.Instance.RegisterJsonRpcServices(services); From efbb6da3de225a7d3bbcfbd16873137ebb557b23 Mon Sep 17 00:00:00 2001 From: "lukasz.rozmej" Date: Fri, 12 Jun 2026 14:03:09 +0200 Subject: [PATCH 15/35] address review: simplifications, pooling, no-alloc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - KzgPolynomialCommitments.ComputeCells: collapse to one-liner - PayloadAttributes: restore explanatory comments, clarify why IsSupportedFcuForkCombination is called twice (informative error branch + version mismatch branch) - PayloadAttributesValidateTests / SszMiddlewareTests: dedup via TestCaseSource / TestCase - EngineRpcModule.engine_getClientVersionV1: collapse to expression body - EngineRpcModule.Paris/Amsterdam: drop the unused BitArray? overload; V4 receives custodyColumns but ignores it - GetBlobsHandlerV4: pool the 131 KB cellsBuffer via ArrayPool - SszCodec.EncodeForkchoiceUpdatedResponse: drop redundant stackalloc+ToArray - SszVersionDescriptors: hoist AsExecutionPayload() to local (was called twice in V3/V4/V5 NewPayload) - SszRestPaths.MapForkToVersion: case-insensitive IEqualityComparer instead of ToLowerInvariant() allocations - SszMiddleware: remove two more ToLowerInvariant() allocations - SszRestLimits: hoist MAX_BODIES_REQUEST / MAX_BLOBS_REQUEST as shared constants used by capabilities and both bodies handlers - GetPayloadBodiesByHashSszHandler: 413 request-too-large when hashes exceed MAX_BODIES_REQUEST (was previously gated only at engine module level at 1024 — diverged from advertised capabilities) --- .../PayloadAttributesValidateTests.cs | 73 ++++++++----------- .../Producers/PayloadAttributes.cs | 7 +- .../KzgPolynomialCommitments.cs | 3 +- .../SszRest/SszMiddlewareTests.cs | 68 +++++++---------- .../EngineRpcModule.Amsterdam.cs | 2 +- .../EngineRpcModule.Paris.cs | 7 +- .../EngineRpcModule.cs | 9 +-- .../Handlers/GetBlobsHandlerV4.cs | 63 ++++++++-------- .../Handlers/CapabilitiesSszHandler.cs | 4 +- .../GetPayloadBodiesByHashSszHandler.cs | 7 ++ .../GetPayloadBodiesByRangeSszHandler.cs | 7 +- .../SszRest/Handlers/SszRestLimits.cs | 18 +++++ .../SszRest/Handlers/SszRestPaths.cs | 22 +++++- .../SszRest/Handlers/SszVersionDescriptors.cs | 26 +++---- .../SszRest/SszCodec.cs | 6 +- .../SszRest/SszMiddleware.cs | 21 +++--- 16 files changed, 178 insertions(+), 165 deletions(-) create mode 100644 src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/SszRestLimits.cs diff --git a/src/Nethermind/Nethermind.Consensus.Test/PayloadAttributesValidateTests.cs b/src/Nethermind/Nethermind.Consensus.Test/PayloadAttributesValidateTests.cs index 2c03b69df2b0..e1abfe6c1b78 100644 --- a/src/Nethermind/Nethermind.Consensus.Test/PayloadAttributesValidateTests.cs +++ b/src/Nethermind/Nethermind.Consensus.Test/PayloadAttributesValidateTests.cs @@ -12,69 +12,60 @@ namespace Nethermind.Consensus.Test; public class PayloadAttributesValidateTests { - private static PayloadAttributes ValidV3Attributes(ulong timestamp = 1_000UL) => new() + private static PayloadAttributes BuildAttrs(bool withSlotNumber, ulong timestamp = 1_000UL) => new() { Timestamp = timestamp, PrevRandao = Keccak.Zero, SuggestedFeeRecipient = Address.Zero, Withdrawals = [], ParentBeaconBlockRoot = Keccak.Zero, - SlotNumber = null, + SlotNumber = withSlotNumber ? 42UL : null, }; private static ISpecProvider MakeSpecProvider(bool isAmsterdam) { ISpecProvider sp = Substitute.For(); IReleaseSpec spec = Substitute.For(); - spec.IsEip7843Enabled.Returns(isAmsterdam); spec.IsEip4844Enabled.Returns(true); spec.WithdrawalsEnabled.Returns(true); - sp.GetSpec(Arg.Any()).Returns(spec); return sp; } - [Test] - public void Validate_returns_specific_field_error_when_V4_fields_absent_on_Amsterdam_timestamp() - { - ISpecProvider sp = MakeSpecProvider(isAmsterdam: true); - PayloadAttributes attrs = ValidV3Attributes(); - - PayloadAttributesValidationResult result = attrs.Validate(sp, fcuVersion: PayloadAttributesVersions.V4, out string error); - - Assert.That(result, Is.EqualTo(PayloadAttributesValidationResult.InvalidPayloadAttributes)); - Assert.That(error, Is.Not.Null); - Assert.That(error, Does.Contain("must be provided"), - "Error should identify which V4 field is missing, not emit a generic version-mismatch message."); - Assert.That(error, Does.Not.Contain("expected"), - "A 'V4 expected' version-mismatch error would obscure the real problem (missing field)."); - } - - [Test] - public void Validate_succeeds_for_complete_V4_attributes_on_Amsterdam_timestamp() + // Each case asserts a distinct branch of PayloadAttributes.Validate against the spec. + // mustContain/mustNotContain are checked when non-null. + private static readonly object[] s_validateCases = + [ + new object[] { /* isAmsterdam */ true, /* withSlot */ false, /* fcu */ PayloadAttributesVersions.V4, + PayloadAttributesValidationResult.InvalidPayloadAttributes, "must be provided", "expected" }, + new object[] { /* isAmsterdam */ true, /* withSlot */ true, /* fcu */ PayloadAttributesVersions.V4, + PayloadAttributesValidationResult.Success, null!, null! }, + new object[] { /* isAmsterdam */ false, /* withSlot */ true, /* fcu */ PayloadAttributesVersions.V3, + PayloadAttributesValidationResult.UnsupportedFork, null!, null! }, + ]; + + [TestCaseSource(nameof(s_validateCases))] + public void Validate_returns_expected_result( + bool isAmsterdam, bool withSlotNumber, int fcuVersion, + PayloadAttributesValidationResult expected, string errorMustContain, string errorMustNotContain) { - ISpecProvider sp = MakeSpecProvider(isAmsterdam: true); - PayloadAttributes attrs = ValidV3Attributes(); - attrs.SlotNumber = 42UL; + ISpecProvider sp = MakeSpecProvider(isAmsterdam); + PayloadAttributes attrs = BuildAttrs(withSlotNumber); - PayloadAttributesValidationResult result = attrs.Validate(sp, fcuVersion: PayloadAttributesVersions.V4, out string error); + PayloadAttributesValidationResult result = attrs.Validate(sp, fcuVersion, out string error); - Assert.That(result, Is.EqualTo(PayloadAttributesValidationResult.Success)); - Assert.That(error, Is.Null); - } - - [Test] - public void Validate_returns_UnsupportedFork_when_V4_attrs_sent_to_V3_spec() - { - ISpecProvider sp = MakeSpecProvider(isAmsterdam: false); - PayloadAttributes attrs = ValidV3Attributes(); - attrs.SlotNumber = 42UL; - - PayloadAttributesValidationResult result = attrs.Validate(sp, fcuVersion: PayloadAttributesVersions.V3, out string error); - - Assert.That(result, Is.EqualTo(PayloadAttributesValidationResult.UnsupportedFork)); - Assert.That(error, Is.Not.Null); + Assert.That(result, Is.EqualTo(expected)); + if (expected == PayloadAttributesValidationResult.Success) + { + Assert.That(error, Is.Null); + } + else + { + Assert.That(error, Is.Not.Null); + if (errorMustContain is not null) Assert.That(error, Does.Contain(errorMustContain)); + if (errorMustNotContain is not null) Assert.That(error, Does.Not.Contain(errorMustNotContain)); + } } [TestCase(false, PayloadAttributesVersions.V1)] diff --git a/src/Nethermind/Nethermind.Consensus/Producers/PayloadAttributes.cs b/src/Nethermind/Nethermind.Consensus/Producers/PayloadAttributes.cs index ed57acc50b05..bf89f9d79ab6 100644 --- a/src/Nethermind/Nethermind.Consensus/Producers/PayloadAttributes.cs +++ b/src/Nethermind/Nethermind.Consensus/Producers/PayloadAttributes.cs @@ -159,10 +159,11 @@ private static PayloadAttributesValidationResult ValidateVersion( string methodName, [NotNullWhen(false)] out string? error) { - // Attributes structure doesn't match what the fork expects. + // Attributes structure doesn't match what the fork expects (e.g. V3 attrs sent when FCUv3 not yet activated in spec). if (actualVersion != timestampVersion) { error = $"{methodName}{timestampVersion} expected"; + // FCU also doesn't support this fork → UnsupportedFork (post-Paris only) bool unsupportedFork = timestampVersion >= PayloadAttributesVersions.V2 && (actualVersion > timestampVersion || fcuVersion != timestampVersion); return unsupportedFork @@ -170,6 +171,7 @@ private static PayloadAttributesValidationResult ValidateVersion( : PayloadAttributesValidationResult.InvalidPayloadAttributes; } + // This FCU version doesn't support this fork at all (e.g. V3 attrs sent to FCUv2). if (!IsSupportedFcuForkCombination(fcuVersion, actualVersion)) { error = $"{methodName}{fcuVersion} expected"; @@ -188,6 +190,9 @@ public virtual PayloadAttributesValidationResult Validate( int actualVersion = this.GetVersion(); int timestampVersion = specProvider.GetSpec(ForkActivation.TimestampOnly(Timestamp)).ExpectedPayloadAttributesVersion(); + // When attrs are below the timestamp-implied version and the FCU doesn't accept this + // combination (i.e. it's not the V2-accepts-V1 backward-compat case), report the + // specific missing field rather than a generic version-mismatch. if (actualVersion < timestampVersion && !IsSupportedFcuForkCombination(fcuVersion, actualVersion)) { string? fieldError = ValidateFields(timestampVersion); diff --git a/src/Nethermind/Nethermind.Crypto/KzgPolynomialCommitments.cs b/src/Nethermind/Nethermind.Crypto/KzgPolynomialCommitments.cs index 5b391de3c48a..804b02f5f64e 100644 --- a/src/Nethermind/Nethermind.Crypto/KzgPolynomialCommitments.cs +++ b/src/Nethermind/Nethermind.Crypto/KzgPolynomialCommitments.cs @@ -75,6 +75,5 @@ public static void ComputeCellProofs(ReadOnlySpan blob, Span cellPro /// The input blob data. /// The output span of size CELLS_PER_EXT_BLOB * BYTES_PER_CELL (131072 bytes) where cells will be written. - public static void ComputeCells(ReadOnlySpan blob, Span cells) => - Ckzg.ComputeCells(cells, blob, _ckzgSetup); + public static void ComputeCells(ReadOnlySpan blob, Span cells) => Ckzg.ComputeCells(cells, blob, _ckzgSetup); } diff --git a/src/Nethermind/Nethermind.Merge.Plugin.Test/SszRest/SszMiddlewareTests.cs b/src/Nethermind/Nethermind.Merge.Plugin.Test/SszRest/SszMiddlewareTests.cs index 356700360a9f..d977c7111639 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin.Test/SszRest/SszMiddlewareTests.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin.Test/SszRest/SszMiddlewareTests.cs @@ -434,7 +434,7 @@ public async Task Oversized_body_returns_413_without_calling_engine_module() } [Test] - public async Task Unknown_engine_path_returns_404() + public async Task Unknown_engine_path_returns_404_without_delegating_to_next() { bool nextInvoked = false; SszMiddleware mw = BuildMiddleware(_ => { nextInvoked = true; return Task.CompletedTask; }); @@ -446,24 +446,13 @@ public async Task Unknown_engine_path_returns_404() Assert.That(nextInvoked, Is.False, "SSZ middleware should reply 404 itself, not delegate to JSON-RPC"); } - [Test] - public async Task Post_payloads_with_unknown_extra_returns_404_not_500() + // Each case is a different routing rejection that must NOT reach the engine module: unknown resource, + // extra segments on a non-AcceptsPathExtra handler, runs of '/' inside the path. + [TestCase("/payloads/foo/bar", TestName = "Extra_segments_on_non_path_handler_404")] + [TestCase("/payloads//abc", TestName = "Consecutive_slashes_404")] + public async Task POST_with_malformed_fork_path_returns_404(string suffix) { - // Extra segments on a non-AcceptsPathExtra resource must be 404, not silently dispatched. - DefaultHttpContext ctx = MakePostContext($"/engine/v2/{SszRestPaths.Paris}/payloads/foo/bar", []); - - await _middleware.InvokeAsync(ctx); - - Assert.That(ctx.Response.StatusCode, Is.EqualTo(StatusCodes.Status404NotFound)); - await _engineModule.DidNotReceive().engine_newPayloadV1(Arg.Any()); - } - - [Test] - public async Task Path_with_consecutive_slashes_returns_404() - { - // TryRoute must reject runs of '/' so that //abc does not reach - // the payload-id parser and produce a confusing parse error. - DefaultHttpContext ctx = MakePostContext($"/engine/v2/{SszRestPaths.Paris}/payloads//abc", []); + DefaultHttpContext ctx = MakePostContext($"/engine/v2/{SszRestPaths.Paris}{suffix}", []); await _middleware.InvokeAsync(ctx); @@ -832,35 +821,30 @@ public async Task Identity_returns_200_json_regardless_of_Accept_header(string a Assert.That(ctx.Response.ContentType, Does.Contain("application/json")); } - [Test] - public async Task Trailing_slash_on_fork_scoped_path_returns_404() - { - DefaultHttpContext ctx = MakePostContext($"/engine/v2/{SszRestPaths.Cancun}/forkchoice/", []); - await _middleware.InvokeAsync(ctx); - Assert.That(ctx.Response.StatusCode, Is.EqualTo(StatusCodes.Status404NotFound)); - string body = System.Text.Encoding.UTF8.GetString(ResponseBytes(ctx)); - Assert.That(body, Does.Contain("method-not-found")); - } - - [Test] - public async Task Trailing_slash_on_get_path_returns_404() + // Trailing slashes and unknown extra path segments must both 404 — spec forbids trailing slashes + // and handlers without AcceptsPathExtra must reject stray segments. + [TestCase("POST", "/engine/v2/" + SszRestPaths.Cancun + "/forkchoice/", true, TestName = "POST_trailing_slash_fork_scoped_404")] + [TestCase("GET", "/engine/v2/capabilities/", false, TestName = "GET_trailing_slash_diagnostic_404")] + [TestCase("POST", "/engine/v2/" + SszRestPaths.Cancun + "/forkchoice/whatever", false, TestName = "POST_unknown_extra_on_forkchoice_404")] + public async Task Malformed_or_trailing_path_returns_404(string method, string path, bool assertMethodNotFoundBody) { - DefaultHttpContext ctx = MakeBaseContext("GET", "/engine/v2/capabilities/", AuthenticatedPort); - ctx.Request.Headers.Accept = "application/json"; - ctx.Request.Body = Stream.Null; - await _middleware.InvokeAsync(ctx); - Assert.That(ctx.Response.StatusCode, Is.EqualTo(StatusCodes.Status404NotFound)); - } - - [TestCase("/engine/v2/" + SszRestPaths.Cancun + "/forkchoice/whatever")] - [TestCase("/engine/v2/" + SszRestPaths.Paris + "/payloads/foo/bar")] - public async Task Extra_path_segment_on_non_accepting_handler_returns_404(string path) - { - DefaultHttpContext ctx = MakePostContext(path, []); + DefaultHttpContext ctx = method == "POST" + ? MakePostContext(path, []) + : MakeBaseContext("GET", path, AuthenticatedPort); + if (method == "GET") + { + ctx.Request.Headers.Accept = "application/json"; + ctx.Request.Body = Stream.Null; + } await _middleware.InvokeAsync(ctx); Assert.That(ctx.Response.StatusCode, Is.EqualTo(StatusCodes.Status404NotFound)); + if (assertMethodNotFoundBody) + { + string body = System.Text.Encoding.UTF8.GetString(ResponseBytes(ctx)); + Assert.That(body, Does.Contain("method-not-found")); + } } [Test] diff --git a/src/Nethermind/Nethermind.Merge.Plugin/EngineRpcModule.Amsterdam.cs b/src/Nethermind/Nethermind.Merge.Plugin/EngineRpcModule.Amsterdam.cs index a6c9a9ffdd61..2c3133fe1b54 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/EngineRpcModule.Amsterdam.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/EngineRpcModule.Amsterdam.cs @@ -28,7 +28,7 @@ public Task> engine_newPayloadV5(ExecutionPayload => NewPayload(new ExecutionPayloadParams(executionPayload, blobVersionedHashes, parentBeaconBlockRoot, executionRequests), EngineApiVersions.NewPayload.V5); public Task> engine_forkchoiceUpdatedV4(ForkchoiceStateV1 forkchoiceState, PayloadAttributes? payloadAttributes = null, BitArray? custodyColumns = null) - => ForkchoiceUpdated(forkchoiceState, payloadAttributes, EngineApiVersions.Fcu.V4, custodyColumns); + => ForkchoiceUpdated(forkchoiceState, payloadAttributes, EngineApiVersions.Fcu.V4); public Task>> engine_getPayloadBodiesByHashV2(IReadOnlyList blockHashes) => _executionGetPayloadBodiesByHashV2Handler.Handle(blockHashes); diff --git a/src/Nethermind/Nethermind.Merge.Plugin/EngineRpcModule.Paris.cs b/src/Nethermind/Nethermind.Merge.Plugin/EngineRpcModule.Paris.cs index b47a5d13bf3b..cffac3716cb9 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/EngineRpcModule.Paris.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/EngineRpcModule.Paris.cs @@ -2,7 +2,6 @@ // SPDX-License-Identifier: LGPL-3.0-only using System; -using System.Collections; using System.Diagnostics; using System.Threading; using System.Threading.Tasks; @@ -42,12 +41,8 @@ public Task> engine_forkchoiceUpdatedV1 public Task> engine_newPayloadV1(ExecutionPayload executionPayload) => NewPayload(executionPayload, EngineApiVersions.NewPayload.V1); - protected Task> ForkchoiceUpdated( - ForkchoiceStateV1 forkchoiceState, PayloadAttributes? payloadAttributes, int version) - => ForkchoiceUpdated(forkchoiceState, payloadAttributes, version, custodyColumns: null); - protected async Task> ForkchoiceUpdated( - ForkchoiceStateV1 forkchoiceState, PayloadAttributes? payloadAttributes, int version, BitArray? custodyColumns) + ForkchoiceStateV1 forkchoiceState, PayloadAttributes? payloadAttributes, int version) { _engineRequestsTracker.OnForkchoiceUpdatedCalled(); if (await _locker.WaitAsync(_timeout)) diff --git a/src/Nethermind/Nethermind.Merge.Plugin/EngineRpcModule.cs b/src/Nethermind/Nethermind.Merge.Plugin/EngineRpcModule.cs index 540cc28a9a8b..4c9027783d77 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/EngineRpcModule.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/EngineRpcModule.cs @@ -45,11 +45,6 @@ public partial class EngineRpcModule( public ResultWrapper> engine_exchangeCapabilities(IEnumerable methods) => _capabilitiesHandler.Handle(methods as HashSet ?? [.. methods]); - public ResultWrapper engine_getClientVersionV1(ClientVersionV1 clientVersionV1) - { - ClientVersionV1 elVersion = new(); - return string.IsNullOrEmpty(clientVersionV1.Code) - ? ResultWrapper.Success([elVersion]) - : ResultWrapper.Success([elVersion, clientVersionV1]); - } + public ResultWrapper engine_getClientVersionV1(ClientVersionV1 clientVersionV1) => + ResultWrapper.Success(string.IsNullOrEmpty(clientVersionV1.Code) ? [new()] : [new(), clientVersionV1]); } diff --git a/src/Nethermind/Nethermind.Merge.Plugin/Handlers/GetBlobsHandlerV4.cs b/src/Nethermind/Nethermind.Merge.Plugin/Handlers/GetBlobsHandlerV4.cs index a480a2394423..f61d1033decd 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/Handlers/GetBlobsHandlerV4.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/Handlers/GetBlobsHandlerV4.cs @@ -2,6 +2,7 @@ // SPDX-License-Identifier: LGPL-3.0-only using System; +using System.Buffers; using System.Collections; using System.Collections.Generic; using System.Threading.Tasks; @@ -16,9 +17,7 @@ namespace Nethermind.Merge.Plugin.Handlers; public class GetBlobsHandlerV4(ITxPool txPool) : IAsyncHandler?> { private const int MaxRequest = 128; - - private static readonly Task?>> NotFound = - Task.FromResult(ResultWrapper?>.Success(null)); + private const int CellsBufferSize = Ckzg.CellsPerExtBlob * Ckzg.BytesPerCell; public Task?>> HandleAsync(GetBlobsHandlerV4Request request) { @@ -39,45 +38,51 @@ public class GetBlobsHandlerV4(ITxPool txPool) : IAsyncHandler.Shared.Rent(CellsBufferSize); + try { - byte[]? blob = blobs[i]; - if (blob is null) - { - response[i] = null; - continue; - } + Span cellsSpan = cellsBuffer.AsSpan(0, CellsBufferSize); - // We have the blob and proofs for this blob. - // Let's compute all 128 cells. - byte[] cellsBuffer = new byte[Ckzg.CellsPerExtBlob * Ckzg.BytesPerCell]; - KzgPolynomialCommitments.ComputeCells(blob, cellsBuffer); + for (int i = 0; i < n; i++) + { + byte[]? blob = blobs[i]; + if (blob is null) + { + response[i] = null; + continue; + } - byte[]?[] blobCells = new byte[Ckzg.CellsPerExtBlob][]; - byte[]?[] cellProofs = new byte[Ckzg.CellsPerExtBlob][]; + KzgPolynomialCommitments.ComputeCells(blob, cellsSpan); - ReadOnlySpan blobProofs = proofs[i].Span; + byte[]?[] blobCells = new byte[Ckzg.CellsPerExtBlob][]; + byte[]?[] cellProofs = new byte[Ckzg.CellsPerExtBlob][]; + ReadOnlySpan blobProofs = proofs[i].Span; - for (int cellIdx = 0; cellIdx < Ckzg.CellsPerExtBlob; cellIdx++) - { - if (request.IndicesBitarray.Get(cellIdx)) + for (int cellIdx = 0; cellIdx < Ckzg.CellsPerExtBlob; cellIdx++) { + if (!request.IndicesBitarray.Get(cellIdx)) continue; + byte[] cell = new byte[Ckzg.BytesPerCell]; - Buffer.BlockCopy(cellsBuffer, cellIdx * Ckzg.BytesPerCell, cell, 0, Ckzg.BytesPerCell); + cellsSpan.Slice(cellIdx * Ckzg.BytesPerCell, Ckzg.BytesPerCell).CopyTo(cell); blobCells[cellIdx] = cell; byte[] cellProof = new byte[Ckzg.BytesPerProof]; - Buffer.BlockCopy(blobProofs[cellIdx], 0, cellProof, 0, Ckzg.BytesPerProof); + blobProofs[cellIdx].AsSpan(0, Ckzg.BytesPerProof).CopyTo(cellProof); cellProofs[cellIdx] = cellProof; } - } - response[i] = new BlobCellsAndProofs - { - Available = true, - BlobCells = blobCells, - Proofs = cellProofs - }; + response[i] = new BlobCellsAndProofs + { + Available = true, + BlobCells = blobCells, + Proofs = cellProofs + }; + } + } + finally + { + ArrayPool.Shared.Return(cellsBuffer); } Metrics.GetBlobsRequestsSuccessTotal++; diff --git a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/CapabilitiesSszHandler.cs b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/CapabilitiesSszHandler.cs index 400fdfc7abe2..a8cbad3f1b51 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/CapabilitiesSszHandler.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/CapabilitiesSszHandler.cs @@ -52,8 +52,8 @@ await ctx.Response.WriteAsync($$""" }, "unscoped_endpoints": ["capabilities", "identity"], "limits": { - "bodies.max_count": 32, - "blobs.max_versioned_hashes": 128, + "bodies.max_count": {{SszRestLimits.MaxBodiesRequest}}, + "blobs.max_versioned_hashes": {{SszRestLimits.MaxBlobsRequest}}, "payload.max_bytes": {{SszMiddleware.MaxBodySize}} } } diff --git a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/GetPayloadBodiesByHashSszHandler.cs b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/GetPayloadBodiesByHashSszHandler.cs index 005fe94a8e4c..e86dd5754c3c 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/GetPayloadBodiesByHashSszHandler.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/GetPayloadBodiesByHashSszHandler.cs @@ -28,6 +28,13 @@ public sealed class GetPayloadBodiesByHashSszHandler(IEngineR public override async Task HandleAsync(HttpContext ctx, int v, ReadOnlyMemory extra, ReadOnlySequence body) { Hash256[] hashes = SszCodec.DecodeGetPayloadBodiesByHashRequest(body); + if (hashes.Length > SszRestLimits.MaxBodiesRequest) + { + await WriteErrorAsync(ctx, StatusCodes.Status413PayloadTooLarge, + $"hash count {hashes.Length} exceeds the limit of {SszRestLimits.MaxBodiesRequest}", + MergeErrorCodes.TooLargeRequest); + return; + } ResultWrapper> result = await TVersion.Call(engineModule, hashes); await WriteSszResultAsync(ctx, result, TVersion.Encode); } diff --git a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/GetPayloadBodiesByRangeSszHandler.cs b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/GetPayloadBodiesByRangeSszHandler.cs index a58dbbaf57c1..d7a55f0ba138 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/GetPayloadBodiesByRangeSszHandler.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/GetPayloadBodiesByRangeSszHandler.cs @@ -20,9 +20,6 @@ public sealed class GetPayloadBodiesByRangeSszHandler(IEngine where TVersion : struct, IPayloadBodiesByRangeVersion where TResult : class { - // per spec: MAX_BODIES_REQUEST = 2**5 = 32. The previous value of 128 matched MAX_BLOBS_REQUEST but contradicted the bodies spec. - private const int MaxPayloadBodiesRequest = 32; - public override string HttpMethod => "GET"; public override string Resource => SszRestPaths.PayloadBodiesByRange; public override int? Version => TVersion.VersionNumber; @@ -44,10 +41,10 @@ await WriteErrorAsync(ctx, StatusCodes.Status400BadRequest, SszRestErrorCodes.InvalidRequest); return; } - if (count > MaxPayloadBodiesRequest) + if (count > SszRestLimits.MaxBodiesRequest) { await WriteErrorAsync(ctx, StatusCodes.Status413PayloadTooLarge, - $"count {count} exceeds the limit of {MaxPayloadBodiesRequest}", + $"count {count} exceeds the limit of {SszRestLimits.MaxBodiesRequest}", MergeErrorCodes.TooLargeRequest); return; } diff --git a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/SszRestLimits.cs b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/SszRestLimits.cs new file mode 100644 index 000000000000..ef7aca2e1e4a --- /dev/null +++ b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/SszRestLimits.cs @@ -0,0 +1,18 @@ +// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited +// SPDX-License-Identifier: LGPL-3.0-only + +namespace Nethermind.Merge.Plugin.SszRest.Handlers; + +/// +/// Single source of truth for Engine API v2 REST request-size limits. +/// +/// +/// Per execution-apis#793: MAX_BODIES_REQUEST = 2**5 = 32, +/// MAX_BLOBS_REQUEST = 2**7 = 128. These values are advertised via +/// GET /engine/v2/capabilities and enforced by the corresponding handlers. +/// +public static class SszRestLimits +{ + public const int MaxBodiesRequest = 32; + public const int MaxBlobsRequest = 128; +} diff --git a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/SszRestPaths.cs b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/SszRestPaths.cs index 110b116adc78..4207ab11aff5 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/SszRestPaths.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/SszRestPaths.cs @@ -87,7 +87,7 @@ public static int ForkOrdinal(string forkUrl) public const string PostV4Blobs = "POST /engine/v2/blobs/v4"; private static readonly FrozenDictionary<(string Fork, string Resource, string Method), int> s_forkVersionMap = - new Dictionary<(string, string, string), int> + new Dictionary<(string Fork, string Resource, string Method), int>(ForkVersionKeyComparer.Instance) { // newPayload (POST payloads) [(Paris, Payloads, "POST")] = 1, @@ -128,10 +128,26 @@ public static int ForkOrdinal(string forkUrl) [(Prague, PayloadBodiesByRange, "GET")] = 1, [(Osaka, PayloadBodiesByRange, "GET")] = 1, [(Amsterdam, PayloadBodiesByRange, "GET")] = 2, - }.ToFrozenDictionary(); + }.ToFrozenDictionary(ForkVersionKeyComparer.Instance); public static int? MapForkToVersion(string fork, string resource, string httpMethod) => - s_forkVersionMap.TryGetValue((fork.ToLowerInvariant(), resource.ToLowerInvariant(), httpMethod), out int version) + s_forkVersionMap.TryGetValue((fork, resource, httpMethod), out int version) ? version : null; + + private sealed class ForkVersionKeyComparer : IEqualityComparer<(string Fork, string Resource, string Method)> + { + public static readonly ForkVersionKeyComparer Instance = new(); + + public bool Equals((string Fork, string Resource, string Method) x, (string Fork, string Resource, string Method) y) => + string.Equals(x.Fork, y.Fork, StringComparison.OrdinalIgnoreCase) + && string.Equals(x.Resource, y.Resource, StringComparison.OrdinalIgnoreCase) + && string.Equals(x.Method, y.Method, StringComparison.Ordinal); + + public int GetHashCode((string Fork, string Resource, string Method) obj) => + HashCode.Combine( + StringComparer.OrdinalIgnoreCase.GetHashCode(obj.Fork), + StringComparer.OrdinalIgnoreCase.GetHashCode(obj.Resource), + obj.Method); + } } diff --git a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/SszVersionDescriptors.cs b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/SszVersionDescriptors.cs index c5e9a03adfe4..5016a6a5ff92 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/SszVersionDescriptors.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/SszVersionDescriptors.cs @@ -45,32 +45,30 @@ public static Task> Call(IEngineRpcModule engine, { public static int VersionNumber => EngineApiVersions.NewPayload.V3; public static Task> Call(IEngineRpcModule engine, in NewPayloadV3RequestWire wire) - => engine.engine_newPayloadV3( - wire.ExecutionPayload.AsExecutionPayload(), - SszCodec.GetBlobVersionedHashes(wire.ExecutionPayload.AsExecutionPayload()), - wire.ParentBeaconBlockRoot); + { + ExecutionPayloadV3 ep = wire.ExecutionPayload.AsExecutionPayload(); + return engine.engine_newPayloadV3(ep, SszCodec.GetBlobVersionedHashes(ep), wire.ParentBeaconBlockRoot); + } } public readonly struct NewPayloadDescriptorV4 : INewPayloadVersion { public static int VersionNumber => EngineApiVersions.NewPayload.V4; public static Task> Call(IEngineRpcModule engine, in NewPayloadV4RequestWire wire) - => engine.engine_newPayloadV4( - wire.ExecutionPayload.AsExecutionPayload(), - SszCodec.GetBlobVersionedHashes(wire.ExecutionPayload.AsExecutionPayload()), - wire.ParentBeaconBlockRoot, - wire.ExecutionRequests.ToExecutionRequests()); + { + ExecutionPayloadV3 ep = wire.ExecutionPayload.AsExecutionPayload(); + return engine.engine_newPayloadV4(ep, SszCodec.GetBlobVersionedHashes(ep), wire.ParentBeaconBlockRoot, wire.ExecutionRequests.ToExecutionRequests()); + } } public readonly struct NewPayloadDescriptorV5 : INewPayloadVersion { public static int VersionNumber => EngineApiVersions.NewPayload.V5; public static Task> Call(IEngineRpcModule engine, in NewPayloadV5RequestWire wire) - => engine.engine_newPayloadV5( - wire.ExecutionPayload.AsExecutionPayload(), - SszCodec.GetBlobVersionedHashes(wire.ExecutionPayload.AsExecutionPayload()), - wire.ParentBeaconBlockRoot, - wire.ExecutionRequests.ToExecutionRequests()); + { + ExecutionPayloadV4 ep = wire.ExecutionPayload.AsExecutionPayload(); + return engine.engine_newPayloadV5(ep, SszCodec.GetBlobVersionedHashes(ep), wire.ParentBeaconBlockRoot, wire.ExecutionRequests.ToExecutionRequests()); + } } public interface IForkchoiceUpdatedVersion where TWire : struct, ISszCodec diff --git a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszCodec.cs b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszCodec.cs index f2098209fe4d..fa9c82598e54 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszCodec.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszCodec.cs @@ -41,11 +41,11 @@ public static int EncodeForkchoiceUpdatedResponse(ForkchoiceUpdatedV1Result resp if (hex.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) hex = hex[2..]; if (hex.Length != 16) throw new InvalidOperationException($"Invalid payload id '{resp.PayloadId}': expected 16 hex chars, got {hex.Length}"); - Span idSpan = stackalloc byte[8]; - Bytes.FromHexString(hex, idSpan); // ByteVector[8]: transmitted as-is (no LE flip — the bytes are already the // opaque token; the spec says treat payload_id as opaque bytes, not a uint64). - pidList = [new SszPayloadId { Bytes = idSpan.ToArray() }]; + byte[] idBytes = new byte[8]; + Bytes.FromHexString(hex, idBytes); + pidList = [new SszPayloadId { Bytes = idBytes }]; } return EncodeToWriter(new ForkchoiceUpdatedResponseWire diff --git a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszMiddleware.cs b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszMiddleware.cs index 92f7fc62c784..7a76f6285d92 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszMiddleware.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszMiddleware.cs @@ -80,14 +80,14 @@ private static (FrozenDictionary> post, foreach (ISszEndpointHandler h in handlers) { - string resource = h.Resource.ToLowerInvariant(); + // Dictionaries are keyed case-insensitively below — keep resource as-is, no lowercasing. Dictionary> dict = h.HttpMethod.Equals("GET", StringComparison.OrdinalIgnoreCase) ? getDict : postDict; - if (!dict.TryGetValue(resource, out List? list)) - dict[resource] = list = []; + if (!dict.TryGetValue(h.Resource, out List? list)) + dict[h.Resource] = list = []; list.Add(h); } @@ -316,21 +316,24 @@ private static bool TryRoute(string path, out int version, out string? fork, } ReadOnlySpan forkSpan = span[..nextSlash]; - string forkStr = forkSpan.ToString().ToLowerInvariant(); - // SszRestPaths.SupportedForks is the single source of truth for recognised fork names. - if (!SszRestPaths.SupportedForks.Contains(forkStr)) + // SszRestPaths.SupportedForks uses OrdinalIgnoreCase, so no lowercasing needed. + if (!SszRestPaths.SupportedForks.GetAlternateLookup>().Contains(forkSpan)) { - if (forkStr.StartsWith("v") && forkStr.Length > 1 && int.TryParse(forkStr.AsSpan(1), out _)) + // Reject `/engine/v2/v/...` (looks like a version-only path with no fork) before + // emitting an unsupported-fork error — let the caller produce a 404 instead. + if (forkSpan.Length > 1 + && (forkSpan[0] == 'v' || forkSpan[0] == 'V') + && int.TryParse(forkSpan[1..], out _)) { return false; } - fork = forkStr; + fork = forkSpan.ToString(); unsupportedFork = true; return false; } - fork = forkStr; + fork = forkSpan.ToString(); if (nextSlash < span.Length) { offset += nextSlash + 1; From 486fbc01580c4861e9c723e3e1e5af1f6173970a Mon Sep 17 00:00:00 2001 From: "lukasz.rozmej" Date: Fri, 12 Jun 2026 14:37:16 +0200 Subject: [PATCH 16/35] SszRestPaths: ForkVersionKey record + cached span lookup - Replace the (string,string,string)+IEqualityComparer key with a private record struct that overrides Equals/GetHashCode directly - Rename s_forkVersionMap to _forkVersionMap - Cache SupportedForks.GetAlternateLookup> as SupportedForksSpanLookup so SszMiddleware.TryRoute doesn't construct it per request --- .../SszRest/Handlers/SszRestPaths.cs | 99 ++++++++++--------- .../SszRest/SszMiddleware.cs | 2 +- 2 files changed, 55 insertions(+), 46 deletions(-) diff --git a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/SszRestPaths.cs b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/SszRestPaths.cs index 4207ab11aff5..3f3527e2fe4f 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/SszRestPaths.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/SszRestPaths.cs @@ -22,6 +22,13 @@ public static class SszRestPaths Paris, Shanghai, Cancun, Prague, Osaka, Amsterdam }.ToFrozenSet(StringComparer.OrdinalIgnoreCase); + /// + /// Cached alternate lookup for , + /// so the per-request GetAlternateLookup call in SszMiddleware.TryRoute is avoided. + /// + public static readonly FrozenSet.AlternateLookup> SupportedForksSpanLookup = + SupportedForks.GetAlternateLookup>(); + public static readonly IReadOnlyList SupportedForksOrdered = [Paris, Shanghai, Cancun, Prague, Osaka, Amsterdam]; @@ -86,68 +93,70 @@ public static int ForkOrdinal(string forkUrl) public const string GetV2PayloadBodiesByRange = "GET /engine/v2/" + Amsterdam + "/bodies"; public const string PostV4Blobs = "POST /engine/v2/blobs/v4"; - private static readonly FrozenDictionary<(string Fork, string Resource, string Method), int> s_forkVersionMap = - new Dictionary<(string Fork, string Resource, string Method), int>(ForkVersionKeyComparer.Instance) + private static readonly FrozenDictionary _forkVersionMap = + new Dictionary { // newPayload (POST payloads) - [(Paris, Payloads, "POST")] = 1, - [(Shanghai, Payloads, "POST")] = 2, - [(Cancun, Payloads, "POST")] = 3, - [(Prague, Payloads, "POST")] = 4, - [(Osaka, Payloads, "POST")] = 4, - [(Amsterdam, Payloads, "POST")] = 5, + [new(Paris, Payloads, "POST")] = 1, + [new(Shanghai, Payloads, "POST")] = 2, + [new(Cancun, Payloads, "POST")] = 3, + [new(Prague, Payloads, "POST")] = 4, + [new(Osaka, Payloads, "POST")] = 4, + [new(Amsterdam, Payloads, "POST")] = 5, // getPayload (GET payloads) - [(Paris, Payloads, "GET")] = 1, - [(Shanghai, Payloads, "GET")] = 2, - [(Cancun, Payloads, "GET")] = 3, - [(Prague, Payloads, "GET")] = 4, - [(Osaka, Payloads, "GET")] = 5, - [(Amsterdam, Payloads, "GET")] = 6, + [new(Paris, Payloads, "GET")] = 1, + [new(Shanghai, Payloads, "GET")] = 2, + [new(Cancun, Payloads, "GET")] = 3, + [new(Prague, Payloads, "GET")] = 4, + [new(Osaka, Payloads, "GET")] = 5, + [new(Amsterdam, Payloads, "GET")] = 6, // forkchoiceUpdated (POST forkchoice) - [(Paris, Forkchoice, "POST")] = 1, - [(Shanghai, Forkchoice, "POST")] = 2, - [(Cancun, Forkchoice, "POST")] = 3, - [(Prague, Forkchoice, "POST")] = 3, - [(Osaka, Forkchoice, "POST")] = 3, - [(Amsterdam, Forkchoice, "POST")] = 4, + [new(Paris, Forkchoice, "POST")] = 1, + [new(Shanghai, Forkchoice, "POST")] = 2, + [new(Cancun, Forkchoice, "POST")] = 3, + [new(Prague, Forkchoice, "POST")] = 3, + [new(Osaka, Forkchoice, "POST")] = 3, + [new(Amsterdam, Forkchoice, "POST")] = 4, // bodies/hash (POST) - [(Paris, PayloadBodiesByHash, "POST")] = 1, - [(Shanghai, PayloadBodiesByHash, "POST")] = 1, - [(Cancun, PayloadBodiesByHash, "POST")] = 1, - [(Prague, PayloadBodiesByHash, "POST")] = 1, - [(Osaka, PayloadBodiesByHash, "POST")] = 1, - [(Amsterdam, PayloadBodiesByHash, "POST")] = 2, + [new(Paris, PayloadBodiesByHash, "POST")] = 1, + [new(Shanghai, PayloadBodiesByHash, "POST")] = 1, + [new(Cancun, PayloadBodiesByHash, "POST")] = 1, + [new(Prague, PayloadBodiesByHash, "POST")] = 1, + [new(Osaka, PayloadBodiesByHash, "POST")] = 1, + [new(Amsterdam, PayloadBodiesByHash, "POST")] = 2, // bodies (GET) - [(Paris, PayloadBodiesByRange, "GET")] = 1, - [(Shanghai, PayloadBodiesByRange, "GET")] = 1, - [(Cancun, PayloadBodiesByRange, "GET")] = 1, - [(Prague, PayloadBodiesByRange, "GET")] = 1, - [(Osaka, PayloadBodiesByRange, "GET")] = 1, - [(Amsterdam, PayloadBodiesByRange, "GET")] = 2, - }.ToFrozenDictionary(ForkVersionKeyComparer.Instance); + [new(Paris, PayloadBodiesByRange, "GET")] = 1, + [new(Shanghai, PayloadBodiesByRange, "GET")] = 1, + [new(Cancun, PayloadBodiesByRange, "GET")] = 1, + [new(Prague, PayloadBodiesByRange, "GET")] = 1, + [new(Osaka, PayloadBodiesByRange, "GET")] = 1, + [new(Amsterdam, PayloadBodiesByRange, "GET")] = 2, + }.ToFrozenDictionary(); public static int? MapForkToVersion(string fork, string resource, string httpMethod) => - s_forkVersionMap.TryGetValue((fork, resource, httpMethod), out int version) + _forkVersionMap.TryGetValue(new ForkVersionKey(fork, resource, httpMethod), out int version) ? version : null; - private sealed class ForkVersionKeyComparer : IEqualityComparer<(string Fork, string Resource, string Method)> + /// + /// Key for _forkVersionMap: fork name and resource compared case-insensitively; HTTP + /// method ordinally. + /// + private readonly record struct ForkVersionKey(string Fork, string Resource, string Method) { - public static readonly ForkVersionKeyComparer Instance = new(); - - public bool Equals((string Fork, string Resource, string Method) x, (string Fork, string Resource, string Method) y) => - string.Equals(x.Fork, y.Fork, StringComparison.OrdinalIgnoreCase) - && string.Equals(x.Resource, y.Resource, StringComparison.OrdinalIgnoreCase) - && string.Equals(x.Method, y.Method, StringComparison.Ordinal); + public bool Equals(ForkVersionKey other) => + string.Equals(Fork, other.Fork, StringComparison.OrdinalIgnoreCase) + && string.Equals(Resource, other.Resource, StringComparison.OrdinalIgnoreCase) + && string.Equals(Method, other.Method, StringComparison.Ordinal); - public int GetHashCode((string Fork, string Resource, string Method) obj) => + public override int GetHashCode() => HashCode.Combine( - StringComparer.OrdinalIgnoreCase.GetHashCode(obj.Fork), - StringComparer.OrdinalIgnoreCase.GetHashCode(obj.Resource), - obj.Method); + StringComparer.OrdinalIgnoreCase.GetHashCode(Fork), + StringComparer.OrdinalIgnoreCase.GetHashCode(Resource), + Method); } } diff --git a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszMiddleware.cs b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszMiddleware.cs index 7a76f6285d92..32af389f08cd 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszMiddleware.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszMiddleware.cs @@ -317,7 +317,7 @@ private static bool TryRoute(string path, out int version, out string? fork, ReadOnlySpan forkSpan = span[..nextSlash]; // SszRestPaths.SupportedForks uses OrdinalIgnoreCase, so no lowercasing needed. - if (!SszRestPaths.SupportedForks.GetAlternateLookup>().Contains(forkSpan)) + if (!SszRestPaths.SupportedForksSpanLookup.Contains(forkSpan)) { // Reject `/engine/v2/v/...` (looks like a version-only path with no fork) before // emitting an unsupported-fork error — let the caller produce a 404 instead. From 3ca5b97554b99b093ae09b98d6a66191d2a17fa3 Mon Sep 17 00:00:00 2001 From: "lukasz.rozmej" Date: Fri, 12 Jun 2026 14:54:50 +0200 Subject: [PATCH 17/35] address review: cache capabilities, inline-array cell, dedup - CapabilitiesSszHandler: build the response body once and reuse; ISpecProvider state is fixed at startup so the JSON doc is constant - SszBlobCell: switch from [StructLayout(Size=1024)] + MemoryMarshal + Unsafe.As to C# 12 [InlineArray(1024)] with [UnscopedRef] AsSpan - SszRestPaths._forkVersionMap: replace 36 (fork, resource, method) entries with a per-fork ForkVersions record (one row per fork); versions reference EngineApiVersions.* constants instead of magic ints - SszVersionDescriptors: dedup the 4 GetTimestamp bodies via a generic ISszPayloadAttributesWire constraint on FirstTimestamp - GetPayloadBodiesByRangeSszHandler: hoist the inline parse-and-400 blocks into a QueryParams.TryReadLong helper --- .../Handlers/CapabilitiesSszHandler.cs | 33 ++++- .../GetPayloadBodiesByRangeSszHandler.cs | 14 +- .../SszRest/Handlers/QueryParams.cs | 39 ++++++ .../SszRest/Handlers/SszRestPaths.cs | 128 +++++++++--------- .../SszRest/Handlers/SszVersionDescriptors.cs | 19 ++- .../SszRest/SszBlobCell.cs | 12 +- .../SszRest/SszWireTypes.cs | 18 ++- 7 files changed, 175 insertions(+), 88 deletions(-) create mode 100644 src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/QueryParams.cs diff --git a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/CapabilitiesSszHandler.cs b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/CapabilitiesSszHandler.cs index a8cbad3f1b51..c8b1e8457f7f 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/CapabilitiesSszHandler.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/CapabilitiesSszHandler.cs @@ -4,7 +4,9 @@ using System; using System.Buffers; using System.Collections.Generic; +using System.Text; using System.Text.Json; +using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.Http; using Nethermind.Core.Specs; @@ -15,13 +17,36 @@ namespace Nethermind.Merge.Plugin.SszRest.Handlers; /// Handles GET /engine/v2/capabilities, the HTTP/REST equivalent of /// engine_exchangeCapabilities. /// +/// +/// The response body is fully determined by state, which is +/// fixed for the lifetime of the EL. The body is built once on first request and reused. +/// public sealed class CapabilitiesSszHandler(ISpecProvider specProvider) : SszEndpointHandlerBase { + private byte[]? _cachedBody; + public override string HttpMethod => "GET"; public override string Resource => SszRestPaths.Capabilities; public override int? Version => null; - public override async Task HandleAsync(HttpContext ctx, int version, ReadOnlyMemory extra, ReadOnlySequence body) + public override Task HandleAsync(HttpContext ctx, int version, ReadOnlyMemory extra, ReadOnlySequence body) + { + byte[] cached = _cachedBody ?? InitializeCachedBody(); + ctx.Response.ContentType = "application/json"; + ctx.Response.StatusCode = StatusCodes.Status200OK; + ctx.Response.ContentLength = cached.Length; + return ctx.Response.Body.WriteAsync(cached, 0, cached.Length, ctx.RequestAborted); + } + + private byte[] InitializeCachedBody() + { + // Benign race: two threads may both build the body on first hit; whoever wins the + // CompareExchange wins the cache slot. Subsequent requests are lock-free. + byte[] built = BuildBody(specProvider); + return Interlocked.CompareExchange(ref _cachedBody, built, null) ?? built; + } + + private static byte[] BuildBody(ISpecProvider specProvider) { int timestampForkCount = ComputeTimestampForkCount(specProvider); @@ -41,9 +66,7 @@ public override async Task HandleAsync(HttpContext ctx, int version, ReadOnlyMem supportedForksJson = JsonSerializer.Serialize(forkSlice); } - ctx.Response.ContentType = "application/json"; - ctx.Response.StatusCode = StatusCodes.Status200OK; - await ctx.Response.WriteAsync($$""" + return Encoding.UTF8.GetBytes($$""" { "supported_forks": {{supportedForksJson}}, "fork_scoped_endpoints": ["payloads", "forkchoice", "bodies"], @@ -57,7 +80,7 @@ await ctx.Response.WriteAsync($$""" "payload.max_bytes": {{SszMiddleware.MaxBodySize}} } } - """, ctx.RequestAborted); + """); } private static int ComputeTimestampForkCount(ISpecProvider specProvider) diff --git a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/GetPayloadBodiesByRangeSszHandler.cs b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/GetPayloadBodiesByRangeSszHandler.cs index d7a55f0ba138..2f0c89d89d94 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/GetPayloadBodiesByRangeSszHandler.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/GetPayloadBodiesByRangeSszHandler.cs @@ -27,18 +27,16 @@ public sealed class GetPayloadBodiesByRangeSszHandler(IEngine public override async Task HandleAsync(HttpContext ctx, int v, ReadOnlyMemory extra, ReadOnlySequence body) { // body is empty for GET; parameters come from the query string. - if (!long.TryParse(ctx.Request.Query["from"], out long start) || start < 0) + if (!QueryParams.TryReadLong(ctx, "from", static n => n >= 0, + "must be a non-negative integer block number", out long start, out Task? error)) { - await WriteErrorAsync(ctx, StatusCodes.Status400BadRequest, - "Missing or invalid 'from' query parameter: must be a non-negative integer block number", - SszRestErrorCodes.InvalidRequest); + await error; return; } - if (!long.TryParse(ctx.Request.Query["count"], out long count) || count <= 0) + if (!QueryParams.TryReadLong(ctx, "count", static n => n > 0, + "must be a positive integer", out long count, out error)) { - await WriteErrorAsync(ctx, StatusCodes.Status400BadRequest, - "Missing or invalid 'count' query parameter: must be a positive integer", - SszRestErrorCodes.InvalidRequest); + await error; return; } if (count > SszRestLimits.MaxBodiesRequest) diff --git a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/QueryParams.cs b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/QueryParams.cs new file mode 100644 index 000000000000..a0941e940290 --- /dev/null +++ b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/QueryParams.cs @@ -0,0 +1,39 @@ +// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited +// SPDX-License-Identifier: LGPL-3.0-only + +using System; +using System.Diagnostics.CodeAnalysis; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; + +namespace Nethermind.Merge.Plugin.SszRest.Handlers; + +internal static class QueryParams +{ + /// + /// Reads a required query parameter named and + /// validates it with . On failure, + /// is set to a pending 400 invalid-request response that the caller MUST await. + /// + public static bool TryReadLong( + HttpContext ctx, + string name, + Func isValid, + string validationDescription, + out long value, + [NotNullWhen(false)] out Task? errorTask) + { + if (long.TryParse(ctx.Request.Query[name], out value) && isValid(value)) + { + errorTask = null; + return true; + } + + errorTask = SszEndpointHandlerBase.WriteErrorAsync( + ctx, + StatusCodes.Status400BadRequest, + $"Missing or invalid '{name}' query parameter: {validationDescription}", + SszRestErrorCodes.InvalidRequest); + return false; + } +} diff --git a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/SszRestPaths.cs b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/SszRestPaths.cs index 3f3527e2fe4f..f71f82f71bf7 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/SszRestPaths.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/SszRestPaths.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Frozen; using System.Collections.Generic; +using Nethermind.Consensus; namespace Nethermind.Merge.Plugin.SszRest.Handlers; @@ -93,70 +94,73 @@ public static int ForkOrdinal(string forkUrl) public const string GetV2PayloadBodiesByRange = "GET /engine/v2/" + Amsterdam + "/bodies"; public const string PostV4Blobs = "POST /engine/v2/blobs/v4"; - private static readonly FrozenDictionary _forkVersionMap = - new Dictionary - { - // newPayload (POST payloads) - [new(Paris, Payloads, "POST")] = 1, - [new(Shanghai, Payloads, "POST")] = 2, - [new(Cancun, Payloads, "POST")] = 3, - [new(Prague, Payloads, "POST")] = 4, - [new(Osaka, Payloads, "POST")] = 4, - [new(Amsterdam, Payloads, "POST")] = 5, - - // getPayload (GET payloads) - [new(Paris, Payloads, "GET")] = 1, - [new(Shanghai, Payloads, "GET")] = 2, - [new(Cancun, Payloads, "GET")] = 3, - [new(Prague, Payloads, "GET")] = 4, - [new(Osaka, Payloads, "GET")] = 5, - [new(Amsterdam, Payloads, "GET")] = 6, - - // forkchoiceUpdated (POST forkchoice) - [new(Paris, Forkchoice, "POST")] = 1, - [new(Shanghai, Forkchoice, "POST")] = 2, - [new(Cancun, Forkchoice, "POST")] = 3, - [new(Prague, Forkchoice, "POST")] = 3, - [new(Osaka, Forkchoice, "POST")] = 3, - [new(Amsterdam, Forkchoice, "POST")] = 4, - - // bodies/hash (POST) - [new(Paris, PayloadBodiesByHash, "POST")] = 1, - [new(Shanghai, PayloadBodiesByHash, "POST")] = 1, - [new(Cancun, PayloadBodiesByHash, "POST")] = 1, - [new(Prague, PayloadBodiesByHash, "POST")] = 1, - [new(Osaka, PayloadBodiesByHash, "POST")] = 1, - [new(Amsterdam, PayloadBodiesByHash, "POST")] = 2, - - // bodies (GET) - [new(Paris, PayloadBodiesByRange, "GET")] = 1, - [new(Shanghai, PayloadBodiesByRange, "GET")] = 1, - [new(Cancun, PayloadBodiesByRange, "GET")] = 1, - [new(Prague, PayloadBodiesByRange, "GET")] = 1, - [new(Osaka, PayloadBodiesByRange, "GET")] = 1, - [new(Amsterdam, PayloadBodiesByRange, "GET")] = 2, - }.ToFrozenDictionary(); - - public static int? MapForkToVersion(string fork, string resource, string httpMethod) => - _forkVersionMap.TryGetValue(new ForkVersionKey(fork, resource, httpMethod), out int version) - ? version - : null; - /// - /// Key for _forkVersionMap: fork name and resource compared case-insensitively; HTTP - /// method ordinally. + /// Per-fork engine method versions. Adding a new fork is one row. /// - private readonly record struct ForkVersionKey(string Fork, string Resource, string Method) + private readonly record struct ForkVersions( + int NewPayload, int GetPayload, int Forkchoice, int BodiesByHash, int BodiesByRange); + + private static readonly FrozenDictionary _forkVersions = + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + [Paris] = new( + NewPayload: EngineApiVersions.NewPayload.V1, + GetPayload: EngineApiVersions.GetPayload.V1, + Forkchoice: EngineApiVersions.Fcu.V1, + BodiesByHash: EngineApiVersions.PayloadBodiesByHash.V1, + BodiesByRange: EngineApiVersions.PayloadBodiesByRange.V1), + [Shanghai] = new( + NewPayload: EngineApiVersions.NewPayload.V2, + GetPayload: EngineApiVersions.GetPayload.V2, + Forkchoice: EngineApiVersions.Fcu.V2, + BodiesByHash: EngineApiVersions.PayloadBodiesByHash.V1, + BodiesByRange: EngineApiVersions.PayloadBodiesByRange.V1), + [Cancun] = new( + NewPayload: EngineApiVersions.NewPayload.V3, + GetPayload: EngineApiVersions.GetPayload.V3, + Forkchoice: EngineApiVersions.Fcu.V3, + BodiesByHash: EngineApiVersions.PayloadBodiesByHash.V1, + BodiesByRange: EngineApiVersions.PayloadBodiesByRange.V1), + [Prague] = new( + NewPayload: EngineApiVersions.NewPayload.V4, + GetPayload: EngineApiVersions.GetPayload.V4, + Forkchoice: EngineApiVersions.Fcu.V3, + BodiesByHash: EngineApiVersions.PayloadBodiesByHash.V1, + BodiesByRange: EngineApiVersions.PayloadBodiesByRange.V1), + [Osaka] = new( + NewPayload: EngineApiVersions.NewPayload.V4, + GetPayload: EngineApiVersions.GetPayload.V5, + Forkchoice: EngineApiVersions.Fcu.V3, + BodiesByHash: EngineApiVersions.PayloadBodiesByHash.V1, + BodiesByRange: EngineApiVersions.PayloadBodiesByRange.V1), + [Amsterdam] = new( + NewPayload: EngineApiVersions.NewPayload.V5, + GetPayload: EngineApiVersions.GetPayload.V6, + Forkchoice: EngineApiVersions.Fcu.V4, + BodiesByHash: EngineApiVersions.PayloadBodiesByHash.V2, + BodiesByRange: EngineApiVersions.PayloadBodiesByRange.V2), + }.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase); + + public static int? MapForkToVersion(string fork, string resource, string httpMethod) { - public bool Equals(ForkVersionKey other) => - string.Equals(Fork, other.Fork, StringComparison.OrdinalIgnoreCase) - && string.Equals(Resource, other.Resource, StringComparison.OrdinalIgnoreCase) - && string.Equals(Method, other.Method, StringComparison.Ordinal); - - public override int GetHashCode() => - HashCode.Combine( - StringComparer.OrdinalIgnoreCase.GetHashCode(Fork), - StringComparer.OrdinalIgnoreCase.GetHashCode(Resource), - Method); + if (!_forkVersions.TryGetValue(fork, out ForkVersions v)) return null; + + // Resource comparisons are case-insensitive to match the previous behaviour + // (URL segments are lowercase per spec, but routing accepts any case). + if (string.Equals(httpMethod, "POST", StringComparison.Ordinal)) + { + if (Eq(resource, Payloads)) return v.NewPayload; + if (Eq(resource, Forkchoice)) return v.Forkchoice; + if (Eq(resource, PayloadBodiesByHash)) return v.BodiesByHash; + } + else if (string.Equals(httpMethod, "GET", StringComparison.Ordinal)) + { + if (Eq(resource, Payloads)) return v.GetPayload; + if (Eq(resource, PayloadBodiesByRange)) return v.BodiesByRange; + } + + return null; + + static bool Eq(string a, string b) => string.Equals(a, b, StringComparison.OrdinalIgnoreCase); } } diff --git a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/SszVersionDescriptors.cs b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/SszVersionDescriptors.cs index 5016a6a5ff92..afe265f43d06 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/SszVersionDescriptors.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/SszVersionDescriptors.cs @@ -78,6 +78,17 @@ public interface IForkchoiceUpdatedVersion where TWire : struct, ISszCode static abstract ulong? GetTimestamp(in TWire wire); } +/// +/// Helpers shared by all ForkchoiceUpdated descriptors. +/// +internal static class ForkchoiceUpdatedHelpers +{ + /// First-element timestamp of an optional payload-attributes wire list, or null. + public static ulong? FirstTimestamp(TAttr[]? attrs) + where TAttr : struct, ISszPayloadAttributesWire + => attrs is { Length: > 0 } a ? a[0].Timestamp : null; +} + public readonly struct ForkchoiceUpdatedDescriptorV1 : IForkchoiceUpdatedVersion { public static int VersionNumber => EngineApiVersions.Fcu.V1; @@ -88,7 +99,7 @@ public static Task> Call(IEngineRpcModu return engine.engine_forkchoiceUpdatedV1(state, attrs); } public static ulong? GetTimestamp(in ForkchoiceUpdatedV1RequestWire wire) => - wire.PayloadAttributes is { Length: > 0 } a ? a[0].Timestamp : null; + ForkchoiceUpdatedHelpers.FirstTimestamp(wire.PayloadAttributes); } public readonly struct ForkchoiceUpdatedDescriptorV2 : IForkchoiceUpdatedVersion @@ -101,7 +112,7 @@ public static Task> Call(IEngineRpcModu return engine.engine_forkchoiceUpdatedV2(state, attrs); } public static ulong? GetTimestamp(in ForkchoiceUpdatedV2RequestWire wire) => - wire.PayloadAttributes is { Length: > 0 } a ? a[0].Timestamp : null; + ForkchoiceUpdatedHelpers.FirstTimestamp(wire.PayloadAttributes); } public readonly struct ForkchoiceUpdatedDescriptorV3 : IForkchoiceUpdatedVersion @@ -114,7 +125,7 @@ public static Task> Call(IEngineRpcModu return engine.engine_forkchoiceUpdatedV3(state, attrs); } public static ulong? GetTimestamp(in ForkchoiceUpdatedV3RequestWire wire) => - wire.PayloadAttributes is { Length: > 0 } a ? a[0].Timestamp : null; + ForkchoiceUpdatedHelpers.FirstTimestamp(wire.PayloadAttributes); } public readonly struct ForkchoiceUpdatedDescriptorV4 : IForkchoiceUpdatedVersion @@ -128,7 +139,7 @@ public static Task> Call(IEngineRpcModu return engine.engine_forkchoiceUpdatedV4(state, attrs, custody); } public static ulong? GetTimestamp(in ForkchoiceUpdatedRequestWire wire) => - wire.PayloadAttributes is { Length: > 0 } a ? a[0].Timestamp : null; + ForkchoiceUpdatedHelpers.FirstTimestamp(wire.PayloadAttributes); } public readonly struct GetPayloadDescriptorV1 : IGetPayloadVersion diff --git a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszBlobCell.cs b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszBlobCell.cs index bb691a465902..713c0a57b5ea 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszBlobCell.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszBlobCell.cs @@ -2,20 +2,22 @@ // SPDX-License-Identifier: LGPL-3.0-only using System; +using System.Diagnostics.CodeAnalysis; using System.IO; using System.Runtime.CompilerServices; -using System.Runtime.InteropServices; namespace Nethermind.Merge.Plugin.SszRest; /// /// Inline 1024-byte Blob Cell representation used by Engine API SSZ wire types. /// -[StructLayout(LayoutKind.Sequential, Size = 1024)] +[InlineArray(BlobCellLength)] public struct SszBlobCell { public const int BlobCellLength = 1024; + private byte _element0; + public static SszBlobCell FromSpan(ReadOnlySpan span) { if (span.Length != BlobCellLength) @@ -24,10 +26,10 @@ public static SszBlobCell FromSpan(ReadOnlySpan span) } SszBlobCell result = default; - span.CopyTo(MemoryMarshal.CreateSpan(ref Unsafe.As(ref result), BlobCellLength)); + span.CopyTo(result); return result; } - public ReadOnlySpan AsSpan() => - MemoryMarshal.CreateReadOnlySpan(ref Unsafe.As(ref Unsafe.AsRef(in this)), BlobCellLength); + [UnscopedRef] + public ReadOnlySpan AsSpan() => this; } diff --git a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszWireTypes.cs b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszWireTypes.cs index 5d2421e17ad8..ad4ca8200ecb 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszWireTypes.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszWireTypes.cs @@ -45,8 +45,18 @@ public partial struct ForkchoiceStateWire public Hash256 FinalizedBlockHash { get; set; } } +/// +/// Marker for the per-fork SSZ payload-attributes wire structs, exposing the field needed +/// by fork-routing logic (timestamp). Each PayloadAttributes*Wire already has the +/// Timestamp property; the interface lets generic helpers consume them uniformly. +/// +public interface ISszPayloadAttributesWire +{ + ulong Timestamp { get; } +} + [SszContainer] -public partial struct PayloadAttributesV1Wire +public partial struct PayloadAttributesV1Wire : ISszPayloadAttributesWire { public ulong Timestamp { get; set; } public Hash256 PrevRandao { get; set; } @@ -54,7 +64,7 @@ public partial struct PayloadAttributesV1Wire } [SszContainer] -public partial struct PayloadAttributesV2Wire +public partial struct PayloadAttributesV2Wire : ISszPayloadAttributesWire { public ulong Timestamp { get; set; } public Hash256 PrevRandao { get; set; } @@ -63,7 +73,7 @@ public partial struct PayloadAttributesV2Wire } [SszContainer] -public partial struct PayloadAttributesV3Wire +public partial struct PayloadAttributesV3Wire : ISszPayloadAttributesWire { public ulong Timestamp { get; set; } public Hash256 PrevRandao { get; set; } @@ -73,7 +83,7 @@ public partial struct PayloadAttributesV3Wire } [SszContainer] -public partial struct PayloadAttributesWire +public partial struct PayloadAttributesWire : ISszPayloadAttributesWire { public ulong Timestamp { get; set; } public Hash256 PrevRandao { get; set; } From e30f3c7902c4c376dfa88bf7d54aa4974cc33230 Mon Sep 17 00:00:00 2001 From: Dyslex7c Date: Fri, 12 Jun 2026 18:20:23 +0530 Subject: [PATCH 18/35] refactor: implement memory pooling for `engine_getBlobs` REST/RPC endpoints --- .../Data/BlobsV2DirectResponse.cs | 25 ++- .../Data/BlobsV4DirectResponse.cs | 160 ++++++++++++++++++ .../Handlers/GetBlobsHandlerV2.cs | 32 ++-- .../Handlers/GetBlobsHandlerV4.cs | 119 +++++++++---- .../Collections/BlobTxDistinctSortedPool.cs | 4 +- .../PersistentBlobTxDistinctSortedPool.cs | 6 +- src/Nethermind/Nethermind.TxPool/ITxPool.cs | 2 +- .../Nethermind.TxPool/NullTxPool.cs | 2 +- src/Nethermind/Nethermind.TxPool/TxPool.cs | 2 +- 9 files changed, 292 insertions(+), 60 deletions(-) create mode 100644 src/Nethermind/Nethermind.Merge.Plugin/Data/BlobsV4DirectResponse.cs diff --git a/src/Nethermind/Nethermind.Merge.Plugin/Data/BlobsV2DirectResponse.cs b/src/Nethermind/Nethermind.Merge.Plugin/Data/BlobsV2DirectResponse.cs index 0145547b7963..0385c4865aa7 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/Data/BlobsV2DirectResponse.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/Data/BlobsV2DirectResponse.cs @@ -9,26 +9,32 @@ using System.IO.Pipelines; using System.Threading; using System.Threading.Tasks; +using Nethermind.Core.Collections; using Nethermind.JsonRpc; using Nethermind.Serialization.Json; namespace Nethermind.Merge.Plugin.Data; /// Writes blob/proof V2 results directly into a . -public sealed class BlobsV2DirectResponse : IStreamableResult, IReadOnlyList +public sealed class BlobsV2DirectResponse : IStreamableResult, IReadOnlyList, IDisposable { - private readonly byte[]?[] _blobs; - private readonly ReadOnlyMemory[] _proofs; + private readonly ArrayPoolList _blobs; + private readonly ArrayPoolList> _proofs; private readonly int _count; - public BlobsV2DirectResponse(byte[]?[] blobs, ReadOnlyMemory[] proofs, int count) + public BlobsV2DirectResponse(ArrayPoolList blobs, ArrayPoolList> proofs, int count) { - Debug.Assert(count <= blobs.Length && count <= proofs.Length, "count must not exceed array lengths"); + Debug.Assert(count <= blobs.Count && count <= proofs.Count, "count must not exceed list lengths"); _blobs = blobs; _proofs = proofs; _count = count; } + public BlobsV2DirectResponse(byte[]?[] blobs, ReadOnlyMemory[] proofs, int count) + : this(new ArrayPoolList(blobs.Length, blobs), new ArrayPoolList>(proofs.Length, proofs), count) + { + } + public int Count => _count; public BlobAndProofV2? this[int index] @@ -41,7 +47,8 @@ public BlobAndProofV2? this[int index] } public ValueTask WriteToAsync(PipeWriter writer, CancellationToken cancellationToken) => - StreamableResultWriter.WriteArrayAsync(writer, _count, new ItemWriter(_blobs, _proofs), cancellationToken); + StreamableResultWriter.WriteArrayAsync(writer, _count, + new ItemWriter(_blobs.UnsafeGetInternalArray(), _proofs.UnsafeGetInternalArray()), cancellationToken); IEnumerator IEnumerable.GetEnumerator() { @@ -56,6 +63,12 @@ public ValueTask WriteToAsync(PipeWriter writer, CancellationToken cancellationT IEnumerator IEnumerable.GetEnumerator() => ((IEnumerable)this).GetEnumerator(); + public void Dispose() + { + _blobs.Dispose(); + _proofs.Dispose(); + } + private readonly struct ItemWriter(byte[]?[] blobs, ReadOnlyMemory[] proofsByBlob) : IJsonArrayItemWriter { public void WriteItem(PipeWriter writer, int index) diff --git a/src/Nethermind/Nethermind.Merge.Plugin/Data/BlobsV4DirectResponse.cs b/src/Nethermind/Nethermind.Merge.Plugin/Data/BlobsV4DirectResponse.cs new file mode 100644 index 000000000000..953f24cbaf17 --- /dev/null +++ b/src/Nethermind/Nethermind.Merge.Plugin/Data/BlobsV4DirectResponse.cs @@ -0,0 +1,160 @@ +// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited +// SPDX-License-Identifier: LGPL-3.0-only + +using System; +using System.Buffers; +using System.Collections; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO.Pipelines; +using System.Threading; +using System.Threading.Tasks; +using CkzgLib; +using Nethermind.Core.Collections; +using Nethermind.JsonRpc; +using Nethermind.Serialization.Json; + +namespace Nethermind.Merge.Plugin.Data; + +/// Writes blob-cells-and-proofs V4 results directly into a . +public sealed class BlobsV4DirectResponse : IStreamableResult, IReadOnlyList, IDisposable +{ + private readonly ArrayPoolList _blobs; + private readonly ArrayPoolList> _proofs; + private readonly BlobCellsAndProofs?[] _response; + private readonly int _count; + + public BlobsV4DirectResponse( + ArrayPoolList blobs, + ArrayPoolList> proofs, + BlobCellsAndProofs?[] response, + int count) + { + Debug.Assert(count <= blobs.Count && count <= proofs.Count && count <= response.Length, + "count must not exceed array lengths"); + _blobs = blobs; + _proofs = proofs; + _response = response; + _count = count; + } + + public int Count => _count; + + public BlobCellsAndProofs? this[int index] + { + get + { + ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual((uint)index, (uint)_count, nameof(index)); + return _response[index]; + } + } + + public ValueTask WriteToAsync(PipeWriter writer, CancellationToken cancellationToken) => + StreamableResultWriter.WriteArrayAsync(writer, _count, new ItemWriter(_response), cancellationToken); + + IEnumerator IEnumerable.GetEnumerator() + { + for (int i = 0; i < _count; i++) + { + yield return _response[i]; + } + } + + IEnumerator IEnumerable.GetEnumerator() => ((IEnumerable)this).GetEnumerator(); + + public void Dispose() + { + _blobs.Dispose(); + _proofs.Dispose(); + if (_response is not null) + { + for (int i = 0; i < _count; i++) + { + BlobCellsAndProofs? item = _response[i]; + if (item is not null && item.Available) + { + if (item.BlobCells is not null) + { + for (int j = 0; j < Ckzg.CellsPerExtBlob; j++) + { + byte[]? cell = item.BlobCells[j]; + if (cell is not null) + { + ArrayPool.Shared.Return(cell); + } + } + ArrayPool.Shared.Return(item.BlobCells, clearArray: true); + } + if (item.Proofs is not null) + { + for (int j = 0; j < Ckzg.CellsPerExtBlob; j++) + { + byte[]? proof = item.Proofs[j]; + if (proof is not null) + { + ArrayPool.Shared.Return(proof); + } + } + ArrayPool.Shared.Return(item.Proofs, clearArray: true); + } + } + } + ArrayPool.Shared.Return(_response, clearArray: true); + } + } + + private readonly struct ItemWriter(BlobCellsAndProofs?[] response) : IJsonArrayItemWriter + { + public void WriteItem(PipeWriter writer, int index) + { + BlobCellsAndProofs? item = response[index]; + if (item is null || !item.Available) + { + writer.Write("null"u8); + return; + } + + writer.Write("{\"available\":true,\"blobCells\":["u8); + + byte[]?[]? blobCells = item.BlobCells; + if (blobCells is not null) + { + for (int c = 0; c < Ckzg.CellsPerExtBlob; c++) + { + if (c > 0) writer.Write(","u8); + byte[]? cell = blobCells[c]; + if (cell is null) + { + writer.Write("null"u8); + } + else + { + HexWriter.WriteHexString(writer, cell.AsSpan(0, Ckzg.BytesPerCell), chunked: true); + } + } + } + + writer.Write("],\"proofs\":["u8); + + byte[]?[]? proofs = item.Proofs; + if (proofs is not null) + { + for (int p = 0; p < Ckzg.CellsPerExtBlob; p++) + { + if (p > 0) writer.Write(","u8); + byte[]? proof = proofs[p]; + if (proof is null) + { + writer.Write("null"u8); + } + else + { + HexWriter.WriteHexString(writer, proof.AsSpan(0, Ckzg.BytesPerProof), chunked: false); + } + } + } + + writer.Write("]}"u8); + } + } +} diff --git a/src/Nethermind/Nethermind.Merge.Plugin/Handlers/GetBlobsHandlerV2.cs b/src/Nethermind/Nethermind.Merge.Plugin/Handlers/GetBlobsHandlerV2.cs index 5ad5e6aaa249..eb1cf6aca304 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/Handlers/GetBlobsHandlerV2.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/Handlers/GetBlobsHandlerV2.cs @@ -2,6 +2,7 @@ // SPDX-License-Identifier: LGPL-3.0-only using System; +using Nethermind.Core.Collections; using System.Collections.Generic; using System.Threading.Tasks; using Nethermind.JsonRpc; @@ -27,20 +28,31 @@ public class GetBlobsHandlerV2(ITxPool txPool) : IAsyncHandler[] proofs = new ReadOnlyMemory[n]; - int count = txPool.TryGetBlobsAndProofsV1(request.BlobVersionedHashes, blobs, proofs); + ArrayPoolList blobs = new(n, n); + ArrayPoolList> proofs = new(n, n); + try + { + int count = txPool.TryGetBlobsAndProofsV1(request.BlobVersionedHashes, blobs.AsSpan(), proofs.AsSpan()); + + Metrics.GetBlobsRequestsInBlobpoolTotal += count; - Metrics.GetBlobsRequestsInBlobpoolTotal += count; + // quick fail if we don't have some blob (unless partial return is allowed) + if (!request.AllowPartialReturn && count != n) + { + blobs.Dispose(); + proofs.Dispose(); + return ReturnEmptyArray(); + } - // quick fail if we don't have some blob (unless partial return is allowed) - if (!request.AllowPartialReturn && count != n) + Metrics.GetBlobsRequestsSuccessTotal++; + return ResultWrapper?>.Success(new BlobsV2DirectResponse(blobs, proofs, n)); + } + catch { - return ReturnEmptyArray(); + blobs.Dispose(); + proofs.Dispose(); + throw; } - - Metrics.GetBlobsRequestsSuccessTotal++; - return ResultWrapper?>.Success(new BlobsV2DirectResponse(blobs, proofs, n)); } private Task?>> ReturnEmptyArray() diff --git a/src/Nethermind/Nethermind.Merge.Plugin/Handlers/GetBlobsHandlerV4.cs b/src/Nethermind/Nethermind.Merge.Plugin/Handlers/GetBlobsHandlerV4.cs index f61d1033decd..78d4ec5bf139 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/Handlers/GetBlobsHandlerV4.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/Handlers/GetBlobsHandlerV4.cs @@ -7,6 +7,7 @@ using System.Collections.Generic; using System.Threading.Tasks; using CkzgLib; +using Nethermind.Core.Collections; using Nethermind.Crypto; using Nethermind.JsonRpc; using Nethermind.Merge.Plugin.Data; @@ -17,7 +18,9 @@ namespace Nethermind.Merge.Plugin.Handlers; public class GetBlobsHandlerV4(ITxPool txPool) : IAsyncHandler?> { private const int MaxRequest = 128; - private const int CellsBufferSize = Ckzg.CellsPerExtBlob * Ckzg.BytesPerCell; + + private static readonly Task?>> NotFound = + Task.FromResult(ResultWrapper?>.Success(null)); public Task?>> HandleAsync(GetBlobsHandlerV4Request request) { @@ -30,19 +33,16 @@ public class GetBlobsHandlerV4(ITxPool txPool) : IAsyncHandler[] proofs = new ReadOnlyMemory[n]; - int count = txPool.TryGetBlobsAndProofsV1(request.BlobVersionedHashes, blobs, proofs); - - Metrics.GetBlobsRequestsInBlobpoolTotal += count; - - BlobCellsAndProofs?[] response = new BlobCellsAndProofs?[n]; - - // Reuse one large scratch buffer for ComputeCells across all blobs in this request. - byte[] cellsBuffer = ArrayPool.Shared.Rent(CellsBufferSize); + ArrayPoolList blobs = new(n, n); + ArrayPoolList> proofs = new(n, n); + BlobCellsAndProofs?[]? response = null; try { - Span cellsSpan = cellsBuffer.AsSpan(0, CellsBufferSize); + int count = txPool.TryGetBlobsAndProofsV1(request.BlobVersionedHashes, blobs.AsSpan(), proofs.AsSpan()); + + Metrics.GetBlobsRequestsInBlobpoolTotal += count; + + response = ArrayPool.Shared.Rent(n); for (int i = 0; i < n; i++) { @@ -53,40 +53,87 @@ public class GetBlobsHandlerV4(ITxPool txPool) : IAsyncHandler cellsBuffer = new(Ckzg.CellsPerExtBlob * Ckzg.BytesPerCell); + KzgPolynomialCommitments.ComputeCells(blob, cellsBuffer); + + byte[]?[] blobCells = ArrayPool.Shared.Rent(Ckzg.CellsPerExtBlob); + byte[]?[] cellProofs = ArrayPool.Shared.Rent(Ckzg.CellsPerExtBlob); - byte[]?[] blobCells = new byte[Ckzg.CellsPerExtBlob][]; - byte[]?[] cellProofs = new byte[Ckzg.CellsPerExtBlob][]; ReadOnlySpan blobProofs = proofs[i].Span; - for (int cellIdx = 0; cellIdx < Ckzg.CellsPerExtBlob; cellIdx++) + try { - if (!request.IndicesBitarray.Get(cellIdx)) continue; - - byte[] cell = new byte[Ckzg.BytesPerCell]; - cellsSpan.Slice(cellIdx * Ckzg.BytesPerCell, Ckzg.BytesPerCell).CopyTo(cell); - blobCells[cellIdx] = cell; - - byte[] cellProof = new byte[Ckzg.BytesPerProof]; - blobProofs[cellIdx].AsSpan(0, Ckzg.BytesPerProof).CopyTo(cellProof); - cellProofs[cellIdx] = cellProof; + for (int cellIdx = 0; cellIdx < Ckzg.CellsPerExtBlob; cellIdx++) + { + if (request.IndicesBitarray.Get(cellIdx)) + { + byte[] cell = ArrayPool.Shared.Rent(Ckzg.BytesPerCell); + cellsBuffer.Slice(cellIdx * Ckzg.BytesPerCell, Ckzg.BytesPerCell).CopyTo(cell); + blobCells[cellIdx] = cell; + + byte[] cellProof = ArrayPool.Shared.Rent(Ckzg.BytesPerProof); + blobProofs[cellIdx].AsSpan(0, Ckzg.BytesPerProof).CopyTo(cellProof); + cellProofs[cellIdx] = cellProof; + } + } + + response[i] = new BlobCellsAndProofs + { + Available = true, + BlobCells = blobCells, + Proofs = cellProofs + }; } - - response[i] = new BlobCellsAndProofs + catch { - Available = true, - BlobCells = blobCells, - Proofs = cellProofs - }; + for (int cellIdx = 0; cellIdx < Ckzg.CellsPerExtBlob; cellIdx++) + { + if (blobCells[cellIdx] is { } cell) ArrayPool.Shared.Return(cell); + if (cellProofs[cellIdx] is { } cellProof) ArrayPool.Shared.Return(cellProof); + } + ArrayPool.Shared.Return(blobCells, clearArray: true); + ArrayPool.Shared.Return(cellProofs, clearArray: true); + throw; + } } + + Metrics.GetBlobsRequestsSuccessTotal++; + return ResultWrapper?>.Success( + new BlobsV4DirectResponse(blobs, proofs, response, n)); } - finally + catch { - ArrayPool.Shared.Return(cellsBuffer); + blobs.Dispose(); + proofs.Dispose(); + if (response is not null) + { + for (int i = 0; i < n; i++) + { + BlobCellsAndProofs? item = response[i]; + if (item is not null && item.Available) + { + if (item.BlobCells is not null) + { + for (int cellIdx = 0; cellIdx < Ckzg.CellsPerExtBlob; cellIdx++) + { + if (item.BlobCells[cellIdx] is { } cell) ArrayPool.Shared.Return(cell); + } + ArrayPool.Shared.Return(item.BlobCells, clearArray: true); + } + if (item.Proofs is not null) + { + for (int cellIdx = 0; cellIdx < Ckzg.CellsPerExtBlob; cellIdx++) + { + if (item.Proofs[cellIdx] is { } cellProof) ArrayPool.Shared.Return(cellProof); + } + ArrayPool.Shared.Return(item.Proofs, clearArray: true); + } + } + } + ArrayPool.Shared.Return(response, clearArray: true); + } + throw; } - - Metrics.GetBlobsRequestsSuccessTotal++; - return ResultWrapper?>.Success(response); } } diff --git a/src/Nethermind/Nethermind.TxPool/Collections/BlobTxDistinctSortedPool.cs b/src/Nethermind/Nethermind.TxPool/Collections/BlobTxDistinctSortedPool.cs index ed59ba55d4f8..d194c63821cb 100644 --- a/src/Nethermind/Nethermind.TxPool/Collections/BlobTxDistinctSortedPool.cs +++ b/src/Nethermind/Nethermind.TxPool/Collections/BlobTxDistinctSortedPool.cs @@ -77,8 +77,8 @@ private bool TryGetBlobAndProof( public virtual int TryGetBlobsAndProofsV1( byte[][] requestedBlobVersionedHashes, - byte[]?[] blobs, - ReadOnlyMemory[] proofs) + Span blobs, + Span> proofs) { using McsLock.Disposable lockRelease = Lock.Acquire(); int found = 0; diff --git a/src/Nethermind/Nethermind.TxPool/Collections/PersistentBlobTxDistinctSortedPool.cs b/src/Nethermind/Nethermind.TxPool/Collections/PersistentBlobTxDistinctSortedPool.cs index 1cf14103c6e6..9b85e48f7d15 100644 --- a/src/Nethermind/Nethermind.TxPool/Collections/PersistentBlobTxDistinctSortedPool.cs +++ b/src/Nethermind/Nethermind.TxPool/Collections/PersistentBlobTxDistinctSortedPool.cs @@ -95,9 +95,9 @@ protected override bool TryGetValueNonLocked(ValueHash256 hash, [NotNullWhen(tru } public override int TryGetBlobsAndProofsV1( - byte[][] requestedBlobVersionedHashes, - byte[]?[] blobs, - ReadOnlyMemory[] proofs) + byte[][] requestedBlobVersionedHashes, + Span blobs, + Span> proofs) { int found = 0; int missCount = 0; diff --git a/src/Nethermind/Nethermind.TxPool/ITxPool.cs b/src/Nethermind/Nethermind.TxPool/ITxPool.cs index 18d24657306d..d0ed67939ad2 100644 --- a/src/Nethermind/Nethermind.TxPool/ITxPool.cs +++ b/src/Nethermind/Nethermind.TxPool/ITxPool.cs @@ -60,7 +60,7 @@ bool TryGetBlobAndProofV1(byte[] blobVersionedHash, [NotNullWhen(true)] out byte[]? blob, [NotNullWhen(true)] out byte[][]? cellProofs); int TryGetBlobsAndProofsV1(byte[][] requestedBlobVersionedHashes, - byte[]?[] blobs, ReadOnlyMemory[] proofs); + Span blobs, Span> proofs); UInt256 GetLatestPendingNonce(Address address); event EventHandler NewDiscovered; event EventHandler NewPending; diff --git a/src/Nethermind/Nethermind.TxPool/NullTxPool.cs b/src/Nethermind/Nethermind.TxPool/NullTxPool.cs index 0c5828873544..48ca506df620 100644 --- a/src/Nethermind/Nethermind.TxPool/NullTxPool.cs +++ b/src/Nethermind/Nethermind.TxPool/NullTxPool.cs @@ -86,7 +86,7 @@ public bool TryGetBlobAndProofV1(byte[] blobVersionedHash, } public int TryGetBlobsAndProofsV1(byte[][] requestedBlobVersionedHashes, - byte[]?[] blobs, ReadOnlyMemory[] proofs) => 0; + Span blobs, Span> proofs) => 0; public UInt256 GetLatestPendingNonce(Address address) => 0; diff --git a/src/Nethermind/Nethermind.TxPool/TxPool.cs b/src/Nethermind/Nethermind.TxPool/TxPool.cs index 84453d4d8f48..e8416d934a5a 100644 --- a/src/Nethermind/Nethermind.TxPool/TxPool.cs +++ b/src/Nethermind/Nethermind.TxPool/TxPool.cs @@ -229,7 +229,7 @@ public bool TryGetBlobAndProofV1(byte[] blobVersionedHash, => _blobTransactions.TryGetBlobAndProofV1(blobVersionedHash, out blob, out cellProofs); public int TryGetBlobsAndProofsV1(byte[][] requestedBlobVersionedHashes, - byte[]?[] blobs, ReadOnlyMemory[] proofs) + Span blobs, Span> proofs) => _blobTransactions.TryGetBlobsAndProofsV1(requestedBlobVersionedHashes, blobs, proofs); private void OnRemovedTx(object? sender, SortedPool.SortedPoolRemovedEventArgs args) => RemovePendingDelegations(args.Value); From 4439b5fa20e8b1c27787ec607cd3c1f1ae4cca41 Mon Sep 17 00:00:00 2001 From: "lukasz.rozmej" Date: Fri, 12 Jun 2026 15:13:55 +0200 Subject: [PATCH 19/35] simplify FCU URL-fork match to a single spec.Name comparison Replaces the TransitionActivations walk that counted distinct specs to derive a fork ordinal with a direct GetSpec + Name comparison: the spec that timestamp resolves to already carries its fork name. Removes the now-unused SszRestPaths.ForkOrdinal helper. --- .../Handlers/ForkchoiceUpdatedSszHandler.cs | 55 ++++--------------- .../SszRest/Handlers/SszRestPaths.cs | 10 ---- 2 files changed, 11 insertions(+), 54 deletions(-) diff --git a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/ForkchoiceUpdatedSszHandler.cs b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/ForkchoiceUpdatedSszHandler.cs index 6d5e55add361..8c1a894f90b1 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/ForkchoiceUpdatedSszHandler.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/ForkchoiceUpdatedSszHandler.cs @@ -30,56 +30,23 @@ public override async Task HandleAsync(HttpContext ctx, int version, ReadOnlyMem TWire.Decode(body, out TWire wire); ulong? timestamp = TVersion.GetTimestamp(wire); - if (timestamp.HasValue) + if (timestamp.HasValue + && ctx.Items.TryGetValue("SszRouteFork", out object? forkObj) + && forkObj is string urlFork) { - if (ctx.Items.TryGetValue("SszRouteFork", out object? forkObj) && forkObj is string urlFork) + // The fork the payload would activate (from its timestamp) must match the URL's + // {fork} segment — otherwise the CL is asking the wrong endpoint for this payload. + IReleaseSpec payloadSpec = specProvider.GetSpec(ForkActivation.TimestampOnly(timestamp.Value)); + if (!string.Equals(payloadSpec.Name, urlFork, StringComparison.OrdinalIgnoreCase)) { - if (!TimestampMatchesForkOrdinal(timestamp.Value, SszRestPaths.ForkOrdinal(urlFork))) - { - await WriteErrorAsync(ctx, StatusCodes.Status400BadRequest, - $"URL fork '{urlFork}' does not match the fork for timestamp {timestamp.Value}", - MergeErrorCodes.UnsupportedFork); - return; - } + await WriteErrorAsync(ctx, StatusCodes.Status400BadRequest, + $"URL fork '{urlFork}' does not match the fork for timestamp {timestamp.Value}", + MergeErrorCodes.UnsupportedFork); + return; } } ResultWrapper result = await TVersion.Call(engineModule, wire); await WriteSszResultAsync(ctx, result, SszCodec.EncodeForkchoiceUpdatedResponse); } - - private bool TimestampMatchesForkOrdinal(ulong timestamp, int urlForkOrdinal) - { - if (urlForkOrdinal < 0) - return false; - - IReleaseSpec payloadSpec = specProvider.GetSpec(ForkActivation.TimestampOnly(timestamp)); - - // Count distinct spec objects produced by consecutive timestamp-based TransitionActivations. - // The count at which payloadSpec first appears is the payload's fork ordinal in SupportedForksOrdered. - int payloadForkOrdinal = -1; - int ordinal = 0; - IReleaseSpec? lastSeen = null; - foreach (ForkActivation fa in specProvider.TransitionActivations) - { - // Skip block-number-only activations (pre-Merge); post-Merge forks use timestamps. - if (fa.Timestamp is null) - continue; - - IReleaseSpec s = specProvider.GetSpec(fa); - if (ReferenceEquals(s, lastSeen)) - continue; - - if (ReferenceEquals(s, payloadSpec)) - { - payloadForkOrdinal = ordinal; - break; - } - - lastSeen = s; - ordinal++; - } - - return payloadForkOrdinal == urlForkOrdinal; - } } diff --git a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/SszRestPaths.cs b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/SszRestPaths.cs index f71f82f71bf7..c257803fccf3 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/SszRestPaths.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/SszRestPaths.cs @@ -33,16 +33,6 @@ public static class SszRestPaths public static readonly IReadOnlyList SupportedForksOrdered = [Paris, Shanghai, Cancun, Prague, Osaka, Amsterdam]; - public static int ForkOrdinal(string forkUrl) - { - for (int i = 0; i < SupportedForksOrdered.Count; i++) - { - if (string.Equals(SupportedForksOrdered[i], forkUrl, StringComparison.OrdinalIgnoreCase)) - return i; - } - return -1; - } - public const string Payloads = "payloads"; public const string Forkchoice = "forkchoice"; From e4aa9489e1e638a9ff7de35350b8a242038f8e21 Mon Sep 17 00:00:00 2001 From: "lukasz.rozmej" Date: Fri, 12 Jun 2026 15:54:20 +0200 Subject: [PATCH 20/35] move engine-api versions onto NamedReleaseSpec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-fork engine API versions (newPayload, getPayload, forkchoice, bodies/hash, bodies/range) now live as properties on each fork's Nethermind.Specs.Forks.* class, set inside Apply() only when the value changes vs. the parent. Values flow forward through the spec inheritance chain so e.g. Cancun's bodies/hash version is V1 inherited from Shanghai without being respecified. Also: - Move EngineApiVersions.cs to Nethermind.Core so Nethermind.Specs can reference it (Specs cannot reference Consensus — circular). Namespace stays Nethermind.Consensus by intent; .editorconfig has an IDE0130 exemption for the file. - Derive EngineApiUrlSegment from `Name.ToLowerInvariant()` on NamedReleaseSpec — no manual wiring per fork. - SszRestPaths now uses a single Dictionary as its source of truth (insertion-order preserved); SupportedForks / SupportedForksOrdered / SupportedForksSpanLookup all derive from it. MapForkToVersion looks up the version directly on the spec instance, dropping the parallel 36-entry version table. - Simplify ForkchoiceUpdatedSszHandler URL-fork check by extracting it into a small helper, and use namespace alias `Forks = Nethermind.Specs.Forks` to avoid shadowing. --- .editorconfig | 5 + .../EngineApiVersions.cs | 4 + .../Handlers/ForkchoiceUpdatedSszHandler.cs | 34 ++++--- .../SszRest/Handlers/SszRestPaths.cs | 99 +++++++------------ .../Nethermind.Specs/Forks/15_Paris.cs | 5 + .../Nethermind.Specs/Forks/16_Shanghai.cs | 7 ++ .../Nethermind.Specs/Forks/17_Cancun.cs | 5 + .../Nethermind.Specs/Forks/18_Prague.cs | 5 + .../Nethermind.Specs/Forks/19_Osaka.cs | 6 ++ .../Nethermind.Specs/Forks/25_Amsterdam.cs | 6 ++ .../Forks/NamedReleaseSpec.cs | 27 +++++ 11 files changed, 128 insertions(+), 75 deletions(-) rename src/Nethermind/{Nethermind.Consensus => Nethermind.Core}/EngineApiVersions.cs (88%) diff --git a/.editorconfig b/.editorconfig index b73c69610fc3..d57dfae51abc 100644 --- a/.editorconfig +++ b/.editorconfig @@ -132,6 +132,11 @@ dotnet_diagnostic.IDE0130.severity = none [src/Nethermind/Nethermind.Abi/Abi{DefinitionConverter,ParameterConverter,Type}.cs] dotnet_diagnostic.IDE0130.severity = none +# IDE0130: EngineApiVersions lives in Nethermind.Core for dependency-direction reasons +# (Specs cannot reference Consensus) but the type stays in the Nethermind.Consensus namespace. +[src/Nethermind/Nethermind.Core/EngineApiVersions.cs] +dotnet_diagnostic.IDE0130.severity = none + # IDE0130: Rlp-side helpers intentionally exposed under Nethermind.Core* namespaces; # Eip7702 subfolder follows the project's flat-namespace convention. [src/Nethermind/Nethermind.Serialization.Rlp/{NettyBufferMemoryOwner,OwnedBlockBodies,Eip7702/AuthorizationTupleDecoder}.cs] diff --git a/src/Nethermind/Nethermind.Consensus/EngineApiVersions.cs b/src/Nethermind/Nethermind.Core/EngineApiVersions.cs similarity index 88% rename from src/Nethermind/Nethermind.Consensus/EngineApiVersions.cs rename to src/Nethermind/Nethermind.Core/EngineApiVersions.cs index 68fd4bc253ed..490eebbe2754 100644 --- a/src/Nethermind/Nethermind.Consensus/EngineApiVersions.cs +++ b/src/Nethermind/Nethermind.Core/EngineApiVersions.cs @@ -1,6 +1,10 @@ // SPDX-FileCopyrightText: 2023 Demerzel Solutions Limited // SPDX-License-Identifier: LGPL-3.0-only +// The type lives under Nethermind.Consensus by intent — engine API versions are a consensus-layer +// concept — but the file ships in Nethermind.Core so the lower Nethermind.Specs layer can reference +// it (Consensus → Blockchain → Specs makes the reverse direction circular). IDE0130 is suppressed +// for this file in .editorconfig. namespace Nethermind.Consensus; /// diff --git a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/ForkchoiceUpdatedSszHandler.cs b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/ForkchoiceUpdatedSszHandler.cs index 8c1a894f90b1..056c9b6d304c 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/ForkchoiceUpdatedSszHandler.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/ForkchoiceUpdatedSszHandler.cs @@ -29,24 +29,30 @@ public override async Task HandleAsync(HttpContext ctx, int version, ReadOnlyMem { TWire.Decode(body, out TWire wire); - ulong? timestamp = TVersion.GetTimestamp(wire); - if (timestamp.HasValue - && ctx.Items.TryGetValue("SszRouteFork", out object? forkObj) - && forkObj is string urlFork) + if (GetUrlForkMismatchMessage(ctx, TVersion.GetTimestamp(wire)) is { } mismatch) { - // The fork the payload would activate (from its timestamp) must match the URL's - // {fork} segment — otherwise the CL is asking the wrong endpoint for this payload. - IReleaseSpec payloadSpec = specProvider.GetSpec(ForkActivation.TimestampOnly(timestamp.Value)); - if (!string.Equals(payloadSpec.Name, urlFork, StringComparison.OrdinalIgnoreCase)) - { - await WriteErrorAsync(ctx, StatusCodes.Status400BadRequest, - $"URL fork '{urlFork}' does not match the fork for timestamp {timestamp.Value}", - MergeErrorCodes.UnsupportedFork); - return; - } + await WriteErrorAsync(ctx, StatusCodes.Status400BadRequest, mismatch, MergeErrorCodes.UnsupportedFork); + return; } ResultWrapper result = await TVersion.Call(engineModule, wire); await WriteSszResultAsync(ctx, result, SszCodec.EncodeForkchoiceUpdatedResponse); } + + /// + /// Returns an error message if the payload's timestamp fork doesn't match the URL {fork} + /// segment, or null when the check doesn't apply (no payload attributes / no route fork). + /// + private string? GetUrlForkMismatchMessage(HttpContext ctx, ulong? timestamp) + { + if (!timestamp.HasValue + || !ctx.Items.TryGetValue("SszRouteFork", out object? forkObj) + || forkObj is not string urlFork) + return null; + + IReleaseSpec payloadSpec = specProvider.GetSpec(ForkActivation.TimestampOnly(timestamp.Value)); + return string.Equals(payloadSpec.Name, urlFork, StringComparison.OrdinalIgnoreCase) + ? null + : $"URL fork '{urlFork}' does not match the fork for timestamp {timestamp.Value}"; + } } diff --git a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/SszRestPaths.cs b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/SszRestPaths.cs index c257803fccf3..c6505bdd34ef 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/SszRestPaths.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/SszRestPaths.cs @@ -4,7 +4,9 @@ using System; using System.Collections.Frozen; using System.Collections.Generic; -using Nethermind.Consensus; +// Namespace alias: SszRestPaths exports const strings named Paris/Shanghai/... that would otherwise +// shadow the matching Nethermind.Specs.Forks classes when accessed by their unqualified names. +using Forks = Nethermind.Specs.Forks; namespace Nethermind.Merge.Plugin.SszRest.Handlers; @@ -17,11 +19,30 @@ public static class SszRestPaths public const string Osaka = "osaka"; public const string Amsterdam = "amsterdam"; + /// + /// Single source of truth: URL fork segment → . Insertion order + /// (which preserves in modern .NET) gives chronological + /// fork order; all other fork-list views derive from this. + /// + /// + /// Each fork's per-method engine API versions are declared on its own + /// subclass and flow forward through the chain — only the + /// versions that *change* are declared per fork. + /// + private static readonly Dictionary _forkSpecByUrl = new(StringComparer.OrdinalIgnoreCase) + { + [Forks.Paris.Instance.EngineApiUrlSegment!] = Forks.Paris.Instance, + [Forks.Shanghai.Instance.EngineApiUrlSegment!] = Forks.Shanghai.Instance, + [Forks.Cancun.Instance.EngineApiUrlSegment!] = Forks.Cancun.Instance, + [Forks.Prague.Instance.EngineApiUrlSegment!] = Forks.Prague.Instance, + [Forks.Osaka.Instance.EngineApiUrlSegment!] = Forks.Osaka.Instance, + [Forks.Amsterdam.Instance.EngineApiUrlSegment!] = Forks.Amsterdam.Instance, + }; + + public static readonly IReadOnlyList SupportedForksOrdered = [.. _forkSpecByUrl.Keys]; + public static readonly FrozenSet SupportedForks = - new HashSet(StringComparer.OrdinalIgnoreCase) - { - Paris, Shanghai, Cancun, Prague, Osaka, Amsterdam - }.ToFrozenSet(StringComparer.OrdinalIgnoreCase); + SupportedForksOrdered.ToFrozenSet(StringComparer.OrdinalIgnoreCase); /// /// Cached alternate lookup for , @@ -30,9 +51,6 @@ public static class SszRestPaths public static readonly FrozenSet.AlternateLookup> SupportedForksSpanLookup = SupportedForks.GetAlternateLookup>(); - public static readonly IReadOnlyList SupportedForksOrdered = - [Paris, Shanghai, Cancun, Prague, Osaka, Amsterdam]; - public const string Payloads = "payloads"; public const string Forkchoice = "forkchoice"; @@ -85,68 +103,27 @@ public static class SszRestPaths public const string PostV4Blobs = "POST /engine/v2/blobs/v4"; /// - /// Per-fork engine method versions. Adding a new fork is one row. + /// Resolves the per-fork engine API method version for the given + /// + by looking it up on the fork's . + /// Each fork only declares the versions it changes vs. its parent; values flow forward through + /// the spec inheritance chain. /// - private readonly record struct ForkVersions( - int NewPayload, int GetPayload, int Forkchoice, int BodiesByHash, int BodiesByRange); - - private static readonly FrozenDictionary _forkVersions = - new Dictionary(StringComparer.OrdinalIgnoreCase) - { - [Paris] = new( - NewPayload: EngineApiVersions.NewPayload.V1, - GetPayload: EngineApiVersions.GetPayload.V1, - Forkchoice: EngineApiVersions.Fcu.V1, - BodiesByHash: EngineApiVersions.PayloadBodiesByHash.V1, - BodiesByRange: EngineApiVersions.PayloadBodiesByRange.V1), - [Shanghai] = new( - NewPayload: EngineApiVersions.NewPayload.V2, - GetPayload: EngineApiVersions.GetPayload.V2, - Forkchoice: EngineApiVersions.Fcu.V2, - BodiesByHash: EngineApiVersions.PayloadBodiesByHash.V1, - BodiesByRange: EngineApiVersions.PayloadBodiesByRange.V1), - [Cancun] = new( - NewPayload: EngineApiVersions.NewPayload.V3, - GetPayload: EngineApiVersions.GetPayload.V3, - Forkchoice: EngineApiVersions.Fcu.V3, - BodiesByHash: EngineApiVersions.PayloadBodiesByHash.V1, - BodiesByRange: EngineApiVersions.PayloadBodiesByRange.V1), - [Prague] = new( - NewPayload: EngineApiVersions.NewPayload.V4, - GetPayload: EngineApiVersions.GetPayload.V4, - Forkchoice: EngineApiVersions.Fcu.V3, - BodiesByHash: EngineApiVersions.PayloadBodiesByHash.V1, - BodiesByRange: EngineApiVersions.PayloadBodiesByRange.V1), - [Osaka] = new( - NewPayload: EngineApiVersions.NewPayload.V4, - GetPayload: EngineApiVersions.GetPayload.V5, - Forkchoice: EngineApiVersions.Fcu.V3, - BodiesByHash: EngineApiVersions.PayloadBodiesByHash.V1, - BodiesByRange: EngineApiVersions.PayloadBodiesByRange.V1), - [Amsterdam] = new( - NewPayload: EngineApiVersions.NewPayload.V5, - GetPayload: EngineApiVersions.GetPayload.V6, - Forkchoice: EngineApiVersions.Fcu.V4, - BodiesByHash: EngineApiVersions.PayloadBodiesByHash.V2, - BodiesByRange: EngineApiVersions.PayloadBodiesByRange.V2), - }.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase); - public static int? MapForkToVersion(string fork, string resource, string httpMethod) { - if (!_forkVersions.TryGetValue(fork, out ForkVersions v)) return null; + if (!_forkSpecByUrl.TryGetValue(fork, out Forks.NamedReleaseSpec? spec)) return null; - // Resource comparisons are case-insensitive to match the previous behaviour - // (URL segments are lowercase per spec, but routing accepts any case). + // Resource comparisons are case-insensitive (URL segments are lowercase per spec, + // but routing accepts any case). if (string.Equals(httpMethod, "POST", StringComparison.Ordinal)) { - if (Eq(resource, Payloads)) return v.NewPayload; - if (Eq(resource, Forkchoice)) return v.Forkchoice; - if (Eq(resource, PayloadBodiesByHash)) return v.BodiesByHash; + if (Eq(resource, Payloads)) return spec.EngineApiNewPayloadVersion; + if (Eq(resource, Forkchoice)) return spec.EngineApiForkchoiceVersion; + if (Eq(resource, PayloadBodiesByHash)) return spec.EngineApiPayloadBodiesByHashVersion; } else if (string.Equals(httpMethod, "GET", StringComparison.Ordinal)) { - if (Eq(resource, Payloads)) return v.GetPayload; - if (Eq(resource, PayloadBodiesByRange)) return v.BodiesByRange; + if (Eq(resource, Payloads)) return spec.EngineApiGetPayloadVersion; + if (Eq(resource, PayloadBodiesByRange)) return spec.EngineApiPayloadBodiesByRangeVersion; } return null; diff --git a/src/Nethermind/Nethermind.Specs/Forks/15_Paris.cs b/src/Nethermind/Nethermind.Specs/Forks/15_Paris.cs index 258c51962ad0..5a72b0391159 100644 --- a/src/Nethermind/Nethermind.Specs/Forks/15_Paris.cs +++ b/src/Nethermind/Nethermind.Specs/Forks/15_Paris.cs @@ -1,6 +1,8 @@ // SPDX-FileCopyrightText: 2024 Demerzel Solutions Limited // SPDX-License-Identifier: LGPL-3.0-only +using Nethermind.Consensus; + namespace Nethermind.Specs.Forks; public class Paris() : NamedReleaseSpec(GrayGlacier.Instance) @@ -9,6 +11,9 @@ public override void Apply(NamedReleaseSpec spec) { spec.Name = "Paris"; spec.IsPostMerge = true; + spec.EngineApiNewPayloadVersion = EngineApiVersions.NewPayload.V1; + spec.EngineApiGetPayloadVersion = EngineApiVersions.GetPayload.V1; + spec.EngineApiForkchoiceVersion = EngineApiVersions.Fcu.V1; } // Note: the EIP-3675 uncle ban lives on Shanghai, not here. MainnetSpecProvider's // GrayGlacier→Paris boundary is `< ParisBlockNumber`, so block 15537393 (the terminal PoW diff --git a/src/Nethermind/Nethermind.Specs/Forks/16_Shanghai.cs b/src/Nethermind/Nethermind.Specs/Forks/16_Shanghai.cs index cbea0fbe2041..c0d43706f76d 100644 --- a/src/Nethermind/Nethermind.Specs/Forks/16_Shanghai.cs +++ b/src/Nethermind/Nethermind.Specs/Forks/16_Shanghai.cs @@ -1,6 +1,8 @@ // SPDX-FileCopyrightText: 2022 Demerzel Solutions Limited // SPDX-License-Identifier: LGPL-3.0-only +using Nethermind.Consensus; + namespace Nethermind.Specs.Forks; public class Shanghai() : NamedReleaseSpec(Paris.Instance) @@ -17,5 +19,10 @@ public override void Apply(NamedReleaseSpec spec) // MainnetSpecProvider maps the terminal PoW block to Paris.Instance, so spec-gating at // Paris would reject a consensus-valid pre-merge block. Shanghai is unambiguously post-merge. spec.MaximumUncleCount = 0; + spec.EngineApiNewPayloadVersion = EngineApiVersions.NewPayload.V2; + spec.EngineApiGetPayloadVersion = EngineApiVersions.GetPayload.V2; + spec.EngineApiForkchoiceVersion = EngineApiVersions.Fcu.V2; + spec.EngineApiPayloadBodiesByHashVersion = EngineApiVersions.PayloadBodiesByHash.V1; + spec.EngineApiPayloadBodiesByRangeVersion = EngineApiVersions.PayloadBodiesByRange.V1; } } diff --git a/src/Nethermind/Nethermind.Specs/Forks/17_Cancun.cs b/src/Nethermind/Nethermind.Specs/Forks/17_Cancun.cs index 7d6d54252ce0..59a81e414ac8 100644 --- a/src/Nethermind/Nethermind.Specs/Forks/17_Cancun.cs +++ b/src/Nethermind/Nethermind.Specs/Forks/17_Cancun.cs @@ -1,6 +1,7 @@ // SPDX-FileCopyrightText: 2022 Demerzel Solutions Limited // SPDX-License-Identifier: LGPL-3.0-only +using Nethermind.Consensus; using Nethermind.Core; namespace Nethermind.Specs.Forks; @@ -20,5 +21,9 @@ public override void Apply(NamedReleaseSpec spec) spec.MaxBlobCount = 6; spec.TargetBlobCount = 3; spec.BlobBaseFeeUpdateFraction = 3338477; + spec.EngineApiNewPayloadVersion = EngineApiVersions.NewPayload.V3; + spec.EngineApiGetPayloadVersion = EngineApiVersions.GetPayload.V3; + spec.EngineApiForkchoiceVersion = EngineApiVersions.Fcu.V3; + // bodies/hash + bodies/range versions inherit V1 from Shanghai } } diff --git a/src/Nethermind/Nethermind.Specs/Forks/18_Prague.cs b/src/Nethermind/Nethermind.Specs/Forks/18_Prague.cs index b49babefe095..3bca75ad4ddf 100644 --- a/src/Nethermind/Nethermind.Specs/Forks/18_Prague.cs +++ b/src/Nethermind/Nethermind.Specs/Forks/18_Prague.cs @@ -1,6 +1,7 @@ // SPDX-FileCopyrightText: 2024 Demerzel Solutions Limited // SPDX-License-Identifier: LGPL-3.0-only +using Nethermind.Consensus; using Nethermind.Core; namespace Nethermind.Specs.Forks; @@ -22,5 +23,9 @@ public override void Apply(NamedReleaseSpec spec) spec.TargetBlobCount = 6; spec.BlobBaseFeeUpdateFraction = 5007716; spec.DepositContractAddress = Eip6110Constants.MainnetDepositContractAddress; + spec.EngineApiNewPayloadVersion = EngineApiVersions.NewPayload.V4; + spec.EngineApiGetPayloadVersion = EngineApiVersions.GetPayload.V4; + // forkchoice version inherits V3 from Cancun + // bodies versions inherit V1 from Shanghai } } diff --git a/src/Nethermind/Nethermind.Specs/Forks/19_Osaka.cs b/src/Nethermind/Nethermind.Specs/Forks/19_Osaka.cs index 09e6aa58d870..43703f19e418 100644 --- a/src/Nethermind/Nethermind.Specs/Forks/19_Osaka.cs +++ b/src/Nethermind/Nethermind.Specs/Forks/19_Osaka.cs @@ -1,6 +1,8 @@ // SPDX-FileCopyrightText: 2025 Demerzel Solutions Limited // SPDX-License-Identifier: LGPL-3.0-only +using Nethermind.Consensus; + namespace Nethermind.Specs.Forks; public class Osaka() : NamedReleaseSpec(Prague.Instance) @@ -16,5 +18,9 @@ public override void Apply(NamedReleaseSpec spec) spec.IsEip7934Enabled = true; spec.IsEip7939Enabled = true; spec.IsEip7951Enabled = true; + // newPayload version inherits V4 from Prague + spec.EngineApiGetPayloadVersion = EngineApiVersions.GetPayload.V5; + // forkchoice version inherits V3 from Cancun + // bodies versions inherit V1 from Shanghai } } diff --git a/src/Nethermind/Nethermind.Specs/Forks/25_Amsterdam.cs b/src/Nethermind/Nethermind.Specs/Forks/25_Amsterdam.cs index 11fafa37baf3..5be16e7b6997 100644 --- a/src/Nethermind/Nethermind.Specs/Forks/25_Amsterdam.cs +++ b/src/Nethermind/Nethermind.Specs/Forks/25_Amsterdam.cs @@ -1,6 +1,7 @@ // SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited // SPDX-License-Identifier: LGPL-3.0-only +using Nethermind.Consensus; using Nethermind.Core; using Nethermind.Core.Specs; @@ -21,6 +22,11 @@ public override void Apply(NamedReleaseSpec spec) spec.MaxCodeSize = CodeSizeConstants.MaxCodeSizeEip7954; spec.IsEip8024Enabled = true; spec.IsEip8037Enabled = true; + spec.EngineApiNewPayloadVersion = EngineApiVersions.NewPayload.V5; + spec.EngineApiGetPayloadVersion = EngineApiVersions.GetPayload.V6; + spec.EngineApiForkchoiceVersion = EngineApiVersions.Fcu.V4; + spec.EngineApiPayloadBodiesByHashVersion = EngineApiVersions.PayloadBodiesByHash.V2; + spec.EngineApiPayloadBodiesByRangeVersion = EngineApiVersions.PayloadBodiesByRange.V2; } public static IReleaseSpec NoEip8037Instance { get; } = new Amsterdam { IsEip8037Enabled = false }; diff --git a/src/Nethermind/Nethermind.Specs/Forks/NamedReleaseSpec.cs b/src/Nethermind/Nethermind.Specs/Forks/NamedReleaseSpec.cs index c5818440af65..fd0b048c6502 100644 --- a/src/Nethermind/Nethermind.Specs/Forks/NamedReleaseSpec.cs +++ b/src/Nethermind/Nethermind.Specs/Forks/NamedReleaseSpec.cs @@ -35,6 +35,33 @@ public abstract class NamedReleaseSpec : ReleaseSpec /// public bool IsPostMerge { get; set; } + /// + /// Engine API method versions surfaced by this fork. null on forks that don't expose the + /// corresponding endpoint (e.g. pre-merge forks have no engine_* at all; Paris has no + /// getPayloadBodies*). Set inside only when this fork *changes* the + /// value — values flow forward through the chain. + /// + public int? EngineApiNewPayloadVersion { get; set; } + + /// + public int? EngineApiGetPayloadVersion { get; set; } + + /// + public int? EngineApiForkchoiceVersion { get; set; } + + /// + public int? EngineApiPayloadBodiesByHashVersion { get; set; } + + /// + public int? EngineApiPayloadBodiesByRangeVersion { get; set; } + + /// + /// URL {fork} segment for the SSZ-REST Engine API surface — the lowercase fork class name + /// (e.g. "paris"). Whether the segment is actually routable is decided by the engine-API + /// layer's supported-fork set, not here. + /// + public string? EngineApiUrlSegment => Name?.ToLowerInvariant(); + protected NamedReleaseSpec(NamedReleaseSpec? parent) { Parent = parent; From 17908e3a6f5bacb4e7dfd294baf02269ed89a620 Mon Sep 17 00:00:00 2001 From: "lukasz.rozmej" Date: Fri, 12 Jun 2026 15:59:50 +0200 Subject: [PATCH 21/35] GetBlobsHandlerV4: clear pool-rented arrays to avoid stale-slot corruption MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ArrayPool.Rent returns arrays with stale references. The cleanup paths (inner catch, outer catch, and BlobsV4DirectResponse.Dispose) iterate the full rented length and call Return on every non-null entry — for slots the handler never wrote (an exception fired mid-loop, or the IndicesBitarray bit was 0), this returns arrays we don't own to the pool, leading to the same buffer being handed to two callers. Add three Array.Clear calls after the relevant Rents so unwritten slots are guaranteed null. --- .../Nethermind.Merge.Plugin/Handlers/GetBlobsHandlerV4.cs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/Nethermind/Nethermind.Merge.Plugin/Handlers/GetBlobsHandlerV4.cs b/src/Nethermind/Nethermind.Merge.Plugin/Handlers/GetBlobsHandlerV4.cs index 78d4ec5bf139..47e094eeb90c 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/Handlers/GetBlobsHandlerV4.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/Handlers/GetBlobsHandlerV4.cs @@ -43,6 +43,10 @@ public class GetBlobsHandlerV4(ITxPool txPool) : IAsyncHandler.Shared.Rent(n); + // ArrayPool.Rent returns arrays with stale references. The outer catch and + // BlobsV4DirectResponse.Dispose iterate response[0..n-1] and would otherwise + // return arrays from a prior caller to our pool, corrupting it. + Array.Clear(response, 0, n); for (int i = 0; i < n; i++) { @@ -58,6 +62,10 @@ public class GetBlobsHandlerV4(ITxPool txPool) : IAsyncHandler.Shared.Rent(Ckzg.CellsPerExtBlob); byte[]?[] cellProofs = ArrayPool.Shared.Rent(Ckzg.CellsPerExtBlob); + // Same rationale as for `response`: unfilled indices (where the bitarray bit is 0) + // would otherwise expose stale byte[] references to the cleanup paths. + Array.Clear(blobCells, 0, Ckzg.CellsPerExtBlob); + Array.Clear(cellProofs, 0, Ckzg.CellsPerExtBlob); ReadOnlySpan blobProofs = proofs[i].Span; From 24767605b8081a116f3ef70defa651a31506523f Mon Sep 17 00:00:00 2001 From: "lukasz.rozmej" Date: Fri, 12 Jun 2026 16:24:57 +0200 Subject: [PATCH 22/35] =?UTF-8?q?SszRestPaths:=20build=20fork=E2=86=92spec?= =?UTF-8?q?=20map=20by=20walking=20from=20Forks.Amsterdam?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the explicit per-fork dictionary initializer with a walk back through NamedReleaseSpec.Parent starting at Forks.Amsterdam.Instance. Forks that don't introduce engine-API version changes (BPO blob-parameter overrides) are filtered out. Add NamedReleaseSpecExtensions.IntroducesEngineApiChange as an extension method on NamedReleaseSpec — true iff the spec changes at least one engine-API method version vs. its Parent. --- .../SszRest/Handlers/SszRestPaths.cs | 41 +++++++++++-------- .../Forks/NamedReleaseSpec.cs | 15 +++++++ 2 files changed, 40 insertions(+), 16 deletions(-) diff --git a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/SszRestPaths.cs b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/SszRestPaths.cs index c6505bdd34ef..c4a84e6adf3d 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/SszRestPaths.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/SszRestPaths.cs @@ -4,8 +4,7 @@ using System; using System.Collections.Frozen; using System.Collections.Generic; -// Namespace alias: SszRestPaths exports const strings named Paris/Shanghai/... that would otherwise -// shadow the matching Nethermind.Specs.Forks classes when accessed by their unqualified names. +using Nethermind.Specs.Forks; using Forks = Nethermind.Specs.Forks; namespace Nethermind.Merge.Plugin.SszRest.Handlers; @@ -20,24 +19,34 @@ public static class SszRestPaths public const string Amsterdam = "amsterdam"; /// - /// Single source of truth: URL fork segment → . Insertion order - /// (which preserves in modern .NET) gives chronological - /// fork order; all other fork-list views derive from this. + /// Single source of truth: URL fork segment → . Built by + /// walking back from the latest fork via and + /// keeping every fork that introduces an engine-API method-version change vs. its parent. + /// BPO blob-parameter override forks inherit all engine-API versions and so are filtered out. /// /// - /// Each fork's per-method engine API versions are declared on its own - /// subclass and flow forward through the chain — only the - /// versions that *change* are declared per fork. + /// To add support for a new fork, add it as a with its + /// engine-API version overrides and update the latest argument here. /// - private static readonly Dictionary _forkSpecByUrl = new(StringComparer.OrdinalIgnoreCase) + private static readonly Dictionary _forkSpecByUrl = + BuildForkSpecsByUrl(Forks.Amsterdam.Instance); + + private static Dictionary BuildForkSpecsByUrl(Forks.NamedReleaseSpec latest) { - [Forks.Paris.Instance.EngineApiUrlSegment!] = Forks.Paris.Instance, - [Forks.Shanghai.Instance.EngineApiUrlSegment!] = Forks.Shanghai.Instance, - [Forks.Cancun.Instance.EngineApiUrlSegment!] = Forks.Cancun.Instance, - [Forks.Prague.Instance.EngineApiUrlSegment!] = Forks.Prague.Instance, - [Forks.Osaka.Instance.EngineApiUrlSegment!] = Forks.Osaka.Instance, - [Forks.Amsterdam.Instance.EngineApiUrlSegment!] = Forks.Amsterdam.Instance, - }; + // Stack reverses parent-chain order (Amsterdam → … → Paris becomes Paris → … → Amsterdam), + // so the resulting dictionary preserves chronological insertion order. + Stack ordered = new(); + for (Forks.NamedReleaseSpec? spec = latest; spec?.EngineApiNewPayloadVersion is not null; spec = spec.Parent) + { + if (spec.IntroducesEngineApiChange()) + ordered.Push(spec); + } + + Dictionary result = new(StringComparer.OrdinalIgnoreCase); + foreach (Forks.NamedReleaseSpec spec in ordered) + result[spec.EngineApiUrlSegment!] = spec; + return result; + } public static readonly IReadOnlyList SupportedForksOrdered = [.. _forkSpecByUrl.Keys]; diff --git a/src/Nethermind/Nethermind.Specs/Forks/NamedReleaseSpec.cs b/src/Nethermind/Nethermind.Specs/Forks/NamedReleaseSpec.cs index fd0b048c6502..6488a4c0ed94 100644 --- a/src/Nethermind/Nethermind.Specs/Forks/NamedReleaseSpec.cs +++ b/src/Nethermind/Nethermind.Specs/Forks/NamedReleaseSpec.cs @@ -97,3 +97,18 @@ public abstract class NamedReleaseSpec(NamedReleaseSpec? parent) : NamedR { public static NamedReleaseSpec Instance { get; } = new TSelf(); } + +public static class NamedReleaseSpecExtensions +{ + /// + /// True iff this fork changes at least one engine-API method version vs. its + /// . Used to filter forks that only tweak per-fork + /// constants (blob-parameter overrides etc.) and reuse the parent's engine-API surface. + /// + public static bool IntroducesEngineApiChange(this NamedReleaseSpec spec) => + spec.EngineApiNewPayloadVersion != spec.Parent?.EngineApiNewPayloadVersion + || spec.EngineApiGetPayloadVersion != spec.Parent?.EngineApiGetPayloadVersion + || spec.EngineApiForkchoiceVersion != spec.Parent?.EngineApiForkchoiceVersion + || spec.EngineApiPayloadBodiesByHashVersion != spec.Parent?.EngineApiPayloadBodiesByHashVersion + || spec.EngineApiPayloadBodiesByRangeVersion != spec.Parent?.EngineApiPayloadBodiesByRangeVersion; +} From b2935ab86a8980cbd54d252ea849929a9096acbf Mon Sep 17 00:00:00 2001 From: "lukasz.rozmej" Date: Fri, 12 Jun 2026 17:16:42 +0200 Subject: [PATCH 23/35] EngineApiVersions: move to Nethermind.Core namespace; SszRestPaths: drop URL consts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The EngineApiVersions type was sitting in Nethermind.Consensus namespace but shipping in Nethermind.Core for dependency reasons; an .editorconfig exemption suppressed IDE0130. Move it to Nethermind.Core namespace properly and drop the exemption — 13 consumers gain a `using Nethermind.Core;` (or already had one) and lose the now-redundant `using Nethermind.Consensus;`. Also drop the public const Paris/Shanghai/.../Amsterdam strings from SszRestPaths — they were hardcoded duplicates of NamedReleaseSpec.Name + ToLowerInvariant. The route documentation strings (PostV1Payloads, etc.) become static readonly, built from per-fork private helpers (_paris, _shanghai, …) that read EngineApiUrlSegment off the fork singletons. SszMiddlewareTests converts the affected `[TestCase]` attributes (which needed compile-time string concatenation) to `[TestCaseSource]` with arrays referencing the fork instances; inline `$"…"` interpolations use the test-class-local *Url helper fields. --- .editorconfig | 5 - .../Nethermind.Core/EngineApiVersions.cs | 6 +- .../SszRest/SszMiddlewareTests.cs | 94 +++++++++++++------ .../EngineRpcModule.Amsterdam.cs | 2 +- .../EngineRpcModule.Cancun.cs | 2 +- .../EngineRpcModule.Paris.cs | 2 +- .../EngineRpcModule.Prague.cs | 2 +- .../EngineRpcModule.Shanghai.cs | 2 +- .../Handlers/GetPayloadV1Handler.cs | 2 +- .../Handlers/GetPayloadV2Handler.cs | 2 +- .../Handlers/GetPayloadV3Handler.cs | 2 +- .../Handlers/GetPayloadV4Handler.cs | 2 +- .../Handlers/GetPayloadV5Handler.cs | 2 +- .../Handlers/GetPayloadV6Handler.cs | 2 +- .../SszRest/Handlers/GetBlobsSszHandler.cs | 2 +- .../SszRest/Handlers/SszRestPaths.cs | 61 ++++++------ .../SszRest/Handlers/SszVersionDescriptors.cs | 2 +- .../Nethermind.Specs/Forks/15_Paris.cs | 2 +- .../Nethermind.Specs/Forks/16_Shanghai.cs | 2 +- .../Nethermind.Specs/Forks/17_Cancun.cs | 1 - .../Nethermind.Specs/Forks/18_Prague.cs | 1 - .../Nethermind.Specs/Forks/19_Osaka.cs | 2 +- .../Nethermind.Specs/Forks/25_Amsterdam.cs | 1 - 23 files changed, 111 insertions(+), 90 deletions(-) diff --git a/.editorconfig b/.editorconfig index d57dfae51abc..b73c69610fc3 100644 --- a/.editorconfig +++ b/.editorconfig @@ -132,11 +132,6 @@ dotnet_diagnostic.IDE0130.severity = none [src/Nethermind/Nethermind.Abi/Abi{DefinitionConverter,ParameterConverter,Type}.cs] dotnet_diagnostic.IDE0130.severity = none -# IDE0130: EngineApiVersions lives in Nethermind.Core for dependency-direction reasons -# (Specs cannot reference Consensus) but the type stays in the Nethermind.Consensus namespace. -[src/Nethermind/Nethermind.Core/EngineApiVersions.cs] -dotnet_diagnostic.IDE0130.severity = none - # IDE0130: Rlp-side helpers intentionally exposed under Nethermind.Core* namespaces; # Eip7702 subfolder follows the project's flat-namespace convention. [src/Nethermind/Nethermind.Serialization.Rlp/{NettyBufferMemoryOwner,OwnedBlockBodies,Eip7702/AuthorizationTupleDecoder}.cs] diff --git a/src/Nethermind/Nethermind.Core/EngineApiVersions.cs b/src/Nethermind/Nethermind.Core/EngineApiVersions.cs index 490eebbe2754..a7f17cb290a1 100644 --- a/src/Nethermind/Nethermind.Core/EngineApiVersions.cs +++ b/src/Nethermind/Nethermind.Core/EngineApiVersions.cs @@ -1,11 +1,7 @@ // SPDX-FileCopyrightText: 2023 Demerzel Solutions Limited // SPDX-License-Identifier: LGPL-3.0-only -// The type lives under Nethermind.Consensus by intent — engine API versions are a consensus-layer -// concept — but the file ships in Nethermind.Core so the lower Nethermind.Specs layer can reference -// it (Consensus → Blockchain → Specs makes the reverse direction circular). IDE0130 is suppressed -// for this file in .editorconfig. -namespace Nethermind.Consensus; +namespace Nethermind.Core; /// /// Engine API method version constants, grouped by method. diff --git a/src/Nethermind/Nethermind.Merge.Plugin.Test/SszRest/SszMiddlewareTests.cs b/src/Nethermind/Nethermind.Merge.Plugin.Test/SszRest/SszMiddlewareTests.cs index d977c7111639..655af1e79d2f 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin.Test/SszRest/SszMiddlewareTests.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin.Test/SszRest/SszMiddlewareTests.cs @@ -22,6 +22,7 @@ using Nethermind.Merge.Plugin.Data; using Nethermind.Merge.Plugin.SszRest; using Nethermind.Merge.Plugin.SszRest.Handlers; +using Nethermind.Specs.Forks; using System.Linq; using NSubstitute; using NSubstitute.Core; @@ -47,6 +48,11 @@ public class SszMiddlewareTests ".eyJpYXQiOjE2NDQ5OTQ5NzF9" + ".RmIbZajyYGF9fhAq7A9YrTetdf15ebHIJiSdAhX7PME"; + private static readonly string ParisUrl = Paris.Instance.EngineApiUrlSegment!; + private static readonly string ShanghaiUrl = Shanghai.Instance.EngineApiUrlSegment!; + private static readonly string CancunUrl = Cancun.Instance.EngineApiUrlSegment!; + private static readonly string AmsterdamUrl = Amsterdam.Instance.EngineApiUrlSegment!; + [SetUp] public void SetUp() { @@ -157,8 +163,13 @@ private static byte[] EncodeToBytes(T value, Func, int return w.WrittenSpan.ToArray(); } - [TestCase(1, "/engine/v2/" + SszRestPaths.Paris + "/payloads")] - [TestCase(2, "/engine/v2/" + SszRestPaths.Shanghai + "/payloads")] + private static readonly object[] s_newPayloadRoutingCases = + [ + new object[] { 1, $"/engine/v2/{ParisUrl}/payloads" }, + new object[] { 2, $"/engine/v2/{ShanghaiUrl}/payloads" }, + ]; + + [TestCaseSource(nameof(s_newPayloadRoutingCases))] public async Task NewPayload_routes_to_correct_engine_module_version(int version, string path) { PayloadStatusV1 status = new() { Status = PayloadStatus.Valid, LatestValidHash = TestItem.KeccakA }; @@ -178,8 +189,13 @@ public async Task NewPayload_routes_to_correct_engine_module_version(int version await _engineModule.Received(version == 2 ? 1 : 0).engine_newPayloadV2(Arg.Any()); } - [TestCase(1, "/engine/v2/" + SszRestPaths.Paris + "/payloads/0x0102030405060708")] - [TestCase(2, "/engine/v2/" + SszRestPaths.Shanghai + "/payloads/0x0102030405060708")] + private static readonly object[] s_getPayloadRoutingCases = + [ + new object[] { 1, $"/engine/v2/{ParisUrl}/payloads/0x0102030405060708" }, + new object[] { 2, $"/engine/v2/{ShanghaiUrl}/payloads/0x0102030405060708" }, + ]; + + [TestCaseSource(nameof(s_getPayloadRoutingCases))] public async Task GetPayload_routes_to_correct_handler_with_no_store_header(int version, string path) { _engineModule.engine_getPayloadV1(Arg.Any()) @@ -197,10 +213,15 @@ public async Task GetPayload_routes_to_correct_handler_with_no_store_header(int await _engineModule.Received(version == 2 ? 1 : 0).engine_getPayloadV2(Arg.Any()); } - [TestCase("/engine/v2/" + SszRestPaths.Paris + "/forkchoice", 1)] - [TestCase("/engine/v2/" + SszRestPaths.Shanghai + "/forkchoice", 2)] - [TestCase("/engine/v2/" + SszRestPaths.Cancun + "/forkchoice", 3)] - [TestCase("/engine/v2/" + SszRestPaths.Amsterdam + "/forkchoice", 4)] + private static readonly object[] s_forkchoiceRoutingCases = + [ + new object[] { $"/engine/v2/{ParisUrl}/forkchoice", 1 }, + new object[] { $"/engine/v2/{ShanghaiUrl}/forkchoice", 2 }, + new object[] { $"/engine/v2/{CancunUrl}/forkchoice", 3 }, + new object[] { $"/engine/v2/{AmsterdamUrl}/forkchoice", 4 }, + ]; + + [TestCaseSource(nameof(s_forkchoiceRoutingCases))] public async Task Forkchoice_calls_correct_engine_module_version(string path, int version) { ForkchoiceUpdatedV1Result fcuResult = new() @@ -291,8 +312,13 @@ public async Task GetBlobsV4_routes_to_engine_getBlobsV4() await _engineModule.Received(1).engine_getBlobsV4(Arg.Any(), Arg.Any()); } - [TestCase(1, "/engine/v2/" + SszRestPaths.Shanghai + "/bodies/hash")] - [TestCase(2, "/engine/v2/" + SszRestPaths.Amsterdam + "/bodies/hash")] + private static readonly object[] s_bodiesByHashRoutingCases = + [ + new object[] { 1, $"/engine/v2/{ShanghaiUrl}/bodies/hash" }, + new object[] { 2, $"/engine/v2/{AmsterdamUrl}/bodies/hash" }, + ]; + + [TestCaseSource(nameof(s_bodiesByHashRoutingCases))] public async Task GetPayloadBodiesByHash_routes_to_correct_engine_method(int version, string path) { _engineModule.engine_getPayloadBodiesByHashV1(Arg.Any>()) @@ -312,8 +338,13 @@ public async Task GetPayloadBodiesByHash_routes_to_correct_engine_method(int ver await _engineModule.Received(version == 2 ? 1 : 0).engine_getPayloadBodiesByHashV2(Arg.Any>()); } - [TestCase(1, "/engine/v2/" + SszRestPaths.Shanghai + "/bodies")] - [TestCase(2, "/engine/v2/" + SszRestPaths.Amsterdam + "/bodies")] + private static readonly object[] s_bodiesByRangeRoutingCases = + [ + new object[] { 1, $"/engine/v2/{ShanghaiUrl}/bodies" }, + new object[] { 2, $"/engine/v2/{AmsterdamUrl}/bodies" }, + ]; + + [TestCaseSource(nameof(s_bodiesByRangeRoutingCases))] public async Task GetPayloadBodiesByRange_routes_to_correct_engine_method_with_correct_args(int version, string path) { const long expectedStart = 7; @@ -412,7 +443,7 @@ public async Task Authentication_failure_returns_401_and_does_not_call_engine_mo _auth.Authenticate(Arg.Any()).Returns(false); byte[] body = BuildMinimalV1NewPayloadRequest(); - DefaultHttpContext ctx = MakePostContext($"/engine/v2/{SszRestPaths.Paris}/payloads", body); + DefaultHttpContext ctx = MakePostContext($"/engine/v2/{ParisUrl}/payloads", body); await _middleware.InvokeAsync(ctx); @@ -423,7 +454,7 @@ public async Task Authentication_failure_returns_401_and_does_not_call_engine_mo [Test] public async Task Oversized_body_returns_413_without_calling_engine_module() { - DefaultHttpContext ctx = MakePostContext($"/engine/v2/{SszRestPaths.Paris}/payloads", []); + DefaultHttpContext ctx = MakePostContext($"/engine/v2/{ParisUrl}/payloads", []); ctx.Request.ContentLength = SszMiddleware.MaxBodySize + 1; ctx.Request.Body = new MemoryStream(new byte[1]); @@ -438,7 +469,7 @@ public async Task Unknown_engine_path_returns_404_without_delegating_to_next() { bool nextInvoked = false; SszMiddleware mw = BuildMiddleware(_ => { nextInvoked = true; return Task.CompletedTask; }); - DefaultHttpContext ctx = MakePostContext($"/engine/v2/{SszRestPaths.Paris}/unknown-resource", []); + DefaultHttpContext ctx = MakePostContext($"/engine/v2/{ParisUrl}/unknown-resource", []); await mw.InvokeAsync(ctx); @@ -452,7 +483,7 @@ public async Task Unknown_engine_path_returns_404_without_delegating_to_next() [TestCase("/payloads//abc", TestName = "Consecutive_slashes_404")] public async Task POST_with_malformed_fork_path_returns_404(string suffix) { - DefaultHttpContext ctx = MakePostContext($"/engine/v2/{SszRestPaths.Paris}{suffix}", []); + DefaultHttpContext ctx = MakePostContext($"/engine/v2/{ParisUrl}{suffix}", []); await _middleware.InvokeAsync(ctx); @@ -466,7 +497,7 @@ public async Task Malformed_ssz_body_returns_400_without_propagating_exception() byte[] garbage = new byte[64]; new Random(42).NextBytes(garbage); - DefaultHttpContext ctx = MakePostContext($"/engine/v2/{SszRestPaths.Paris}/payloads", garbage); + DefaultHttpContext ctx = MakePostContext($"/engine/v2/{ParisUrl}/payloads", garbage); Func act = () => _middleware.InvokeAsync(ctx); @@ -479,7 +510,7 @@ public async Task Malformed_ssz_body_returns_400_without_propagating_exception() public async Task Truncated_body_with_overstated_content_length_returns_400() { byte[] body = new byte[16]; - DefaultHttpContext ctx = MakePostContext($"/engine/v2/{SszRestPaths.Paris}/payloads", body); + DefaultHttpContext ctx = MakePostContext($"/engine/v2/{ParisUrl}/payloads", body); // Declare more bytes than the stream will deliver — ReadAtLeastAsync returns short. ctx.Request.ContentLength = body.Length + 64; @@ -525,7 +556,7 @@ public async Task Server_error_skips_WriteError_when_request_already_aborted() _engineModule.engine_newPayloadV1(Arg.Any()) .Returns>>(_ => throw new InvalidOperationException("simulated server error")); - DefaultHttpContext ctx = MakePostContext($"/engine/v2/{SszRestPaths.Paris}/payloads", BuildMinimalV1NewPayloadRequest()); + DefaultHttpContext ctx = MakePostContext($"/engine/v2/{ParisUrl}/payloads", BuildMinimalV1NewPayloadRequest()); // Simulate the encode-failure → ctx.Abort() effect by pre-cancelling RequestAborted. // DefaultHttpContext's Abort() is a no-op without a real lifetime feature, so we @@ -553,7 +584,7 @@ public async Task Encoder_returning_zero_length_for_non_null_data_yields_204() SszMiddleware middleware = new( _ => Task.CompletedTask, _urlCollection, _auth, [handler], _processExitSource, LimboLogs.Instance); - DefaultHttpContext ctx = MakePostContext($"/engine/v2/{SszRestPaths.Paris}/{ZeroLengthEncodeHandler.ResourceName}", []); + DefaultHttpContext ctx = MakePostContext($"/engine/v2/{ParisUrl}/{ZeroLengthEncodeHandler.ResourceName}", []); await middleware.InvokeAsync(ctx); @@ -745,7 +776,7 @@ public async Task Forkchoice_unsupported_fork_returns_400() }; byte[] body = ForkchoiceUpdatedV3RequestWire.Encode(request); - DefaultHttpContext ctx = MakePostContext($"/engine/v2/{SszRestPaths.Cancun}/forkchoice", body); + DefaultHttpContext ctx = MakePostContext($"/engine/v2/{CancunUrl}/forkchoice", body); await _middleware.InvokeAsync(ctx); @@ -776,7 +807,7 @@ public async Task Forkchoice_stale_fork_url_without_attributes_is_allowed() }; byte[] body = ForkchoiceUpdatedV3RequestWire.Encode(request); - DefaultHttpContext ctx = MakePostContext($"/engine/v2/{SszRestPaths.Cancun}/forkchoice", body); + DefaultHttpContext ctx = MakePostContext($"/engine/v2/{CancunUrl}/forkchoice", body); await _middleware.InvokeAsync(ctx); @@ -823,9 +854,14 @@ public async Task Identity_returns_200_json_regardless_of_Accept_header(string a // Trailing slashes and unknown extra path segments must both 404 — spec forbids trailing slashes // and handlers without AcceptsPathExtra must reject stray segments. - [TestCase("POST", "/engine/v2/" + SszRestPaths.Cancun + "/forkchoice/", true, TestName = "POST_trailing_slash_fork_scoped_404")] - [TestCase("GET", "/engine/v2/capabilities/", false, TestName = "GET_trailing_slash_diagnostic_404")] - [TestCase("POST", "/engine/v2/" + SszRestPaths.Cancun + "/forkchoice/whatever", false, TestName = "POST_unknown_extra_on_forkchoice_404")] + private static readonly object[] s_malformedPathCases = + [ + new object[] { "POST", $"/engine/v2/{CancunUrl}/forkchoice/", true }, + new object[] { "GET", "/engine/v2/capabilities/", false }, + new object[] { "POST", $"/engine/v2/{CancunUrl}/forkchoice/whatever", false }, + ]; + + [TestCaseSource(nameof(s_malformedPathCases))] public async Task Malformed_or_trailing_path_returns_404(string method, string path, bool assertMethodNotFoundBody) { DefaultHttpContext ctx = method == "POST" @@ -872,7 +908,7 @@ public async Task Unknown_blob_version_returns_404() [Test] public async Task Invalid_payload_id_in_path_returns_400() { - DefaultHttpContext ctx = MakeGetContext($"/engine/v2/{SszRestPaths.Paris}/payloads/0xZZZZZZZZZZZZZZZZ"); + DefaultHttpContext ctx = MakeGetContext($"/engine/v2/{ParisUrl}/payloads/0xZZZZZZZZZZZZZZZZ"); await _middleware.InvokeAsync(ctx); @@ -882,7 +918,7 @@ public async Task Invalid_payload_id_in_path_returns_400() [Test] public async Task GetPayloadBodiesByRange_over_limit_returns_413_request_too_large() { - DefaultHttpContext ctx = MakeGetContext($"/engine/v2/{SszRestPaths.Shanghai}/bodies"); + DefaultHttpContext ctx = MakeGetContext($"/engine/v2/{ShanghaiUrl}/bodies"); ctx.Request.QueryString = new QueryString("?from=1&count=1000"); await _middleware.InvokeAsync(ctx); @@ -898,7 +934,7 @@ public async Task GetPayloadBodiesByRange_from_zero_is_valid() _engineModule.engine_getPayloadBodiesByRangeV1(Arg.Any(), Arg.Any()) .Returns(ResultWrapper>.Success([])); - DefaultHttpContext ctx = MakeGetContext($"/engine/v2/{SszRestPaths.Shanghai}/bodies"); + DefaultHttpContext ctx = MakeGetContext($"/engine/v2/{ShanghaiUrl}/bodies"); ctx.Request.QueryString = new QueryString("?from=0&count=1"); await _middleware.InvokeAsync(ctx); @@ -912,7 +948,7 @@ public async Task Error_response_has_correct_RFC7807_shape_type_only_for_canned_ { byte[] garbage = new byte[64]; new Random(42).NextBytes(garbage); - DefaultHttpContext ctx = MakePostContext($"/engine/v2/{SszRestPaths.Paris}/payloads", garbage); + DefaultHttpContext ctx = MakePostContext($"/engine/v2/{ParisUrl}/payloads", garbage); await _middleware.InvokeAsync(ctx); diff --git a/src/Nethermind/Nethermind.Merge.Plugin/EngineRpcModule.Amsterdam.cs b/src/Nethermind/Nethermind.Merge.Plugin/EngineRpcModule.Amsterdam.cs index 2c3133fe1b54..ba5f00d71017 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/EngineRpcModule.Amsterdam.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/EngineRpcModule.Amsterdam.cs @@ -4,7 +4,7 @@ using System.Collections; using System.Collections.Generic; using System.Threading.Tasks; -using Nethermind.Consensus; +using Nethermind.Core; using Nethermind.Consensus.Producers; using Nethermind.Core.Crypto; using Nethermind.JsonRpc; diff --git a/src/Nethermind/Nethermind.Merge.Plugin/EngineRpcModule.Cancun.cs b/src/Nethermind/Nethermind.Merge.Plugin/EngineRpcModule.Cancun.cs index bc58a54bcebc..449cc8b7ab36 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/EngineRpcModule.Cancun.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/EngineRpcModule.Cancun.cs @@ -3,7 +3,7 @@ using System.Collections.Generic; using System.Threading.Tasks; -using Nethermind.Consensus; +using Nethermind.Core; using Nethermind.Consensus.Producers; using Nethermind.Core.Crypto; using Nethermind.JsonRpc; diff --git a/src/Nethermind/Nethermind.Merge.Plugin/EngineRpcModule.Paris.cs b/src/Nethermind/Nethermind.Merge.Plugin/EngineRpcModule.Paris.cs index cffac3716cb9..7f00504866e5 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/EngineRpcModule.Paris.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/EngineRpcModule.Paris.cs @@ -6,7 +6,7 @@ using System.Threading; using System.Threading.Tasks; using Nethermind.Api; -using Nethermind.Consensus; +using Nethermind.Core; using Nethermind.Consensus.Producers; using Nethermind.Core.Exceptions; using Nethermind.Core.Specs; diff --git a/src/Nethermind/Nethermind.Merge.Plugin/EngineRpcModule.Prague.cs b/src/Nethermind/Nethermind.Merge.Plugin/EngineRpcModule.Prague.cs index 79c9af457e3b..e55a7a565def 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/EngineRpcModule.Prague.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/EngineRpcModule.Prague.cs @@ -2,7 +2,7 @@ // SPDX-License-Identifier: LGPL-3.0-only using System.Threading.Tasks; -using Nethermind.Consensus; +using Nethermind.Core; using Nethermind.Core.Crypto; using Nethermind.JsonRpc; using Nethermind.Merge.Plugin.Data; diff --git a/src/Nethermind/Nethermind.Merge.Plugin/EngineRpcModule.Shanghai.cs b/src/Nethermind/Nethermind.Merge.Plugin/EngineRpcModule.Shanghai.cs index 21d1f6f014c0..7c2911154646 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/EngineRpcModule.Shanghai.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/EngineRpcModule.Shanghai.cs @@ -3,7 +3,7 @@ using System.Collections.Generic; using System.Threading.Tasks; -using Nethermind.Consensus; +using Nethermind.Core; using Nethermind.Consensus.Producers; using Nethermind.Core.Crypto; using Nethermind.JsonRpc; diff --git a/src/Nethermind/Nethermind.Merge.Plugin/Handlers/GetPayloadV1Handler.cs b/src/Nethermind/Nethermind.Merge.Plugin/Handlers/GetPayloadV1Handler.cs index 8b169cf72b40..0e5e7618ad8c 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/Handlers/GetPayloadV1Handler.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/Handlers/GetPayloadV1Handler.cs @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: 2022 Demerzel Solutions Limited // SPDX-License-Identifier: LGPL-3.0-only -using Nethermind.Consensus; +using Nethermind.Core; using Nethermind.Consensus.Producers; using Nethermind.Core.Specs; using Nethermind.Logging; diff --git a/src/Nethermind/Nethermind.Merge.Plugin/Handlers/GetPayloadV2Handler.cs b/src/Nethermind/Nethermind.Merge.Plugin/Handlers/GetPayloadV2Handler.cs index 739d39e56ef4..e8ab1109117b 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/Handlers/GetPayloadV2Handler.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/Handlers/GetPayloadV2Handler.cs @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: 2022 Demerzel Solutions Limited // SPDX-License-Identifier: LGPL-3.0-only -using Nethermind.Consensus; +using Nethermind.Core; using Nethermind.Consensus.Producers; using Nethermind.Core.Specs; using Nethermind.Logging; diff --git a/src/Nethermind/Nethermind.Merge.Plugin/Handlers/GetPayloadV3Handler.cs b/src/Nethermind/Nethermind.Merge.Plugin/Handlers/GetPayloadV3Handler.cs index 43d2caade012..e7b044a6aff1 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/Handlers/GetPayloadV3Handler.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/Handlers/GetPayloadV3Handler.cs @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: 2025 Demerzel Solutions Limited // SPDX-License-Identifier: LGPL-3.0-only -using Nethermind.Consensus; +using Nethermind.Core; using Nethermind.Consensus.Producers; using Nethermind.Core.Specs; using Nethermind.Logging; diff --git a/src/Nethermind/Nethermind.Merge.Plugin/Handlers/GetPayloadV4Handler.cs b/src/Nethermind/Nethermind.Merge.Plugin/Handlers/GetPayloadV4Handler.cs index 82f08fe81cf0..6b7884cfcd8e 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/Handlers/GetPayloadV4Handler.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/Handlers/GetPayloadV4Handler.cs @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: 2025 Demerzel Solutions Limited // SPDX-License-Identifier: LGPL-3.0-only -using Nethermind.Consensus; +using Nethermind.Core; using Nethermind.Core.Specs; using Nethermind.Logging; using Nethermind.Merge.Plugin.BlockProduction; diff --git a/src/Nethermind/Nethermind.Merge.Plugin/Handlers/GetPayloadV5Handler.cs b/src/Nethermind/Nethermind.Merge.Plugin/Handlers/GetPayloadV5Handler.cs index 2946d3ecf6c8..a305c5becfab 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/Handlers/GetPayloadV5Handler.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/Handlers/GetPayloadV5Handler.cs @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: 2025 Demerzel Solutions Limited // SPDX-License-Identifier: LGPL-3.0-only -using Nethermind.Consensus; +using Nethermind.Core; using Nethermind.Core.Specs; using Nethermind.Logging; using Nethermind.Merge.Plugin.BlockProduction; diff --git a/src/Nethermind/Nethermind.Merge.Plugin/Handlers/GetPayloadV6Handler.cs b/src/Nethermind/Nethermind.Merge.Plugin/Handlers/GetPayloadV6Handler.cs index a4bd1b36d8dc..900faef67240 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/Handlers/GetPayloadV6Handler.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/Handlers/GetPayloadV6Handler.cs @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited // SPDX-License-Identifier: LGPL-3.0-only -using Nethermind.Consensus; +using Nethermind.Core; using Nethermind.Core.Specs; using Nethermind.Logging; using Nethermind.Merge.Plugin.BlockProduction; diff --git a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/GetBlobsSszHandler.cs b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/GetBlobsSszHandler.cs index b8fb09aacf90..883750ed9c25 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/GetBlobsSszHandler.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/GetBlobsSszHandler.cs @@ -6,7 +6,7 @@ using System.Collections.Generic; using System.Threading.Tasks; using Microsoft.AspNetCore.Http; -using Nethermind.Consensus; +using Nethermind.Core; using Nethermind.JsonRpc; using Nethermind.Merge.Plugin.Data; diff --git a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/SszRestPaths.cs b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/SszRestPaths.cs index c4a84e6adf3d..eb586d6af0a9 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/SszRestPaths.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/SszRestPaths.cs @@ -11,12 +11,12 @@ namespace Nethermind.Merge.Plugin.SszRest.Handlers; public static class SszRestPaths { - public const string Paris = "paris"; - public const string Shanghai = "shanghai"; - public const string Cancun = "cancun"; - public const string Prague = "prague"; - public const string Osaka = "osaka"; - public const string Amsterdam = "amsterdam"; + private static readonly string _paris = Forks.Paris.Instance.EngineApiUrlSegment!; + private static readonly string _shanghai = Forks.Shanghai.Instance.EngineApiUrlSegment!; + private static readonly string _cancun = Forks.Cancun.Instance.EngineApiUrlSegment!; + private static readonly string _prague = Forks.Prague.Instance.EngineApiUrlSegment!; + private static readonly string _osaka = Forks.Osaka.Instance.EngineApiUrlSegment!; + private static readonly string _amsterdam = Forks.Amsterdam.Instance.EngineApiUrlSegment!; /// /// Single source of truth: URL fork segment → . Built by @@ -74,41 +74,38 @@ public static class SszRestPaths public const string Blobs = "blobs"; - // Paris - public const string PostV1Payloads = "POST /engine/v2/" + Paris + "/payloads"; - public const string GetV1Payloads = "GET /engine/v2/" + Paris + "/payloads/{payload_id}"; - public const string PostV1Forkchoice = "POST /engine/v2/" + Paris + "/forkchoice"; + // Documentation strings for the SSZ-REST routes — used by EngineRpcCapabilitiesProvider + // (registration) and EngineModuleTests (coverage assertions). Built at static-init time from + // each fork's EngineApiUrlSegment so the route docs stay in sync with the routing layer. + public static readonly string PostV1Payloads = $"POST /engine/v2/{_paris}/payloads"; + public static readonly string GetV1Payloads = $"GET /engine/v2/{_paris}/payloads/{{payload_id}}"; + public static readonly string PostV1Forkchoice = $"POST /engine/v2/{_paris}/forkchoice"; public const string PostV1Capabilities = "GET /engine/v2/capabilities"; public const string PostV1ClientVersion = "GET /engine/v2/identity"; - // Shanghai - public const string PostV2Payloads = "POST /engine/v2/" + Shanghai + "/payloads"; - public const string PostV2Forkchoice = "POST /engine/v2/" + Shanghai + "/forkchoice"; - public const string GetV2Payloads = "GET /engine/v2/" + Shanghai + "/payloads/{payload_id}"; - public const string PostV1PayloadBodiesByHash = "POST /engine/v2/" + Shanghai + "/bodies/hash"; - public const string GetV1PayloadBodiesByRange = "GET /engine/v2/" + Shanghai + "/bodies"; - - // Cancun - public const string PostV3Payloads = "POST /engine/v2/" + Cancun + "/payloads"; - public const string PostV3Forkchoice = "POST /engine/v2/" + Cancun + "/forkchoice"; - public const string GetV3Payloads = "GET /engine/v2/" + Cancun + "/payloads/{payload_id}"; + public static readonly string PostV2Payloads = $"POST /engine/v2/{_shanghai}/payloads"; + public static readonly string PostV2Forkchoice = $"POST /engine/v2/{_shanghai}/forkchoice"; + public static readonly string GetV2Payloads = $"GET /engine/v2/{_shanghai}/payloads/{{payload_id}}"; + public static readonly string PostV1PayloadBodiesByHash = $"POST /engine/v2/{_shanghai}/bodies/hash"; + public static readonly string GetV1PayloadBodiesByRange = $"GET /engine/v2/{_shanghai}/bodies"; + + public static readonly string PostV3Payloads = $"POST /engine/v2/{_cancun}/payloads"; + public static readonly string PostV3Forkchoice = $"POST /engine/v2/{_cancun}/forkchoice"; + public static readonly string GetV3Payloads = $"GET /engine/v2/{_cancun}/payloads/{{payload_id}}"; public const string PostV1Blobs = "POST /engine/v2/blobs/v1"; - // Prague - public const string PostV4Payloads = "POST /engine/v2/" + Prague + "/payloads"; - public const string GetV4Payloads = "GET /engine/v2/" + Prague + "/payloads/{payload_id}"; + public static readonly string PostV4Payloads = $"POST /engine/v2/{_prague}/payloads"; + public static readonly string GetV4Payloads = $"GET /engine/v2/{_prague}/payloads/{{payload_id}}"; - // Osaka - public const string GetV5Payloads = "GET /engine/v2/" + Osaka + "/payloads/{payload_id}"; + public static readonly string GetV5Payloads = $"GET /engine/v2/{_osaka}/payloads/{{payload_id}}"; public const string PostV2Blobs = "POST /engine/v2/blobs/v2"; public const string PostV3Blobs = "POST /engine/v2/blobs/v3"; - // Amsterdam - public const string PostV5Payloads = "POST /engine/v2/" + Amsterdam + "/payloads"; - public const string GetV6Payloads = "GET /engine/v2/" + Amsterdam + "/payloads/{payload_id}"; - public const string PostV4Forkchoice = "POST /engine/v2/" + Amsterdam + "/forkchoice"; - public const string PostV2PayloadBodiesByHash = "POST /engine/v2/" + Amsterdam + "/bodies/hash"; - public const string GetV2PayloadBodiesByRange = "GET /engine/v2/" + Amsterdam + "/bodies"; + public static readonly string PostV5Payloads = $"POST /engine/v2/{_amsterdam}/payloads"; + public static readonly string GetV6Payloads = $"GET /engine/v2/{_amsterdam}/payloads/{{payload_id}}"; + public static readonly string PostV4Forkchoice = $"POST /engine/v2/{_amsterdam}/forkchoice"; + public static readonly string PostV2PayloadBodiesByHash = $"POST /engine/v2/{_amsterdam}/bodies/hash"; + public static readonly string GetV2PayloadBodiesByRange = $"GET /engine/v2/{_amsterdam}/bodies"; public const string PostV4Blobs = "POST /engine/v2/blobs/v4"; /// diff --git a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/SszVersionDescriptors.cs b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/SszVersionDescriptors.cs index afe265f43d06..d0db85824c93 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/SszVersionDescriptors.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/SszVersionDescriptors.cs @@ -5,7 +5,7 @@ using System.Collections; using System.Collections.Generic; using System.Threading.Tasks; -using Nethermind.Consensus; +using Nethermind.Core; using Nethermind.Consensus.Producers; using Nethermind.Core.Crypto; using Nethermind.JsonRpc; diff --git a/src/Nethermind/Nethermind.Specs/Forks/15_Paris.cs b/src/Nethermind/Nethermind.Specs/Forks/15_Paris.cs index 5a72b0391159..2230b5e005b7 100644 --- a/src/Nethermind/Nethermind.Specs/Forks/15_Paris.cs +++ b/src/Nethermind/Nethermind.Specs/Forks/15_Paris.cs @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: 2024 Demerzel Solutions Limited // SPDX-License-Identifier: LGPL-3.0-only -using Nethermind.Consensus; +using Nethermind.Core; namespace Nethermind.Specs.Forks; diff --git a/src/Nethermind/Nethermind.Specs/Forks/16_Shanghai.cs b/src/Nethermind/Nethermind.Specs/Forks/16_Shanghai.cs index c0d43706f76d..dd47164f3016 100644 --- a/src/Nethermind/Nethermind.Specs/Forks/16_Shanghai.cs +++ b/src/Nethermind/Nethermind.Specs/Forks/16_Shanghai.cs @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: 2022 Demerzel Solutions Limited // SPDX-License-Identifier: LGPL-3.0-only -using Nethermind.Consensus; +using Nethermind.Core; namespace Nethermind.Specs.Forks; diff --git a/src/Nethermind/Nethermind.Specs/Forks/17_Cancun.cs b/src/Nethermind/Nethermind.Specs/Forks/17_Cancun.cs index 59a81e414ac8..4b16a7de05f3 100644 --- a/src/Nethermind/Nethermind.Specs/Forks/17_Cancun.cs +++ b/src/Nethermind/Nethermind.Specs/Forks/17_Cancun.cs @@ -1,7 +1,6 @@ // SPDX-FileCopyrightText: 2022 Demerzel Solutions Limited // SPDX-License-Identifier: LGPL-3.0-only -using Nethermind.Consensus; using Nethermind.Core; namespace Nethermind.Specs.Forks; diff --git a/src/Nethermind/Nethermind.Specs/Forks/18_Prague.cs b/src/Nethermind/Nethermind.Specs/Forks/18_Prague.cs index 3bca75ad4ddf..ff7b60a42fca 100644 --- a/src/Nethermind/Nethermind.Specs/Forks/18_Prague.cs +++ b/src/Nethermind/Nethermind.Specs/Forks/18_Prague.cs @@ -1,7 +1,6 @@ // SPDX-FileCopyrightText: 2024 Demerzel Solutions Limited // SPDX-License-Identifier: LGPL-3.0-only -using Nethermind.Consensus; using Nethermind.Core; namespace Nethermind.Specs.Forks; diff --git a/src/Nethermind/Nethermind.Specs/Forks/19_Osaka.cs b/src/Nethermind/Nethermind.Specs/Forks/19_Osaka.cs index 43703f19e418..79f55dee1b69 100644 --- a/src/Nethermind/Nethermind.Specs/Forks/19_Osaka.cs +++ b/src/Nethermind/Nethermind.Specs/Forks/19_Osaka.cs @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: 2025 Demerzel Solutions Limited // SPDX-License-Identifier: LGPL-3.0-only -using Nethermind.Consensus; +using Nethermind.Core; namespace Nethermind.Specs.Forks; diff --git a/src/Nethermind/Nethermind.Specs/Forks/25_Amsterdam.cs b/src/Nethermind/Nethermind.Specs/Forks/25_Amsterdam.cs index 5be16e7b6997..c13d697b7afe 100644 --- a/src/Nethermind/Nethermind.Specs/Forks/25_Amsterdam.cs +++ b/src/Nethermind/Nethermind.Specs/Forks/25_Amsterdam.cs @@ -1,7 +1,6 @@ // SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited // SPDX-License-Identifier: LGPL-3.0-only -using Nethermind.Consensus; using Nethermind.Core; using Nethermind.Core.Specs; From 8b35d68dca4760cf9083dd3caacda59f4d0a8297 Mon Sep 17 00:00:00 2001 From: "lukasz.rozmej" Date: Fri, 12 Jun 2026 17:38:00 +0200 Subject: [PATCH 24/35] GetBlobsV4 SSZ encode: fix cell length + slice rented buffers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SszBlobCell.BlobCellLength was 1024; EIP-7594 defines BYTES_PER_CELL = 2048 (FIELD_ELEMENTS_PER_CELL=64 * BYTES_PER_FIELD_ELEMENT=32) and Ckzg.BytesPerCell matches. With BlobCellLength=1024 any non-empty V4 response throws InvalidDataException at encode → 500. EncodeGetBlobsV4Response also passed pool-rented byte[] arrays to SszBlobCell.FromSpan / SszKzgCommitment.FromSpan, which require exact length. ArrayPool.Shared.Rent(48) returns length 64 (bucket-rounded), so proof encoding always failed even with correct cell length. Slice to spec-exact length at both call sites. Add SszCodecTests.EncodeGetBlobsV4Response_with_pool_rented_cells_and_proofs to lock in the codec path against the actual pool-rented buffers; existing V4 middleware tests stub Success(null) → 204 and miss this entirely. Also drop the unused, mismatched constants on BlobCellsAndProofs (no consumers; Ckzg.* is the source of truth). Other fixes from review: - Startup.cs: restore Http1 + DisableAltSvcHeader for non-engine ports (else-branch was lost when engine-port HTTP/2 was added). - SszMiddleware: move X-Engine-Client-Version JSON parse off every authenticated request into ClientVersionSszHandler where it's actually consumed. - SszMiddleware.TryRoute: reject /engine/v2/capabilities/foo and /engine/v2/identity/foo as 404 method-not-found instead of falling through to fork parsing and emitting 400 unsupported-fork. - EngineRpcModule.Amsterdam: trace-log custodyColumns on FCUv4 receipt so it's auditable rather than silently dropped (no consumer yet). - CapabilitiesSszHandler: emit compact JSON. - EngineApiVersions.GetBlobs.V4 comment: Osaka (EIP-7594/PeerDAS), not Amsterdam — matches existing IsEip7594Enabled gating. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Nethermind.Core/EngineApiVersions.cs | 2 +- .../SszRest/SszCodecTests.cs | 26 ++++++++++++ .../SszRest/SszMiddlewareTests.cs | 2 +- .../Data/BlobCellsAndProofs.cs | 3 -- .../EngineRpcModule.Amsterdam.cs | 8 +++- .../Handlers/CapabilitiesSszHandler.cs | 24 +++++------ .../Handlers/ClientVersionSszHandler.cs | 32 +++++++++++++-- .../SszRest/SszBlobCell.cs | 5 ++- .../SszRest/SszCodec.cs | 40 +++++++++---------- .../SszRest/SszMiddleware.cs | 32 ++++----------- .../Nethermind.Runner/JsonRpc/Startup.cs | 5 +++ 11 files changed, 107 insertions(+), 72 deletions(-) diff --git a/src/Nethermind/Nethermind.Core/EngineApiVersions.cs b/src/Nethermind/Nethermind.Core/EngineApiVersions.cs index a7f17cb290a1..35c6eeb96c78 100644 --- a/src/Nethermind/Nethermind.Core/EngineApiVersions.cs +++ b/src/Nethermind/Nethermind.Core/EngineApiVersions.cs @@ -50,7 +50,7 @@ public static class GetBlobs public const int V1 = 1; // Cancun public const int V2 = 2; // Osaka public const int V3 = 3; // Osaka (allowPartialReturn = true) - public const int V4 = 4; // Amsterdam (cell retrieval) + public const int V4 = 4; // Osaka (cell retrieval, EIP-7594/PeerDAS) public const int Latest = V4; } diff --git a/src/Nethermind/Nethermind.Merge.Plugin.Test/SszRest/SszCodecTests.cs b/src/Nethermind/Nethermind.Merge.Plugin.Test/SszRest/SszCodecTests.cs index 1ece3269dbaa..1d1206c5f0af 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin.Test/SszRest/SszCodecTests.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin.Test/SszRest/SszCodecTests.cs @@ -155,6 +155,32 @@ public void EncodeGetBlobsV3Response_null_vs_present_entry_differ_in_size() Assert.That(withPresent.Length, Is.GreaterThan(withNull.Length)); } + [Test] + public void EncodeGetBlobsV4Response_with_pool_rented_cells_and_proofs_round_trips() + { + // Reproduces what GetBlobsHandlerV4 builds: pool-rented byte[] arrays sized + // by Ckzg.BytesPerCell (2048) and Ckzg.BytesPerProof (48). ArrayPool.Rent(48) + // hands back a 64-byte array — the encoder must slice to spec-exact length + // or SszKzgCommitment.FromSpan throws. Likewise for SszBlobCell. + const int cellsPerExtBlob = 128; + byte[]?[] cells = new byte[]?[cellsPerExtBlob]; + byte[]?[] proofs = new byte[]?[cellsPerExtBlob]; + cells[0] = ArrayPool.Shared.Rent(SszBlobCell.BlobCellLength); + proofs[0] = ArrayPool.Shared.Rent(SszKzgCommitment.KzgCommitmentLength); + try + { + BlobCellsAndProofs entry = new() { Available = true, BlobCells = cells, Proofs = proofs }; + byte[] encoded = Encode>([entry], SszCodec.EncodeGetBlobsV4Response); + + Assert.That(encoded.Length, Is.GreaterThan(0)); + } + finally + { + ArrayPool.Shared.Return(cells[0]!); + ArrayPool.Shared.Return(proofs[0]!); + } + } + private static IEnumerable NonEmptyEncodings() { yield return new TestCaseData((Action>)(w => diff --git a/src/Nethermind/Nethermind.Merge.Plugin.Test/SszRest/SszMiddlewareTests.cs b/src/Nethermind/Nethermind.Merge.Plugin.Test/SszRest/SszMiddlewareTests.cs index 655af1e79d2f..c0612d7eaceb 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin.Test/SszRest/SszMiddlewareTests.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin.Test/SszRest/SszMiddlewareTests.cs @@ -107,7 +107,7 @@ private SszMiddleware BuildMiddleware(RequestDelegate? next = null) new GetPayloadBodiesByRangeSszHandler(_engineModule), new GetPayloadBodiesByRangeSszHandler(_engineModule), - new ClientVersionSszHandler(_engineModule), + new ClientVersionSszHandler(_engineModule, LimboLogs.Instance), new CapabilitiesSszHandler(_specProvider), ]; diff --git a/src/Nethermind/Nethermind.Merge.Plugin/Data/BlobCellsAndProofs.cs b/src/Nethermind/Nethermind.Merge.Plugin/Data/BlobCellsAndProofs.cs index 7b37fd32452c..9a5cd5a78ab8 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/Data/BlobCellsAndProofs.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/Data/BlobCellsAndProofs.cs @@ -5,9 +5,6 @@ namespace Nethermind.Merge.Plugin.Data; public class BlobCellsAndProofs { - public const int CellsPerExtBlob = 128; - public const int BytesPerCell = 1024; - public const int BytesPerProof = 48; public bool Available { get; init; } public byte[]?[]? BlobCells { get; init; } public byte[]?[]? Proofs { get; init; } diff --git a/src/Nethermind/Nethermind.Merge.Plugin/EngineRpcModule.Amsterdam.cs b/src/Nethermind/Nethermind.Merge.Plugin/EngineRpcModule.Amsterdam.cs index ba5f00d71017..7c57e0a4da6b 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/EngineRpcModule.Amsterdam.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/EngineRpcModule.Amsterdam.cs @@ -28,7 +28,13 @@ public Task> engine_newPayloadV5(ExecutionPayload => NewPayload(new ExecutionPayloadParams(executionPayload, blobVersionedHashes, parentBeaconBlockRoot, executionRequests), EngineApiVersions.NewPayload.V5); public Task> engine_forkchoiceUpdatedV4(ForkchoiceStateV1 forkchoiceState, PayloadAttributes? payloadAttributes = null, BitArray? custodyColumns = null) - => ForkchoiceUpdated(forkchoiceState, payloadAttributes, EngineApiVersions.Fcu.V4); + { + // Per execution-apis #793: custody-column updates are best-effort, errors swallowed. + // No EL-side custody consumer wired yet — log at trace level so the CL request is auditable. + if (custodyColumns is not null && _logger.IsTrace) + _logger.Trace($"engine_forkchoiceUpdatedV4 received custody columns ({custodyColumns.Count} bits) — not yet applied"); + return ForkchoiceUpdated(forkchoiceState, payloadAttributes, EngineApiVersions.Fcu.V4); + } public Task>> engine_getPayloadBodiesByHashV2(IReadOnlyList blockHashes) => _executionGetPayloadBodiesByHashV2Handler.Handle(blockHashes); diff --git a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/CapabilitiesSszHandler.cs b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/CapabilitiesSszHandler.cs index c8b1e8457f7f..9b1b4b9e68f5 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/CapabilitiesSszHandler.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/CapabilitiesSszHandler.cs @@ -66,21 +66,15 @@ private static byte[] BuildBody(ISpecProvider specProvider) supportedForksJson = JsonSerializer.Serialize(forkSlice); } - return Encoding.UTF8.GetBytes($$""" - { - "supported_forks": {{supportedForksJson}}, - "fork_scoped_endpoints": ["payloads", "forkchoice", "bodies"], - "independently_versioned": { - "blobs": ["v1", "v2", "v3", "v4"] - }, - "unscoped_endpoints": ["capabilities", "identity"], - "limits": { - "bodies.max_count": {{SszRestLimits.MaxBodiesRequest}}, - "blobs.max_versioned_hashes": {{SszRestLimits.MaxBlobsRequest}}, - "payload.max_bytes": {{SszMiddleware.MaxBodySize}} - } - } - """); + return Encoding.UTF8.GetBytes( + $"{{\"supported_forks\":{supportedForksJson}," + + $"\"fork_scoped_endpoints\":[\"payloads\",\"forkchoice\",\"bodies\"]," + + $"\"independently_versioned\":{{\"blobs\":[\"v1\",\"v2\",\"v3\",\"v4\"]}}," + + $"\"unscoped_endpoints\":[\"capabilities\",\"identity\"]," + + $"\"limits\":{{" + + $"\"bodies.max_count\":{SszRestLimits.MaxBodiesRequest}," + + $"\"blobs.max_versioned_hashes\":{SszRestLimits.MaxBlobsRequest}," + + $"\"payload.max_bytes\":{SszMiddleware.MaxBodySize}}}}}"); } private static int ComputeTimestampForkCount(ISpecProvider specProvider) diff --git a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/ClientVersionSszHandler.cs b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/ClientVersionSszHandler.cs index 2f8689a40770..abe22be7417c 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/ClientVersionSszHandler.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/ClientVersionSszHandler.cs @@ -5,8 +5,10 @@ using System.Buffers; using System.Threading.Tasks; using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Primitives; using Nethermind.Core; using Nethermind.JsonRpc; +using Nethermind.Logging; using Nethermind.Merge.Plugin.Data; namespace Nethermind.Merge.Plugin.SszRest.Handlers; @@ -15,22 +17,24 @@ namespace Nethermind.Merge.Plugin.SszRest.Handlers; /// Handles GET /engine/v2/identity, the HTTP/REST equivalent of /// engine_getClientVersionV1. /// -public sealed class ClientVersionSszHandler(IEngineRpcModule engineModule) : SszEndpointHandlerBase +public sealed class ClientVersionSszHandler(IEngineRpcModule engineModule, ILogManager logManager) : SszEndpointHandlerBase { private readonly IEngineRpcModule _engineModule = engineModule; + private readonly ILogger _logger = logManager.GetClassLogger(); private static readonly System.Text.Json.JsonSerializerOptions _jsonOptions = new() { PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase }; + private static readonly System.Text.Json.JsonSerializerOptions _headerJsonOptions = + new() { PropertyNameCaseInsensitive = true }; + public override string HttpMethod => "GET"; public override string Resource => SszRestPaths.ClientVersion; public override int? Version => null; public override async Task HandleAsync(HttpContext ctx, int version, ReadOnlyMemory extra, ReadOnlySequence body) { - ClientVersionV1 clientVersion = ctx.Items.TryGetValue("X-Engine-Client-Version", out object? clvObj) && clvObj is ClientVersionV1 clv - ? clv - : default; + ClientVersionV1 clientVersion = TryParseClientVersionHeader(ctx); ResultWrapper result = _engineModule.engine_getClientVersionV1(clientVersion); if (result.Result.ResultType != ResultType.Success) @@ -44,4 +48,24 @@ public override async Task HandleAsync(HttpContext ctx, int version, ReadOnlyMem string json = System.Text.Json.JsonSerializer.Serialize(result.Data, _jsonOptions); await ctx.Response.WriteAsync(json, ctx.RequestAborted); } + + private ClientVersionV1 TryParseClientVersionHeader(HttpContext ctx) + { + if (!ctx.Request.Headers.TryGetValue("X-Engine-Client-Version", out StringValues headerValues) || headerValues.Count == 0) + return default; + + string? headerVal = headerValues[0]; + if (string.IsNullOrWhiteSpace(headerVal)) + return default; + + try + { + return System.Text.Json.JsonSerializer.Deserialize(headerVal, _headerJsonOptions); + } + catch (Exception ex) + { + if (_logger.IsTrace) _logger.Trace($"SSZ-REST: ignoring malformed X-Engine-Client-Version header: {ex.Message}"); + return default; + } + } } diff --git a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszBlobCell.cs b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszBlobCell.cs index 713c0a57b5ea..04cfd6046506 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszBlobCell.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszBlobCell.cs @@ -9,12 +9,13 @@ namespace Nethermind.Merge.Plugin.SszRest; /// -/// Inline 1024-byte Blob Cell representation used by Engine API SSZ wire types. +/// Inline 2048-byte Blob Cell representation used by Engine API SSZ wire types. +/// Matches EIP-7594 BYTES_PER_CELL = FIELD_ELEMENTS_PER_CELL(64) * BYTES_PER_FIELD_ELEMENT(32). /// [InlineArray(BlobCellLength)] public struct SszBlobCell { - public const int BlobCellLength = 1024; + public const int BlobCellLength = 2048; private byte _element0; diff --git a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszCodec.cs b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszCodec.cs index fa9c82598e54..c37d5205f66e 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszCodec.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszCodec.cs @@ -174,6 +174,7 @@ public static (byte[][] hashes, System.Collections.BitArray indices) DecodeGetBl public static int EncodeGetBlobsV4Response(IReadOnlyList blobs, IBufferWriter writer) { + const int CellsPerExtBlob = 128; int count = blobs.Count; BlobV4EntryWire[] arr = new BlobV4EntryWire[count]; for (int i = 0; i < count; i++) @@ -182,29 +183,28 @@ public static int EncodeGetBlobsV4Response(IReadOnlyList bl if (b is null || !b.Available) { arr[i] = new BlobV4EntryWire { Available = false, Contents = default }; + continue; } - else + + NullableBlobCellWire[] cells = new NullableBlobCellWire[CellsPerExtBlob]; + NullableKzgProofWire[] proofs = new NullableKzgProofWire[CellsPerExtBlob]; + for (int j = 0; j < CellsPerExtBlob; j++) { - NullableBlobCellWire[] cells = new NullableBlobCellWire[128]; - NullableKzgProofWire[] proofs = new NullableKzgProofWire[128]; - for (int j = 0; j < 128; j++) - { - byte[]? cell = b.BlobCells?[j]; - byte[]? proof = b.Proofs?[j]; - cells[j] = cell is null - ? new() { Cell = [] } - : new() { Cell = [SszBlobCell.FromSpan(cell)] }; - proofs[j] = proof is null - ? new() { Proof = [] } - : new() { Proof = [SszKzgCommitment.FromSpan(proof)] }; - } - - arr[i] = new BlobV4EntryWire - { - Available = true, - Contents = new BlobCellsAndProofsWire { BlobCells = cells, Proofs = proofs } - }; + byte[]? cell = b.BlobCells?[j]; + byte[]? proof = b.Proofs?[j]; + cells[j] = cell is null + ? new() { Cell = [] } + : new() { Cell = [SszBlobCell.FromSpan(cell.AsSpan(0, SszBlobCell.BlobCellLength))] }; + proofs[j] = proof is null + ? new() { Proof = [] } + : new() { Proof = [SszKzgCommitment.FromSpan(proof.AsSpan(0, SszKzgCommitment.KzgCommitmentLength))] }; } + + arr[i] = new BlobV4EntryWire + { + Available = true, + Contents = new BlobCellsAndProofsWire { BlobCells = cells, Proofs = proofs } + }; } return EncodeToWriter(new GetBlobsV4ResponseWire { Entries = arr }, writer); } diff --git a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszMiddleware.cs b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszMiddleware.cs index 32af389f08cd..e294e73c957d 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszMiddleware.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszMiddleware.cs @@ -17,7 +17,6 @@ using Nethermind.JsonRpc.Modules; using Nethermind.Logging; using Nethermind.Merge.Plugin.SszRest.Handlers; -using Nethermind.Merge.Plugin.Data; namespace Nethermind.Merge.Plugin.SszRest; @@ -48,9 +47,6 @@ public sealed class SszMiddleware private readonly FrozenDictionary>.AlternateLookup> _postLookup; private readonly FrozenDictionary>.AlternateLookup> _getLookup; - private static readonly System.Text.Json.JsonSerializerOptions _headerJsonOptions = - new() { PropertyNameCaseInsensitive = true }; - private enum SszRequestKind { NotEngine, EngineWrongMediaType, EngineOk } public SszMiddleware( @@ -133,23 +129,6 @@ public Task InvokeAsync(HttpContext ctx) private async Task ProcessSszRequestAsync(HttpContext ctx) { - if (ctx.Request.Headers.TryGetValue("X-Engine-Client-Version", out Microsoft.Extensions.Primitives.StringValues headerValues) && headerValues.Count > 0) - { - string? headerVal = headerValues[0]; - if (!string.IsNullOrWhiteSpace(headerVal)) - { - try - { - ClientVersionV1 clVer = System.Text.Json.JsonSerializer.Deserialize(headerVal, _headerJsonOptions); - ctx.Items["X-Engine-Client-Version"] = clVer; - } - catch (Exception ex) - { - if (_logger.IsTrace) _logger.Trace($"SSZ-REST: ignoring malformed X-Engine-Client-Version header: {ex.Message}"); - } - } - } - string? authHeader = ctx.Request.Headers.Authorization; if (authHeader is null || !await _auth.Authenticate(authHeader)) { @@ -281,15 +260,18 @@ private static bool TryRoute(string path, out int version, out string? fork, span = span[offset..]; if (span.IsEmpty) return false; - if (span.Equals("identity".AsSpan(), StringComparison.OrdinalIgnoreCase)) + if (span.Equals("identity".AsSpan(), StringComparison.OrdinalIgnoreCase) + || span.Equals("capabilities".AsSpan(), StringComparison.OrdinalIgnoreCase)) { pathSegment = path.AsMemory(offset); return true; } - if (span.Equals("capabilities".AsSpan(), StringComparison.OrdinalIgnoreCase)) + // Unscoped endpoints don't accept path extras — reject "/identity/foo" / "/capabilities/foo" + // as 404 method-not-found rather than letting them fall through to fork parsing. + if (span.StartsWith("identity/".AsSpan(), StringComparison.OrdinalIgnoreCase) + || span.StartsWith("capabilities/".AsSpan(), StringComparison.OrdinalIgnoreCase)) { - pathSegment = path.AsMemory(offset); - return true; + return false; } if (span.StartsWith("blobs/".AsSpan(), StringComparison.OrdinalIgnoreCase)) diff --git a/src/Nethermind/Nethermind.Runner/JsonRpc/Startup.cs b/src/Nethermind/Nethermind.Runner/JsonRpc/Startup.cs index 34d5d57c2c3d..77628ed5cb7f 100644 --- a/src/Nethermind/Nethermind.Runner/JsonRpc/Startup.cs +++ b/src/Nethermind/Nethermind.Runner/JsonRpc/Startup.cs @@ -113,6 +113,11 @@ public void ConfigureServices(IServiceCollection services) // Engine API JSON-RPC still relies on HTTP/1.1 and shares the same listener. listenOptions.Protocols = HttpProtocols.Http1AndHttp2; } + else + { + listenOptions.Protocols = HttpProtocols.Http1; + listenOptions.DisableAltSvcHeader = true; + } }); }); Bootstrap.Instance.RegisterJsonRpcServices(services); From cfac38969502c5a1696310c7571bb1f16bda85df Mon Sep 17 00:00:00 2001 From: "lukasz.rozmej" Date: Fri, 12 Jun 2026 17:39:57 +0200 Subject: [PATCH 25/35] SszMiddlewareTests: drop s_ prefix; use EngineApiVersions for version numbers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename TestCaseSource fields from s_*RoutingCases to *RoutingCases to match the surrounding test-file naming convention. Replace the literal 1/2/3/4 version arguments with the corresponding EngineApiVersions constants (NewPayload.V1, GetPayload.V2, Fcu.V3, etc.) so the source mapping between URL fork and engine API version is explicit. PayloadAttributesValidateTests: same s_ → PascalCase rename. --- .../PayloadAttributesValidateTests.cs | 4 +- .../SszRest/SszMiddlewareTests.cs | 48 +++++++++---------- 2 files changed, 26 insertions(+), 26 deletions(-) diff --git a/src/Nethermind/Nethermind.Consensus.Test/PayloadAttributesValidateTests.cs b/src/Nethermind/Nethermind.Consensus.Test/PayloadAttributesValidateTests.cs index e1abfe6c1b78..3e543f9c7647 100644 --- a/src/Nethermind/Nethermind.Consensus.Test/PayloadAttributesValidateTests.cs +++ b/src/Nethermind/Nethermind.Consensus.Test/PayloadAttributesValidateTests.cs @@ -35,7 +35,7 @@ private static ISpecProvider MakeSpecProvider(bool isAmsterdam) // Each case asserts a distinct branch of PayloadAttributes.Validate against the spec. // mustContain/mustNotContain are checked when non-null. - private static readonly object[] s_validateCases = + private static readonly object[] ValidateCases = [ new object[] { /* isAmsterdam */ true, /* withSlot */ false, /* fcu */ PayloadAttributesVersions.V4, PayloadAttributesValidationResult.InvalidPayloadAttributes, "must be provided", "expected" }, @@ -45,7 +45,7 @@ private static ISpecProvider MakeSpecProvider(bool isAmsterdam) PayloadAttributesValidationResult.UnsupportedFork, null!, null! }, ]; - [TestCaseSource(nameof(s_validateCases))] + [TestCaseSource(nameof(ValidateCases))] public void Validate_returns_expected_result( bool isAmsterdam, bool withSlotNumber, int fcuVersion, PayloadAttributesValidationResult expected, string errorMustContain, string errorMustNotContain) diff --git a/src/Nethermind/Nethermind.Merge.Plugin.Test/SszRest/SszMiddlewareTests.cs b/src/Nethermind/Nethermind.Merge.Plugin.Test/SszRest/SszMiddlewareTests.cs index 655af1e79d2f..12b229d8abf2 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin.Test/SszRest/SszMiddlewareTests.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin.Test/SszRest/SszMiddlewareTests.cs @@ -163,13 +163,13 @@ private static byte[] EncodeToBytes(T value, Func, int return w.WrittenSpan.ToArray(); } - private static readonly object[] s_newPayloadRoutingCases = + private static readonly object[] NewPayloadRoutingCases = [ - new object[] { 1, $"/engine/v2/{ParisUrl}/payloads" }, - new object[] { 2, $"/engine/v2/{ShanghaiUrl}/payloads" }, + new object[] { EngineApiVersions.NewPayload.V1, $"/engine/v2/{ParisUrl}/payloads" }, + new object[] { EngineApiVersions.NewPayload.V2, $"/engine/v2/{ShanghaiUrl}/payloads" }, ]; - [TestCaseSource(nameof(s_newPayloadRoutingCases))] + [TestCaseSource(nameof(NewPayloadRoutingCases))] public async Task NewPayload_routes_to_correct_engine_module_version(int version, string path) { PayloadStatusV1 status = new() { Status = PayloadStatus.Valid, LatestValidHash = TestItem.KeccakA }; @@ -189,13 +189,13 @@ public async Task NewPayload_routes_to_correct_engine_module_version(int version await _engineModule.Received(version == 2 ? 1 : 0).engine_newPayloadV2(Arg.Any()); } - private static readonly object[] s_getPayloadRoutingCases = + private static readonly object[] GetPayloadRoutingCases = [ - new object[] { 1, $"/engine/v2/{ParisUrl}/payloads/0x0102030405060708" }, - new object[] { 2, $"/engine/v2/{ShanghaiUrl}/payloads/0x0102030405060708" }, + new object[] { EngineApiVersions.GetPayload.V1, $"/engine/v2/{ParisUrl}/payloads/0x0102030405060708" }, + new object[] { EngineApiVersions.GetPayload.V2, $"/engine/v2/{ShanghaiUrl}/payloads/0x0102030405060708" }, ]; - [TestCaseSource(nameof(s_getPayloadRoutingCases))] + [TestCaseSource(nameof(GetPayloadRoutingCases))] public async Task GetPayload_routes_to_correct_handler_with_no_store_header(int version, string path) { _engineModule.engine_getPayloadV1(Arg.Any()) @@ -213,15 +213,15 @@ public async Task GetPayload_routes_to_correct_handler_with_no_store_header(int await _engineModule.Received(version == 2 ? 1 : 0).engine_getPayloadV2(Arg.Any()); } - private static readonly object[] s_forkchoiceRoutingCases = + private static readonly object[] ForkchoiceRoutingCases = [ - new object[] { $"/engine/v2/{ParisUrl}/forkchoice", 1 }, - new object[] { $"/engine/v2/{ShanghaiUrl}/forkchoice", 2 }, - new object[] { $"/engine/v2/{CancunUrl}/forkchoice", 3 }, - new object[] { $"/engine/v2/{AmsterdamUrl}/forkchoice", 4 }, + new object[] { $"/engine/v2/{ParisUrl}/forkchoice", EngineApiVersions.Fcu.V1 }, + new object[] { $"/engine/v2/{ShanghaiUrl}/forkchoice", EngineApiVersions.Fcu.V2 }, + new object[] { $"/engine/v2/{CancunUrl}/forkchoice", EngineApiVersions.Fcu.V3 }, + new object[] { $"/engine/v2/{AmsterdamUrl}/forkchoice", EngineApiVersions.Fcu.V4 }, ]; - [TestCaseSource(nameof(s_forkchoiceRoutingCases))] + [TestCaseSource(nameof(ForkchoiceRoutingCases))] public async Task Forkchoice_calls_correct_engine_module_version(string path, int version) { ForkchoiceUpdatedV1Result fcuResult = new() @@ -312,13 +312,13 @@ public async Task GetBlobsV4_routes_to_engine_getBlobsV4() await _engineModule.Received(1).engine_getBlobsV4(Arg.Any(), Arg.Any()); } - private static readonly object[] s_bodiesByHashRoutingCases = + private static readonly object[] BodiesByHashRoutingCases = [ - new object[] { 1, $"/engine/v2/{ShanghaiUrl}/bodies/hash" }, - new object[] { 2, $"/engine/v2/{AmsterdamUrl}/bodies/hash" }, + new object[] { EngineApiVersions.PayloadBodiesByHash.V1, $"/engine/v2/{ShanghaiUrl}/bodies/hash" }, + new object[] { EngineApiVersions.PayloadBodiesByHash.V2, $"/engine/v2/{AmsterdamUrl}/bodies/hash" }, ]; - [TestCaseSource(nameof(s_bodiesByHashRoutingCases))] + [TestCaseSource(nameof(BodiesByHashRoutingCases))] public async Task GetPayloadBodiesByHash_routes_to_correct_engine_method(int version, string path) { _engineModule.engine_getPayloadBodiesByHashV1(Arg.Any>()) @@ -338,13 +338,13 @@ public async Task GetPayloadBodiesByHash_routes_to_correct_engine_method(int ver await _engineModule.Received(version == 2 ? 1 : 0).engine_getPayloadBodiesByHashV2(Arg.Any>()); } - private static readonly object[] s_bodiesByRangeRoutingCases = + private static readonly object[] BodiesByRangeRoutingCases = [ - new object[] { 1, $"/engine/v2/{ShanghaiUrl}/bodies" }, - new object[] { 2, $"/engine/v2/{AmsterdamUrl}/bodies" }, + new object[] { EngineApiVersions.PayloadBodiesByRange.V1, $"/engine/v2/{ShanghaiUrl}/bodies" }, + new object[] { EngineApiVersions.PayloadBodiesByRange.V2, $"/engine/v2/{AmsterdamUrl}/bodies" }, ]; - [TestCaseSource(nameof(s_bodiesByRangeRoutingCases))] + [TestCaseSource(nameof(BodiesByRangeRoutingCases))] public async Task GetPayloadBodiesByRange_routes_to_correct_engine_method_with_correct_args(int version, string path) { const long expectedStart = 7; @@ -854,14 +854,14 @@ public async Task Identity_returns_200_json_regardless_of_Accept_header(string a // Trailing slashes and unknown extra path segments must both 404 — spec forbids trailing slashes // and handlers without AcceptsPathExtra must reject stray segments. - private static readonly object[] s_malformedPathCases = + private static readonly object[] MalformedPathCases = [ new object[] { "POST", $"/engine/v2/{CancunUrl}/forkchoice/", true }, new object[] { "GET", "/engine/v2/capabilities/", false }, new object[] { "POST", $"/engine/v2/{CancunUrl}/forkchoice/whatever", false }, ]; - [TestCaseSource(nameof(s_malformedPathCases))] + [TestCaseSource(nameof(MalformedPathCases))] public async Task Malformed_or_trailing_path_returns_404(string method, string path, bool assertMethodNotFoundBody) { DefaultHttpContext ctx = method == "POST" From fa1768627bad172954ba40b322b292729a2ba63e Mon Sep 17 00:00:00 2001 From: "lukasz.rozmej" Date: Fri, 12 Jun 2026 17:46:59 +0200 Subject: [PATCH 26/35] test: cover unscoped-endpoint extra-segment rejection in TryRoute MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /engine/v2/capabilities/foo and /engine/v2/identity/foo now go through the same 404 method-not-found path as other malformed routes — locks in the TryRoute change against regression. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../SszRest/SszMiddlewareTests.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/Nethermind/Nethermind.Merge.Plugin.Test/SszRest/SszMiddlewareTests.cs b/src/Nethermind/Nethermind.Merge.Plugin.Test/SszRest/SszMiddlewareTests.cs index c0612d7eaceb..4247e9fb003c 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin.Test/SszRest/SszMiddlewareTests.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin.Test/SszRest/SszMiddlewareTests.cs @@ -853,11 +853,15 @@ public async Task Identity_returns_200_json_regardless_of_Accept_header(string a } // Trailing slashes and unknown extra path segments must both 404 — spec forbids trailing slashes - // and handlers without AcceptsPathExtra must reject stray segments. + // and handlers without AcceptsPathExtra must reject stray segments. Unscoped endpoints + // (capabilities, identity) must reject any extra segment instead of mis-classifying + // the trailing segment as an unsupported fork. private static readonly object[] s_malformedPathCases = [ new object[] { "POST", $"/engine/v2/{CancunUrl}/forkchoice/", true }, new object[] { "GET", "/engine/v2/capabilities/", false }, + new object[] { "GET", "/engine/v2/capabilities/foo", true }, + new object[] { "GET", "/engine/v2/identity/foo", true }, new object[] { "POST", $"/engine/v2/{CancunUrl}/forkchoice/whatever", false }, ]; From e96869b62e1143190eebc733b67bc30cf3d42642 Mon Sep 17 00:00:00 2001 From: "lukasz.rozmej" Date: Fri, 12 Jun 2026 18:08:17 +0200 Subject: [PATCH 27/35] address two-reviewer findings on SSZ-REST surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ForkchoiceUpdatedSszHandler.GetUrlForkMismatchMessage compared payloadSpec.Name to the URL fork segment. spec.Name is a display string ("bpo1", "Cancun NoBlobs", subclass variants), so a payload timestamped inside a BPO fork sent to /engine/v2/osaka/forkchoice was being rejected as unsupported-fork even though Osaka and BPO1 share the same engine API surface. Add SszRestPaths.GetEngineApiUrlSegment to walk the parent chain to the engine-API ancestor (BPO1 → Osaka, "Cancun NoBlobs" → Cancun) and compare against that. Regression test Forkchoice_payload_in_BPO_fork_routes_to_parent_url fails on the prior spec.Name comparison. Other findings from the same reviews: - GetPayloadSszHandler: set Cache-Control: no-store before the payload-id parse, so the header lands on the 400 path too — spec applies it to all GET /payloads/{id} responses. - EngineRpcCapabilitiesProvider: GET /engine/v2/blobs/v4 is annotated // Osaka (EIP-7594/PeerDAS) and gated by IsEip7594Enabled; move it into the Osaka block where it belongs. - TryResolveHandler: drop the pathSegment.ToString() allocation; work off ReadOnlyMemory/ReadOnlySpan, with a span overload of MapForkToVersion. Now zero-alloc on the routing hot path. - EncodeGetBlobsV4Response: guard against b.BlobCells / b.Proofs that are shorter than CellsPerExtBlob (defensive; current callers are safe). - GetBlobsHandlerV4: track GetBlobsRequestsFailureTotal on partial misses for symmetry with GetBlobsHandler V1. - EngineRpcCapabilitiesProvider: publish _ssz via Volatile.Write and read via Volatile.Read for clarity (the implicit acquire through _jsonRpc was correct but obscured the contract). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../SszRest/SszMiddlewareTests.cs | 42 +++++++++++++++++++ .../Handlers/EngineRpcCapabilitiesProvider.cs | 8 ++-- .../Handlers/GetBlobsHandlerV4.cs | 3 +- .../Handlers/ForkchoiceUpdatedSszHandler.cs | 3 +- .../SszRest/Handlers/GetPayloadSszHandler.cs | 2 +- .../SszRest/Handlers/SszRestPaths.cs | 19 ++++++++- .../SszRest/SszCodec.cs | 6 ++- .../SszRest/SszMiddleware.cs | 32 +++++++------- 8 files changed, 87 insertions(+), 28 deletions(-) diff --git a/src/Nethermind/Nethermind.Merge.Plugin.Test/SszRest/SszMiddlewareTests.cs b/src/Nethermind/Nethermind.Merge.Plugin.Test/SszRest/SszMiddlewareTests.cs index 7c3ac58bed9e..0131219a3748 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin.Test/SszRest/SszMiddlewareTests.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin.Test/SszRest/SszMiddlewareTests.cs @@ -51,6 +51,7 @@ public class SszMiddlewareTests private static readonly string ParisUrl = Paris.Instance.EngineApiUrlSegment!; private static readonly string ShanghaiUrl = Shanghai.Instance.EngineApiUrlSegment!; private static readonly string CancunUrl = Cancun.Instance.EngineApiUrlSegment!; + private static readonly string OsakaUrl = Osaka.Instance.EngineApiUrlSegment!; private static readonly string AmsterdamUrl = Amsterdam.Instance.EngineApiUrlSegment!; [SetUp] @@ -817,6 +818,47 @@ public async Task Forkchoice_stale_fork_url_without_attributes_is_allowed() _specProvider.DidNotReceive().GetSpec(Arg.Any()); } + [Test] + public async Task Forkchoice_payload_in_BPO_fork_routes_to_parent_url() + { + ForkchoiceUpdatedV1Result fcuResult = new() + { + PayloadStatus = new PayloadStatusV1 { Status = PayloadStatus.Valid, LatestValidHash = TestItem.KeccakA } + }; + _engineModule.engine_forkchoiceUpdatedV3(Arg.Any(), Arg.Any()) + .Returns(ResultWrapper.Success(fcuResult)); + + const ulong payloadTs = 1_000UL; + _specProvider.GetSpec(Arg.Is(fa => fa.Timestamp == payloadTs)) + .Returns(BPO1.Instance); + + ForkchoiceUpdatedV3RequestWire request = new() + { + ForkchoiceState = new ForkchoiceStateWire + { + HeadBlockHash = TestItem.KeccakA, + SafeBlockHash = TestItem.KeccakB, + FinalizedBlockHash = Keccak.Zero + }, + PayloadAttributes = [new PayloadAttributesV3Wire + { + Timestamp = payloadTs, + SuggestedFeeRecipient = TestItem.AddressA, + PrevRandao = Keccak.Zero, + Withdrawals = [], + ParentBeaconBlockRoot = Keccak.Zero + }] + }; + byte[] body = ForkchoiceUpdatedV3RequestWire.Encode(request); + + DefaultHttpContext ctx = MakePostContext($"/engine/v2/{OsakaUrl}/forkchoice", body); + + await _middleware.InvokeAsync(ctx); + + Assert.That(ctx.Response.StatusCode, Is.EqualTo(StatusCodes.Status200OK)); + await _engineModule.Received(1).engine_forkchoiceUpdatedV3(Arg.Any(), Arg.Any()); + } + [TestCase("application/json")] [TestCase("*/*")] [TestCase("text/html, application/json;q=0.9, */*;q=0.8")] diff --git a/src/Nethermind/Nethermind.Merge.Plugin/Handlers/EngineRpcCapabilitiesProvider.cs b/src/Nethermind/Nethermind.Merge.Plugin/Handlers/EngineRpcCapabilitiesProvider.cs index 9deb254d6bf3..e101c2326cbc 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/Handlers/EngineRpcCapabilitiesProvider.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/Handlers/EngineRpcCapabilitiesProvider.cs @@ -27,14 +27,14 @@ public class EngineRpcCapabilitiesProvider(ISpecProvider specProvider) : IRpcCap public FrozenDictionary GetJsonRpcCapabilities() { EnsureBuilt(); - return _jsonRpc!; + return Volatile.Read(ref _jsonRpc)!; } /// SSZ-REST path capabilities only (e.g. "POST /engine/v1/payloads"). public FrozenDictionary GetSszRestPaths() { EnsureBuilt(); - return _ssz!; + return Volatile.Read(ref _ssz)!; } /// Union of JSON-RPC capabilities and SSZ-REST paths — what @@ -61,7 +61,7 @@ private void EnsureBuilt() { if (Volatile.Read(ref _jsonRpc) is not null) return; Build(specProvider.GetFinalSpec(), out Dictionary json, out Dictionary ssz); - _ssz = ssz.ToFrozenDictionary(); + Volatile.Write(ref _ssz, ssz.ToFrozenDictionary()); Volatile.Write(ref _jsonRpc, json.ToFrozenDictionary()); } @@ -118,6 +118,7 @@ void Configure(string method, string path, RpcCapabilityOptions options) Configure(nameof(IEngineRpcModule.engine_getPayloadV5), SszRestPaths.GetV5Payloads, GateWithWarn(spec.IsEip7594Enabled)); Configure(nameof(IEngineRpcModule.engine_getBlobsV2), SszRestPaths.PostV2Blobs, Gate(spec.IsEip7594Enabled)); Configure(nameof(IEngineRpcModule.engine_getBlobsV3), SszRestPaths.PostV3Blobs, Gate(spec.IsEip7594Enabled)); + Configure(nameof(IEngineRpcModule.engine_getBlobsV4), SszRestPaths.PostV4Blobs, Gate(spec.IsEip7594Enabled)); // Amsterdam Configure(nameof(IEngineRpcModule.engine_getPayloadV6), SszRestPaths.GetV6Payloads, GateWithWarn(spec.IsEip7928Enabled)); @@ -125,7 +126,6 @@ void Configure(string method, string path, RpcCapabilityOptions options) Configure(nameof(IEngineRpcModule.engine_forkchoiceUpdatedV4), SszRestPaths.PostV4Forkchoice, GateWithWarn(spec.IsEip7843Enabled)); Configure(nameof(IEngineRpcModule.engine_getPayloadBodiesByHashV2), SszRestPaths.PostV2PayloadBodiesByHash, GateWithWarn(spec.IsEip7928Enabled)); Configure(nameof(IEngineRpcModule.engine_getPayloadBodiesByRangeV2), SszRestPaths.GetV2PayloadBodiesByRange, GateWithWarn(spec.IsEip7928Enabled)); - Configure(nameof(IEngineRpcModule.engine_getBlobsV4), SszRestPaths.PostV4Blobs, Gate(spec.IsEip7594Enabled)); json = jsonLocal; ssz = sszLocal; diff --git a/src/Nethermind/Nethermind.Merge.Plugin/Handlers/GetBlobsHandlerV4.cs b/src/Nethermind/Nethermind.Merge.Plugin/Handlers/GetBlobsHandlerV4.cs index 47e094eeb90c..3ca4db330a07 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/Handlers/GetBlobsHandlerV4.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/Handlers/GetBlobsHandlerV4.cs @@ -105,7 +105,8 @@ public class GetBlobsHandlerV4(ITxPool txPool) : IAsyncHandler?>.Success( new BlobsV4DirectResponse(blobs, proofs, response, n)); } diff --git a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/ForkchoiceUpdatedSszHandler.cs b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/ForkchoiceUpdatedSszHandler.cs index 056c9b6d304c..d1657c86636c 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/ForkchoiceUpdatedSszHandler.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/ForkchoiceUpdatedSszHandler.cs @@ -51,7 +51,8 @@ public override async Task HandleAsync(HttpContext ctx, int version, ReadOnlyMem return null; IReleaseSpec payloadSpec = specProvider.GetSpec(ForkActivation.TimestampOnly(timestamp.Value)); - return string.Equals(payloadSpec.Name, urlFork, StringComparison.OrdinalIgnoreCase) + string? payloadForkSegment = SszRestPaths.GetEngineApiUrlSegment(payloadSpec); + return string.Equals(payloadForkSegment, urlFork, StringComparison.OrdinalIgnoreCase) ? null : $"URL fork '{urlFork}' does not match the fork for timestamp {timestamp.Value}"; } diff --git a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/GetPayloadSszHandler.cs b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/GetPayloadSszHandler.cs index 456810b52239..037901b06764 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/GetPayloadSszHandler.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/GetPayloadSszHandler.cs @@ -28,9 +28,9 @@ public sealed class GetPayloadSszHandler(IEngineRpcModule eng public override async Task HandleAsync(HttpContext ctx, int v, ReadOnlyMemory extra, ReadOnlySequence body) { + ctx.Response.Headers.CacheControl = "no-store"; if (TryParsePayloadId(extra.Span, out byte[] id, out string err)) { - ctx.Response.Headers.CacheControl = "no-store"; await WriteSszResultAsync(ctx, await TVersion.Call(engine, id), static (d, w) => TVersion.Encode(d!, w)); } else diff --git a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/SszRestPaths.cs b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/SszRestPaths.cs index eb586d6af0a9..d7f83b522910 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/SszRestPaths.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/SszRestPaths.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Frozen; using System.Collections.Generic; +using Nethermind.Core.Specs; using Nethermind.Specs.Forks; using Forks = Nethermind.Specs.Forks; @@ -114,7 +115,7 @@ public static class SszRestPaths /// Each fork only declares the versions it changes vs. its parent; values flow forward through /// the spec inheritance chain. /// - public static int? MapForkToVersion(string fork, string resource, string httpMethod) + public static int? MapForkToVersion(string fork, ReadOnlySpan resource, string httpMethod) { if (!_forkSpecByUrl.TryGetValue(fork, out Forks.NamedReleaseSpec? spec)) return null; @@ -134,6 +135,20 @@ public static class SszRestPaths return null; - static bool Eq(string a, string b) => string.Equals(a, b, StringComparison.OrdinalIgnoreCase); + static bool Eq(ReadOnlySpan a, string b) => a.Equals(b.AsSpan(), StringComparison.OrdinalIgnoreCase); + } + + /// + /// Returns the URL fork segment that owns 's engine API surface, + /// walking up the parent chain so BPO forks resolve to their parent (e.g. bpo1 → osaka). + /// + public static string? GetEngineApiUrlSegment(IReleaseSpec spec) + { + for (Forks.NamedReleaseSpec? n = spec as Forks.NamedReleaseSpec; n is not null; n = n.Parent) + { + if (n.EngineApiUrlSegment is { } seg && _forkSpecByUrl.ContainsKey(seg)) + return seg; + } + return null; } } diff --git a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszCodec.cs b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszCodec.cs index c37d5205f66e..d4aa3dd298f6 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszCodec.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszCodec.cs @@ -188,10 +188,12 @@ public static int EncodeGetBlobsV4Response(IReadOnlyList bl NullableBlobCellWire[] cells = new NullableBlobCellWire[CellsPerExtBlob]; NullableKzgProofWire[] proofs = new NullableKzgProofWire[CellsPerExtBlob]; + byte[]?[]? srcCells = b.BlobCells; + byte[]?[]? srcProofs = b.Proofs; for (int j = 0; j < CellsPerExtBlob; j++) { - byte[]? cell = b.BlobCells?[j]; - byte[]? proof = b.Proofs?[j]; + byte[]? cell = j < (srcCells?.Length ?? 0) ? srcCells![j] : null; + byte[]? proof = j < (srcProofs?.Length ?? 0) ? srcProofs![j] : null; cells[j] = cell is null ? new() { Cell = [] } : new() { Cell = [SszBlobCell.FromSpan(cell.AsSpan(0, SszBlobCell.BlobCellLength))] }; diff --git a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszMiddleware.cs b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszMiddleware.cs index e294e73c957d..6f3bb5930cfd 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszMiddleware.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszMiddleware.cs @@ -339,25 +339,26 @@ private bool TryResolveHandler(string method, ReadOnlyMemory pathSegment, bool isPost = HttpMethods.IsPost(method); bool isGet = !isPost && HttpMethods.IsGet(method); - string resourceStr = pathSegment.ToString(); - string extraStr = string.Empty; + ReadOnlyMemory resource = pathSegment; + ReadOnlyMemory extraMem = default; - int firstSlash = resourceStr.IndexOf('/'); + int firstSlash = pathSegment.Span.IndexOf('/'); if (firstSlash > 0) { - extraStr = resourceStr[(firstSlash + 1)..]; - resourceStr = resourceStr[..firstSlash]; + extraMem = pathSegment[(firstSlash + 1)..]; + resource = pathSegment[..firstSlash]; } - if (resourceStr.Equals("bodies", StringComparison.OrdinalIgnoreCase) && extraStr.Equals("hash", StringComparison.OrdinalIgnoreCase)) + if (resource.Span.Equals("bodies".AsSpan(), StringComparison.OrdinalIgnoreCase) + && extraMem.Span.Equals("hash".AsSpan(), StringComparison.OrdinalIgnoreCase)) { - resourceStr = "bodies/hash"; - extraStr = string.Empty; + resource = SszRestPaths.PayloadBodiesByHash.AsMemory(); + extraMem = default; } if (fork is not null) { - int? mappedVersion = MapForkToVersion(fork, resourceStr, method); + int? mappedVersion = SszRestPaths.MapForkToVersion(fork, resource.Span, method); if (mappedVersion is null) return false; version = mappedVersion.Value; } @@ -369,29 +370,29 @@ private bool TryResolveHandler(string method, ReadOnlyMemory pathSegment, FrozenDictionary>.AlternateLookup> lookup = isPost ? _postLookup : _getLookup; - if (lookup.TryGetValue(resourceStr.AsSpan(), out List? exactList)) + if (lookup.TryGetValue(resource.Span, out List? exactList)) { ISszEndpointHandler? fallback = null; foreach (ISszEndpointHandler candidate in exactList) { if (candidate.Version == version) { - if (!string.IsNullOrEmpty(extraStr) && !candidate.AcceptsPathExtra) + if (!extraMem.IsEmpty && !candidate.AcceptsPathExtra) return false; handler = candidate; - extra = extraStr.AsMemory(); + extra = extraMem; return true; } if (candidate.Version is null) fallback = candidate; } if (fallback is not null) { - if (!string.IsNullOrEmpty(extraStr) && !fallback.AcceptsPathExtra) + if (!extraMem.IsEmpty && !fallback.AcceptsPathExtra) return false; handler = fallback; - extra = extraStr.AsMemory(); + extra = extraMem; return true; } } @@ -400,9 +401,6 @@ private bool TryResolveHandler(string method, ReadOnlyMemory pathSegment, return false; } - public static int? MapForkToVersion(string fork, string resource, string httpMethod) => - SszRestPaths.MapForkToVersion(fork, resource, httpMethod); - private static SszRequestKind ClassifySszRequest(HttpContext ctx) { From d6d3e66c529d1a6f69752335a60ab884f4f7f717 Mon Sep 17 00:00:00 2001 From: "lukasz.rozmej" Date: Fri, 12 Jun 2026 22:01:08 +0200 Subject: [PATCH 28/35] style: remove unused using directives flagged by IDE0005 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Nethermind.Merge.Plugin/Data/IExecutionPayloadParams.cs | 1 - .../Nethermind.Optimism.Test/OptimismPayloadAttributesTests.cs | 1 - 2 files changed, 2 deletions(-) diff --git a/src/Nethermind/Nethermind.Merge.Plugin/Data/IExecutionPayloadParams.cs b/src/Nethermind/Nethermind.Merge.Plugin/Data/IExecutionPayloadParams.cs index caebfa7a5f43..e5adcfc64343 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/Data/IExecutionPayloadParams.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/Data/IExecutionPayloadParams.cs @@ -2,7 +2,6 @@ // SPDX-License-Identifier: LGPL-3.0-only using System; -using Nethermind.Consensus; using Nethermind.Core; using Nethermind.Core.Crypto; using Nethermind.Core.Specs; diff --git a/src/Nethermind/Nethermind.Optimism.Test/OptimismPayloadAttributesTests.cs b/src/Nethermind/Nethermind.Optimism.Test/OptimismPayloadAttributesTests.cs index 52e5f34cd1f3..aff5cfe42455 100644 --- a/src/Nethermind/Nethermind.Optimism.Test/OptimismPayloadAttributesTests.cs +++ b/src/Nethermind/Nethermind.Optimism.Test/OptimismPayloadAttributesTests.cs @@ -2,7 +2,6 @@ // SPDX-License-Identifier: LGPL-3.0-only using System.Collections.Generic; -using Nethermind.Consensus; using Nethermind.Consensus.Producers; using Nethermind.Core; using Nethermind.Core.Crypto; From 7ca8a9707a7c645dbd20bcd3e3b8ee5061a19518 Mon Sep 17 00:00:00 2001 From: "lukasz.rozmej" Date: Fri, 12 Jun 2026 22:26:46 +0200 Subject: [PATCH 29/35] align SSZ-REST surface with execution-apis #793 spec S2 (validation_error wire shape): PayloadStatus.validation_error per spec is Optional[String] = List[List[byte, 1024], 1]. PR encoded it as List[byte, 1024] directly, missing the outer one-element-list layer. Non-empty errors emitted 4 bytes less than spec. Wrap the bytes in a new SszValidationError container and expose it via [SszList(1)]. S5 (bodies fork-range filtering): per spec, /engine/v2/{fork}/bodies/... responses MUST mark entries available=false when the block timestamp falls outside the URL fork's range. Apply the filter at the SSZ-REST boundary (the underlying engine handler stays unscoped because it's shared with JSON-RPC). New helper BodiesForkFilter resolves each entry's engine-API URL segment via SszRestPaths.GetEngineApiUrlSegment and zeroes out non-matching entries. By-hash uses the requested hashes for lookup; by-range uses start+index. IBlockFinder bridged into the SSZ DI container. Regression test GetPayloadBodiesByHash_marks_out_of_fork_blocks_unavailable verifies a Cancun-timestamped block at /shanghai/bodies/hash decodes as available=false (and the test fails on the unfiltered handler). S8 (payload.max_bytes): drop from 128 MiB to 64 MiB to match the spec capabilities-response example. The previous inline comment claimed alignment with a non-existent MAX_REQUEST_BODY_SIZE spec constant; replace it with a reference to the actual spec text. S1 (target_gas_limit) and S3 (BYTES_PER_CELL spec ambiguity) skipped per author preference: target_gas_limit will land separately when the block-builder side wires through, and BYTES_PER_CELL = 2048 stays because it's the only value that interops with c-kzg. Both raised on the upstream spec PR. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../SszRest/SszMiddlewareTests.cs | 40 +++++++++-- .../SszRest/Handlers/BodiesForkFilter.cs | 66 +++++++++++++++++++ .../GetPayloadBodiesByHashSszHandler.cs | 14 +++- .../GetPayloadBodiesByRangeSszHandler.cs | 14 +++- .../SszRest/SszCodec.cs | 18 +++-- .../SszRest/SszMiddleware.cs | 8 +-- .../SszRest/SszMiddlewareConfigurer.cs | 1 + .../SszRest/SszWireTypes.cs | 8 ++- 8 files changed, 152 insertions(+), 17 deletions(-) create mode 100644 src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/BodiesForkFilter.cs diff --git a/src/Nethermind/Nethermind.Merge.Plugin.Test/SszRest/SszMiddlewareTests.cs b/src/Nethermind/Nethermind.Merge.Plugin.Test/SszRest/SszMiddlewareTests.cs index 0131219a3748..bce1d87c2e12 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin.Test/SszRest/SszMiddlewareTests.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin.Test/SszRest/SszMiddlewareTests.cs @@ -35,6 +35,7 @@ public class SszMiddlewareTests { private IEngineRpcModule _engineModule = null!; private ISpecProvider _specProvider = null!; + private Nethermind.Blockchain.Find.IBlockFinder _blockFinder = null!; private IJsonRpcUrlCollection _urlCollection = null!; private IRpcAuthentication _auth = null!; @@ -59,6 +60,7 @@ public void SetUp() { _engineModule = Substitute.For(); _specProvider = Substitute.For(); + _blockFinder = Substitute.For(); _urlCollection = Substitute.For(); _auth = Substitute.For(); @@ -102,11 +104,11 @@ private SszMiddleware BuildMiddleware(RequestDelegate? next = null) new GetBlobsV2SszHandler(_engineModule), new GetBlobsV4SszHandler(_engineModule), - new GetPayloadBodiesByHashSszHandler(_engineModule), - new GetPayloadBodiesByHashSszHandler(_engineModule), + new GetPayloadBodiesByHashSszHandler(_engineModule, _blockFinder, _specProvider), + new GetPayloadBodiesByHashSszHandler(_engineModule, _blockFinder, _specProvider), - new GetPayloadBodiesByRangeSszHandler(_engineModule), - new GetPayloadBodiesByRangeSszHandler(_engineModule), + new GetPayloadBodiesByRangeSszHandler(_engineModule, _blockFinder, _specProvider), + new GetPayloadBodiesByRangeSszHandler(_engineModule, _blockFinder, _specProvider), new ClientVersionSszHandler(_engineModule, LimboLogs.Instance), new CapabilitiesSszHandler(_specProvider), @@ -339,6 +341,36 @@ public async Task GetPayloadBodiesByHash_routes_to_correct_engine_method(int ver await _engineModule.Received(version == 2 ? 1 : 0).engine_getPayloadBodiesByHashV2(Arg.Any>()); } + [Test] + public async Task GetPayloadBodiesByHash_marks_out_of_fork_blocks_unavailable() + { + Hash256 inFork = TestItem.KeccakA; + Hash256 outOfFork = TestItem.KeccakB; + _engineModule.engine_getPayloadBodiesByHashV1(Arg.Any>()) + .Returns(ResultWrapper>.Success( + [new ExecutionPayloadBodyV1Result([], null), new ExecutionPayloadBodyV1Result([], null)])); + + BlockHeader shanghaiHeader = Build.A.BlockHeader.WithNumber(10).WithTimestamp(1_000UL).TestObject; + BlockHeader cancunHeader = Build.A.BlockHeader.WithNumber(20).WithTimestamp(2_000UL).TestObject; + _blockFinder.FindHeader(inFork).Returns(shanghaiHeader); + _blockFinder.FindHeader(outOfFork).Returns(cancunHeader); + _specProvider.GetSpec(Arg.Is(fa => fa.Timestamp == 1_000UL)).Returns(Shanghai.Instance); + _specProvider.GetSpec(Arg.Is(fa => fa.Timestamp == 2_000UL)).Returns(Cancun.Instance); + + byte[] body = BuildPayloadBodiesByHashRequest([inFork, outOfFork]); + DefaultHttpContext ctx = MakePostContext($"/engine/v2/{ShanghaiUrl}/bodies/hash", body); + + await _middleware.InvokeAsync(ctx); + + Assert.That(ctx.Response.StatusCode, Is.EqualTo(StatusCodes.Status200OK)); + byte[] resp = ResponseBytes(ctx); + PayloadBodiesV1ResponseWire.Decode(new ReadOnlySequence(resp), out PayloadBodiesV1ResponseWire decoded); + Assert.That(decoded.Entries, Is.Not.Null); + Assert.That(decoded.Entries!.Length, Is.EqualTo(2)); + Assert.That(decoded.Entries[0].Available, Is.True, "Shanghai block at /shanghai/bodies must stay available"); + Assert.That(decoded.Entries[1].Available, Is.False, "Cancun block at /shanghai/bodies must surface as unavailable"); + } + private static readonly object[] BodiesByRangeRoutingCases = [ new object[] { EngineApiVersions.PayloadBodiesByRange.V1, $"/engine/v2/{ShanghaiUrl}/bodies" }, diff --git a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/BodiesForkFilter.cs b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/BodiesForkFilter.cs new file mode 100644 index 000000000000..170ed46bd32d --- /dev/null +++ b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/BodiesForkFilter.cs @@ -0,0 +1,66 @@ +// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited +// SPDX-License-Identifier: LGPL-3.0-only + +using System; +using System.Collections.Generic; +using Nethermind.Blockchain.Find; +using Nethermind.Core; +using Nethermind.Core.Crypto; +using Nethermind.Core.Specs; + +namespace Nethermind.Merge.Plugin.SszRest.Handlers; + +/// +/// Per execution-apis #793, /engine/v2/{fork}/bodies/... responses MUST mark +/// blocks whose timestamp falls outside the URL fork as available=false. +/// Applied at the SSZ-REST boundary because the underlying engine handler is shared +/// with the un-scoped JSON-RPC engine_getPayloadBodies* methods. +/// +internal static class BodiesForkFilter +{ + public static IReadOnlyList FilterByHash( + IReadOnlyList bodies, + IReadOnlyList hashes, + string? urlFork, + IBlockFinder blockFinder, + ISpecProvider specProvider) + where TResult : class + { + if (urlFork is null || bodies.Count == 0) return bodies; + TResult?[] result = new TResult?[bodies.Count]; + for (int i = 0; i < bodies.Count; i++) + { + if (bodies[i] is null) continue; + BlockHeader? header = blockFinder.FindHeader(hashes[i]); + if (header is not null && Matches(header, urlFork, specProvider)) + result[i] = bodies[i]; + } + return result; + } + + public static IReadOnlyList FilterByRange( + IReadOnlyList bodies, + long start, + string? urlFork, + IBlockFinder blockFinder, + ISpecProvider specProvider) + where TResult : class + { + if (urlFork is null || bodies.Count == 0) return bodies; + TResult?[] result = new TResult?[bodies.Count]; + for (int i = 0; i < bodies.Count; i++) + { + if (bodies[i] is null) continue; + BlockHeader? header = blockFinder.FindHeader(start + i); + if (header is not null && Matches(header, urlFork, specProvider)) + result[i] = bodies[i]; + } + return result; + } + + private static bool Matches(BlockHeader header, string urlFork, ISpecProvider specProvider) + { + IReleaseSpec spec = specProvider.GetSpec(header); + return string.Equals(SszRestPaths.GetEngineApiUrlSegment(spec), urlFork, StringComparison.OrdinalIgnoreCase); + } +} diff --git a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/GetPayloadBodiesByHashSszHandler.cs b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/GetPayloadBodiesByHashSszHandler.cs index e86dd5754c3c..21a645de382c 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/GetPayloadBodiesByHashSszHandler.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/GetPayloadBodiesByHashSszHandler.cs @@ -6,7 +6,10 @@ using System.Collections.Generic; using System.Threading.Tasks; using Microsoft.AspNetCore.Http; +using Nethermind.Blockchain.Find; +using Nethermind.Core; using Nethermind.Core.Crypto; +using Nethermind.Core.Specs; using Nethermind.JsonRpc; namespace Nethermind.Merge.Plugin.SszRest.Handlers; @@ -16,7 +19,10 @@ namespace Nethermind.Merge.Plugin.SszRest.Handlers; /// of engine_getPayloadBodiesByHashV{N}. Generic over a per-version descriptor /// so adding a Vn+1 endpoint is one new descriptor + one DI line. /// -public sealed class GetPayloadBodiesByHashSszHandler(IEngineRpcModule engineModule) +public sealed class GetPayloadBodiesByHashSszHandler( + IEngineRpcModule engineModule, + IBlockFinder blockFinder, + ISpecProvider specProvider) : SszEndpointHandlerBase where TVersion : struct, IPayloadBodiesByHashVersion where TResult : class @@ -36,6 +42,12 @@ await WriteErrorAsync(ctx, StatusCodes.Status413PayloadTooLarge, return; } ResultWrapper> result = await TVersion.Call(engineModule, hashes); + string? urlFork = ctx.Items.TryGetValue("SszRouteFork", out object? f) ? f as string : null; + if (result.Result.ResultType == ResultType.Success && result.Data is not null) + { + result = ResultWrapper>.Success( + BodiesForkFilter.FilterByHash(result.Data, hashes, urlFork, blockFinder, specProvider)); + } await WriteSszResultAsync(ctx, result, TVersion.Encode); } } diff --git a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/GetPayloadBodiesByRangeSszHandler.cs b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/GetPayloadBodiesByRangeSszHandler.cs index 2f0c89d89d94..52cb1e0b3c8a 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/GetPayloadBodiesByRangeSszHandler.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/GetPayloadBodiesByRangeSszHandler.cs @@ -6,6 +6,9 @@ using System.Collections.Generic; using System.Threading.Tasks; using Microsoft.AspNetCore.Http; +using Nethermind.Blockchain.Find; +using Nethermind.Core; +using Nethermind.Core.Specs; using Nethermind.JsonRpc; namespace Nethermind.Merge.Plugin.SszRest.Handlers; @@ -15,7 +18,10 @@ namespace Nethermind.Merge.Plugin.SszRest.Handlers; /// of engine_getPayloadBodiesByRangeV{N}. Generic over a per-version descriptor /// so adding a Vn+1 endpoint is one new descriptor + one DI line. /// -public sealed class GetPayloadBodiesByRangeSszHandler(IEngineRpcModule engineModule) +public sealed class GetPayloadBodiesByRangeSszHandler( + IEngineRpcModule engineModule, + IBlockFinder blockFinder, + ISpecProvider specProvider) : SszEndpointHandlerBase where TVersion : struct, IPayloadBodiesByRangeVersion where TResult : class @@ -47,6 +53,12 @@ await WriteErrorAsync(ctx, StatusCodes.Status413PayloadTooLarge, return; } ResultWrapper> result = await TVersion.Call(engineModule, start, count); + string? urlFork = ctx.Items.TryGetValue("SszRouteFork", out object? f) ? f as string : null; + if (result.Result.ResultType == ResultType.Success && result.Data is not null) + { + result = ResultWrapper>.Success( + BodiesForkFilter.FilterByRange(result.Data, start, urlFork, blockFinder, specProvider)); + } await WriteSszResultAsync(ctx, result, TVersion.Encode); } } diff --git a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszCodec.cs b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszCodec.cs index d4aa3dd298f6..804f03deff16 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszCodec.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszCodec.cs @@ -315,17 +315,23 @@ public static int EncodeClientVersionResponse(ClientVersionV1[] versions, IBuffe private static PayloadStatusWire BuildPayloadStatusWire(PayloadStatusV1 ps) { const int MaxErrorBytes = 1024; - byte[] errorBytes = ps.ValidationError is not null - ? Encoding.UTF8.GetBytes(ps.ValidationError) - : []; - if (errorBytes.Length > MaxErrorBytes) - errorBytes = errorBytes[..MaxErrorBytes]; + SszValidationError[] error; + if (ps.ValidationError is null) + { + error = []; + } + else + { + byte[] errorBytes = Encoding.UTF8.GetBytes(ps.ValidationError); + if (errorBytes.Length > MaxErrorBytes) errorBytes = errorBytes[..MaxErrorBytes]; + error = [new SszValidationError { Bytes = errorBytes }]; + } return new() { Status = EngineStatusToSsz(ps.Status), LatestValidHash = ps.LatestValidHash is not null ? [ps.LatestValidHash] : [], - ValidationError = errorBytes + ValidationError = error }; } diff --git a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszMiddleware.cs b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszMiddleware.cs index 6f3bb5930cfd..b77d31aae158 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszMiddleware.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszMiddleware.cs @@ -36,11 +36,11 @@ public sealed class SszMiddleware private const string EnginePrefix = "/engine/v2/"; /// - /// Maximum allowed request body size in bytes (128 MiB). - /// Matches MAX_REQUEST_BODY_SIZE in the Engine API SSZ-REST spec - /// (see https://github.com/ethereum/execution-apis/pull/793). + /// Maximum allowed request body size in bytes (64 MiB). + /// Mirrors the payload.max_bytes example value advertised in the Engine API + /// SSZ-REST spec capabilities response (see https://github.com/ethereum/execution-apis/pull/793). /// - public const int MaxBodySize = 0x8000000; + public const int MaxBodySize = 0x4000000; private readonly FrozenDictionary> _postRoutes; private readonly FrozenDictionary> _getRoutes; diff --git a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszMiddlewareConfigurer.cs b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszMiddlewareConfigurer.cs index 744ac7a3d8c2..9bd0facd55dc 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszMiddlewareConfigurer.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszMiddlewareConfigurer.cs @@ -43,6 +43,7 @@ public void Configure(IServiceCollection services) services.Bridge(ctx); services.Bridge(ctx); services.Bridge(ctx); + services.Bridge(ctx); services.AddSingleton>(); services.AddSingleton>(); diff --git a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszWireTypes.cs b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszWireTypes.cs index ad4ca8200ecb..4811433c8a7e 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszWireTypes.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszWireTypes.cs @@ -29,12 +29,18 @@ public partial struct SszWithdrawal public ulong Amount { get; set; } } +[SszContainer] +public partial struct SszValidationError +{ + [SszList(1024)] public byte[]? Bytes { get; set; } +} + [SszContainer] public partial struct PayloadStatusWire { public byte Status { get; set; } [SszList(1)] public Hash256[]? LatestValidHash { get; set; } - [SszList(1024)] public byte[]? ValidationError { get; set; } + [SszList(1)] public SszValidationError[]? ValidationError { get; set; } } [SszContainer] From e0b8f48fe45bd807d40918bf592e540454642216 Mon Sep 17 00:00:00 2001 From: Lukasz Rozmej Date: Fri, 12 Jun 2026 23:03:30 +0200 Subject: [PATCH 30/35] Apply suggestion from @LukaszRozmej --- .../SszRest/Handlers/GetPayloadBodiesByHashSszHandler.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/GetPayloadBodiesByHashSszHandler.cs b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/GetPayloadBodiesByHashSszHandler.cs index 21a645de382c..a94a73581b55 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/GetPayloadBodiesByHashSszHandler.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/GetPayloadBodiesByHashSszHandler.cs @@ -42,9 +42,9 @@ await WriteErrorAsync(ctx, StatusCodes.Status413PayloadTooLarge, return; } ResultWrapper> result = await TVersion.Call(engineModule, hashes); - string? urlFork = ctx.Items.TryGetValue("SszRouteFork", out object? f) ? f as string : null; if (result.Result.ResultType == ResultType.Success && result.Data is not null) { + string? urlFork = ctx.Items.TryGetValue("SszRouteFork", out object? f) ? f as string : null; result = ResultWrapper>.Success( BodiesForkFilter.FilterByHash(result.Data, hashes, urlFork, blockFinder, specProvider)); } From f9d30816a73f906b7ea46b7c4cc1cae1dbb49621 Mon Sep 17 00:00:00 2001 From: "lukasz.rozmej" Date: Fri, 12 Jun 2026 23:22:20 +0200 Subject: [PATCH 31/35] fix disposal leak on SSZ-REST bodies path + align V4 wire to spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit S5 follow-up: the bodies engine handlers return PayloadBodiesV{1,2}DirectResponse which is IDisposable (holds MemoryManager for BAL). The previous S5 fix replaced the ResultWrapper with a new one wrapping the filtered array, dropping the original wrapper on the floor — the pooled BAL memory leaked. Forward disposal via wrapped.AddDisposable(result.Dispose) so the using-block in WriteSszResultAsync chains through to the original. S2 follow-up: SszValidationError wrapped its single variable field in a Container header, adding 4 spurious bytes per non-empty error vs the spec's flat List[List[byte, 1024], 1]. Same issue with NullableBlobCellWire and NullableKzgProofWire — extra 4 bytes per cell entry. Switch all three to [SszContainer(isCollectionItself: true)] so the generator elides the container and emits the bare inner List per spec. Verified by a regression test that asserts an INVALID PayloadStatus with "bad" validation_error encodes to exactly 16 bytes (1 status + 8 offsets + 4 inner-list offset + 3 message bytes) — matches the spec wire layout literally. BodiesForkFilter: drop the now-unused urlFork-is-null defensive branch (caller guards) and dedupe the indexer call per entry (saves an extra PayloadBody.ToResult materialization, which is non-trivial — copies BAL). Tests use Assert.EnterMultipleScope and Has.Length / Is.Empty per the project's NUnit conventions. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../SszRest/SszCodecTests.cs | 50 ++++++++++++++++++- .../SszRest/SszMiddlewareTests.cs | 13 +++-- .../SszRest/Handlers/BodiesForkFilter.cs | 20 ++++---- .../GetPayloadBodiesByHashSszHandler.cs | 11 ++-- .../GetPayloadBodiesByRangeSszHandler.cs | 8 +-- .../SszRest/SszWireTypes.cs | 6 +-- 6 files changed, 83 insertions(+), 25 deletions(-) diff --git a/src/Nethermind/Nethermind.Merge.Plugin.Test/SszRest/SszCodecTests.cs b/src/Nethermind/Nethermind.Merge.Plugin.Test/SszRest/SszCodecTests.cs index 1d1206c5f0af..68ad10f20b8a 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin.Test/SszRest/SszCodecTests.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin.Test/SszRest/SszCodecTests.cs @@ -58,6 +58,45 @@ public void EncodePayloadStatus_with_error_is_larger_than_without() Assert.That(withError.Length, Is.GreaterThan(withoutError.Length)); } + [Test] + public void EncodePayloadStatus_validation_error_wraps_in_optional_list_per_spec() + { + // Per execution-apis #793: Optional[String] = List[List[byte, 1024], 1]. + // Spec wire layout for { Status=INVALID, LatestValidHash=[], ValidationError="bad" }: + // 1 byte status (= 1) + // 4 bytes offset(LatestValidHash) (= 9) + // 4 bytes offset(ValidationError) (= 9, since LatestValidHash is empty) + // 0 bytes LatestValidHash content + // 4 bytes inner-list offset within ValidationError (= 4) + // 3 bytes "bad" + // Total = 16 bytes. + byte[] encoded = Encode( + new PayloadStatusV1 { Status = PayloadStatus.Invalid, ValidationError = "bad" }, + SszCodec.EncodePayloadStatus); + PayloadStatusWire.Decode(Seq(encoded), out PayloadStatusWire decoded); + + using (Assert.EnterMultipleScope()) + { + Assert.That(encoded, Has.Length.EqualTo(16)); + Assert.That(decoded.Status, Is.EqualTo((byte)1)); + Assert.That(decoded.ValidationError, Has.Length.EqualTo(1)); + Assert.That(decoded.ValidationError![0].Bytes, Is.EqualTo("bad"u8.ToArray())); + } + } + + [Test] + public void EncodePayloadStatus_no_validation_error_is_empty_outer_list() + { + byte[] encoded = Encode(new PayloadStatusV1 { Status = PayloadStatus.Valid }, SszCodec.EncodePayloadStatus); + PayloadStatusWire.Decode(Seq(encoded), out PayloadStatusWire decoded); + + using (Assert.EnterMultipleScope()) + { + Assert.That(decoded.Status, Is.EqualTo((byte)0)); + Assert.That(decoded.ValidationError, Is.Empty); + } + } + [Test] public void EncodeForkchoiceUpdatedResponse_payload_id_prefix_is_irrelevant() { @@ -171,8 +210,17 @@ public void EncodeGetBlobsV4Response_with_pool_rented_cells_and_proofs_round_tri { BlobCellsAndProofs entry = new() { Available = true, BlobCells = cells, Proofs = proofs }; byte[] encoded = Encode>([entry], SszCodec.EncodeGetBlobsV4Response); + GetBlobsV4ResponseWire.Decode(Seq(encoded), out GetBlobsV4ResponseWire decoded); - Assert.That(encoded.Length, Is.GreaterThan(0)); + using (Assert.EnterMultipleScope()) + { + Assert.That(encoded, Is.Not.Empty); + Assert.That(decoded.Entries, Has.Length.EqualTo(1)); + Assert.That(decoded.Entries![0].Available, Is.True); + Assert.That(decoded.Entries[0].Contents.BlobCells, Has.Length.EqualTo(cellsPerExtBlob)); + Assert.That(decoded.Entries[0].Contents.BlobCells![0].Cell, Has.Length.EqualTo(1)); + Assert.That(decoded.Entries[0].Contents.BlobCells![1].Cell, Is.Empty); + } } finally { diff --git a/src/Nethermind/Nethermind.Merge.Plugin.Test/SszRest/SszMiddlewareTests.cs b/src/Nethermind/Nethermind.Merge.Plugin.Test/SszRest/SszMiddlewareTests.cs index bce1d87c2e12..c5600709b4cb 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin.Test/SszRest/SszMiddlewareTests.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin.Test/SszRest/SszMiddlewareTests.cs @@ -362,13 +362,16 @@ public async Task GetPayloadBodiesByHash_marks_out_of_fork_blocks_unavailable() await _middleware.InvokeAsync(ctx); - Assert.That(ctx.Response.StatusCode, Is.EqualTo(StatusCodes.Status200OK)); byte[] resp = ResponseBytes(ctx); PayloadBodiesV1ResponseWire.Decode(new ReadOnlySequence(resp), out PayloadBodiesV1ResponseWire decoded); - Assert.That(decoded.Entries, Is.Not.Null); - Assert.That(decoded.Entries!.Length, Is.EqualTo(2)); - Assert.That(decoded.Entries[0].Available, Is.True, "Shanghai block at /shanghai/bodies must stay available"); - Assert.That(decoded.Entries[1].Available, Is.False, "Cancun block at /shanghai/bodies must surface as unavailable"); + + using (Assert.EnterMultipleScope()) + { + Assert.That(ctx.Response.StatusCode, Is.EqualTo(StatusCodes.Status200OK)); + Assert.That(decoded.Entries, Has.Length.EqualTo(2)); + Assert.That(decoded.Entries![0].Available, Is.True, "Shanghai block at /shanghai/bodies must stay available"); + Assert.That(decoded.Entries[1].Available, Is.False, "Cancun block at /shanghai/bodies must surface as unavailable"); + } } private static readonly object[] BodiesByRangeRoutingCases = diff --git a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/BodiesForkFilter.cs b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/BodiesForkFilter.cs index 170ed46bd32d..ddcc00a4bbf4 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/BodiesForkFilter.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/BodiesForkFilter.cs @@ -18,42 +18,42 @@ namespace Nethermind.Merge.Plugin.SszRest.Handlers; /// internal static class BodiesForkFilter { - public static IReadOnlyList FilterByHash( + public static TResult?[] FilterByHash( IReadOnlyList bodies, IReadOnlyList hashes, - string? urlFork, + string urlFork, IBlockFinder blockFinder, ISpecProvider specProvider) where TResult : class { - if (urlFork is null || bodies.Count == 0) return bodies; TResult?[] result = new TResult?[bodies.Count]; for (int i = 0; i < bodies.Count; i++) { - if (bodies[i] is null) continue; + TResult? body = bodies[i]; + if (body is null) continue; BlockHeader? header = blockFinder.FindHeader(hashes[i]); if (header is not null && Matches(header, urlFork, specProvider)) - result[i] = bodies[i]; + result[i] = body; } return result; } - public static IReadOnlyList FilterByRange( + public static TResult?[] FilterByRange( IReadOnlyList bodies, long start, - string? urlFork, + string urlFork, IBlockFinder blockFinder, ISpecProvider specProvider) where TResult : class { - if (urlFork is null || bodies.Count == 0) return bodies; TResult?[] result = new TResult?[bodies.Count]; for (int i = 0; i < bodies.Count; i++) { - if (bodies[i] is null) continue; + TResult? body = bodies[i]; + if (body is null) continue; BlockHeader? header = blockFinder.FindHeader(start + i); if (header is not null && Matches(header, urlFork, specProvider)) - result[i] = bodies[i]; + result[i] = body; } return result; } diff --git a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/GetPayloadBodiesByHashSszHandler.cs b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/GetPayloadBodiesByHashSszHandler.cs index a94a73581b55..284a98b6ed14 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/GetPayloadBodiesByHashSszHandler.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/GetPayloadBodiesByHashSszHandler.cs @@ -42,11 +42,16 @@ await WriteErrorAsync(ctx, StatusCodes.Status413PayloadTooLarge, return; } ResultWrapper> result = await TVersion.Call(engineModule, hashes); - if (result.Result.ResultType == ResultType.Success && result.Data is not null) + if (result.Result.ResultType == ResultType.Success && result.Data is { Count: > 0 } data) { string? urlFork = ctx.Items.TryGetValue("SszRouteFork", out object? f) ? f as string : null; - result = ResultWrapper>.Success( - BodiesForkFilter.FilterByHash(result.Data, hashes, urlFork, blockFinder, specProvider)); + if (urlFork is not null) + { + TResult?[] filtered = BodiesForkFilter.FilterByHash(data, hashes, urlFork, blockFinder, specProvider); + ResultWrapper> wrapped = ResultWrapper>.Success(filtered); + wrapped.AddDisposable(result.Dispose); + result = wrapped; + } } await WriteSszResultAsync(ctx, result, TVersion.Encode); } diff --git a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/GetPayloadBodiesByRangeSszHandler.cs b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/GetPayloadBodiesByRangeSszHandler.cs index 52cb1e0b3c8a..a8483bab02dd 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/GetPayloadBodiesByRangeSszHandler.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/GetPayloadBodiesByRangeSszHandler.cs @@ -54,10 +54,12 @@ await WriteErrorAsync(ctx, StatusCodes.Status413PayloadTooLarge, } ResultWrapper> result = await TVersion.Call(engineModule, start, count); string? urlFork = ctx.Items.TryGetValue("SszRouteFork", out object? f) ? f as string : null; - if (result.Result.ResultType == ResultType.Success && result.Data is not null) + if (urlFork is not null && result.Result.ResultType == ResultType.Success && result.Data is { Count: > 0 } data) { - result = ResultWrapper>.Success( - BodiesForkFilter.FilterByRange(result.Data, start, urlFork, blockFinder, specProvider)); + TResult?[] filtered = BodiesForkFilter.FilterByRange(data, start, urlFork, blockFinder, specProvider); + ResultWrapper> wrapped = ResultWrapper>.Success(filtered); + wrapped.AddDisposable(result.Dispose); + result = wrapped; } await WriteSszResultAsync(ctx, result, TVersion.Encode); } diff --git a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszWireTypes.cs b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszWireTypes.cs index 4811433c8a7e..ac4bc73de5cf 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszWireTypes.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszWireTypes.cs @@ -29,7 +29,7 @@ public partial struct SszWithdrawal public ulong Amount { get; set; } } -[SszContainer] +[SszContainer(isCollectionItself: true)] public partial struct SszValidationError { [SszList(1024)] public byte[]? Bytes { get; set; } @@ -421,13 +421,13 @@ public partial struct GetBlobsV4RequestWire [SszVector(128)] public BitArray? IndicesBitarray { get; set; } } -[SszContainer] +[SszContainer(isCollectionItself: true)] public partial struct NullableBlobCellWire { [SszList(1)] public SszBlobCell[]? Cell { get; set; } } -[SszContainer] +[SszContainer(isCollectionItself: true)] public partial struct NullableKzgProofWire { [SszList(1)] public SszKzgCommitment[]? Proof { get; set; } From 0d43291fb2b2fcdde8701c0c69cd2a8987d5444c Mon Sep 17 00:00:00 2001 From: "lukasz.rozmej" Date: Fri, 12 Jun 2026 23:33:17 +0200 Subject: [PATCH 32/35] fix SszExecutionPayloadV4.BlockAccessList limit to match spec MAX_BAL_BYTES execution-apis #793 defines MAX_BAL_BYTES = MAX_BYTES_PER_TX = 2^30 and uses the same ByteList[MAX_BAL_BYTES] type on both block_access_list fields of ExecutionPayloadAmsterdam and ExecutionPayloadBodyAmsterdam. The PR had: SszExecutionPayloadV4.BlockAccessList: [SszList(0x0100_0000)] (16 MiB) ExecutionPayloadBodyV2Wire.BlockAccessList: [SszList(0x4000_0000)] (1 GiB) Internally inconsistent, and the 16 MiB cap on the payload side would reject spec-conformant CL submissions with a larger BAL even though the bodies side accepts them. Align SszExecutionPayloadV4 to MAX_BAL_BYTES = 2^30. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Nethermind.Merge.Plugin/SszRest/SszExecutionPayload.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszExecutionPayload.cs b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszExecutionPayload.cs index d63030885054..70f67a34253a 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszExecutionPayload.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszExecutionPayload.cs @@ -262,7 +262,10 @@ public SszExecutionPayloadV4() : this(new ExecutionPayloadV4()) { } public override ExecutionPayloadV4 AsExecutionPayload() => Inner; - [SszList(0x0100_0000)] + // MAX_BAL_BYTES = MAX_BYTES_PER_TX (2^30) per execution-apis #793; matches + // ExecutionPayloadBodyV2Wire.BlockAccessList so the payload-submission and + // bodies surfaces accept the same maximum size. + [SszList(0x4000_0000)] public byte[] BlockAccessList { get => Inner.BlockAccessList ?? []; From dfa8e83ae9581181e44bdd0b4ce4973eb79a0624 Mon Sep 17 00:00:00 2001 From: "lukasz.rozmej" Date: Fri, 12 Jun 2026 23:50:59 +0200 Subject: [PATCH 33/35] revert SszExecutionPayloadV4.BlockAccessList limit to 16 MiB MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI red on Ethereum.Blockchain.Pyspec.Test [Sequential] — every Amsterdam fork test fails with "Unable to determine finalized state root at block 0". StatelessExecutor.InputDecoder calls NewPayloadRequest.Merkleize on the SSZ-encoded stateless input, and that root is used to look up the finalized state. The merkle-tree depth for variable lists is fixed by the [SszList(N)] limit (depth = ceil(log2(N/32))), so changing the BAL limit from 0x0100_0000 → 0x4000_0000 changes the V4 payload's merkle root and breaks the lookup against fixtures generated against the old limit. The execution-apis #793 sketch says MAX_BAL_BYTES = MAX_BYTES_PER_TX (2^30), but the pyspec test fixtures (and Nethermind's stateless executor input format that consumes them) were generated against the 16 MiB value. Keep 0x0100_0000 here until the upstream spec value is settled and the fixtures align; the divergence is already flagged on the spec PR (execution-apis#793#issuecomment-4695124899 / -4695633112). Re-fix later once spec + fixtures agree. ExecutionPayloadBodyV2Wire.BlockAccessList stays at 0x4000_0000 — bodies aren't merkleized by the stateless executor. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../SszRest/SszExecutionPayload.cs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszExecutionPayload.cs b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszExecutionPayload.cs index 70f67a34253a..3e44de4e1fed 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszExecutionPayload.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszExecutionPayload.cs @@ -262,10 +262,11 @@ public SszExecutionPayloadV4() : this(new ExecutionPayloadV4()) { } public override ExecutionPayloadV4 AsExecutionPayload() => Inner; - // MAX_BAL_BYTES = MAX_BYTES_PER_TX (2^30) per execution-apis #793; matches - // ExecutionPayloadBodyV2Wire.BlockAccessList so the payload-submission and - // bodies surfaces accept the same maximum size. - [SszList(0x4000_0000)] + // Keep at 0x0100_0000 (16 MiB) to match execution-spec-tests fixtures used by + // StatelessExecutor.InputDecoder, which embeds the SSZ merkle root of + // NewPayloadRequest in pyspec test data. The execution-apis #793 spec lists + // MAX_BAL_BYTES = MAX_BYTES_PER_TX (2^30) — divergence raised upstream. + [SszList(0x0100_0000)] public byte[] BlockAccessList { get => Inner.BlockAccessList ?? []; From dad6790e11ddbbd1918a43f65337abff57f5175f Mon Sep 17 00:00:00 2001 From: "lukasz.rozmej" Date: Sat, 13 Jun 2026 07:50:19 +0200 Subject: [PATCH 34/35] address deep-review nits on SSZ-REST surface - remove dead NotFound field in GetBlobsHandlerV4 - remove unused test helpers (BuildPayloadBodiesByRangeRequest, BuildCapabilitiesRequest, BuildClientVersionRequest, EncodeToBytes) - correct ComputeCells docstring (262144 bytes, not 131072) - TODO: validate custodyColumns.Length when a consumer is wired - ClientVersionSszHandler now maps engine error codes via ErrorCodeToHttpStatus, matching the rest of the handler surface Co-Authored-By: Claude Opus 4.7 (1M context) --- .../KzgPolynomialCommitments.cs | 2 +- .../SszRest/SszMiddlewareTests.cs | 32 ------------------- .../EngineRpcModule.Amsterdam.cs | 2 ++ .../Handlers/GetBlobsHandlerV4.cs | 3 -- .../Handlers/ClientVersionSszHandler.cs | 3 +- .../Handlers/SszEndpointHandlerBase.cs | 2 +- 6 files changed, 6 insertions(+), 38 deletions(-) diff --git a/src/Nethermind/Nethermind.Crypto/KzgPolynomialCommitments.cs b/src/Nethermind/Nethermind.Crypto/KzgPolynomialCommitments.cs index 804b02f5f64e..bfb4c88c5a97 100644 --- a/src/Nethermind/Nethermind.Crypto/KzgPolynomialCommitments.cs +++ b/src/Nethermind/Nethermind.Crypto/KzgPolynomialCommitments.cs @@ -74,6 +74,6 @@ public static void ComputeCellProofs(ReadOnlySpan blob, Span cellPro Ckzg.ComputeCellsAndKzgProofs(new byte[Ckzg.CellsPerExtBlob * Ckzg.BytesPerCell], cellProofs, blob, _ckzgSetup); /// The input blob data. - /// The output span of size CELLS_PER_EXT_BLOB * BYTES_PER_CELL (131072 bytes) where cells will be written. + /// The output span of size CELLS_PER_EXT_BLOB * BYTES_PER_CELL (262144 bytes) where cells will be written. public static void ComputeCells(ReadOnlySpan blob, Span cells) => Ckzg.ComputeCells(cells, blob, _ckzgSetup); } diff --git a/src/Nethermind/Nethermind.Merge.Plugin.Test/SszRest/SszMiddlewareTests.cs b/src/Nethermind/Nethermind.Merge.Plugin.Test/SszRest/SszMiddlewareTests.cs index c5600709b4cb..2d2e8aecdf7f 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin.Test/SszRest/SszMiddlewareTests.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin.Test/SszRest/SszMiddlewareTests.cs @@ -159,13 +159,6 @@ private static byte[] ResponseBytes(HttpContext ctx) return ms.ToArray(); } - private static byte[] EncodeToBytes(T value, Func, int> encode) - { - ArrayBufferWriter w = new(); - encode(value, w); - return w.WrittenSpan.ToArray(); - } - private static readonly object[] NewPayloadRoutingCases = [ new object[] { EngineApiVersions.NewPayload.V1, $"/engine/v2/{ParisUrl}/payloads" }, @@ -711,31 +704,6 @@ private static byte[] BuildHashListRequest(byte[][] hashes) private static byte[] BuildPayloadBodiesByHashRequest(Hash256[] hashes) => BuildHashListRequest(Array.ConvertAll(hashes, h => h.Bytes.ToArray())); - private static byte[] BuildPayloadBodiesByRangeRequest(ulong start, ulong count) - { - byte[] result = new byte[16]; - BitConverter.TryWriteBytes(result.AsSpan(0, 8), start); - BitConverter.TryWriteBytes(result.AsSpan(8, 8), count); - return result; - } - - private static byte[] BuildCapabilitiesRequest(string[] capabilities) => - EncodeToBytes>(capabilities, SszCodec.EncodeCapabilitiesResponse); - - private static byte[] BuildClientVersionRequest() - { - byte[] clientVersion = new byte[16]; - uint offset = 16; - BitConverter.TryWriteBytes(clientVersion.AsSpan(0, 4), offset); - BitConverter.TryWriteBytes(clientVersion.AsSpan(4, 4), offset); - BitConverter.TryWriteBytes(clientVersion.AsSpan(8, 4), offset); - - byte[] request = new byte[4 + clientVersion.Length]; - BitConverter.TryWriteBytes(request.AsSpan(0, 4), (uint)4); - Buffer.BlockCopy(clientVersion, 0, request, 4, clientVersion.Length); - return request; - } - [Test] public async Task ClientVersion_reads_X_Engine_Client_Version_header() { diff --git a/src/Nethermind/Nethermind.Merge.Plugin/EngineRpcModule.Amsterdam.cs b/src/Nethermind/Nethermind.Merge.Plugin/EngineRpcModule.Amsterdam.cs index 7c57e0a4da6b..576b9a9c4464 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/EngineRpcModule.Amsterdam.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/EngineRpcModule.Amsterdam.cs @@ -31,6 +31,8 @@ public Task> engine_forkchoiceUpdatedV4 { // Per execution-apis #793: custody-column updates are best-effort, errors swallowed. // No EL-side custody consumer wired yet — log at trace level so the CL request is auditable. + // TODO(custody): once a consumer is wired, validate custodyColumns.Length == 128 here + // (the SSZ wire enforces this on REST, but the JSON-RPC signature does not). if (custodyColumns is not null && _logger.IsTrace) _logger.Trace($"engine_forkchoiceUpdatedV4 received custody columns ({custodyColumns.Count} bits) — not yet applied"); return ForkchoiceUpdated(forkchoiceState, payloadAttributes, EngineApiVersions.Fcu.V4); diff --git a/src/Nethermind/Nethermind.Merge.Plugin/Handlers/GetBlobsHandlerV4.cs b/src/Nethermind/Nethermind.Merge.Plugin/Handlers/GetBlobsHandlerV4.cs index 3ca4db330a07..0a9144ad4564 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/Handlers/GetBlobsHandlerV4.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/Handlers/GetBlobsHandlerV4.cs @@ -19,9 +19,6 @@ public class GetBlobsHandlerV4(ITxPool txPool) : IAsyncHandler?>> NotFound = - Task.FromResult(ResultWrapper?>.Success(null)); - public Task?>> HandleAsync(GetBlobsHandlerV4Request request) { if (request.BlobVersionedHashes.Length > MaxRequest) diff --git a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/ClientVersionSszHandler.cs b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/ClientVersionSszHandler.cs index abe22be7417c..d4a55246e5bd 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/ClientVersionSszHandler.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/ClientVersionSszHandler.cs @@ -39,7 +39,8 @@ public override async Task HandleAsync(HttpContext ctx, int version, ReadOnlyMem if (result.Result.ResultType != ResultType.Success) { - await WriteErrorAsync(ctx, StatusCodes.Status500InternalServerError, result.Result.Error ?? "engine_getClientVersionV1 failed"); + await WriteErrorAsync(ctx, ErrorCodeToHttpStatus(result.ErrorCode), + result.Result.Error ?? "engine_getClientVersionV1 failed", result.ErrorCode); return; } diff --git a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/SszEndpointHandlerBase.cs b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/SszEndpointHandlerBase.cs index 4708d1a18a97..06ff09645b25 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/SszEndpointHandlerBase.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/SszEndpointHandlerBase.cs @@ -143,7 +143,7 @@ public static async Task WriteErrorAsync(HttpContext ctx, int status, string mes await ctx.Response.WriteAsync(body, ctx.RequestAborted); } - private static int ErrorCodeToHttpStatus(int errorCode) => errorCode switch + protected static int ErrorCodeToHttpStatus(int errorCode) => errorCode switch { MergeErrorCodes.UnknownPayload => StatusCodes.Status404NotFound, MergeErrorCodes.InvalidForkchoiceState => StatusCodes.Status409Conflict, From 13e7ea9a2877323c6f3d6fd3df0fcf34fe563876 Mon Sep 17 00:00:00 2001 From: "lukasz.rozmej" Date: Sat, 13 Jun 2026 20:31:21 +0200 Subject: [PATCH 35/35] map ErrorCodes.ParseError to /engine-api/errors/parse-error Per the spec table in execution-apis #793 (refactor.md L487), JSON-RPC -32700 (body is not valid JSON / SSZ) corresponds to the /engine-api/errors/parse-error type URI. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../SszRest/Handlers/SszEndpointHandlerBase.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/SszEndpointHandlerBase.cs b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/SszEndpointHandlerBase.cs index 06ff09645b25..bc5072cc7120 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/SszEndpointHandlerBase.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/SszEndpointHandlerBase.cs @@ -97,6 +97,9 @@ public static async Task WriteErrorAsync(HttpContext ctx, int status, string mes // that two distinct engine errors sharing the same status emit different type URIs. string type = errorCode switch { + // JSON-RPC standard codes (-32xxx) + ErrorCodes.ParseError => "/engine-api/errors/parse-error", + // Engine-API extension codes (-38xxx) MergeErrorCodes.UnknownPayload => "/engine-api/errors/unknown-payload", MergeErrorCodes.InvalidForkchoiceState => "/engine-api/errors/invalid-forkchoice",