Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ public void GlobalSetup()
_container.Resolve<IJsonRpcConfig>(),
bridge,
blockTree,
blockTree,
_container.Resolve<IReceiptFinder>(),
_container.Resolve<IStateReader>(),
NullTxPool.Instance,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ public partial class EthRpcModuleTests
private const string SecondaryTestAddress = "0x32e4e4c7c5d1cea5db5f9202a9e4d99e56c91a24";
private const string BalanceOfCallData = "0x70a082310000000000000000000000006c1f09f6271fbe133db38db9c9280307f5d22160";
private const string CreateAccessListSender = "0x7f554713be84160fdf0178cc8df86f5aabd33397";
private const string ExpectedHeadTxRawAtIndex1 = "0xf85f020182520894942921b14f1b1c385cd7e0cc2ef7abe5598c8358018025a0e7c5ff3cba254c4fe8f9f12c3f202150bb9a0aebeee349ff2f4acb23585f56bda0575361bb330bf38b9a89dd8279d42a20d34edeaeede9739a7c2bdcbe3242d7bb";

private static readonly Address TestAccount = new(TestAccountAddress);

Expand Down Expand Up @@ -170,6 +171,27 @@ public async Task Eth_get_raw_transaction_by_hash_from_pool()
Assert.That(tx.IsInMempoolForm());
}

[TestCase(true, TestName = "ByHash")]
[TestCase(false, TestName = "ByNumber")]
public async Task EthGetRawTransactionByBlockAndIndex_WhenValidIndex_ReturnsRlpHex(bool byHash)
{
using Context ctx = await Context.Create();
string method = byHash ? "eth_getRawTransactionByBlockHashAndIndex" : "eth_getRawTransactionByBlockNumberAndIndex";
string blockArg = byHash ? ctx.Test.BlockTree.FindHeadBlock()!.Hash!.ToString() : "latest";
string serialized = await ctx.Test.TestEthRpc(method, blockArg, "1");
Assert.That(serialized, Is.EqualTo($"{{\"jsonrpc\":\"2.0\",\"result\":\"{ExpectedHeadTxRawAtIndex1}\",\"id\":67}}"));
}

[TestCase("eth_getRawTransactionByBlockHashAndIndex", null, "99", TestName = "IndexOutOfRange")]
[TestCase("eth_getRawTransactionByBlockNumberAndIndex", "0x9999999", "0", TestName = "BlockUnknown")]
public async Task EthGetRawTransactionByBlockAndIndex_WhenLookupFails_ReturnsNull(string method, string? blockOverride, string index)
{
using Context ctx = await Context.Create();
string blockArg = blockOverride ?? ctx.Test.BlockTree.FindHeadBlock()!.Hash!.ToString();
string serialized = await ctx.Test.TestEthRpc(method, blockArg, index);
Assert.That(serialized, Is.EqualTo("{\"jsonrpc\":\"2.0\",\"result\":null,\"id\":67}"));
}
Comment thread
svlachakis marked this conversation as resolved.
Outdated


[Test]
public async Task eth_maxPriorityFeePerGas_test()
Expand Down Expand Up @@ -1389,6 +1411,72 @@ public async Task Send_raw_transaction_returns_invalid_rlp_for_empty_list()
Assert.That(serialized, Is.EqualTo("{\"jsonrpc\":\"2.0\",\"error\":{\"code\":-32000,\"message\":\"Invalid RLP.\"},\"id\":67}"));
}

[TestCaseSource(nameof(SendRawTransactionSyncFailureCases))]
public async Task EthSendRawTransactionSync_WhenSubmitFailsOrTimesOut_ReturnsExpectedError(
string rawTxHex, string? timeoutMs, int expectedCode, string expectedMessageFragment)
{
using Context ctx = await Context.Create();
string serialized = timeoutMs is null
? await ctx.Test.TestEthRpc("eth_sendRawTransactionSync", rawTxHex)
: await ctx.Test.TestEthRpc("eth_sendRawTransactionSync", rawTxHex, timeoutMs);

serialized.Should().Contain($"\"code\":{expectedCode}");
serialized.Should().Contain(expectedMessageFragment);
}

