Skip to content

eth_signTransaction eliminate allocations & improvements#11587

Merged
svlachakis merged 9 commits into
masterfrom
eth-sign-tx-improvements
May 14, 2026
Merged

eth_signTransaction eliminate allocations & improvements#11587
svlachakis merged 9 commits into
masterfrom
eth-sign-tx-improvements

Conversation

@svlachakis

@svlachakis svlachakis commented May 13, 2026

Copy link
Copy Markdown
Contributor

Fixes Closes Resolves #11578

Changes

  • SignTransactionResult.Raw is now ArrayPoolList<byte> (was byte[]). The DTO implements IDisposable to release the rental at end of response.
  • New ArrayPoolListByteHexConverter in Nethermind.Serialization.Json/ - reusable serialize-only converter that writes ArrayPoolList<byte> as a hex string. Lives next to ByteArrayConverter.
  • New BuildSignedResult private helper in EthRpcModule - encapsulates the rent/encode/wrap dance under a single try/catch so any throw (from RLP encoding, FromTransaction, or wrapper construction) releases the rental through the same path.
  • OwnedByteMemory is untouched - reverted to its original MemoryManager-backed form. debug_getRawBlockAccessList continues to use it unchanged.

Types of changes

What types of changes does your code introduce?

  • Bugfix (a non-breaking change that fixes an issue)
  • New feature (a non-breaking change that adds functionality)
  • Breaking change (a change that causes existing functionality not to work as expected)
  • Optimization
  • Refactoring
  • Documentation update
  • Build-related changes
  • Other: Description

Testing

Requires testing

  • Yes
  • No

@claude

claude Bot commented May 13, 2026

Copy link
Copy Markdown
Contributor

Claude finished @svlachakis's task in 4m 18s —— View job


PR Review

  • Read rule files (robustness, performance)
  • Read all changed files and diffs
  • Trace disposal chain through JSON-RPC pipeline
  • Post inline findings

Summary

The approach is solid — renting from ArrayPool<byte> and wrapping in OwnedByteMemory is the right pattern, and the pooled-path constructor, Interlocked.Exchange double-dispose guard, and CappedArray-backed RlpStream are all correct. However, there is one High defect that completely negates the optimization:

High — pool buffer never returned

SignTransactionResult does not implement IDisposable. The JSON-RPC pipeline disposes OwnedByteMemory only when it is the direct value of the response (as in ResultWrapper<OwnedByteMemory> for debug_getRawBlockAccessList). For eth_signTransaction the value is a ResultWrapper<SignTransactionResult>, and JsonRpcSuccessResponse.Dispose() calls TryDispose() on SignTransactionResult — which does nothing because the class is not IDisposable. Neither does ResultWrapper<T>.Dispose() recurse into fields. Result: the rented byte[] is never returned to ArrayPool.Shared; the optimization defeats its own purpose.

Fix: add IDisposable to SignTransactionResult and call Raw.Dispose() in it. (Inline comment on SignTransactionResult.cs has the concrete suggestion.)

Medium — two-field disposed-state test is fragile

ObjectDisposedException.ThrowIf(_pooledArray is null && _manager is null, this) is logically correct but implicitly depends on the invariant that only one field is live per code path. A comment documenting this invariant (or a single _disposed flag) would prevent future breakage. (Inline comment on OwnedByteMemory.cs:49.)

Low — negative length produces a misleading overflow message

(uint)length wraps negative values to large uints, so the ThrowIfGreaterThan guard fires, but the error message will be confusing ("4294967295 > 100" rather than "length must be non-negative"). ArgumentOutOfRangeException.ThrowIfNegative(length) before the existing check fixes this. (Inline comment on OwnedByteMemory.cs:35 with suggestion.)


Not mergeable until the IDisposable gap on SignTransactionResult is addressed — as-is, the optimization introduces pool-rental overhead with no benefit.

Comment thread src/Nethermind/Nethermind.JsonRpc/Data/SignTransactionResult.cs Outdated
Comment thread src/Nethermind/Nethermind.JsonRpc/OwnedByteMemory.cs Outdated
Comment thread src/Nethermind/Nethermind.JsonRpc/OwnedByteMemory.cs Outdated
@svlachakis

Copy link
Copy Markdown
Contributor Author

@claude review again.

The class is sealed and has exactly two backings, each set by exactly one constructor and nulled only by Dispose. The invariant (_pooledArray is null && _manager is null) ⇔ disposed is enforced structurally — there's no way to construct a non-disposed instance without setting one of them, and the only mutation post-construction is the Interlocked.Exchange in Dispose. A future path would require a new constructor, at which point the maintainer would naturally extend the check.

A dedicated _disposed flag would duplicate state and need its own Interlocked sync without strengthening the invariant. Happy to add it if you'd prefer the explicit signal, but the two-field test is already exhaustive given the structural constraints.

