Skip to content

Commit fa7b5dc

Browse files
benaadamsLukaszRozmejmarcindsobczak
authored
Optimize JSON-RPC request parsing and processing (#10453)
* Add encoded-tx root calc and trie decode perf Allow computing the transactions trie root directly from RLP-encoded transactions and update callers to use it. ExecutionPayload.TryGetBlock now passes the encoded Transactions to TxTrie.CalculateRoot. Implement TxTrie.CalculateRoot(ReadOnlySpan<byte[]>) and InitializeFromEncodedTransactions to populate the trie from encoded payloads and compute RootHash; use a TrackingCappedArrayPool and UpdateRootHash. Add a unit test to assert encoded and decoded transaction paths produce the same root. Also refine trie node RLP encoding parallelization: UseParallel now checks the node's non-null child count (only parallelize when >= 4 children and multiple CPU cores) to avoid parallel overhead on small branches. * Fast block re-encode * Feedback * Cache TokenValidationParameters as readonly field Move TokenValidationParameters construction from per-request AuthenticateCore to the constructor, eliminating repeated allocation on cache-miss auth path. * Implement manual HS256 JWT fast validator with library fallback Add zero-allocation manual JWT validation for known HS256 header formats. Uses HMACSHA256.HashData (static) and CryptographicOperations.FixedTimeEquals for signature verification. Handles iat and exp claims. Falls back to Microsoft.IdentityModel for unrecognized header formats. * Refactor ByteArrayConverter write path Replace WriteRawValue with WriteStringValue(ReadOnlySpan<byte>) on the main value-write path, eliminating manual quote handling. Raise stackalloc threshold to 256 bytes. Use "0x0"u8 literal for zero-value fast path. Delegate-based overload retained for property name writes. * Add fixed-size fast path for 32-byte hash converters Hash256Converter and ValueHash256Converter now use a dedicated 66-byte stackalloc path (0x + 64 hex chars) that skips CountLeadingNibbleZeros, ArrayPool, and the generic ByteArrayConverter write path. * Direct numeric-to-hex for Long, ULong, UInt256 converters Use BitOperations.LeadingZeroCount and nibble-extraction loop to write hex directly without byte array intermediary. UInt256 uses LZCNT on limbs. All paths use WriteStringValue instead of WriteRawValue. Zero values use "0x0"u8 literal. ZeroPaddedHex uses fixed 66-byte stackalloc path. * Replace ForcedNumberConversion AsyncLocal with ThreadStatic cache GetFinalConversion() now reads from a ThreadStatic cache instead of AsyncLocal on every call. The ThreadAwareAsyncLocal wrapper updates both the AsyncLocal and ThreadStatic when Value is set, maintaining backward compatibility with tracing/debug converters. * Pool CancellationTokenSource with TryReset BuildTimeoutCancellationToken now rents from a ConcurrentBag pool. TryReset() returns CTS to pool on completion. Debugger-attached path skips pooling. Eliminates per-request CTS allocation on hot path. * Increase StreamPipeWriter minimumBufferSize from 4KB to 16KB Reduces Grow calls during serialization of typical Engine API responses (~5-20KB). Tradeoff: slightly higher per-connection memory baseline. * Bypass BufferResponses for Engine API + call StartAsync before body Authenticated single responses (Engine API) skip RecyclableStream double-copy. Call StartAsync() before serialization to flush headers early and avoid PinnedBlockMemoryPool.Rent allocations. * Replace JsonRpcContext.Current AsyncLocal with ThreadStatic Replace AsyncLocal<JsonRpcContext?> with [ThreadStatic] field and a ThreadStaticAccessor wrapper that preserves the .Value API shape. This eliminates the ExecutionContext capture cost on every JSON-RPC request. * Use synchronous JsonSerializer.Serialize for PipeWriter path Replace JsonSerializer.SerializeAsync(PipeWriter, ...) with synchronous Utf8JsonWriter + JsonSerializer.Serialize. This eliminates the async state machine allocation since PipeWriter implements IBufferWriter<byte> and data is flushed by the caller's CompleteAsync(). * Eliminate CTS for Engine API path Skip per-request CancellationTokenSource allocation for authenticated Engine API requests. They use CancellationToken.None since they are from trusted consensus clients and must complete for consensus. Connection drops are handled by the PipeReader. Non-engine paths still use the pooled CTS from T08. * Engine API terminal middleware before routing/CORS Add an early-pipeline middleware that intercepts authenticated engine port POST requests before routing, CORS, response compression, and WebSocket middleware. Engine API requests are handled directly, writing to the response body without buffering. Non-engine requests pass through to the standard middleware pipeline. * Skip CountingPipeReader when Content-Length is known In the engine API fast lane, use ctx.Request.BodyReader directly and Content-Length for metrics, eliminating CountingPipeReader overhead. In the standard handler, skip CountingPipeReader when Content-Length is available and fall back to it only for chunked requests. * Seal hot classes and capture concrete types Seal EthereumJsonSerializer, JsonRpcProcessor, and JsonRpcService (no subclasses exist). In Startup.cs, cast DI-resolved interfaces to their concrete types and use those in middleware closures, enabling JIT devirtualization of hot-path method calls. * Micro-optimizations in request processing - Replace LINQ Select in batch deserialization with explicit loop - Use TryGetDecimal instead of GetRawText for numeric JSON-RPC id parsing - Replace ElementAtOrDefault with bounds-checked indexed access in LogRequest - Intern known engine method names via ValueEquals to avoid string allocation - Remove unused System.Linq using from JsonRpcProcessor * Startup warmup for serializer metadata Add EthereumJsonSerializer.WarmupSerializer() that pre-serializes instances to populate System.Text.Json metadata caches. Call it at startup with JsonRpcSuccessResponse and JsonRpcErrorResponse to eliminate cold-start serialization overhead on first engine request. * Move log interpolation to NoInlining local/static functions Keep string interpolation out of hot paths by moving log bodies to [MethodImpl(MethodImplOptions.NoInlining)] functions so the JIT does not inline the interpolation into callers. * STJ source generation for engine, eth, debug, and trace API types Add source-generated JsonSerializerContext instances for all major RPC type families to eliminate reflection-based metadata lookup on hot paths. - EngineApiJsonContext: 19 engine API types (payloads, forkchoice, blobs) - FacadeJsonContext: BlockForRpc, TransactionForRpc, FilterLog, SyncingResult - EthRpcJsonContext: receipts, fee history, account proofs, plus debug/trace types (GethLikeTxTrace, ParityTxTraceFromStore, ChainLevelForRpc, etc.) - JsonRpcResponseJsonContext: success/error response envelope types Infrastructure changes: - AddTypeInfoResolver() on EthereumJsonSerializer with version-tracked propagation to all existing serializer instances - Cache JsonTypeInfo on ExpectedParameter for typed deserialization - WriteJsonRpcResponse() in Startup.cs for typed response serialization - Add [JsonIgnore] to Span properties on Hash256, Bloom, Signature to prevent SYSLIB1225 source generator errors * Optimize JwtAuthentication * Optimize ByteArrayConverter * Test fix * Spelling * Optimize LongConverter * Spell * Optimize Hash256Converter * Optimize UInt256Converter * Fix tests * Spelling * Feedback * Optimize * Optimize * Spell * Also Avx512 * Formatting * Spell * Drop the weird pattern matching * Use concurrentqueue instead * Stream blobs directly Before (3 copies of hex data): 1. ByteArrayConverter → hex-encode into rented ArrayPool<byte> buffer (262KB) 2. Utf8JsonWriter.WriteRawValue → memcpy into Utf8JsonWriter's internal buffer (262KB) 3. Utf8JsonWriter flush → memcpy into PipeWriter/Kestrel send buffer (262KB) After (1 copy): 1. OutputBytesToByteHex → hex-encode directly into writer.GetSpan() (Kestrel's send buffer) So copies 2 and 3 are eliminated. Copy 1 (the binary→hex transform) remains * formatting * Spell * Tidy up * tidy up * Feeedback * Feedback * Reduce contention in trie root hashing Replace lock+List in TrackingCappedArrayPool with ConcurrentQueue for parallel paths and bare List for sequential paths. Skip parallel root hashing for small tries (<=64 items) to avoid scheduling overhead. Fix race condition where ReceiptTrie and TxTrie called UpdateRootHash without propagating canBeParallel, causing concurrent List.Add on a non-thread-safe collection. * Feedback * Run newPayload inline (#10479) * Skip response compression for Engine API requests * Optimize engine_getBlobsV2 with fused batch lookup and zero-copy proofs Replace N+1 lock acquisitions with a single fused TryGetBlobsAndProofsV1 that atomically counts and extracts blobs under one lock. Use ReadOnlyMemory<byte[]> to window into wrapper.Proofs arrays instead of copying via Slice+spread, eliminating ~0.4MB of proof allocations per request. Replace ArrayPoolList with parallel arrays, removing the pool rent/return overhead. * Split blob lookups into two phases to reduce lock hold time (#10173) Add ITxStorage.TryGetMany for batched RocksDB MultiGet and override TryGetBlobsAndProofsV1 in PersistentBlobTxDistinctSortedPool with a two-phase approach: fast in-memory + cache lookups under lock, then a single batched DB read outside the lock. This avoids holding the pool's McsLock during potentially slow I/O for up to 128 blobs per request. Co-Authored-By: Lukasz Rozmej <lukasz@nethermind.io> * Remove dead GetBlobCounts method (#10159) No longer called after getBlobsV2 switched to batched TryGetBlobsAndProofsV1. Co-Authored-By: Marcin Sobczak <77129288+marcindsobczak@users.noreply.github.com> * Feedback * Spelling * Add some const to JwtAuthentication to understand the code better * Consolidate duplicated JSON-RPC request processing pipelines in Startup Extract shared ProcessJsonRpcRequestCoreAsync and PushErrorResponseAsync instance methods from the engine API fast lane and standard handler, eliminating ~100 lines of duplicated processing logic. Unify status code constants, auth error handling, and add streamable response support to the standard path. * Make BlobsV2DirectResponse enumerator explicit; pool byte[64] keys in BlobTxStorage - Convert GetEnumerator to explicit interface implementation since it is only used by tests via IEnumerable<T> cast - Add ConcurrentQueue-based pool for exact-size byte[64] DB lookup keys in TryGetMany to avoid per-call allocations --------- Co-authored-by: Lukasz Rozmej <lukasz@nethermind.io> Co-authored-by: Marcin Sobczak <77129288+marcindsobczak@users.noreply.github.com> Co-authored-by: lukasz.rozmej <lukasz.rozmej@gmail.com>
1 parent fd73930 commit fa7b5dc

69 files changed

Lines changed: 3266 additions & 688 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

cspell.json

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@
5050
"autofac",
5151
"autogen",
5252
"auxdata",
53+
"backpressure",
5354
"badreq",
5455
"barebone",
5556
"baseblock",
@@ -160,6 +161,7 @@
160161
"coef",
161162
"collectd",
162163
"colour",
164+
"CORINFO",
163165
"commitset",
164166
"comparand",
165167
"concurrenc",
@@ -199,6 +201,7 @@
199201
"deque",
200202
"deserialised",
201203
"dests",
204+
"devirtualization",
202205
"devirtualize",
203206
"devirtualized",
204207
"devnet",
@@ -327,6 +330,7 @@
327330
"highbits",
328331
"hiveon",
329332
"hmac",
333+
"hmacsha",
330334
"holesky",
331335
"hoodi",
332336
"hostnames",
@@ -404,6 +408,7 @@
404408
"longdate",
405409
"lookback",
406410
"lukasz",
411+
"lzcnt",
407412
"machdep",
408413
"machinename",
409414
"madv",
@@ -416,6 +421,7 @@
416421
"masternode",
417422
"masternodes",
418423
"masterodes",
424+
"materialisation",
419425
"maxcandidatepeercount",
420426
"maxcandidatepercount",
421427
"maxfee",
@@ -461,6 +467,7 @@
461467
"modexpprecompile",
462468
"morden",
463469
"movbe",
470+
"movsxd",
464471
"movzx",
465472
"mres",
466473
"mscorlib",
@@ -577,6 +584,7 @@
577584
"prioritise",
578585
"protoc",
579586
"prysm",
587+
"pshufb",
580588
"ptree",
581589
"pushgateway",
582590
"pwas",
@@ -706,6 +714,7 @@
706714
"stfld",
707715
"stoppables",
708716
"storagefuzz",
717+
"streamable",
709718
"stree",
710719
"strs",
711720
"stylesheet",
@@ -810,7 +819,10 @@
810819
"voteₙ",
811820
"vpaddd",
812821
"vpcbr",
822+
"vpermb",
823+
"vpermi",
813824
"vpor",
825+
"vpshufb",
814826
"vptest",
815827
"vpxor",
816828
"vzeroupper",
@@ -834,6 +846,7 @@
834846
"xmmword",
835847
"xmlstarlet",
836848
"xnpool",
849+
"xvcj",
837850
"yellowpaper",
838851
"ymmword",
839852
"yparity",

src/Nethermind/Nethermind.Api/IBasicApi.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ public interface IBasicApi
3232
IDbProvider DbProvider { get; }
3333
IEthereumEcdsa EthereumEcdsa { get; }
3434
[SkipServiceCollection]
35-
IJsonSerializer EthereumJsonSerializer { get; }
35+
EthereumJsonSerializer EthereumJsonSerializer { get; }
3636
IFileSystem FileSystem { get; }
3737
IKeyStore? KeyStore { get; set; }
3838
[SkipServiceCollection]

src/Nethermind/Nethermind.Api/NethermindApi.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ public class NethermindApi(NethermindApi.Dependencies dependencies) : INethermin
4545
// A simple class to prevent having to modify subclass of NethermindApi many times
4646
public record Dependencies(
4747
IConfigProvider ConfigProvider,
48-
IJsonSerializer JsonSerializer,
48+
EthereumJsonSerializer JsonSerializer,
4949
ILogManager LogManager,
5050
ChainSpec ChainSpec,
5151
ISpecProvider SpecProvider,
@@ -78,7 +78,7 @@ ILifetimeScope Context
7878
new BuildBlocksWhenRequested();
7979

8080
public IIPResolver IpResolver => Context.Resolve<IIPResolver>();
81-
public IJsonSerializer EthereumJsonSerializer => _dependencies.JsonSerializer;
81+
public EthereumJsonSerializer EthereumJsonSerializer => _dependencies.JsonSerializer;
8282
public IKeyStore? KeyStore { get; set; }
8383
public ILogManager LogManager => _dependencies.LogManager;
8484
public IMessageSerializationService MessageSerializationService => Context.Resolve<IMessageSerializationService>();

src/Nethermind/Nethermind.Blockchain.Test/BlockchainProcessorTests.cs

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -284,11 +284,30 @@ public AfterBlock ProcessedFail(Block block)
284284

285285
public ProcessingTestContext Suggested(Block block, BlockTreeSuggestOptions options = BlockTreeSuggestOptions.ShouldProcess)
286286
{
287-
AddBlockResult result = _blockTree.SuggestBlock(block, options);
288-
if (result != AddBlockResult.Added)
287+
if ((options & BlockTreeSuggestOptions.ShouldProcess) != 0)
289288
{
290-
_logger.Info($"Finished waiting for {block.ToString(Block.Format.Short)} as block was ignored");
291-
_resetEvent.Set();
289+
// Use Task.Run to avoid blocking when AllowSynchronousContinuations
290+
// causes inline processing on the calling thread
291+
Task.Run(() =>
292+
{
293+
AddBlockResult result = _blockTree.SuggestBlock(block, options);
294+
if (result != AddBlockResult.Added)
295+
{
296+
_logger.Info($"Finished waiting for {block.ToString(Block.Format.Short)} as block was ignored");
297+
_resetEvent.Set();
298+
}
299+
});
300+
// Wait for block to be in the tree before returning
301+
SpinWait.SpinUntil(() => _blockTree.IsKnownBlock(block.Number, block.Hash!), ProcessingWait);
302+
}
303+
else
304+
{
305+
AddBlockResult result = _blockTree.SuggestBlock(block, options);
306+
if (result != AddBlockResult.Added)
307+
{
308+
_logger.Info($"Finished waiting for {block.ToString(Block.Format.Short)} as block was ignored");
309+
_resetEvent.Set();
310+
}
292311
}
293312

294313
return this;
@@ -329,8 +348,7 @@ public ProcessingTestContext Recovered(Block block)
329348

330349
public ProcessingTestContext CountIs(int expectedCount)
331350
{
332-
var count = ((IBlockProcessingQueue)_processor).Count;
333-
Assert.That(expectedCount, Is.EqualTo(count));
351+
Assert.That(() => ((IBlockProcessingQueue)_processor).Count, Is.EqualTo(expectedCount).After(ProcessingWait, 10));
334352
return this;
335353
}
336354

src/Nethermind/Nethermind.Blockchain.Test/Proofs/ReceiptTrieTests.cs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,28 @@ public void Can_collect_proof_with_branch()
5858
VerifyProof(proof, trie.RootHash);
5959
}
6060

61+
[Test, MaxTime(Timeout.MaxTestTime)]
62+
public void Parallel_and_non_parallel_root_hashing_produce_same_root()
63+
{
64+
const int receiptCount = 100;
65+
IReleaseSpec spec = MainnetSpecProvider.Instance.GetSpec((MainnetSpecProvider.MuirGlacierBlockNumber, null));
66+
TxReceipt[] receipts = new TxReceipt[receiptCount];
67+
for (int i = 0; i < receiptCount; i++)
68+
{
69+
receipts[i] = Build.A.Receipt.WithAllFieldsFilled.WithGasUsedTotal(1000 + i).TestObject;
70+
}
71+
72+
using TrackingCappedArrayPool parallelPool = new(receiptCount * 4, canBeParallel: true);
73+
ReceiptTrie parallelTrie = new(spec, receipts, _decoder, parallelPool, canBeParallel: true);
74+
Hash256 parallelRoot = parallelTrie.RootHash;
75+
76+
using TrackingCappedArrayPool sequentialPool = new(receiptCount * 4, canBeParallel: false);
77+
ReceiptTrie sequentialTrie = new(spec, receipts, _decoder, sequentialPool, canBeParallel: false);
78+
Hash256 sequentialRoot = sequentialTrie.RootHash;
79+
80+
Assert.That(sequentialRoot, Is.EqualTo(parallelRoot));
81+
}
82+
6183
private void VerifyProof(byte[][] proof, Hash256 receiptRoot)
6284
{
6385
TrieNode node = new(NodeType.Unknown, proof.Last());

src/Nethermind/Nethermind.Blockchain.Test/Proofs/TxTrieTests.cs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
using Nethermind.Core.Crypto;
88
using Nethermind.Core.Specs;
99
using Nethermind.Core.Test.Builders;
10+
using Nethermind.Int256;
1011
using Nethermind.Serialization.Rlp;
1112
using Nethermind.Specs.Forks;
1213
using Nethermind.State.Proofs;
@@ -99,6 +100,26 @@ public void Encoded_and_decoded_transaction_paths_have_same_root()
99100
Assert.That(encodedRoot, Is.EqualTo(decodedRoot));
100101
}
101102

103+
[Test, MaxTime(Timeout.MaxTestTime)]
104+
public void Parallel_and_non_parallel_root_hashing_produce_same_root()
105+
{
106+
const int txCount = 100;
107+
Transaction[] transactions = new Transaction[txCount];
108+
for (int i = 0; i < txCount; i++)
109+
{
110+
transactions[i] = Build.A.Transaction.WithNonce((UInt256)(i + 1)).Signed().TestObject;
111+
}
112+
113+
using TrackingCappedArrayPool pool = new();
114+
TxTrie txTrie = new(transactions, canBuildProof: false, pool);
115+
Hash256 parallelRoot = txTrie.RootHash;
116+
117+
txTrie.UpdateRootHash(canBeParallel: false);
118+
Hash256 nonParallelRoot = txTrie.RootHash;
119+
120+
Assert.That(nonParallelRoot, Is.EqualTo(parallelRoot));
121+
}
122+
102123
private static void VerifyProof(byte[][] proof, Hash256 txRoot)
103124
{
104125
for (int i = proof.Length; i > 0; i--)

src/Nethermind/Nethermind.Consensus/Processing/BlockchainProcessor.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,9 @@ public sealed class BlockchainProcessor : IBlockchainProcessor, IBlockProcessing
5757
new BoundedChannelOptions(MaxProcessingQueueSize)
5858
{
5959
// Optimize for single reader concurrency
60-
SingleReader = true
60+
SingleReader = true,
61+
// If queues are empty we want the block processing to continue on NewPayload thread and inherit its priority
62+
AllowSynchronousContinuations = true,
6163
});
6264

6365
private bool _recoveryComplete = false;

src/Nethermind/Nethermind.Core.Test/Json/ByteArrayConverterTests.cs

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,71 @@ public void Fuzz_RandomHex_SegmentationInvariant()
296296
}
297297
}
298298