private static IEnumerable<TestCaseData> SendRawTransactionSyncFailureCases()
Comment thread
svlachakis marked this conversation as resolved.
{
yield return new TestCaseData("c0", null, ErrorCodes.TransactionRejected, "Invalid RLP")
.SetName("InvalidRlp");

Transaction tx = Build.A.Transaction
.WithNonce(3)
.WithGasLimit(21_000)
.WithGasPrice(20.GWei)
.To(TestItem.AddressB)
.SignedAndResolved(TestItem.PrivateKeyA).TestObject;
string raw = TxDecoder.Instance.Encode(tx, RlpBehaviors.SkipTypedWrapping).Bytes.ToHexString(true);
yield return new TestCaseData(raw, "100", ErrorCodes.Timeout, "not included within 100ms")
.SetName("Timeout");
}

[Test]
public async Task EthSendRawTransactionSync_WhenAlreadyMined_FastPathReturnsReceipt()
{
// The fast-path: TxSender returns the hash, the receipt is already in the bridge, so the
// first loop iteration finds it and returns without ever waiting on the semaphore.
Comment thread
svlachakis marked this conversation as resolved.
Outdated
Transaction tx = Build.A.Transaction
.WithNonce(3)
.WithGasLimit(21_000)
.WithGasPrice(20.GWei)
.To(TestItem.AddressB)
.SignedAndResolved(TestItem.PrivateKeyA).TestObject;
Hash256 txHash = tx.Hash!;
TxReceipt receipt = Build.A.Receipt
.WithBlockNumber(1)
.WithBlockHash(TestItem.KeccakA)
.WithTransactionHash(txHash)
.WithLogs([])
.TestObject;

ITxSender txSender = Substitute.For<ITxSender>();
txSender.SendTransaction(Arg.Any<Transaction>(), Arg.Any<TxHandlingOptions>())
.Returns((txHash, AcceptTxResult.Accepted));

IBlockchainBridge bridge = Substitute.For<IBlockchainBridge>();
bridge.GetTxReceiptInfo(txHash)
.Returns((receipt, 0UL, new TxGasInfo(20.GWei, null, null), 0));

TestRpcBlockchain test = await TestRpcBlockchain.ForTest(SealEngineType.NethDev)
.WithBlockchainBridge(bridge).WithTxSender(txSender).Build();

string raw = TxDecoder.Instance.Encode(tx, RlpBehaviors.SkipTypedWrapping).Bytes.ToHexString(true);
string serialized = await test.TestEthRpc("eth_sendRawTransactionSync", raw);

serialized.Should().Contain($"\"transactionHash\":\"{txHash}\"");
serialized.Should().NotContain("\"error\":");
}

[Test]
public async Task Send_transaction_without_signature_will_not_set_nonce_when_zero_and_not_null()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,7 @@ public async Task<T> Build(Action<ContainerBuilder> configurer) => (T)await _blo
@this.RpcConfig,
@this.Bridge,
@this.BlockFinder,
@this.BlockTree,
@this.ReceiptFinder,
@this.StateReader,
@this.TxPool,
Expand Down
6 changes: 6 additions & 0 deletions src/Nethermind/Nethermind.JsonRpc/IJsonRpcConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -191,4 +191,10 @@ public interface IJsonRpcConfig : IConfig
Description = "Enable strict parsing rules for Block Params and Hashes in RPC requests. this will decrease compatibility but increase compliance with the spec.",
DefaultValue = "true")]
bool StrictHexFormat { get; set; }

[ConfigItem(Description = "Default server-side wait, in milliseconds, for eth_sendRawTransactionSync when the caller omits the timeout argument.", DefaultValue = "20000")]
int RpcTxSyncDefaultTimeoutMs { get; set; }

[ConfigItem(Description = "Maximum server-side wait, in milliseconds, that eth_sendRawTransactionSync will accept; client-supplied timeouts above this are clamped down.", DefaultValue = "60000")]
int RpcTxSyncMaxTimeoutMs { get; set; }
}
2 changes: 2 additions & 0 deletions src/Nethermind/Nethermind.JsonRpc/JsonRpcConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -75,4 +75,6 @@ public string[] EnabledModules
public int FiltersTimeout { get; set; } = 900000;
public bool PreloadRpcModules { get; set; }
public bool StrictHexFormat { get; set; } = true;
public int RpcTxSyncDefaultTimeoutMs { get; set; } = 20_000;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need separate timeouts?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

They seem to serve different purposes. Default is the no-arg UX (~1–2 block times so tx-not-included callers get fast feedback), Max is a server-side cap against clients passing huge timeouts and pinning workers, same split Geth uses for the same reason I believe (RPCTxSyncDefaultTimeout/RPCTxSyncMaxTimeout).