@svlachakis svlachakis mentioned this pull request May 13, 2026
14 tasks
@claude

claude Bot commented May 13, 2026

Copy link
Copy Markdown
Contributor

Claude finished @svlachakis's task in 1m 31s —— View job


Re-review complete

  • Read author's response on Medium finding
  • Read current state of all changed files
  • Re-evaluate High finding (IDisposable on SignTransactionResult)
  • Re-evaluate Medium finding (two-field disposed check) based on author's argument
  • Re-evaluate Low finding (negative length message)

All prior findings addressed

High — fixed. SignTransactionResult now implements IDisposable and calls Raw?.Dispose(). The regression test SignTransactionResult_Dispose_ReturnsRentedBufferToPool correctly drives the exact disposal path (JsonRpcSuccessResponse → TryDispose(Result) → SignTransactionResult.Dispose() → OwnedByteMemory.Dispose() → pool.Return(array)) and asserts the buffer comes back to the pool. This is the right test.

Medium — argument accepted. The structural reasoning holds: OwnedByteMemory is sealed, the two fields are each owned by exactly one constructor and nulled only via Interlocked.Exchange in Dispose. The invariant (_pooledArray is null && _manager is null) ⟺ disposed is exhaustive given those constraints. A separate _disposed flag would duplicate state without strengthening the guarantee. No change needed.

Low — fixed. ArgumentOutOfRangeException.ThrowIfNegative(length) is now present at line 35, before ThrowIfGreaterThan. Negative values produce a clear diagnostic instead of an overflow-wrapped comparison message.


The implementation is correct and the pool lifetime is now fully closed. This is mergeable.

@svlachakis svlachakis marked this pull request as ready for review May 13, 2026 11:26
@svlachakis svlachakis changed the title eliminate allocations & improvements eth_signTransaction eliminate allocations & improvements May 13, 2026
@claude

claude Bot commented May 13, 2026

Copy link
Copy Markdown
Contributor

Claude finished @svlachakis's task in 3m 5s —— View job


PR Review

  • Read rule files (robustness, performance, coding-style)
  • Read all changed files and diff
  • Trace disposal chain through JSON-RPC pipeline
  • Post findings

Summary

All findings from the previous two review rounds have been correctly addressed. The disposal chain is fully wired and correct. Two new minor observations below, neither of which blocks merge.

Disposal chain trace (confirmed correct)

GetSuccessResponse sets JsonRpcSuccessResponse.Result = resultWrapper.Data (i.e. the SignTransactionResult object directly, not the wrapper). JsonRpcSuccessResponse.Dispose()Result.TryDispose()SignTransactionResult.Dispose()Raw?.Dispose()OwnedByteMemory.Dispose()_pool.Return(array). Pool buffer is correctly returned.

Low — redundant null-conditional on required non-nullable property

SignTransactionResult.Dispose() uses Raw?.Dispose(). Raw is declared required OwnedByteMemory Raw (non-nullable, must be set at construction). The ?. is a redundant null-guard against a state that cannot exist. Per repo style: "Trust null annotations, don't add redundant null checks." Raw.Dispose() is sufficient.

Low — theoretical pool leak if FromTransaction throws

In EthRpcModule.cs (around line 424–430): OwnedByteMemory raw = new(...) is created, then used as an argument inside an object initializer for SignTransactionResult. If TransactionForRpc.FromTransaction(tx) throws after raw is constructed, raw is never disposed and the rented buffer leaks. FromTransaction is a pure data transformation and very unlikely to throw, so this is a theoretical concern only.

If hardening is wanted:

OwnedByteMemory raw = new(rented, encodedLength, ArrayPool<byte>.Shared);
try
{
    return ResultWrapper<SignTransactionResult>.Success(new SignTransactionResult
    {
        Raw = raw,
        Tx = TransactionForRpc.FromTransaction(tx)
    });
}
catch
{
    raw.Dispose();
    throw;
}

Both findings are low severity with no production impact expected. The implementation is correct, the pool lifetime is fully closed, double-dispose is guarded atomically, and the regression test correctly drives the full disposal path. Mergeable.

@svlachakis

Copy link
Copy Markdown
Contributor Author

@claude review again

@claude

claude Bot commented May 13, 2026

Copy link
Copy Markdown
Contributor

Claude finished @svlachakis's task in 3m 8s —— View job


PR Review — Round 4

  • Read rule files (robustness, performance, coding-style)
  • Read all changed files
  • Trace full disposal chain
  • Post findings

Summary

Both Low findings from Round 3 have been resolved in the current code. No new issues found.

Disposal chain — confirmed correct

JsonRpcSuccessResponse.Dispose()Result.TryDispose() (calls DisposableExtensions.TryDispose(object?)) → if (item is IDisposable d) matches because SignTransactionResult : IDisposableSignTransactionResult.Dispose()Raw.Dispose()Interlocked.Exchange(ref _pooledArray, null)_pool.Return(array). Pool buffer is correctly returned.