299+
[TestCase(new byte[] { 0xab, 0xcd }, true, true, "\"0xabcd\"")]
300+
[TestCase(new byte[] { 0xab, 0xcd }, false, true, "\"0xabcd\"")]
301+
[TestCase(new byte[] { 0x00, 0xab }, true, true, "\"0xab\"")]
302+
[TestCase(new byte[] { 0x00, 0xab }, false, true, "\"0x00ab\"")]
303+
[TestCase(new byte[] { 0x00, 0x00 }, true, true, "\"0x0\"")]
304+
[TestCase(new byte[] { 0x00, 0x00 }, false, true, "\"0x0000\"")]
305+
[TestCase(new byte[] { 0xab }, true, false, "\"ab\"")]
306+
[TestCase(new byte[] { 0xab }, false, false, "\"ab\"")]
307+
[TestCase(new byte[] { 0x0a }, true, true, "\"0xa\"")]
308+
[TestCase(new byte[] { 0x0a }, false, true, "\"0x0a\"")]
309+
public void Write_OutputFormat(byte[] input, bool skipLeadingZeros, bool addHexPrefix, string expected)
310+
{
311+
using System.IO.MemoryStream ms = new();
312+
using Utf8JsonWriter writer = new(ms);
313+
ByteArrayConverter.Convert(writer, input, skipLeadingZeros, addHexPrefix);
314+
writer.Flush();
315+
Encoding.UTF8.GetString(ms.ToArray()).Should().Be(expected);
316+
}
317+
318+
[Test]
319+
public void Write_LargeOutput_UsesArrayPool()
320+
{
321+
// 200 bytes = 400 hex chars + "0x" prefix + quotes > 256 byte InlineArray threshold
322+
byte[] input = new byte[200];
323+
for (int i = 0; i < input.Length; i++) input[i] = (byte)(i & 0xFF);
324+
325+
using System.IO.MemoryStream ms = new();
326+
using Utf8JsonWriter writer = new(ms);
327+
ByteArrayConverter.Convert(writer, input, skipLeadingZeros: false);
328+
writer.Flush();
329+
string output = Encoding.UTF8.GetString(ms.ToArray());
330+
output.Should().StartWith("\"0x");
331+
output.Should().EndWith("\"");
332+
output.Length.Should().Be(404); // 400 hex + 2 prefix + 2 quotes
333+
}
334+
335+
[Test]
336+
public void WriteAsPropertyName_Format()
337+
{
338+
ByteArrayConverter converter = new();
339+
using System.IO.MemoryStream ms = new();
340+
using Utf8JsonWriter writer = new(ms);
341+
writer.WriteStartObject();
342+
converter.WriteAsPropertyName(writer, new byte[] { 0xab, 0xcd }, JsonSerializerOptions.Default);
343+
writer.WriteNumberValue(1);
344+
writer.WriteEndObject();
345+
writer.Flush();
346+
Encoding.UTF8.GetString(ms.ToArray()).Should().Be("{\"0xabcd\":1}");
347+
}
348+
349+
[Test]
350+
public void WriteAsPropertyName_AllZeros()
351+
{
352+
ByteArrayConverter converter = new();
353+
using System.IO.MemoryStream ms = new();
354+
using Utf8JsonWriter writer = new(ms);
355+
writer.WriteStartObject();
356+
converter.WriteAsPropertyName(writer, new byte[] { 0x00, 0x00 }, JsonSerializerOptions.Default);
357+
writer.WriteNumberValue(1);
358+
writer.WriteEndObject();
359+
writer.Flush();
360+
// skipLeadingZeros: false preserves all zeros
361+
Encoding.UTF8.GetString(ms.ToArray()).Should().Be("{\"0x0000\":1}");
362+
}
363+
299364
[Test]
300365
public void Test_DictionaryKey()
301366
{

src/Nethermind/Nethermind.Core.Test/Json/Hash256ConverterTests.cs

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// SPDX-FileCopyrightText: 2022 Demerzel Solutions Limited
22
// SPDX-License-Identifier: LGPL-3.0-only
33

4+
using System;
45
using System.Text.Json;
56

67
using Nethermind.Core.Crypto;
@@ -22,5 +23,67 @@ public void Can_read_null()
2223
Hash256? result = JsonSerializer.Deserialize<Hash256>("null", options);
2324
Assert.That(result, Is.EqualTo(null));
2425
}
26+
27+
[Test]
28+
public void Writes_zero_hash()
29+
{
30+
Hash256 hash = new(new byte[32]);
31+
string result = JsonSerializer.Serialize(hash, options);
32+
Assert.That(result, Is.EqualTo("\"0x0000000000000000000000000000000000000000000000000000000000000000\""));
33+
}
34+
35+
[Test]
36+
public void Writes_all_ones_hash()
37+
{
38+
byte[] bytes = new byte[32];
39+
Array.Fill(bytes, (byte)0xFF);
40+
Hash256 hash = new(bytes);
41+
string result = JsonSerializer.Serialize(hash, options);
42+
Assert.That(result, Is.EqualTo("\"0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff\""));
43+
}
44+
45+
[Test]
46+
public void Writes_known_hash()
47+
{
48+
// Keccak256 of empty string
49+
Hash256 hash = Keccak.OfAnEmptyString;
50+
string result = JsonSerializer.Serialize(hash, options);
51+
Assert.That(result, Is.EqualTo($"\"0x{hash.ToString(false)}\""));
52+
}
53+
54+
[Test]
55+
public void Writes_sequential_bytes()
56+
{
57+
byte[] bytes = new byte[32];
58+
for (int i = 0; i < 32; i++) bytes[i] = (byte)i;
59+
Hash256 hash = new(bytes);
60+
string result = JsonSerializer.Serialize(hash, options);
61+
Assert.That(result, Is.EqualTo("\"0x000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f\""));
62+
}
63+
64+
[Test]
65+
public void Writes_roundtrip()
66+
{
67+
Hash256 hash = Keccak.Compute("test data"u8);
68+
string json = JsonSerializer.Serialize(hash, options);
69+
Hash256? deserialized = JsonSerializer.Deserialize<Hash256>(json, options);
70+
Assert.That(deserialized, Is.EqualTo(hash));
71+
}
72+
73+
[Test]
74+
public void Writes_each_nibble_value()
75+
{
76+
// Ensure all hex chars 0-f appear correctly
77+
byte[] bytes = new byte[32];
78+
for (int i = 0; i < 16; i++)
79+
{
80+
bytes[i * 2] = (byte)((i << 4) | i); // 0x00, 0x11, 0x22, ..., 0xff
81+
bytes[i * 2 + 1] = (byte)((i << 4) | (15 - i)); // 0x0f, 0x1e, 0x2d, ...
82+
}
83+
Hash256 hash = new(bytes);
84+
string result = JsonSerializer.Serialize(hash, options);
85+
Hash256? roundtrip = JsonSerializer.Deserialize<Hash256>(result, options);
86+
Assert.That(roundtrip, Is.EqualTo(hash));
87+
}
2588
}
2689
}

0 commit comments

Comments
 (0)