Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
12 changes: 10 additions & 2 deletions src/Nethermind/Nethermind.JsonRpc.Benchmark/EthModuleBenchmarks.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ public class EthModuleBenchmarks
{
private EthRpcModule _ethModule;
private IContainer _container;
private HeadBlockSignal _headBlockSignal;

[GlobalSetup]
public void GlobalSetup()
Expand Down Expand Up @@ -65,10 +66,12 @@ public void GlobalSetup()
ISpecProvider specProvider = _container.Resolve<ISpecProvider>();
FeeHistoryOracle feeHistoryOracle = new(blockTree, NullReceiptStorage.Instance, specProvider);

_headBlockSignal = new HeadBlockSignal(blockTree);
_ethModule = new EthRpcModule(
_container.Resolve<IJsonRpcConfig>(),
bridge,
blockTree,
blockTree,
_container.Resolve<IReceiptFinder>(),
_container.Resolve<IStateReader>(),
NullTxPool.Instance,
Expand All @@ -82,11 +85,16 @@ public void GlobalSetup()
_container.Resolve<IProtocolsManager>(),
_container.Resolve<IForkInfo>(),
new LogIndexConfig(),
new BlocksConfig().SecondsPerSlot);
new BlocksConfig().SecondsPerSlot,
_headBlockSignal);
}

[GlobalCleanup]
public void TearDown() => _container.Dispose();
public void TearDown()
{
_headBlockSignal.Dispose();
_container.Dispose();
}

[Benchmark]
public void Current()
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,28 @@ public async Task Eth_get_raw_transaction_by_hash_from_pool()
Assert.That(tx.IsInMempoolForm());
}

[TestCaseSource(nameof(EthGetRawTransactionByBlockAndIndexCases))]
public async Task EthGetRawTransactionByBlockAndIndex(string method, string? blockOverride, string index, string expectedResult)
{
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\":{expectedResult},\"id\":67}}"));
}

private static IEnumerable<TestCaseData> EthGetRawTransactionByBlockAndIndexCases()
{
string raw = $"\"{ExpectedHeadTxRawAtIndex1}\"";
yield return new TestCaseData("eth_getRawTransactionByBlockHashAndIndex", (string?)null, "1", raw)
.SetName("ByHashValidIndex");
yield return new TestCaseData("eth_getRawTransactionByBlockNumberAndIndex", "latest", "1", raw)
.SetName("ByNumberValidIndex");
yield return new TestCaseData("eth_getRawTransactionByBlockHashAndIndex", (string?)null, "99", "null")
.SetName("IndexOutOfRange");
yield return new TestCaseData("eth_getRawTransactionByBlockNumberAndIndex", "0x9999999", "0", "null")
.SetName("BlockUnknown");
}


[Test]
public async Task eth_maxPriorityFeePerGas_test()
Expand Down Expand Up @@ -1389,6 +1412,70 @@ 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()
{
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 All @@ -183,7 +184,8 @@ public async Task<T> Build(Action<ContainerBuilder> configurer) => (T)await _blo
@this.ProtocolsManager,
@this.ForkInfo,
@this.LogIndexConfig,
@this.BlocksConfig.SecondsPerSlot);
@this.BlocksConfig.SecondsPerSlot,
new HeadBlockSignal(@this.BlockTree));

protected override async Task<TestBlockchain> Build(Action<ContainerBuilder>? configurer = null)
{
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 @@ -40,11 +40,13 @@ public class EthModuleFactory(
{
private readonly ulong _secondsPerSlot = blocksConfig.SecondsPerSlot;
private readonly IReadOnlyBlockTree _blockTree = blockTree.AsReadOnly();
private readonly HeadBlockSignal _headBlockSignal = new(blockTree);

public override IEthRpcModule Create() => new EthRpcModule(
config,
blockchainBridgeFactory.CreateBlockchainBridge(),
_blockTree,
blockTree,
receiptStorage,
stateReader,
txPool,
Expand All @@ -58,6 +60,7 @@ public class EthModuleFactory(
protocolsManager,
forkInfo,
logIndexConfig,
_secondsPerSlot);
_secondsPerSlot,
_headBlockSignal);
}
}
97 changes: 94 additions & 3 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 @@ -69,14 +71,16 @@ public partial class EthRpcModule(
IProtocolsManager protocolsManager,
IForkInfo forkInfo,
ILogIndexConfig? logIndexConfig,
ulong? secondsPerSlot) : IEthRpcModule
ulong? secondsPerSlot,
HeadBlockSignal headBlockSignal) : IEthRpcModule
{
public const int GetProofStorageKeyLimit = 1000;
public const int MaxGetStorageSlots = StorageValuesRequest.MaxSlots;
protected readonly Encoding _messageEncoding = Encoding.UTF8;
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 All @@ -89,6 +93,7 @@ public partial class EthRpcModule(
protected readonly IFeeHistoryOracle _feeHistoryOracle = feeHistoryOracle ?? throw new ArgumentNullException(nameof(feeHistoryOracle));
protected readonly IProtocolsManager _protocolsManager = protocolsManager ?? throw new ArgumentNullException(nameof(protocolsManager));
protected readonly ulong _secondsPerSlot = secondsPerSlot ?? throw new ArgumentNullException(nameof(secondsPerSlot));
private readonly HeadBlockSignal _headBlockSignal = headBlockSignal ?? throw new ArgumentNullException(nameof(headBlockSignal));
readonly JsonSerializerOptions UnchangedDictionaryKeyOptions = new(EthereumJsonSerializer.JsonOptionsIndented) { DictionaryKeyPolicy = null };

public ResultWrapper<string> eth_protocolVersion()
Expand Down Expand Up @@ -355,6 +360,58 @@ 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.
{
int waitMs = ResolveSyncTimeoutMs(timeoutMs);
using CancellationTokenSource cts = new(waitMs);

// Submit via the virtual eth_sendRawTransaction so subclass overrides
// propagate without needing a separate sync override.
ResultWrapper<Hash256> sendResult = await eth_sendRawTransaction(transaction);
if (sendResult.Result.ResultType != ResultType.Success)
{
return ResultWrapper<ReceiptForRpc?>.Fail(sendResult.Result.Error ?? "Send failed", sendResult.ErrorCode);
}
Hash256 hash = sendResult.Data;

while (true)
{
// Snapshot the next-head Task BEFORE the receipt check: if a head arrives between
// the check and the await, the snapshot is already completed and the loop re-checks
// immediately. Snapshotting after the check would miss that signal.
Task nextHead = _headBlockSignal.NextHeadTask;

ResultWrapper<ReceiptForRpc?> receiptResult = eth_getTransactionReceipt(hash);
if (receiptResult.Data is not null)
{
return receiptResult;
}

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

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 async Task<ResultWrapper<Hash256>> SendTx(Transaction tx,
TxHandlingOptions txHandlingOptions = TxHandlingOptions.None)
{
Expand Down Expand Up @@ -516,6 +573,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 +616,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 +650,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
Loading
Loading