diff --git a/src/Nethermind/Nethermind.Consensus.Test/PayloadAttributesValidateTests.cs b/src/Nethermind/Nethermind.Consensus.Test/PayloadAttributesValidateTests.cs new file mode 100644 index 000000000000..3e543f9c7647 --- /dev/null +++ b/src/Nethermind/Nethermind.Consensus.Test/PayloadAttributesValidateTests.cs @@ -0,0 +1,84 @@ +// 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 BuildAttrs(bool withSlotNumber, ulong timestamp = 1_000UL) => new() + { + Timestamp = timestamp, + PrevRandao = Keccak.Zero, + SuggestedFeeRecipient = Address.Zero, + Withdrawals = [], + ParentBeaconBlockRoot = Keccak.Zero, + 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; + } + + // Each case asserts a distinct branch of PayloadAttributes.Validate against the spec. + // mustContain/mustNotContain are checked when non-null. + private static readonly object[] 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(ValidateCases))] + public void Validate_returns_expected_result( + bool isAmsterdam, bool withSlotNumber, int fcuVersion, + PayloadAttributesValidationResult expected, string errorMustContain, string errorMustNotContain) + { + ISpecProvider sp = MakeSpecProvider(isAmsterdam); + PayloadAttributes attrs = BuildAttrs(withSlotNumber); + + PayloadAttributesValidationResult result = attrs.Validate(sp, fcuVersion, out string error); + + 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)] + [TestCase(true, PayloadAttributesVersions.V4)] + public void GetVersion_infers_correct_version_from_present_fields( + bool hasSlotNumber, int expectedVersion) + { + PayloadAttributes attrs = new() + { + Timestamp = 1_000UL, + 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 76a49e928601..bf89f9d79ab6 100644 --- a/src/Nethermind/Nethermind.Consensus/Producers/PayloadAttributes.cs +++ b/src/Nethermind/Nethermind.Consensus/Producers/PayloadAttributes.cs @@ -83,7 +83,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 + ; protected static string ComputePayloadId(Span inputSpan) { @@ -158,23 +159,25 @@ 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 (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) - return fcuVersion != timestampVersion && timestampVersion >= PayloadAttributesVersions.V2 + bool unsupportedFork = timestampVersion >= PayloadAttributesVersions.V2 && + (actualVersion > timestampVersion || fcuVersion != timestampVersion); + return unsupportedFork ? PayloadAttributesValidationResult.UnsupportedFork : 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"; + return PayloadAttributesValidationResult.InvalidPayloadAttributes; + } + error = null; return PayloadAttributesValidationResult.Success; } @@ -185,10 +188,25 @@ public virtual PayloadAttributesValidationResult Validate( [NotNullWhen(false)] out string? error) { 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); + 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.Consensus/EngineApiVersions.cs b/src/Nethermind/Nethermind.Core/EngineApiVersions.cs similarity index 94% rename from src/Nethermind/Nethermind.Consensus/EngineApiVersions.cs rename to src/Nethermind/Nethermind.Core/EngineApiVersions.cs index 99e1eb667a0d..35c6eeb96c78 100644 --- a/src/Nethermind/Nethermind.Consensus/EngineApiVersions.cs +++ b/src/Nethermind/Nethermind.Core/EngineApiVersions.cs @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: 2023 Demerzel Solutions Limited // SPDX-License-Identifier: LGPL-3.0-only -namespace Nethermind.Consensus; +namespace Nethermind.Core; /// /// Engine API method version constants, grouped by method. @@ -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; // Osaka (cell retrieval, EIP-7594/PeerDAS) + public const int Latest = V4; } /// engine_getPayloadBodiesByHash method versions. diff --git a/src/Nethermind/Nethermind.Crypto/KzgPolynomialCommitments.cs b/src/Nethermind/Nethermind.Crypto/KzgPolynomialCommitments.cs index cf2284dcaf3c..bfb4c88c5a97 100644 --- a/src/Nethermind/Nethermind.Crypto/KzgPolynomialCommitments.cs +++ b/src/Nethermind/Nethermind.Crypto/KzgPolynomialCommitments.cs @@ -72,4 +72,8 @@ 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 (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.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, }; diff --git a/src/Nethermind/Nethermind.Merge.Plugin.Test/EngineModuleTests.V1.cs b/src/Nethermind/Nethermind.Merge.Plugin.Test/EngineModuleTests.V1.cs index 166eb6113227..768ffada62a2 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; @@ -2031,7 +2032,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()])); } @@ -2089,7 +2090,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)); } @@ -2126,50 +2128,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", + SszRestPaths.PostV1Payloads, + SszRestPaths.GetV1Payloads, + SszRestPaths.PostV1Forkchoice, + SszRestPaths.PostV1Capabilities, + SszRestPaths.PostV1ClientVersion, ]; 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", + SszRestPaths.PostV2Payloads, + SszRestPaths.GetV2Payloads, + SszRestPaths.PostV2Forkchoice, + SszRestPaths.PostV1PayloadBodiesByHash, + SszRestPaths.GetV1PayloadBodiesByRange, ]; private static readonly string[] SszRestPathsCancun = [ - "POST /engine/v3/payloads", - "GET /engine/v3/payloads/{payload_id}", - "POST /engine/v3/forkchoice", - "POST /engine/v1/blobs", + SszRestPaths.PostV3Payloads, + SszRestPaths.GetV3Payloads, + SszRestPaths.PostV3Forkchoice, + SszRestPaths.PostV1Blobs, ]; private static readonly string[] SszRestPathsPrague = [ - "POST /engine/v4/payloads", - "GET /engine/v4/payloads/{payload_id}", + SszRestPaths.PostV4Payloads, + SszRestPaths.GetV4Payloads, ]; private static readonly string[] SszRestPathsOsaka = [ - "GET /engine/v5/payloads/{payload_id}", - "POST /engine/v2/blobs", - "POST /engine/v3/blobs", + SszRestPaths.GetV5Payloads, + SszRestPaths.PostV2Blobs, + SszRestPaths.PostV3Blobs, + SszRestPaths.PostV4Blobs, ]; 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", + 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 17deffec9c53..cb088e136db2 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin.Test/EngineModuleTests.V3.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin.Test/EngineModuleTests.V3.cs @@ -401,9 +401,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(), @@ -855,7 +856,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/EngineModuleTests.V6.cs b/src/Nethermind/Nethermind.Merge.Plugin.Test/EngineModuleTests.V6.cs index b2c74c8407a3..93c04af2cd07 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin.Test/EngineModuleTests.V6.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin.Test/EngineModuleTests.V6.cs @@ -367,7 +367,7 @@ public virtual async Task GetPayloadV6_builds_block_with_BAL(string? customWithd SuggestedFeeRecipient = Address.Zero, ParentBeaconBlockRoot = Keccak.Zero, Withdrawals = [], - SlotNumber = 1 + SlotNumber = 1, }; Transaction tx = Build.A.Transaction @@ -510,7 +510,7 @@ 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, }; Hash256 currentHeadHash = chain.BlockTree.HeadHash; ForkchoiceStateV1 forkchoiceState = new(currentHeadHash, currentHeadHash, currentHeadHash); @@ -849,7 +849,7 @@ private static (Transaction tx, Transaction tx2, Transaction tx3, Withdrawal wit SuggestedFeeRecipient = TestItem.AddressE, ParentBeaconBlockRoot = Keccak.Zero, Withdrawals = [withdrawal], - SlotNumber = slotNumber + SlotNumber = slotNumber, }; 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 5d099816c4ba..68ad10f20b8a 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; @@ -59,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() { @@ -156,6 +194,41 @@ 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); + GetBlobsV4ResponseWire.Decode(Seq(encoded), out GetBlobsV4ResponseWire decoded); + + 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 + { + ArrayPool.Shared.Return(cells[0]!); + ArrayPool.Shared.Return(proofs[0]!); + } + } + private static IEnumerable NonEmptyEncodings() { yield return new TestCaseData((Action>)(w => @@ -179,14 +252,11 @@ 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) { using (Assert.EnterMultipleScope()) { - 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)); @@ -203,7 +273,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 }] }; @@ -212,7 +281,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(); using (Assert.EnterMultipleScope()) @@ -226,7 +294,6 @@ public void DecodeNewPayload_v4_roundtrip_preserves_all_fields() } AssertCommonNewPayloadFields( - hashes, [TestItem.KeccakA, TestItem.KeccakB], decoded.ParentBeaconBlockRoot, TestItem.KeccakC, requests, executionRequest); } @@ -241,7 +308,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 }] }; @@ -250,7 +316,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(); Span blockAccessListSpan = payload.BlockAccessList; @@ -267,7 +332,6 @@ public void DecodeNewPayload_v5_roundtrip_preserves_v4_payload_fields() } 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 de8c2c873a7f..2d2e8aecdf7f 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,10 @@ 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; using NUnit.Framework; namespace Nethermind.Merge.Plugin.Test.SszRest; @@ -30,6 +34,8 @@ namespace Nethermind.Merge.Plugin.Test.SszRest; 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!; @@ -43,10 +49,18 @@ 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 OsakaUrl = Osaka.Instance.EngineApiUrlSegment!; + private static readonly string AmsterdamUrl = Amsterdam.Instance.EngineApiUrlSegment!; + [SetUp] public void SetUp() { _engineModule = Substitute.For(); + _specProvider = Substitute.For(); + _blockFinder = Substitute.For(); _urlCollection = Substitute.For(); _auth = Substitute.For(); @@ -73,10 +87,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), @@ -88,15 +102,16 @@ private SszMiddleware BuildMiddleware(RequestDelegate? next = null) new GetBlobsV1SszHandler(_engineModule), new GetBlobsV2SszHandler(_engineModule), 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), - new CapabilitiesSszHandler(_engineModule), + new ClientVersionSszHandler(_engineModule, LimboLogs.Instance), + new CapabilitiesSszHandler(_specProvider), ]; return new SszMiddleware( @@ -144,15 +159,13 @@ 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" }, + new object[] { EngineApiVersions.NewPayload.V2, $"/engine/v2/{ShanghaiUrl}/payloads" }, + ]; - [TestCase(1, "/engine/v1/payloads")] - [TestCase(2, "/engine/v2/payloads")] + [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 }; @@ -172,8 +185,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/v1/payloads/0x0102030405060708")] - [TestCase(2, "/engine/v2/payloads/0x0102030405060708")] + private static readonly object[] GetPayloadRoutingCases = + [ + new object[] { EngineApiVersions.GetPayload.V1, $"/engine/v2/{ParisUrl}/payloads/0x0102030405060708" }, + new object[] { EngineApiVersions.GetPayload.V2, $"/engine/v2/{ShanghaiUrl}/payloads/0x0102030405060708" }, + ]; + + [TestCaseSource(nameof(GetPayloadRoutingCases))] public async Task GetPayload_routes_to_correct_handler_with_no_store_header(int version, string path) { _engineModule.engine_getPayloadV1(Arg.Any()) @@ -191,84 +209,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()); } - // The Accept header reaches the middleware as StringValues, which carries BOTH forms a client - // may send: a single header line holding a comma-separated list of media ranges, and several - // distinct header lines (a string[]). SSZ negotiation must inspect every range across every - // entry, so octet-stream is honored regardless of which entry or position it appears in. - private static IEnumerable AcceptHeaderCases() - { - // Single StringValues entry (one header line), octet-stream in various positions. - yield return new TestCaseData(new[] { OctetStream }, true).SetName("single_octet_only"); - yield return new TestCaseData(new[] { OctetStream + ", application/json" }, true).SetName("single_octet_first"); - yield return new TestCaseData(new[] { "application/json, " + OctetStream }, true).SetName("single_octet_last"); - yield return new TestCaseData(new[] { "text/html, " + OctetStream + ";q=0.9, */*" }, true).SetName("single_octet_middle_with_q"); - yield return new TestCaseData(new[] { OctetStream + " , application/json" }, true).SetName("single_octet_trailing_ows"); - yield return new TestCaseData(new[] { "application/json" }, false).SetName("single_no_octet"); - yield return new TestCaseData(new[] { "application/json, text/html" }, false).SetName("single_csv_no_octet"); - yield return new TestCaseData(new[] { "application/octet-streamx" }, false).SetName("single_octet_substring"); - yield return new TestCaseData(new[] { "application/json;v=\"a, application/octet-stream, b\"" }, false).SetName("octet_inside_quoted_parameter"); - yield return new TestCaseData(new[] { OctetStream + ";q=0" }, false).SetName("octet_explicit_zero_quality"); - yield return new TestCaseData(new[] { OctetStream + ";q=0.0" }, false).SetName("octet_zero_quality_decimal"); - yield return new TestCaseData(new[] { OctetStream + ";q=0.000" }, false).SetName("octet_zero_quality_decimals"); - yield return new TestCaseData(new[] { OctetStream + ";Q=0" }, false).SetName("octet_zero_quality_uppercase"); - yield return new TestCaseData(new[] { OctetStream + ";q=1" }, true).SetName("octet_unit_quality"); - yield return new TestCaseData(new[] { OctetStream + ";q=0.5" }, true).SetName("octet_positive_quality"); - yield return new TestCaseData(new[] { OctetStream + ";q=0, application/json" }, false).SetName("multi_octet_zero_then_other"); - yield return new TestCaseData(new[] { OctetStream + ";q=0, " + OctetStream }, true).SetName("multi_octet_zero_then_octet_default"); - - // Multiple StringValues entries (Accept sent as separate header lines / string[]). - yield return new TestCaseData(new[] { OctetStream, "application/json" }, true).SetName("multi_octet_first_entry"); - yield return new TestCaseData(new[] { "application/json", OctetStream }, true).SetName("multi_octet_last_entry"); - yield return new TestCaseData(new[] { "application/json", "text/html, " + OctetStream }, true).SetName("multi_octet_in_csv_entry"); - yield return new TestCaseData(new[] { "application/json", "text/html" }, false).SetName("multi_no_octet"); - } - - [TestCaseSource(nameof(AcceptHeaderCases))] - public async Task Get_negotiates_ssz_across_all_accept_ranges(string[] acceptValues, bool handledAsSsz) - { - bool nextInvoked = false; - SszMiddleware mw = BuildMiddleware(_ => { nextInvoked = true; return Task.CompletedTask; }); + private static readonly object[] ForkchoiceRoutingCases = + [ + 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 }, + ]; - // Use an unrouted engine resource: a recognized SSZ request resolves to 404 without invoking - // any handler, so the test isolates the negotiation decision and never depends on engine - // module return values. Recognized -> middleware answers it (404) and never delegates; - // not recognized -> it passes through to next(). - DefaultHttpContext ctx = MakeBaseContext("GET", "/engine/v1/negotiation-probe", AuthenticatedPort); - ctx.Request.Headers.Accept = acceptValues; - ctx.Request.Body = Stream.Null; - - await mw.InvokeAsync(ctx); - - Assert.That(nextInvoked, Is.EqualTo(!handledAsSsz)); - } - - // POST negotiation uses the raw-string HasOctetMediaValue boundary check on Content-Type - // (single-valued, hot path). Guards both directions: a substring like "...streamx" must NOT - // match, while a parameterized "...; charset=utf-8" must (the ';' is a valid token boundary). - [TestCase(OctetStream, true)] - [TestCase("application/octet-stream; charset=utf-8", true)] - [TestCase("application/octet-streamx", false)] - [TestCase("application/json", false)] - [TestCase(" application/octet-stream", false)] - public async Task Post_negotiates_ssz_on_content_type_boundary(string contentType, bool handledAsSsz) - { - bool nextInvoked = false; - SszMiddleware mw = BuildMiddleware(_ => { nextInvoked = true; return Task.CompletedTask; }); - - DefaultHttpContext ctx = MakeBaseContext("POST", "/engine/v1/negotiation-probe", AuthenticatedPort); - ctx.Request.ContentType = contentType; - ctx.Request.ContentLength = 0; - ctx.Request.Body = Stream.Null; - - await mw.InvokeAsync(ctx); - - Assert.That(nextInvoked, Is.EqualTo(!handledAsSsz)); - } - - [TestCase("/engine/v1/forkchoice", 1)] - [TestCase("/engine/v2/forkchoice", 2)] - [TestCase("/engine/v3/forkchoice", 3)] - [TestCase("/engine/v4/forkchoice", 4)] + [TestCaseSource(nameof(ForkchoiceRoutingCases))] public async Task Forkchoice_calls_correct_engine_module_version(string path, int version) { ForkchoiceUpdatedV1Result fcuResult = new() @@ -284,7 +233,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); @@ -311,7 +260,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); @@ -321,8 +270,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()) @@ -339,8 +288,33 @@ 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")] + [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()); + } + + private static readonly object[] BodiesByHashRoutingCases = + [ + new object[] { EngineApiVersions.PayloadBodiesByHash.V1, $"/engine/v2/{ShanghaiUrl}/bodies/hash" }, + new object[] { EngineApiVersions.PayloadBodiesByHash.V2, $"/engine/v2/{AmsterdamUrl}/bodies/hash" }, + ]; + + [TestCaseSource(nameof(BodiesByHashRoutingCases))] public async Task GetPayloadBodiesByHash_routes_to_correct_engine_method(int version, string path) { _engineModule.engine_getPayloadBodiesByHashV1(Arg.Any>()) @@ -360,8 +334,46 @@ 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")] + [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); + + byte[] resp = ResponseBytes(ctx); + PayloadBodiesV1ResponseWire.Decode(new ReadOnlySequence(resp), out PayloadBodiesV1ResponseWire decoded); + + 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 = + [ + new object[] { EngineApiVersions.PayloadBodiesByRange.V1, $"/engine/v2/{ShanghaiUrl}/bodies" }, + new object[] { EngineApiVersions.PayloadBodiesByRange.V2, $"/engine/v2/{AmsterdamUrl}/bodies" }, + ]; + + [TestCaseSource(nameof(BodiesByRangeRoutingCases))] public async Task GetPayloadBodiesByRange_routes_to_correct_engine_method_with_correct_args(int version, string path) { const long expectedStart = 7; @@ -376,8 +388,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); @@ -393,17 +406,48 @@ 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)); + _specProvider.TransitionActivations.Returns([]); - 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] + 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] @@ -413,13 +457,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)); } @@ -429,7 +472,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/{ParisUrl}/payloads", body); await _middleware.InvokeAsync(ctx); @@ -440,7 +483,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/{ParisUrl}/payloads", []); ctx.Request.ContentLength = SszMiddleware.MaxBodySize + 1; ctx.Request.Body = new MemoryStream(new byte[1]); @@ -451,11 +494,11 @@ 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; }); - DefaultHttpContext ctx = MakePostContext("/engine/v1/unknown-resource", []); + DefaultHttpContext ctx = MakePostContext($"/engine/v2/{ParisUrl}/unknown-resource", []); await mw.InvokeAsync(ctx); @@ -463,23 +506,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) { - DefaultHttpContext ctx = MakePostContext("/engine/v1/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/v1/payloads//abc", []); + DefaultHttpContext ctx = MakePostContext($"/engine/v2/{ParisUrl}{suffix}", []); await _middleware.InvokeAsync(ctx); @@ -493,12 +526,12 @@ 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/{ParisUrl}/payloads", garbage); 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)); } @@ -506,7 +539,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/{ParisUrl}/payloads", body); // Declare more bytes than the stream will deliver — ReadAtLeastAsync returns short. ctx.Request.ContentLength = body.Length + 64; @@ -523,7 +556,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); @@ -552,7 +585,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/{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 @@ -580,7 +613,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/{ParisUrl}/{ZeroLengthEncodeHandler.ResourceName}", []); await middleware.InvokeAsync(ctx); @@ -590,7 +623,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; @@ -644,6 +678,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]; @@ -656,29 +704,323 @@ 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) + [Test] + public async Task ClientVersion_reads_X_Engine_Client_Version_header() { - byte[] result = new byte[16]; - BitConverter.TryWriteBytes(result.AsSpan(0, 8), start); - BitConverter.TryWriteBytes(result.AsSpan(8, 8), count); - return result; + 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")); } - private static byte[] BuildCapabilitiesRequest(string[] capabilities) => - EncodeToBytes>(capabilities, SszCodec.EncodeCapabilitiesResponse); + [Test] + public async Task Forkchoice_unsupported_fork_returns_400() + { + IReleaseSpec shanghaiSpec = Substitute.For(); + IReleaseSpec cancunSpec = Substitute.For(); + + const ulong shanghaiTs = 1_000UL; + const ulong cancunTs = 2_000UL; + const ulong payloadTs = 1_500UL; - private static byte[] BuildClientVersionRequest() + 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() + { + 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/{CancunUrl}/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")); + } + + [Test] + public async Task Forkchoice_stale_fork_url_without_attributes_is_allowed() { - 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); + 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/{CancunUrl}/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()); + } + + [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); - 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; + 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")] + 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")); + } + + // Trailing slashes and unknown extra path segments must both 404 — spec forbids trailing slashes + // 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[] 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 }, + ]; + + [TestCaseSource(nameof(MalformedPathCases))] + public async Task Malformed_or_trailing_path_returns_404(string method, string path, bool assertMethodNotFoundBody) + { + 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] + 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/{ParisUrl}/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/{ShanghaiUrl}/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/{ShanghaiUrl}/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/{ParisUrl}/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.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..9a5cd5a78ab8 --- /dev/null +++ b/src/Nethermind/Nethermind.Merge.Plugin/Data/BlobCellsAndProofs.cs @@ -0,0 +1,12 @@ +// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited +// SPDX-License-Identifier: LGPL-3.0-only + +namespace Nethermind.Merge.Plugin.Data; + +public class BlobCellsAndProofs +{ + 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/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/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.Merge.Plugin/EngineRpcModule.Amsterdam.cs b/src/Nethermind/Nethermind.Merge.Plugin/EngineRpcModule.Amsterdam.cs index f88284f27687..576b9a9c4464 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/EngineRpcModule.Amsterdam.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/EngineRpcModule.Amsterdam.cs @@ -1,9 +1,10 @@ // 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; +using Nethermind.Core; using Nethermind.Consensus.Producers; using Nethermind.Core.Crypto; using Nethermind.JsonRpc; @@ -18,18 +19,31 @@ 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) + { + // 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); + } 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.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 e76e56000f2c..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; @@ -41,7 +41,8 @@ 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 async Task> ForkchoiceUpdated( + ForkchoiceStateV1 forkchoiceState, PayloadAttributes? payloadAttributes, int version) { _engineRequestsTracker.OnForkchoiceUpdatedCalled(); if (await _locker.WaitAsync(_timeout)) @@ -64,6 +65,7 @@ protected async Task> ForkchoiceUpdated } } + protected async Task> NewPayload(IExecutionPayloadParams executionPayloadParams, int version) { _engineRequestsTracker.OnNewPayloadCalled(); 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/EngineRpcModule.cs b/src/Nethermind/Nethermind.Merge.Plugin/EngineRpcModule.cs index 2bde522688e7..4c9027783d77 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,6 @@ 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) => + ResultWrapper.Success(string.IsNullOrEmpty(clientVersionV1.Code) ? [new()] : [new(), clientVersionV1]); } diff --git a/src/Nethermind/Nethermind.Merge.Plugin/Handlers/EngineRpcCapabilitiesProvider.cs b/src/Nethermind/Nethermind.Merge.Plugin/Handlers/EngineRpcCapabilitiesProvider.cs index 67072e0e3b4d..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()); } @@ -102,7 +102,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)); @@ -118,13 +118,14 @@ 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)); 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)); json = jsonLocal; ssz = sszLocal; 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 new file mode 100644 index 000000000000..0a9144ad4564 --- /dev/null +++ b/src/Nethermind/Nethermind.Merge.Plugin/Handlers/GetBlobsHandlerV4.cs @@ -0,0 +1,146 @@ +// 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.Threading.Tasks; +using CkzgLib; +using Nethermind.Core.Collections; +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; + + 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; + ArrayPoolList blobs = new(n, n); + ArrayPoolList> proofs = new(n, n); + BlobCellsAndProofs?[]? response = null; + try + { + int count = txPool.TryGetBlobsAndProofsV1(request.BlobVersionedHashes, blobs.AsSpan(), proofs.AsSpan()); + + Metrics.GetBlobsRequestsInBlobpoolTotal += count; + + response = ArrayPool.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++) + { + byte[]? blob = blobs[i]; + if (blob is null) + { + response[i] = null; + continue; + } + + using ArrayPoolSpan cellsBuffer = new(Ckzg.CellsPerExtBlob * Ckzg.BytesPerCell); + KzgPolynomialCommitments.ComputeCells(blob, cellsBuffer); + + byte[]?[] blobCells = ArrayPool.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; + + try + { + 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 + }; + } + catch + { + 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; + } + } + + if (count == n) Metrics.GetBlobsRequestsSuccessTotal++; + else Metrics.GetBlobsRequestsFailureTotal++; + return ResultWrapper?>.Success( + new BlobsV4DirectResponse(blobs, proofs, response, n)); + } + catch + { + 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; + } + } +} + +public readonly record struct GetBlobsHandlerV4Request(byte[][] BlobVersionedHashes, BitArray IndicesBitarray); 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/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 71eaa467ea7b..c82650155817 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/MergePlugin.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/MergePlugin.cs @@ -318,6 +318,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/BodiesForkFilter.cs b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/BodiesForkFilter.cs new file mode 100644 index 000000000000..ddcc00a4bbf4 --- /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 TResult?[] FilterByHash( + IReadOnlyList bodies, + IReadOnlyList hashes, + string urlFork, + IBlockFinder blockFinder, + ISpecProvider specProvider) + where TResult : class + { + TResult?[] result = new TResult?[bodies.Count]; + for (int i = 0; i < bodies.Count; i++) + { + 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] = body; + } + return result; + } + + public static TResult?[] FilterByRange( + IReadOnlyList bodies, + long start, + string urlFork, + IBlockFinder blockFinder, + ISpecProvider specProvider) + where TResult : class + { + TResult?[] result = new TResult?[bodies.Count]; + for (int i = 0; i < bodies.Count; i++) + { + 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] = body; + } + 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/CapabilitiesSszHandler.cs b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/CapabilitiesSszHandler.cs index 3e39810f24d5..9b1b4b9e68f5 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/CapabilitiesSszHandler.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/CapabilitiesSszHandler.cs @@ -3,25 +3,101 @@ 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; 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 +/// +/// 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 { - public override string HttpMethod => "POST"; + private byte[]? _cachedBody; + + 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) + public override Task HandleAsync(HttpContext ctx, int version, ReadOnlyMemory extra, ReadOnlySequence body) { - string[] caps = SszCodec.DecodeCapabilitiesRequest(body); - await WriteSszResultAsync(ctx, engineModule.engine_exchangeCapabilities(caps), - SszCodec.EncodeCapabilitiesResponse); + 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); + + 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); + } + + 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) + { + 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/ClientVersionSszHandler.cs b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/ClientVersionSszHandler.cs index d1c1f66a9805..d4a55246e5bd 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/ClientVersionSszHandler.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/ClientVersionSszHandler.cs @@ -5,27 +5,68 @@ 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; /// -/// 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 +public sealed class ClientVersionSszHandler(IEngineRpcModule engineModule, ILogManager logManager) : SszEndpointHandlerBase { private readonly IEngineRpcModule _engineModule = engineModule; + private readonly ILogger _logger = logManager.GetClassLogger(); - public override string HttpMethod => "POST"; + 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 => 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 = TryParseClientVersionHeader(ctx); + ResultWrapper result = _engineModule.engine_getClientVersionV1(clientVersion); + + if (result.Result.ResultType != ResultType.Success) + { + await WriteErrorAsync(ctx, ErrorCodeToHttpStatus(result.ErrorCode), + result.Result.Error ?? "engine_getClientVersionV1 failed", result.ErrorCode); + return; + } + + ctx.Response.ContentType = "application/json"; + ctx.Response.StatusCode = StatusCodes.Status200OK; + 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/Handlers/ForkchoiceUpdatedSszHandler.cs b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/ForkchoiceUpdatedSszHandler.cs index a68994525f6c..d1657c86636c 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,7 +28,32 @@ public sealed class ForkchoiceUpdatedSszHandler(IEngineRpcModul public override async Task HandleAsync(HttpContext ctx, int version, ReadOnlyMemory extra, ReadOnlySequence body) { TWire.Decode(body, out TWire wire); + + if (GetUrlForkMismatchMessage(ctx, TVersion.GetTimestamp(wire)) is { } mismatch) + { + 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)); + 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/GetBlobsSszHandler.cs b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/GetBlobsSszHandler.cs index c898748996dc..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; @@ -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/GetPayloadBodiesByHashSszHandler.cs b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/GetPayloadBodiesByHashSszHandler.cs index 005fe94a8e4c..284a98b6ed14 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 @@ -28,7 +34,25 @@ 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); + 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; + 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 ab41a753ee2f..a8483bab02dd 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/GetPayloadBodiesByRangeSszHandler.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/GetPayloadBodiesByRangeSszHandler.cs @@ -6,36 +6,61 @@ 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; /// -/// 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. /// -public sealed class GetPayloadBodiesByRangeSszHandler(IEngineRpcModule engineModule) +public sealed class GetPayloadBodiesByRangeSszHandler( + IEngineRpcModule engineModule, + IBlockFinder blockFinder, + ISpecProvider specProvider) : SszEndpointHandlerBase where TVersion : struct, IPayloadBodiesByRangeVersion where TResult : class { - 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); - if (count > MaxPayloadBodiesRequest) + // body is empty for GET; parameters come from the query string. + 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, - $"count {count} exceeds the SSZ limit of {MaxPayloadBodiesRequest}"); + await error; + return; + } + if (!QueryParams.TryReadLong(ctx, "count", static n => n > 0, + "must be a positive integer", out long count, out error)) + { + await error; + return; + } + if (count > SszRestLimits.MaxBodiesRequest) + { + await WriteErrorAsync(ctx, StatusCodes.Status413PayloadTooLarge, + $"count {count} exceeds the limit of {SszRestLimits.MaxBodiesRequest}", + MergeErrorCodes.TooLargeRequest); return; } ResultWrapper> result = await TVersion.Call(engineModule, start, count); + string? urlFork = ctx.Items.TryGetValue("SszRouteFork", out object? f) ? f as string : null; + if (urlFork is not null && result.Result.ResultType == ResultType.Success && result.Data is { Count: > 0 } data) + { + 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/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/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/SszEndpointHandlerBase.cs b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/SszEndpointHandlerBase.cs index 502442448a89..bc5072cc7120 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,72 @@ 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 + { + // 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", + 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 + protected 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/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 4b33e46aed60..d7f83b522910 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/SszRestPaths.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/SszRestPaths.cs @@ -1,56 +1,154 @@ // SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited // SPDX-License-Identifier: LGPL-3.0-only +using System; +using System.Collections.Frozen; +using System.Collections.Generic; +using Nethermind.Core.Specs; +using Nethermind.Specs.Forks; +using Forks = Nethermind.Specs.Forks; + namespace Nethermind.Merge.Plugin.SszRest.Handlers; public static class SszRestPaths { + 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 + /// 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. + /// + /// + /// 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 = + BuildForkSpecsByUrl(Forks.Amsterdam.Instance); + + private static Dictionary BuildForkSpecsByUrl(Forks.NamedReleaseSpec latest) + { + // 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]; + + public static readonly FrozenSet SupportedForks = + SupportedForksOrdered.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 const string Payloads = "payloads"; public const string Forkchoice = "forkchoice"; 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"; - - // 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"; - - // 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"; - - // Prague - public const string PostV4Payloads = "POST /engine/v4/payloads"; - public const string GetV4Payloads = "GET /engine/v4/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"; - - // 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"; + // 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"; + + 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"; + + public static readonly string PostV4Payloads = $"POST /engine/v2/{_prague}/payloads"; + public static readonly string GetV4Payloads = $"GET /engine/v2/{_prague}/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"; + + 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"; + + /// + /// 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. + /// + public static int? MapForkToVersion(string fork, ReadOnlySpan resource, string httpMethod) + { + if (!_forkSpecByUrl.TryGetValue(fork, out Forks.NamedReleaseSpec? spec)) return null; + + // 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 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 spec.EngineApiGetPayloadVersion; + if (Eq(resource, PayloadBodiesByRange)) return spec.EngineApiPayloadBodiesByRangeVersion; + } + + return null; + + 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/Handlers/SszVersionDescriptors.cs b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/SszVersionDescriptors.cs index 7ff1ac99cdb7..d0db85824c93 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/SszVersionDescriptors.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/SszVersionDescriptors.cs @@ -2,9 +2,10 @@ // SPDX-License-Identifier: LGPL-3.0-only using System.Buffers; +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; @@ -44,38 +45,48 @@ 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(), - wire.ExpectedBlobVersionedHashes ?? [], - 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(), - wire.ExpectedBlobVersionedHashes ?? [], - 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(), - wire.ExpectedBlobVersionedHashes ?? [], - 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 { static abstract int VersionNumber { get; } static abstract Task> Call(IEngineRpcModule engine, in TWire wire); + 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 @@ -87,6 +98,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) => + ForkchoiceUpdatedHelpers.FirstTimestamp(wire.PayloadAttributes); } public readonly struct ForkchoiceUpdatedDescriptorV2 : IForkchoiceUpdatedVersion @@ -98,6 +111,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) => + ForkchoiceUpdatedHelpers.FirstTimestamp(wire.PayloadAttributes); } public readonly struct ForkchoiceUpdatedDescriptorV3 : IForkchoiceUpdatedVersion @@ -109,6 +124,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) => + ForkchoiceUpdatedHelpers.FirstTimestamp(wire.PayloadAttributes); } public readonly struct ForkchoiceUpdatedDescriptorV4 : IForkchoiceUpdatedVersion @@ -118,8 +135,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) => + ForkchoiceUpdatedHelpers.FirstTimestamp(wire.PayloadAttributes); } public readonly struct GetPayloadDescriptorV1 : IGetPayloadVersion @@ -250,3 +270,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..04cfd6046506 --- /dev/null +++ b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszBlobCell.cs @@ -0,0 +1,36 @@ +// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited +// SPDX-License-Identifier: LGPL-3.0-only + +using System; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Runtime.CompilerServices; + +namespace Nethermind.Merge.Plugin.SszRest; + +/// +/// 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 = 2048; + + private byte _element0; + + 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(result); + return result; + } + + [UnscopedRef] + public ReadOnlySpan AsSpan() => this; +} 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..804f03deff16 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)]; + // 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). + byte[] idBytes = new byte[8]; + Bytes.FromHexString(hex, idBytes); + pidList = [new SszPayloadId { Bytes = idBytes }]; } return EncodeToWriter(new ForkchoiceUpdatedResponseWire @@ -121,42 +122,93 @@ 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) + { + const int CellsPerExtBlob = 128; + 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 }; + continue; + } + + 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 = 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))] }; + 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 GetBlobsV3ResponseWire { BlobsAndProofs = arr }, writer); + return EncodeToWriter(new GetBlobsV4ResponseWire { Entries = arr }, writer); } public static Hash256[] DecodeGetPayloadBodiesByHashRequest(ReadOnlySequence buf) @@ -174,25 +226,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) @@ -259,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 }; } @@ -307,4 +369,24 @@ private static PayloadAttributes BuildPayloadAttributes( SlotNumber = slotNumber }; + public static Hash256[] GetBlobVersionedHashes(ExecutionPayload payload) + { + Result decoded = payload.TryGetTransactions(); + if (decoded.IsError) return []; + 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; + 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/SszExecutionPayload.cs b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszExecutionPayload.cs index d63030885054..3e44de4e1fed 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszExecutionPayload.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszExecutionPayload.cs @@ -262,6 +262,10 @@ public SszExecutionPayloadV4() : this(new ExecutionPayloadV4()) { } public override ExecutionPayloadV4 AsExecutionPayload() => Inner; + // 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 { diff --git a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszMiddleware.cs b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszMiddleware.cs index b0f910489166..b77d31aae158 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszMiddleware.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszMiddleware.cs @@ -8,7 +8,6 @@ using System.IO; using System.IO.Pipelines; using System.Net.Mime; -using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.Http; @@ -33,24 +32,22 @@ public sealed class SszMiddleware private readonly ILogger _logger; private readonly CancellationToken _processExitToken; - // Path: /engine/v{N}/{resource}[/{extra}] - private const string EnginePrefix = "/engine/v"; - private const string Octet = MediaTypeNames.Application.Octet; + // Path: /engine/v2/{fork}/{resource}[/{extra}] + private const string EnginePrefix = "/engine/v2/"; /// - /// 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 (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 = 0x1000000; + public const int MaxBodySize = 0x4000000; private readonly FrozenDictionary> _postRoutes; private readonly FrozenDictionary> _getRoutes; 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 enum SszRequestKind { NotEngine, EngineWrongMediaType, EngineOk } public SszMiddleware( RequestDelegate next, @@ -65,15 +62,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 = []; @@ -81,14 +76,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); } @@ -96,27 +91,15 @@ 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) { - if (!IsSszRequest(ctx)) - { + SszRequestKind kind = ClassifySszRequest(ctx); + + if (kind == SszRequestKind.NotEngine) return _next(ctx); - } if (_processExitToken.IsCancellationRequested) { @@ -129,6 +112,17 @@ 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); } @@ -142,27 +136,43 @@ 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/v2/{pathSegment.Span}", + SszRestErrorCodes.MethodNotFound); } else { + if (fork is not null) + { + ctx.Items["SszRouteFork"] = fork; + } + 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/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 @@ -200,16 +210,18 @@ 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. 422 Unprocessable Entity is reserved - // for "Invalid payload attributes" and is emitted by the handler chain via + // 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 // 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) { @@ -229,52 +241,96 @@ 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 = 1; + fork = null; pathSegment = default; + unsupportedFork = false; ReadOnlySpan span = path.AsSpan(); if (!span.StartsWith(EnginePrefix.AsSpan(), StringComparison.OrdinalIgnoreCase)) return false; + if (span.EndsWith("/")) + return false; + int offset = EnginePrefix.Length; span = span[offset..]; + if (span.IsEmpty) return false; - int slashPos = span.IndexOf('/'); - if (slashPos <= 0) return false; + if (span.Equals("identity".AsSpan(), StringComparison.OrdinalIgnoreCase) + || span.Equals("capabilities".AsSpan(), StringComparison.OrdinalIgnoreCase)) + { + pathSegment = path.AsMemory(offset); + return true; + } + // 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)) + { + return false; + } - if (!int.TryParse(span[..slashPos], out version)) + if (span.StartsWith("blobs/".AsSpan(), StringComparison.OrdinalIgnoreCase)) + { + ReadOnlySpan sub = span["blobs/".Length..]; + if (sub.StartsWith("v", StringComparison.OrdinalIgnoreCase)) + { + if (int.TryParse(sub[1..], out int blobVer)) + { + version = blobVer; + pathSegment = path.AsMemory(offset, "blobs".Length); + return true; + } + } return false; + } - offset += slashPos + 1; - span = span[(slashPos + 1)..]; - if (span.IsEmpty) 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; + } - // Allowed path-segment chars: ASCII alphanumeric, '-' (kebab-case resources), - // and '/' (between resource and extra). Reject runs of '/' in the same pass — - // saves an extra scan and gives one rejection point for both validations. - bool prevSlash = false; - foreach (char c in span) + ReadOnlySpan forkSpan = span[..nextSlash]; + // SszRestPaths.SupportedForks uses OrdinalIgnoreCase, so no lowercasing needed. + if (!SszRestPaths.SupportedForksSpanLookup.Contains(forkSpan)) { - if (c == '/') + // 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 _)) { - if (prevSlash) return false; - prevSlash = true; - continue; - } - if (!char.IsAsciiLetterOrDigit(c) && c != '-') return false; - prevSlash = false; + } + + fork = forkSpan.ToString(); + unsupportedFork = true; + return false; } - // 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); + fork = forkSpan.ToString(); + 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; @@ -283,6 +339,30 @@ private bool TryResolveHandler(string method, ReadOnlyMemory pathSegment, bool isPost = HttpMethods.IsPost(method); bool isGet = !isPost && HttpMethods.IsGet(method); + ReadOnlyMemory resource = pathSegment; + ReadOnlyMemory extraMem = default; + + int firstSlash = pathSegment.Span.IndexOf('/'); + if (firstSlash > 0) + { + extraMem = pathSegment[(firstSlash + 1)..]; + resource = pathSegment[..firstSlash]; + } + + if (resource.Span.Equals("bodies".AsSpan(), StringComparison.OrdinalIgnoreCase) + && extraMem.Span.Equals("hash".AsSpan(), StringComparison.OrdinalIgnoreCase)) + { + resource = SszRestPaths.PayloadBodiesByHash.AsMemory(); + extraMem = default; + } + + if (fork is not null) + { + int? mappedVersion = SszRestPaths.MapForkToVersion(fork, resource.Span, method); + if (mappedVersion is null) return false; + version = mappedVersion.Value; + } + FrozenDictionary>? exactDict = isPost ? _postRoutes : isGet ? _getRoutes : null; if (exactDict is not null) @@ -290,148 +370,97 @@ private bool TryResolveHandler(string method, ReadOnlyMemory pathSegment, FrozenDictionary>.AlternateLookup> lookup = isPost ? _postLookup : _getLookup; - if (lookup.TryGetValue(pathSegment.Span, out List? exactList)) + if (lookup.TryGetValue(resource.Span, 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) + { + if (!extraMem.IsEmpty && !candidate.AcceptsPathExtra) + return false; + + handler = candidate; + extra = extraMem; + return true; + } if (candidate.Version is null) fallback = candidate; } - if (fallback is not null) { handler = fallback; extra = default; return true; } - } - } - - ISszEndpointHandler? prefixFallback = null; - ReadOnlyMemory prefixFallbackExtra = default; - - (string Resource, List Handlers)[] prefixRoutes = - isPost ? _postPrefixRoutes : isGet ? _getPrefixRoutes : []; - - ReadOnlySpan pathSpan = pathSegment.Span; - foreach ((string routeResource, List candidates) in prefixRoutes) - { - 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 (candidate.Version == version) + if (fallback is not null) { - handler = candidate; - extra = tail; - return true; - } + if (!extraMem.IsEmpty && !fallback.AcceptsPathExtra) + return false; - if (candidate.Version is null) - { - prefixFallback = candidate; - prefixFallbackExtra = tail; + handler = fallback; + extra = extraMem; + return true; } } } - if (prefixFallback is not null) - { - handler = prefixFallback; - extra = prefixFallbackExtra; - return true; - } - return false; } - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static bool IsSszRequest(HttpContext ctx) + + private static SszRequestKind ClassifySszRequest(HttpContext ctx) { - PathString path = ctx.Request.Path; - if (!path.HasValue || !path.Value!.StartsWith("/engine/", StringComparison.OrdinalIgnoreCase)) - return false; + string path = ctx.Request.Path.Value ?? string.Empty; - return AcceptsSszResponse(ctx); + if (!path.StartsWith("/engine/", StringComparison.OrdinalIgnoreCase)) + return SszRequestKind.NotEngine; - [MethodImpl(MethodImplOptions.NoInlining)] - static bool AcceptsSszResponse(HttpContext ctx) => - ctx.Request.Method switch - { - "POST" => HasOctetMediaValue(ctx.Request.ContentType), - "GET" => AcceptsOctet(ctx.Request), - _ => false - }; + 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 _); - static bool AcceptsOctet(HttpRequest request) + if (!isVersioned) + return SszRequestKind.NotEngine; + + bool isEnginePrefix = path.StartsWith(EnginePrefix, StringComparison.OrdinalIgnoreCase); + + switch (ctx.Request.Method) { - foreach (string? entry in request.Headers.Accept) - { - if (entry is null) continue; - ReadOnlySpan span = entry.AsSpan(); - while (!span.IsEmpty) + 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; + + // Hot-path SSZ GET endpoints require Accept: application/octet-stream. + foreach (string? v in ctx.Request.Headers.Accept) { - int comma = IndexOfUnquoted(span, ','); - if (IsOctetMediaRange(comma < 0 ? span : span[..comma])) return true; - if (comma < 0) break; - span = span[(comma + 1)..]; + if (v is not null && v.Contains( + MediaTypeNames.Application.Octet, StringComparison.OrdinalIgnoreCase)) + return SszRequestKind.EngineOk; } - } - return false; - } - static bool IsOctetMediaRange(ReadOnlySpan range) - { - ReadOnlySpan token = range.TrimStart(); - int semi = IndexOfUnquoted(token, ';'); - ReadOnlySpan type = (semi >= 0 ? token[..semi] : token).TrimEnd(); - if (!type.Equals(Octet, StringComparison.OrdinalIgnoreCase)) - return false; - return semi < 0 || !HasZeroQValue(token[(semi + 1)..]); - } - - static bool HasZeroQValue(ReadOnlySpan parameters) - { - while (!parameters.IsEmpty) - { - int next = IndexOfUnquoted(parameters, ';'); - ReadOnlySpan param = (next < 0 ? parameters : parameters[..next]).Trim(); - if (param.Length >= 2 && (param[0] | 0x20) == 'q' && param[1] == '=') - return IsZeroQ(param[2..]); - if (next < 0) break; - parameters = parameters[(next + 1)..]; - } - return false; - } + return SszRequestKind.NotEngine; - static bool IsZeroQ(ReadOnlySpan qValue) - { - if (qValue.IsEmpty || qValue[0] != '0') return false; - for (int i = 1; i < qValue.Length; i++) - { - if (qValue[i] != '.' && qValue[i] != '0') return false; - } - return true; + default: + return SszRequestKind.NotEngine; } + } - static int IndexOfUnquoted(ReadOnlySpan s, char target) - { - bool inQuote = false; - for (int i = 0; i < s.Length; i++) - { - char c = s[i]; - if (inQuote && c == '\\' && i + 1 < s.Length) { i++; continue; } - if (c == '"') inQuote = !inQuote; - else if (c == target && !inQuote) return i; - } - return -1; - } + private static bool IsDiagnosticGetPath(string path) + { + ReadOnlySpan span = path.AsSpan(); + const string capabilitiesPath = "/engine/v2/capabilities"; + const string identityPath = "/engine/v2/identity"; - static bool HasOctetMediaValue(string? headerValue) - => headerValue is not null && headerValue.StartsWith(Octet, StringComparison.OrdinalIgnoreCase) && - (headerValue.Length == Octet.Length || headerValue[Octet.Length] is ';' or ' ' or '\t'); + 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] == '/'); } /// diff --git a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszMiddlewareConfigurer.cs b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszMiddlewareConfigurer.cs index 19f0bff63afb..9bd0facd55dc 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,7 +41,9 @@ public void Configure(IServiceCollection services) services.Bridge(ctx); services.Bridge(ctx); services.Bridge(ctx); + services.Bridge(ctx); services.Bridge(ctx); + services.Bridge(ctx); services.AddSingleton>(); services.AddSingleton>(); @@ -64,6 +67,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..ac4bc73de5cf 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; @@ -28,12 +29,18 @@ public partial struct SszWithdrawal public ulong Amount { get; set; } } +[SszContainer(isCollectionItself: true)] +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] @@ -44,8 +51,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; } @@ -53,7 +70,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; } @@ -62,7 +79,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; } @@ -72,7 +89,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; } @@ -108,13 +125,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 +163,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 +170,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 +178,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 +263,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 +327,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 +344,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 +383,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(isCollectionItself: true)] +public partial struct NullableBlobCellWire +{ + [SszList(1)] public SszBlobCell[]? Cell { get; set; } +} + +[SszContainer(isCollectionItself: true)] +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.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; diff --git a/src/Nethermind/Nethermind.Runner/JsonRpc/Startup.cs b/src/Nethermind/Nethermind.Runner/JsonRpc/Startup.cs index 979099d8d975..77628ed5cb7f 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; @@ -87,14 +88,36 @@ 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 = (int)1.MiB; + options.Limits.Http2.InitialStreamWindowSize = (int)1.MiB; + options.ConfigureEndpointDefaults(listenOptions => { - listenOptions.Protocols = HttpProtocols.Http1; - listenOptions.DisableAltSvcHeader = true; + 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); diff --git a/src/Nethermind/Nethermind.Specs/Forks/15_Paris.cs b/src/Nethermind/Nethermind.Specs/Forks/15_Paris.cs index 258c51962ad0..2230b5e005b7 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.Core; + 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..dd47164f3016 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.Core; + 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..4b16a7de05f3 100644 --- a/src/Nethermind/Nethermind.Specs/Forks/17_Cancun.cs +++ b/src/Nethermind/Nethermind.Specs/Forks/17_Cancun.cs @@ -20,5 +20,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..ff7b60a42fca 100644 --- a/src/Nethermind/Nethermind.Specs/Forks/18_Prague.cs +++ b/src/Nethermind/Nethermind.Specs/Forks/18_Prague.cs @@ -22,5 +22,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..79f55dee1b69 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.Core; + 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..c13d697b7afe 100644 --- a/src/Nethermind/Nethermind.Specs/Forks/25_Amsterdam.cs +++ b/src/Nethermind/Nethermind.Specs/Forks/25_Amsterdam.cs @@ -21,6 +21,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..6488a4c0ed94 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; @@ -70,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; +} 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 @@ + + diff --git a/src/Nethermind/Nethermind.Taiko.Test/CertainBatchLookupTests.cs b/src/Nethermind/Nethermind.Taiko.Test/CertainBatchLookupTests.cs index 1592c8d1bfbb..aa74701d1249 100644 --- a/src/Nethermind/Nethermind.Taiko.Test/CertainBatchLookupTests.cs +++ b/src/Nethermind/Nethermind.Taiko.Test/CertainBatchLookupTests.cs @@ -328,6 +328,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(), diff --git a/src/Nethermind/Nethermind.Taiko/Rpc/TaikoEngineRpcModule.cs b/src/Nethermind/Nethermind.Taiko/Rpc/TaikoEngineRpcModule.cs index 3cd72cf05828..0151fe385a5e 100644 --- a/src/Nethermind/Nethermind.Taiko/Rpc/TaikoEngineRpcModule.cs +++ b/src/Nethermind/Nethermind.Taiko/Rpc/TaikoEngineRpcModule.cs @@ -49,6 +49,7 @@ public class TaikoEngineRpcModule(IAsyncHandler getPa IHandler, IReadOnlyList> capabilitiesHandler, IAsyncHandler> getBlobsHandler, IAsyncHandler?> getBlobsHandlerV2, + IAsyncHandler?> getBlobsHandlerV4, IHandler, IReadOnlyList> getPayloadBodiesByHashV2Handler, IGetPayloadBodiesByRangeV2Handler getPayloadBodiesByRangeV2Handler, IEngineRequestsTracker engineRequestsTracker, @@ -75,6 +76,7 @@ public class TaikoEngineRpcModule(IAsyncHandler getPa capabilitiesHandler, getBlobsHandler, getBlobsHandlerV2, + getBlobsHandlerV4, getPayloadBodiesByHashV2Handler, getPayloadBodiesByRangeV2Handler, engineRequestsTracker, 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);