Round 3 Low-1 — fixed

SignTransactionResult.Dispose() now reads Raw.Dispose(), not Raw?.Dispose(). The redundant null-conditional is gone, consistent with the repo rule "trust null annotations, don't add redundant null checks."

Round 3 Low-2 — fixed

EthRpcModule.cs now wraps the encode/return path in a try/catch { raw.Dispose(); throw; } block. If TxDecoder.Instance.Encode or TransactionForRpc.FromTransaction throws, the rented buffer is correctly returned before the exception propagates.

Additional checks (no issues found)

  • ResultWrapper<SignTransactionResult> is IDisposable but is not disposed in the JsonRpcService success path — only resultWrapper.Data is extracted and set on JsonRpcSuccessResponse.Result. SignTransactionResult is therefore disposed exactly once, via JsonRpcSuccessResponse.Dispose(). No double-dispose risk.
  • OwnedByteMemory.Dispose() uses Interlocked.Exchange on both branches; concurrent or repeated calls are safe.
  • All early-return error paths in eth_signTransaction occur before rented/raw are created, so no leak on validation failures.
  • The regression test correctly drives the exact pipeline disposal path and asserts the buffer reaches pool.Return.

Mergeable. All Critical/High/Medium/Low findings from prior rounds are resolved.

Comment thread src/Nethermind/Nethermind.JsonRpc/Modules/Eth/EthRpcModule.cs
Comment thread src/Nethermind/Nethermind.JsonRpc/OwnedByteMemory.cs
@svlachakis

Copy link
Copy Markdown
Contributor Author

@claude review again after applying @LukaszRozmej's suggestions

@claude

claude Bot commented May 13, 2026

Copy link
Copy Markdown
Contributor

Claude finished @svlachakis's task in 5m 40s —— View job


PR Review — Round 5 (post-LukaszRozmej suggestions)

  • Read rule files (robustness, performance, coding-style)
  • Read all changed files
  • Trace disposal chain with new ArrayPoolList<byte> approach
  • Check ArrayPoolListByteHexConverter and project dependencies

Summary

LukaszRozmej's suggestions have been correctly applied. OwnedByteMemory is back to its original single-constructor form (no changes to existing users like debug_getRawBlockAccessList). SignTransactionResult.Raw is now ArrayPoolList<byte>, which is a well-established type in the codebase with built-in pool management. One informational note below; no blocking issues.

Disposal chain — confirmed correct

JsonRpcSuccessResponse.Dispose()Result.TryDispose() (if (item is IDisposable d) matches because SignTransactionResult : IDisposable) → SignTransactionResult.Dispose()Raw.Dispose()ArrayPoolListCore<byte>.Dispose(pool, ...)pool.Return(array). Pool buffer is correctly returned to SafeArrayPool<byte>.Shared (= ArrayPool<byte>.Shared in non-ZK builds). ✓

Project dependencies — no issue

Nethermind.JsonRpc reaches Nethermind.Serialization.Json (where ArrayPoolListByteHexConverter lives) transitively via Nethermind.Sockets. The using Nethermind.Serialization.Json; in SignTransactionResult.cs and OwnedByteMemory.cs has always relied on this transitive path. ✓

BuildSignedResult — clean refactor

Extracted to private static, correctly scoped try/catch disposes buffer on any throw, and TransactionForRpc.FromTransaction or TxDecoder.Encode throwing after pool rental does not leak. ✓

ArrayPoolListByteHexConverter — correct

value.AsSpan() returns _array.AsSpan(0, _count) where _count = length (set at construction). ByteArrayConverter.Convert(writer, ReadOnlySpan<byte>, ...) is called correctly. Serialize-only with NotSupportedException on read is the right contract. ✓


Informational — double memory write at construction

new ArrayPoolList<byte>(length, length) calls _array.AsSpan(0, length).Clear() in the constructor (the startingCount path). The RLP encoder then overwrites all length bytes. The zero-initialisation is dead work. The overhead is entirely negligible relative to ECDSA signing and doesn't affect correctness, but it is slightly at odds with the allocation-reduction theme. No fix required — the ArrayPoolList<T> API doesn't expose a "pre-sized, uncleared" path without going further into internals than is warranted here.


Mergeable. No Critical, High, or Medium findings. All prior-round findings are resolved.

@svlachakis svlachakis requested a review from LukaszRozmej May 13, 2026 14:49
Comment thread src/Nethermind/Nethermind.JsonRpc/Data/SignTransactionResult.cs Outdated
@svlachakis svlachakis merged commit 828f5ef into master May 14, 2026
568 checks passed
@svlachakis svlachakis deleted the eth-sign-tx-improvements branch May 14, 2026 09:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

perf(rpc): pool encoded raw bytes returned by eth_signTransaction

3 participants