public int RpcTxSyncMaxTimeoutMs { get; set; } = 60_000;
};
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ public class EthModuleFactory(
config,
blockchainBridgeFactory.CreateBlockchainBridge(),
_blockTree,
blockTree,
receiptStorage,
stateReader,
txPool,
Expand Down
126 changes: 124 additions & 2 deletions src/Nethermind/Nethermind.JsonRpc/Modules/Eth/EthRpcModule.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited
// SPDX-License-Identifier: LGPL-3.0-only

using Nethermind.Blockchain;
using Nethermind.Blockchain.Filters;
using Nethermind.Blockchain.Find;
using Nethermind.Blockchain.Receipts;
Expand Down Expand Up @@ -56,6 +57,7 @@ public partial class EthRpcModule(
IJsonRpcConfig rpcConfig,
IBlockchainBridge blockchainBridge,
IBlockFinder blockFinder,
IBlockTree blockTree,
IReceiptFinder receiptFinder,
IStateReader stateReader,
ITxPool txPool,
Expand All @@ -77,6 +79,7 @@ public partial class EthRpcModule(
protected readonly IJsonRpcConfig _rpcConfig = rpcConfig ?? throw new ArgumentNullException(nameof(rpcConfig));
protected readonly IBlockchainBridge _blockchainBridge = blockchainBridge ?? throw new ArgumentNullException(nameof(blockchainBridge));
protected readonly IBlockFinder _blockFinder = blockFinder ?? throw new ArgumentNullException(nameof(blockFinder));
private readonly IBlockTree _blockTree = blockTree ?? throw new ArgumentNullException(nameof(blockTree));
protected readonly IReceiptFinder _receiptFinder = receiptFinder ?? throw new ArgumentNullException(nameof(receiptFinder));
protected readonly IStateReader _stateReader = stateReader ?? throw new ArgumentNullException(nameof(stateReader));
protected readonly ITxPool _txPool = txPool ?? throw new ArgumentNullException(nameof(txPool));
Expand Down Expand Up @@ -355,6 +358,91 @@ public virtual async Task<ResultWrapper<Hash256>> eth_sendRawTransaction(byte[]
}
}

public async Task<ResultWrapper<ReceiptForRpc?>> eth_sendRawTransactionSync(byte[] transaction, ulong? timeoutMs = null)
Comment thread
svlachakis marked this conversation as resolved.
{
Transaction tx;
try
{
tx = TxDecoder.Instance.DecodeCompleteNotNull(transaction,
RlpBehaviors.AllowUnsigned | RlpBehaviors.SkipTypedWrapping | RlpBehaviors.InMempoolForm);
}
catch (RlpException)
{
return ResultWrapper<ReceiptForRpc?>.Fail("Invalid RLP.", ErrorCodes.TransactionRejected);
}

int waitMs = ResolveSyncTimeoutMs(timeoutMs);
using CancellationTokenSource cts = new(waitMs);
// Coalescing signal — back-to-back blocks fold into a single Release; the catches handle
// both the at-capacity case and the in-flight-after-dispose race (event dispatch snapshots
// the invocation list, so an unsubscribe in finally cannot stop a handler already running).
using SemaphoreSlim signal = new(0, 1);
void OnNewHead(object? sender, BlockEventArgs _)
{
try { signal.Release(); }
catch (SemaphoreFullException) { }
catch (ObjectDisposedException) { }
}

// Subscribe before submit to avoid losing a fast inclusion to a race.
_blockTree.NewHeadBlock += OnNewHead;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

N semaphores and N event subscriptions per call. Maybe they should be consolidated to 1?

Copy link
Copy Markdown
Contributor Author

@svlachakis svlachakis May 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think a single SemaphoreSlim won't work (Release wakes only one waiter), so the real alternative is a shared TCS that gets swapped on each block. Looks like it saves a few microseconds per block, but adds a race: if a block lands between the receipt check and the await, the shared TCS has already swapped to the next block, and the call sits idle for ~12s before checking again. N per-call semaphores avoid that because each release queues, even with bad timing the next await returns right away.

In the case where one semaphore receives two releases, if these two Release() calls happen before any WaitAsync():

  • 1st: count 0 → 1
  • 2nd: count is already at max → throws SemaphoreFullException (we catch it)
  • That second release is lost / coalesced, not queued

Microseconds vs occasionally missing a block I think it's better to keep N.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TCS is fine, IMO race is avoidable


try
{
ResultWrapper<Hash256> sendResult = await SendTx(tx);
if (sendResult.Result.ResultType != ResultType.Success)
{
return ResultWrapper<ReceiptForRpc?>.Fail(sendResult.Result.Error ?? "Send failed", sendResult.ErrorCode);
}
Hash256 hash = sendResult.Data;

// First iteration is the fast path — tx may already be mined.
while (true)
{
ReceiptForRpc? receipt = TryGetReceipt(hash);
if (receipt is not null)
{
return ResultWrapper<ReceiptForRpc?>.Success(receipt);
}

try
{
await signal.WaitAsync(cts.Token);
}
catch (OperationCanceledException)
{
return ResultWrapper<ReceiptForRpc?>.Fail(
$"Transaction {hash} was added to the pool but not included within {waitMs}ms.",
ErrorCodes.Timeout);
}
}
}
finally
{
_blockTree.NewHeadBlock -= OnNewHead;
}
}

private int ResolveSyncTimeoutMs(ulong? requestedMs)
{
// Clamp >0 so a negative max can't wrap to a huge ulong and overflow back to a negative delay.
int max = Math.Max(1, _rpcConfig.RpcTxSyncMaxTimeoutMs);
if (requestedMs is { } req && req > 0)
{
return (int)Math.Min(req, (ulong)max);
}
int @default = _rpcConfig.RpcTxSyncDefaultTimeoutMs;
return @default > 0 ? Math.Min(@default, max) : max;
}

private ReceiptForRpc? TryGetReceipt(Hash256 txHash)
{
(TxReceipt? receipt, ulong blockTimestamp, TxGasInfo? gasInfo, int logIndexStart) = _blockchainBridge.GetTxReceiptInfo(txHash);
return receipt is null || gasInfo is null
? null
: new ReceiptForRpc(txHash, receipt, blockTimestamp, gasInfo.Value, logIndexStart);
}

private async Task<ResultWrapper<Hash256>> SendTx(Transaction tx,
TxHandlingOptions txHandlingOptions = TxHandlingOptions.None)
{
Expand Down Expand Up @@ -516,6 +604,40 @@ public ResultWrapper<TransactionForRpc> eth_getTransactionByBlockNumberAndIndex(
return result;
}

public ResultWrapper<string?> eth_getRawTransactionByBlockHashAndIndex(Hash256 blockHash, UInt256 positionIndex)
{
ResultWrapper<string?> result = GetRawTransactionByBlockAndIndex(new BlockParameter(blockHash), positionIndex);
if (_logger.IsTrace && result.Result.ResultType == ResultType.Success) _logger.Trace($"eth_getRawTransactionByBlockHashAndIndex request {blockHash}, index: {positionIndex}, result length: {result.Data?.Length ?? 0}");
return result;
}

public ResultWrapper<string?> eth_getRawTransactionByBlockNumberAndIndex(BlockParameter blockParameter, UInt256 positionIndex)
{
ResultWrapper<string?> result = GetRawTransactionByBlockAndIndex(blockParameter, positionIndex);
if (_logger.IsTrace && result.Result.ResultType == ResultType.Success) _logger.Trace($"eth_getRawTransactionByBlockNumberAndIndex request {blockParameter}, index: {positionIndex}, result length: {result.Data?.Length ?? 0}");
return result;
}

private ResultWrapper<string?> GetRawTransactionByBlockAndIndex(BlockParameter blockParameter, UInt256 positionIndex)
{
SearchResult<Block> searchResult = _blockFinder.SearchForBlock(blockParameter);
if (searchResult.IsError)
{
return ResultWrapper<string?>.Success(null);
}

Block block = searchResult.Object!;
if (positionIndex >= block.Transactions.Length)
{
return ResultWrapper<string?>.Success(null);
}

Transaction transaction = block.Transactions[(int)positionIndex];
// Block-stored txs never carry a sidecar (blob commitments live separately), so consensus form only.
using NettyRlpStream stream = TxDecoder.Instance.EncodeToNewNettyStream(transaction, RlpBehaviors.SkipTypedWrapping);
return ResultWrapper<string?>.Success(stream.AsSpan().ToHexString(true));
}

protected virtual ResultWrapper<TransactionForRpc?> GetTransactionByBlockAndIndex(BlockParameter blockParameter, UInt256 positionIndex)
{
SearchResult<Block> searchResult = _blockFinder.SearchForBlock(blockParameter);
Expand All @@ -525,7 +647,7 @@ public ResultWrapper<TransactionForRpc> eth_getTransactionByBlockNumberAndIndex(
}

Block block = searchResult.Object!;
if (positionIndex < 0 || positionIndex > block.Transactions.Length - 1)
if (positionIndex >= block.Transactions.Length)
{
return ResultWrapper<TransactionForRpc?>.Success(null);
}
Expand Down Expand Up @@ -559,7 +681,7 @@ public ResultWrapper<TransactionForRpc> eth_getTransactionByBlockNumberAndIndex(
}

Block block = searchResult.Object!;
if (positionIndex < 0 || positionIndex > block.Uncles.Length - 1)
if (positionIndex >= block.Uncles.Length)
{
return ResultWrapper<BlockForRpc?>.Success(null);
}
Expand Down
21 changes: 21 additions & 0 deletions src/Nethermind/Nethermind.JsonRpc/Modules/Eth/IEthRpcModule.cs
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,13 @@ public interface IEthRpcModule : IRpcModule
)]
Task<ResultWrapper<Hash256>> eth_sendRawTransaction([JsonRpcParameter(ExampleValue = "[\"0xf86380843b9aca0082520894b943b13292086848d8180d75c73361107920bb1a80802ea0385656b91b8f1f5139e9ba3449b946a446c9cfe7adb91b180ddc22c33b17ac4da01fe821879d386b140fd8080dcaaa98b8c709c5025c8c4dea1334609ebac41b6c\"]")] byte[] transaction);

[JsonRpcMethod(IsImplemented = true,
Description = "Submits a raw transaction and waits for inclusion in a block, returning the receipt or a timeout error.",
IsSharable = false)]
Task<ResultWrapper<ReceiptForRpc?>> eth_sendRawTransactionSync(
[JsonRpcParameter(ExampleValue = "[\"0xf86380843b9aca0082520894b943b13292086848d8180d75c73361107920bb1a80802ea0385656b91b8f1f5139e9ba3449b946a446c9cfe7adb91b180ddc22c33b17ac4da01fe821879d386b140fd8080dcaaa98b8c709c5025c8c4dea1334609ebac41b6c\"]")] byte[] transaction,
ulong? timeoutMs = null);

[JsonRpcMethod(IsImplemented = true,
Description = "Executes a tx call (does not create a transaction)",
IsSharable = false,
Expand Down Expand Up @@ -247,6 +254,20 @@ ResultWrapper<TransactionForRpc> eth_getTransactionByBlockHashAndIndex(
ResultWrapper<TransactionForRpc> eth_getTransactionByBlockNumberAndIndex(
[JsonRpcParameter(ExampleValue = "[\"5111256\",\"0x8\"]")] BlockParameter blockParameter, UInt256 positionIndex);

[JsonRpcMethod(IsImplemented = true,
Comment thread
svlachakis marked this conversation as resolved.
Description = "Retrieves a transaction RLP by block hash and index",
IsSharable = true)]
ResultWrapper<string?> eth_getRawTransactionByBlockHashAndIndex(
[JsonRpcParameter(ExampleValue = "[\"0xfe47fb3539ccce9d19a032473effdd6ce19e3c921bbae2746152ccf82ceef48e\",\"0x2\"]")] Hash256 blockHash,
UInt256 positionIndex);

[JsonRpcMethod(IsImplemented = true,
Description = "Retrieves a transaction RLP by block number and index",
IsSharable = true)]
ResultWrapper<string?> eth_getRawTransactionByBlockNumberAndIndex(
[JsonRpcParameter(ExampleValue = "[\"5111256\",\"0x8\"]")] BlockParameter blockParameter,
UInt256 positionIndex);

[JsonRpcMethod(IsImplemented = true,
Description = "Retrieves a transaction receipt by tx hash",
IsSharable = true,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -577,6 +577,7 @@ public static TestRpcBlockchain.Builder<TestRpcBlockchain> WithOptimismEthRpcMod
blockchain.RpcConfig,
blockchain.Bridge,
blockchain.BlockFinder,
blockchain.BlockTree,
blockchain.ReceiptFinder,
blockchain.StateReader,
blockchain.TxPool,
Expand Down
Loading
Loading