From a9d7717d64499c0be7ace19effd7d9fa16212aaa Mon Sep 17 00:00:00 2001 From: stavrosvl7 Date: Thu, 7 May 2026 22:45:50 +0300 Subject: [PATCH 01/18] eth_sign_transaction implementation --- .../RpcTransaction/BlobTransactionForRpc.cs | 6 + .../Eth/RpcTransaction/TransactionForRpc.cs | 2 +- .../Eth/EthRpcModuleTests.SignTransaction.cs | 162 ++++++++++++++++++ .../Data/SignTransactionResult.cs | 16 ++ .../Nethermind.JsonRpc/IJsonRpcConfig.cs | 3 + .../Nethermind.JsonRpc/JsonRpcConfig.cs | 1 + .../Modules/Eth/EthRpcModule.cs | 134 +++++++++++++++ .../Modules/Eth/IEthRpcModule.cs | 7 + 8 files changed, 330 insertions(+), 1 deletion(-) create mode 100644 src/Nethermind/Nethermind.JsonRpc.Test/Modules/Eth/EthRpcModuleTests.SignTransaction.cs create mode 100644 src/Nethermind/Nethermind.JsonRpc/Data/SignTransactionResult.cs diff --git a/src/Nethermind/Nethermind.Facade/Eth/RpcTransaction/BlobTransactionForRpc.cs b/src/Nethermind/Nethermind.Facade/Eth/RpcTransaction/BlobTransactionForRpc.cs index 71c95c5a358b..ccfab9d4bcec 100644 --- a/src/Nethermind/Nethermind.Facade/Eth/RpcTransaction/BlobTransactionForRpc.cs +++ b/src/Nethermind/Nethermind.Facade/Eth/RpcTransaction/BlobTransactionForRpc.cs @@ -25,6 +25,12 @@ public class BlobTransactionForRpc : EIP1559TransactionForRpc, IFromTransaction< [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public byte[][]? Blobs { get; set; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public byte[][]? Commitments { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public byte[][]? Proofs { get; set; } + [JsonConstructor] public BlobTransactionForRpc() { } diff --git a/src/Nethermind/Nethermind.Facade/Eth/RpcTransaction/TransactionForRpc.cs b/src/Nethermind/Nethermind.Facade/Eth/RpcTransaction/TransactionForRpc.cs index 9dd271e248d1..71ea69fce616 100644 --- a/src/Nethermind/Nethermind.Facade/Eth/RpcTransaction/TransactionForRpc.cs +++ b/src/Nethermind/Nethermind.Facade/Eth/RpcTransaction/TransactionForRpc.cs @@ -55,7 +55,7 @@ public abstract class TransactionForRpc /// the type to match the target block's fork rules. /// [JsonIgnore] - internal bool IsTypeDefaulted { get; set; } + public bool IsTypeDefaulted { get; internal set; } [JsonConstructor] protected TransactionForRpc() { } diff --git a/src/Nethermind/Nethermind.JsonRpc.Test/Modules/Eth/EthRpcModuleTests.SignTransaction.cs b/src/Nethermind/Nethermind.JsonRpc.Test/Modules/Eth/EthRpcModuleTests.SignTransaction.cs new file mode 100644 index 000000000000..b82242370b9e --- /dev/null +++ b/src/Nethermind/Nethermind.JsonRpc.Test/Modules/Eth/EthRpcModuleTests.SignTransaction.cs @@ -0,0 +1,162 @@ +// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited +// SPDX-License-Identifier: LGPL-3.0-only + +using System; +using System.Threading.Tasks; +using FluentAssertions; +using Nethermind.Core; +using Nethermind.Crypto; +using Nethermind.Facade.Eth.RpcTransaction; +using Nethermind.Int256; +using Nethermind.JsonRpc.Client; +using Nethermind.JsonRpc.Data; +using Nethermind.Serialization.Rlp; +using NUnit.Framework; + +namespace Nethermind.JsonRpc.Test.Modules.Eth; + +public partial class EthRpcModuleTests +{ + // Address derived from PrivateKey 0x00..01 by WalletExtensions.SetupTestAccounts + private const string UnlockedTestAccount = "0x7e5f4552091a69125d5dfcb7b8c2659029395bdf"; + private const string LockedAccount = "0x000000000000000000000000000000000000dead"; + private const string FeeFieldsMissingMessage = "missing gasPrice or maxFeePerGas/maxPriorityFeePerGas"; + + [TestCase(TxType.Legacy, "gas", "gas not specified", TestName = "GasMissing")] + [TestCase(TxType.Legacy, "gasPrice", FeeFieldsMissingMessage, TestName = "LegacyFeesMissing")] + [TestCase(TxType.EIP1559, "maxFeePerGas", FeeFieldsMissingMessage, TestName = "Eip1559MaxFeePerGasMissing")] + [TestCase(TxType.EIP1559, "maxPriorityFeePerGas", FeeFieldsMissingMessage, TestName = "Eip1559MaxPriorityFeePerGasMissing")] + [TestCase(TxType.Legacy, "nonce", "nonce not specified", TestName = "NonceMissing")] + public async Task SignTransaction_WhenRequiredFieldMissing_ReturnsInvalidInput(TxType type, string omitField, string expectedMessage) + { + TransactionForRpc rpcTx = BuildTx(type, omitField); + string response = await SignTransaction(rpcTx); + + response.Should().Contain($"\"code\":{ErrorCodes.InvalidInput}", + "missing required field must surface as InvalidInput so callers can branch on it"); + response.Should().Contain(expectedMessage, + "error message must be precise so callers know which field to fix"); + } + + [TestCase(LockedAccount, null, TestName = "WrongAccount")] + [TestCase(null, "from", TestName = "FromMissing")] + public async Task SignTransaction_WhenSenderNotUnlocked_ReturnsAuthError(string? fromOverride, string? omitField) + { + // Missing-from defaults to Address.Zero; both paths fail the IsUnlocked check with the same response. + TransactionForRpc rpcTx = BuildTx(TxType.Legacy, omitField, fromOverride); + string response = await SignTransaction(rpcTx); + + response.Should().Contain($"\"code\":{ErrorCodes.InvalidInput}", + "wallet lookup failure surfaces as -32000 to align with keystore error handling"); + response.Should().Contain("authentication needed: password or unlock", + "wording must match keystore error so tools that text-match keep working"); + } + + [Test] + public async Task SignTransaction_WhenTotalFeeExceedsCap_ReturnsInvalidInput() + { + EIP1559TransactionForRpc rpcTx = (EIP1559TransactionForRpc)BuildTx(TxType.EIP1559); + rpcTx.MaxFeePerGas = (UInt256)50_000_000_000_000UL; // 50000 gwei * 30400 gas = 1.52 ETH > 1 ETH cap + rpcTx.MaxPriorityFeePerGas = (UInt256)1_000_000_000UL; + + string response = await SignTransaction(rpcTx); + + response.Should().Contain("exceeds the configured cap", + "fees above RpcTxFeeCap must be rejected before signing — DOS / fat-finger guard"); + } + + [Test] + public async Task SignTransaction_WhenBlobTxMissingCommitments_ReturnsInvalidInput() + { + byte[] versionedHash = new byte[32]; + versionedHash[0] = 0x01; + BlobTransactionForRpc rpcTx = new() + { + From = new Address(UnlockedTestAccount), + To = new Address("0x2d44c0e097f6cd0f514edac633d82e01280b4a5c"), + Gas = 0x76c0, + Nonce = (UInt256)0, + MaxFeePerGas = (UInt256)0x9184e72a000, + MaxPriorityFeePerGas = (UInt256)0x3b9aca00, + MaxFeePerBlobGas = (UInt256)1_000_000, + BlobVersionedHashes = [versionedHash], + Blobs = [new byte[131072]], + }; + + string response = await SignTransaction(rpcTx); + + response.Should().Contain("commitments must be provided alongside blobs", + "blob signing without commitments must surface a precise error so callers know what to add"); + } + + [TestCase(TxType.Legacy, typeof(LegacyTransactionForRpc), TestName = "Legacy")] + [TestCase(TxType.EIP1559, typeof(EIP1559TransactionForRpc), TestName = "Eip1559")] + public async Task SignTransaction_WhenValid_RawRoundTripsAndTxEcho(TxType type, Type expectedEchoType) + { + TransactionForRpc rpcTx = BuildTx(type); + SignTransactionResult result = await SignTransactionForResult(rpcTx); + + Transaction decoded = TxDecoder.Instance.DecodeCompleteNotNull( + result.Raw, + RlpBehaviors.AllowUnsigned | RlpBehaviors.SkipTypedWrapping | RlpBehaviors.InMempoolForm); + + decoded.Type.Should().Be(type, "type must round-trip through RLP encode/decode"); + decoded.GasLimit.Should().Be(0x76c0L, "gas must round-trip exactly — caller provided it explicitly"); + decoded.Nonce.Should().Be((UInt256)0, "nonce must round-trip — caller provided it explicitly"); + + Address recovered = new EthereumEcdsa(decoded.ChainId ?? 1).RecoverAddress(decoded)!; + recovered.Should().Be(new Address(UnlockedTestAccount), + "signature must recover to the from address — raw is the canonical signed artifact"); + + result.Tx.Should().BeOfType(expectedEchoType, + "tx echo must preserve subclass so JSON shape survives for clients that branch on type"); + } + + private async Task SignTransaction(TransactionForRpc rpcTx) + { + using Context ctx = await Context.Create(); + return await ctx.Test.TestEthRpc("eth_signTransaction", rpcTx); + } + + private async Task SignTransactionForResult(TransactionForRpc rpcTx) + { + using Context ctx = await Context.Create(); + string serialized = await ctx.Test.TestEthRpc("eth_signTransaction", rpcTx); + JsonRpcResponse response = ctx.Test.JsonSerializer.Deserialize>(serialized)!; + response.Result.Should().NotBeNull("precondition: signing must succeed for valid input"); + return response.Result!; + } + + private static TransactionForRpc BuildTx(TxType type, string? omitField = null, string? fromOverride = null) + { + Address? from = fromOverride is not null + ? new Address(fromOverride) + : (omitField == "from" ? null : new Address(UnlockedTestAccount)); + + Address to = new("0x2d44c0e097f6cd0f514edac633d82e01280b4a5c"); + UInt256 value = 0x9184e72a; + long gas = 0x76c0; + UInt256 nonce = 0; + + return type == TxType.EIP1559 + ? new EIP1559TransactionForRpc + { + From = from, + To = to, + Value = value, + Gas = omitField == "gas" ? null : gas, + Nonce = omitField == "nonce" ? null : nonce, + MaxFeePerGas = omitField == "maxFeePerGas" ? null : (UInt256?)0x9184e72a000, + MaxPriorityFeePerGas = omitField == "maxPriorityFeePerGas" ? null : (UInt256?)0x3b9aca00, + } + : new LegacyTransactionForRpc + { + From = from, + To = to, + Value = value, + Gas = omitField == "gas" ? null : gas, + Nonce = omitField == "nonce" ? null : nonce, + GasPrice = omitField == "gasPrice" ? null : (UInt256?)0x9184e72a000, + }; + } +} diff --git a/src/Nethermind/Nethermind.JsonRpc/Data/SignTransactionResult.cs b/src/Nethermind/Nethermind.JsonRpc/Data/SignTransactionResult.cs new file mode 100644 index 000000000000..3321acc63741 --- /dev/null +++ b/src/Nethermind/Nethermind.JsonRpc/Data/SignTransactionResult.cs @@ -0,0 +1,16 @@ +// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited +// SPDX-License-Identifier: LGPL-3.0-only + +using System.Text.Json.Serialization; +using Nethermind.Facade.Eth.RpcTransaction; + +namespace Nethermind.JsonRpc.Data; + +public class SignTransactionResult +{ + [JsonPropertyName("raw")] + public byte[] Raw { get; init; } = null!; + + [JsonPropertyName("tx")] + public TransactionForRpc Tx { get; init; } = null!; +} diff --git a/src/Nethermind/Nethermind.JsonRpc/IJsonRpcConfig.cs b/src/Nethermind/Nethermind.JsonRpc/IJsonRpcConfig.cs index 6ab58d241091..d27b8f33c63f 100644 --- a/src/Nethermind/Nethermind.JsonRpc/IJsonRpcConfig.cs +++ b/src/Nethermind/Nethermind.JsonRpc/IJsonRpcConfig.cs @@ -169,6 +169,9 @@ public interface IJsonRpcConfig : IConfig [ConfigItem(Description = "The error margin used in the `eth_estimateGas` JSON-RPC method, in basis points.", DefaultValue = "150")] int EstimateErrorMargin { get; set; } + [ConfigItem(Description = "Maximum total tx fee (gasPrice * gasLimit, in wei) the node will sign in eth_signTransaction. 0 disables the cap. Default 1 ETH.", DefaultValue = "1000000000000000000")] + ulong RpcTxFeeCap { get; set; } + [ConfigItem(Description = "The JSON-RPC server CORS origins.", DefaultValue = "*")] string[] CorsOrigins { get; set; } diff --git a/src/Nethermind/Nethermind.JsonRpc/JsonRpcConfig.cs b/src/Nethermind/Nethermind.JsonRpc/JsonRpcConfig.cs index 4a477e4cb7c6..2b4737012fbb 100644 --- a/src/Nethermind/Nethermind.JsonRpc/JsonRpcConfig.cs +++ b/src/Nethermind/Nethermind.JsonRpc/JsonRpcConfig.cs @@ -68,6 +68,7 @@ public string[] EnabledModules public long? MaxBatchResponseBodySize { get; set; } = 32.MiB; public long? MaxSimulateBlocksCap { get; set; } = 256; public int EstimateErrorMargin { get; set; } = 150; + public ulong RpcTxFeeCap { get; set; } = 1_000_000_000_000_000_000UL; public string[] CorsOrigins { get; set; } = ["*"]; public int WebSocketsProcessingConcurrency { get; set; } = 1; public int IpcProcessingConcurrency { get; set; } = 1; diff --git a/src/Nethermind/Nethermind.JsonRpc/Modules/Eth/EthRpcModule.cs b/src/Nethermind/Nethermind.JsonRpc/Modules/Eth/EthRpcModule.cs index d8e98d7959a8..fed4e35af456 100644 --- a/src/Nethermind/Nethermind.JsonRpc/Modules/Eth/EthRpcModule.cs +++ b/src/Nethermind/Nethermind.JsonRpc/Modules/Eth/EthRpcModule.cs @@ -44,6 +44,7 @@ using System.Text.Json.Nodes; using System.Threading; using System.Threading.Tasks; +using Nethermind.Crypto; using Block = Nethermind.Core.Block; using BlockHeader = Nethermind.Core.BlockHeader; using ResultType = Nethermind.Core.ResultType; @@ -355,6 +356,139 @@ public virtual async Task> eth_sendRawTransaction(byte[] } } + public virtual ResultWrapper eth_signTransaction(TransactionForRpc rpcTx) + { + if (rpcTx.Gas is null) + return ResultWrapper.Fail("gas not specified", ErrorCodes.InvalidInput); + + if (!HasFeeFields(rpcTx)) + return ResultWrapper.Fail("missing gasPrice or maxFeePerGas/maxPriorityFeePerGas", ErrorCodes.InvalidInput); + + LegacyTransactionForRpc? legacy = rpcTx as LegacyTransactionForRpc; + if (legacy?.Nonce is null) + return ResultWrapper.Fail("nonce not specified", ErrorCodes.InvalidInput); + + Address from = legacy.From ?? Address.Zero; + if (!_wallet.IsUnlocked(from)) + return ResultWrapper.Fail("authentication needed: password or unlock", ErrorCodes.InvalidInput); + + rpcTx = PromoteToEip1559IfDefaultLegacy(rpcTx); + + Result txResult = rpcTx.ToTransaction(validateUserInput: true); + if (!txResult.Success(out Transaction tx, out string error)) + return ResultWrapper.Fail(error, ErrorCodes.InvalidInput); + + ulong chainId = _blockchainBridge.GetChainId(); + tx.ChainId = chainId; + + ResultWrapper? feeCapError = CheckTxFeeCap(tx); + if (feeCapError is not null) + return feeCapError; + + // Sidecar must be attached before encode; signing only sets tx.Signature so the wrapper survives. + if (rpcTx is BlobTransactionForRpc blobTx) + { + string? attachError = TryAttachBlobSidecar(tx, blobTx); + if (attachError is not null) + return ResultWrapper.Fail(attachError, ErrorCodes.InvalidInput); + } + + try + { + _wallet.Sign(tx, chainId); + } + catch (SecurityException) + { + return ResultWrapper.Fail("authentication needed: password or unlock", ErrorCodes.InvalidInput); + } + + tx.Hash = tx.CalculateHash(); + + byte[] raw = TxDecoder.Instance.Encode(tx, RlpBehaviors.SkipTypedWrapping | RlpBehaviors.InMempoolForm).Bytes; + + return ResultWrapper.Success(new SignTransactionResult + { + Raw = raw, + Tx = TransactionForRpc.FromTransaction(tx) + }); + } + + private static bool HasFeeFields(TransactionForRpc rpcTx) + { + if (rpcTx is EIP1559TransactionForRpc eip1559 + && eip1559.MaxFeePerGas is not null + && eip1559.MaxPriorityFeePerGas is not null) + { + return true; + } + + return rpcTx is LegacyTransactionForRpc legacy && legacy.GasPrice is not null; + } + + private static TransactionForRpc PromoteToEip1559IfDefaultLegacy(TransactionForRpc rpcTx) + { + if (!rpcTx.IsTypeDefaulted) return rpcTx; + if (rpcTx is AccessListTransactionForRpc or EIP1559TransactionForRpc) return rpcTx; + if (rpcTx is not LegacyTransactionForRpc legacy) return rpcTx; + + return new EIP1559TransactionForRpc + { + From = legacy.From, + To = legacy.To, + Value = legacy.Value, + Gas = legacy.Gas, + Nonce = legacy.Nonce, + Input = legacy.Input, + ChainId = legacy.ChainId, + MaxFeePerGas = legacy.GasPrice, + MaxPriorityFeePerGas = legacy.GasPrice, + }; + } + + private ResultWrapper? CheckTxFeeCap(Transaction tx) + { + ulong cap = _rpcConfig.RpcTxFeeCap; + if (cap == 0) return null; + + UInt256 perGas = tx.Type >= TxType.EIP1559 ? tx.MaxFeePerGas : tx.GasPrice; + UInt256 totalFee = perGas * (UInt256)tx.GasLimit; + UInt256 capWei = cap; + + if (totalFee <= capWei) return null; + + return ResultWrapper.Fail( + $"tx fee ({FormatWeiAsEther(totalFee)} ether) exceeds the configured cap ({FormatWeiAsEther(capWei)} ether)", + ErrorCodes.InvalidInput); + } + + private static string FormatWeiAsEther(UInt256 wei) + { + const ulong WeiPerEther = 1_000_000_000_000_000_000UL; + const ulong WeiPerCenti = 10_000_000_000_000_000UL; + UInt256 ether = wei / (UInt256)WeiPerEther; + UInt256 centiTotal = wei / (UInt256)WeiPerCenti; + UInt256.Mod(centiTotal, (UInt256)100, out UInt256 centi); + return $"{ether}.{(ulong)centi:D2}"; + } + + private string? TryAttachBlobSidecar(Transaction tx, BlobTransactionForRpc blobTx) + { + if (blobTx.Blobs is null || blobTx.Blobs.Length == 0) + return "blob transaction requires non-empty blobs"; + if (blobTx.Commitments is null || blobTx.Commitments.Length != blobTx.Blobs.Length) + return "commitments must be provided alongside blobs (one per blob)"; + if (blobTx.Proofs is null) + return "proofs must be provided alongside blobs"; + + BlockHeader? head = _blockFinder.Head?.Header; + ProofVersion version = head is null + ? ProofVersion.V0 + : _specProvider.GetSpec(head).BlobProofVersion; + + tx.NetworkWrapper = new ShardBlobNetworkWrapper(blobTx.Blobs, blobTx.Commitments, blobTx.Proofs, version); + return null; + } + private async Task> SendTx(Transaction tx, TxHandlingOptions txHandlingOptions = TxHandlingOptions.None) { diff --git a/src/Nethermind/Nethermind.JsonRpc/Modules/Eth/IEthRpcModule.cs b/src/Nethermind/Nethermind.JsonRpc/Modules/Eth/IEthRpcModule.cs index cf75526583cb..f472dc744dbd 100644 --- a/src/Nethermind/Nethermind.JsonRpc/Modules/Eth/IEthRpcModule.cs +++ b/src/Nethermind/Nethermind.JsonRpc/Modules/Eth/IEthRpcModule.cs @@ -155,6 +155,13 @@ public interface IEthRpcModule : IRpcModule )] Task> eth_sendRawTransaction([JsonRpcParameter(ExampleValue = "[\"0xf86380843b9aca0082520894b943b13292086848d8180d75c73361107920bb1a80802ea0385656b91b8f1f5139e9ba3449b946a446c9cfe7adb91b180ddc22c33b17ac4da01fe821879d386b140fd8080dcaaa98b8c709c5025c8c4dea1334609ebac41b6c\"]")] byte[] transaction); + [JsonRpcMethod(IsImplemented = true, + Description = "Signs the transaction using the unlocked sender account and returns the RLP-encoded signed transaction together with the parsed object.", + IsSharable = true, + ExampleResponse = "{\"raw\":\"0x02f86c0182520894b943b13292086848d8180d75c73361107920bb1a80...\",\"tx\":{\"type\":\"0x2\",\"nonce\":\"0x0\",\"gas\":\"0x5208\",\"to\":\"0x...\"}}")] + ResultWrapper eth_signTransaction( + [JsonRpcParameter(ExampleValue = "[{\"from\":\"0xc2208fe87805279b03c1a8a78d7ee4bfdb0e48ee\",\"to\":\"0x2d44c0e097f6cd0f514edac633d82e01280b4a5c\",\"value\":\"0x9184e72a\",\"gas\":\"0x76c0\",\"gasPrice\":\"0x9184e72a000\",\"nonce\":\"0x0\"}]")] TransactionForRpc rpcTx); + [JsonRpcMethod(IsImplemented = true, Description = "Executes a tx call (does not create a transaction)", IsSharable = false, From 421a55938889691ce4cbd59196a16fcba3a4a24e Mon Sep 17 00:00:00 2001 From: stavrosvl7 Date: Thu, 7 May 2026 23:00:35 +0300 Subject: [PATCH 02/18] claude review --- .../Eth/RpcTransaction/TransactionForRpc.cs | 4 ++- .../Eth/EthRpcModuleTests.SignTransaction.cs | 35 ++++++++++++++----- .../Modules/Eth/EthRpcModule.cs | 23 +++++++----- 3 files changed, 43 insertions(+), 19 deletions(-) diff --git a/src/Nethermind/Nethermind.Facade/Eth/RpcTransaction/TransactionForRpc.cs b/src/Nethermind/Nethermind.Facade/Eth/RpcTransaction/TransactionForRpc.cs index 71ea69fce616..89ec3167f5a8 100644 --- a/src/Nethermind/Nethermind.Facade/Eth/RpcTransaction/TransactionForRpc.cs +++ b/src/Nethermind/Nethermind.Facade/Eth/RpcTransaction/TransactionForRpc.cs @@ -52,7 +52,9 @@ public abstract class TransactionForRpc /// /// True when the transaction type was inferred by rather than /// explicitly provided in the JSON request. When set, can resolve - /// the type to match the target block's fork rules. + /// the type to match the target block's fork rules. The setter is internal so only the converter + /// can mutate it; the getter is public so cross-assembly consumers (RPC modules) can read it + /// when deciding whether to apply default-type semantics. /// [JsonIgnore] public bool IsTypeDefaulted { get; internal set; } diff --git a/src/Nethermind/Nethermind.JsonRpc.Test/Modules/Eth/EthRpcModuleTests.SignTransaction.cs b/src/Nethermind/Nethermind.JsonRpc.Test/Modules/Eth/EthRpcModuleTests.SignTransaction.cs index b82242370b9e..5d78cf307d95 100644 --- a/src/Nethermind/Nethermind.JsonRpc.Test/Modules/Eth/EthRpcModuleTests.SignTransaction.cs +++ b/src/Nethermind/Nethermind.JsonRpc.Test/Modules/Eth/EthRpcModuleTests.SignTransaction.cs @@ -24,9 +24,12 @@ public partial class EthRpcModuleTests [TestCase(TxType.Legacy, "gas", "gas not specified", TestName = "GasMissing")] [TestCase(TxType.Legacy, "gasPrice", FeeFieldsMissingMessage, TestName = "LegacyFeesMissing")] + [TestCase(TxType.AccessList, "gasPrice", FeeFieldsMissingMessage, TestName = "AccessListFeesMissing")] [TestCase(TxType.EIP1559, "maxFeePerGas", FeeFieldsMissingMessage, TestName = "Eip1559MaxFeePerGasMissing")] [TestCase(TxType.EIP1559, "maxPriorityFeePerGas", FeeFieldsMissingMessage, TestName = "Eip1559MaxPriorityFeePerGasMissing")] - [TestCase(TxType.Legacy, "nonce", "nonce not specified", TestName = "NonceMissing")] + [TestCase(TxType.Legacy, "nonce", "nonce not specified", TestName = "LegacyNonceMissing")] + [TestCase(TxType.AccessList, "nonce", "nonce not specified", TestName = "AccessListNonceMissing")] + [TestCase(TxType.EIP1559, "nonce", "nonce not specified", TestName = "Eip1559NonceMissing")] public async Task SignTransaction_WhenRequiredFieldMissing_ReturnsInvalidInput(TxType type, string omitField, string expectedMessage) { TransactionForRpc rpcTx = BuildTx(type, omitField); @@ -85,11 +88,12 @@ public async Task SignTransaction_WhenBlobTxMissingCommitments_ReturnsInvalidInp string response = await SignTransaction(rpcTx); - response.Should().Contain("commitments must be provided alongside blobs", + response.Should().Contain("blobs, commitments and proofs must all be provided", "blob signing without commitments must surface a precise error so callers know what to add"); } [TestCase(TxType.Legacy, typeof(LegacyTransactionForRpc), TestName = "Legacy")] + [TestCase(TxType.AccessList, typeof(AccessListTransactionForRpc), TestName = "AccessList")] [TestCase(TxType.EIP1559, typeof(EIP1559TransactionForRpc), TestName = "Eip1559")] public async Task SignTransaction_WhenValid_RawRoundTripsAndTxEcho(TxType type, Type expectedEchoType) { @@ -137,26 +141,39 @@ private static TransactionForRpc BuildTx(TxType type, string? omitField = null, UInt256 value = 0x9184e72a; long gas = 0x76c0; UInt256 nonce = 0; + UInt256 gasPrice = 0x9184e72a000; - return type == TxType.EIP1559 - ? new EIP1559TransactionForRpc + return type switch + { + TxType.EIP1559 => new EIP1559TransactionForRpc { From = from, To = to, Value = value, Gas = omitField == "gas" ? null : gas, Nonce = omitField == "nonce" ? null : nonce, - MaxFeePerGas = omitField == "maxFeePerGas" ? null : (UInt256?)0x9184e72a000, + MaxFeePerGas = omitField == "maxFeePerGas" ? null : (UInt256?)gasPrice, MaxPriorityFeePerGas = omitField == "maxPriorityFeePerGas" ? null : (UInt256?)0x3b9aca00, - } - : new LegacyTransactionForRpc + }, + TxType.AccessList => new AccessListTransactionForRpc + { + From = from, + To = to, + Value = value, + Gas = omitField == "gas" ? null : gas, + Nonce = omitField == "nonce" ? null : nonce, + GasPrice = omitField == "gasPrice" ? null : (UInt256?)gasPrice, + AccessList = new AccessListForRpc(), + }, + _ => new LegacyTransactionForRpc { From = from, To = to, Value = value, Gas = omitField == "gas" ? null : gas, Nonce = omitField == "nonce" ? null : nonce, - GasPrice = omitField == "gasPrice" ? null : (UInt256?)0x9184e72a000, - }; + GasPrice = omitField == "gasPrice" ? null : (UInt256?)gasPrice, + }, + }; } } diff --git a/src/Nethermind/Nethermind.JsonRpc/Modules/Eth/EthRpcModule.cs b/src/Nethermind/Nethermind.JsonRpc/Modules/Eth/EthRpcModule.cs index fed4e35af456..5b0280893d43 100644 --- a/src/Nethermind/Nethermind.JsonRpc/Modules/Eth/EthRpcModule.cs +++ b/src/Nethermind/Nethermind.JsonRpc/Modules/Eth/EthRpcModule.cs @@ -364,6 +364,8 @@ public virtual ResultWrapper eth_signTransaction(Transact if (!HasFeeFields(rpcTx)) return ResultWrapper.Fail("missing gasPrice or maxFeePerGas/maxPriorityFeePerGas", ErrorCodes.InvalidInput); + // All concrete tx subtypes (AccessList, EIP1559, Blob, SetCode) derive from LegacyTransactionForRpc, + // so this cast is total and Nonce/From are accessed via the base class properties. LegacyTransactionForRpc? legacy = rpcTx as LegacyTransactionForRpc; if (legacy?.Nonce is null) return ResultWrapper.Fail("nonce not specified", ErrorCodes.InvalidInput); @@ -463,10 +465,8 @@ private static TransactionForRpc PromoteToEip1559IfDefaultLegacy(TransactionForR private static string FormatWeiAsEther(UInt256 wei) { - const ulong WeiPerEther = 1_000_000_000_000_000_000UL; - const ulong WeiPerCenti = 10_000_000_000_000_000UL; - UInt256 ether = wei / (UInt256)WeiPerEther; - UInt256 centiTotal = wei / (UInt256)WeiPerCenti; + UInt256 ether = wei / Unit.Ether; + UInt256 centiTotal = wei / (Unit.Ether / 100); UInt256.Mod(centiTotal, (UInt256)100, out UInt256 centi); return $"{ether}.{(ulong)centi:D2}"; } @@ -475,17 +475,22 @@ private static string FormatWeiAsEther(UInt256 wei) { if (blobTx.Blobs is null || blobTx.Blobs.Length == 0) return "blob transaction requires non-empty blobs"; - if (blobTx.Commitments is null || blobTx.Commitments.Length != blobTx.Blobs.Length) - return "commitments must be provided alongside blobs (one per blob)"; - if (blobTx.Proofs is null) - return "proofs must be provided alongside blobs"; + if (blobTx.Commitments is null || blobTx.Proofs is null) + return "blobs, commitments and proofs must all be provided"; BlockHeader? head = _blockFinder.Head?.Header; ProofVersion version = head is null ? ProofVersion.V0 : _specProvider.GetSpec(head).BlobProofVersion; - tx.NetworkWrapper = new ShardBlobNetworkWrapper(blobTx.Blobs, blobTx.Commitments, blobTx.Proofs, version); + ShardBlobNetworkWrapper wrapper = new(blobTx.Blobs, blobTx.Commitments, blobTx.Proofs, version); + IBlobProofsManager manager = IBlobProofsManager.For(version); + if (!manager.ValidateLengths(wrapper)) + return "blob sidecar lengths invalid (blobs/commitments/proofs counts or individual byte sizes)"; + if (tx.BlobVersionedHashes is not null && !manager.ValidateHashes(wrapper, tx.BlobVersionedHashes)) + return "blob commitments do not match the supplied blobVersionedHashes"; + + tx.NetworkWrapper = wrapper; return null; } From 906a592bb444d7bf83cd2c368eeb69dd9d97bbeb Mon Sep 17 00:00:00 2001 From: stavrosvl7 Date: Thu, 7 May 2026 23:19:19 +0300 Subject: [PATCH 03/18] improvements --- .../Eth/RpcTransaction/TransactionForRpc.cs | 28 ++++++++++++---- .../Eth/EthRpcModuleTests.SignTransaction.cs | 32 ++++++++++++++++++- .../Modules/Eth/EthRpcModule.cs | 17 +++++++--- 3 files changed, 64 insertions(+), 13 deletions(-) diff --git a/src/Nethermind/Nethermind.Facade/Eth/RpcTransaction/TransactionForRpc.cs b/src/Nethermind/Nethermind.Facade/Eth/RpcTransaction/TransactionForRpc.cs index 89ec3167f5a8..3fada11fc85e 100644 --- a/src/Nethermind/Nethermind.Facade/Eth/RpcTransaction/TransactionForRpc.cs +++ b/src/Nethermind/Nethermind.Facade/Eth/RpcTransaction/TransactionForRpc.cs @@ -52,12 +52,20 @@ public abstract class TransactionForRpc /// /// True when the transaction type was inferred by rather than /// explicitly provided in the JSON request. When set, can resolve - /// the type to match the target block's fork rules. The setter is internal so only the converter - /// can mutate it; the getter is public so cross-assembly consumers (RPC modules) can read it - /// when deciding whether to apply default-type semantics. + /// the type to match the target block's fork rules. /// [JsonIgnore] - public bool IsTypeDefaulted { get; internal set; } + internal bool IsTypeDefaulted { get; set; } + + /// + /// True when the JSON request contained an explicit type field. Distinct from + /// : this flag tracks whether the caller pinned a type, even + /// when discriminator-based routing (e.g. presence of gasPrice) would have picked the + /// same type. Consumers use this to decide whether to apply spec-style auto-promotion to + /// newer tx types. + /// + [JsonIgnore] + public bool HasExplicitType { get; internal set; } [JsonConstructor] protected TransactionForRpc() { } @@ -142,24 +150,30 @@ internal static void RegisterTransactionType() where T : TransactionForRpc, I Utf8JsonReader txTypeReader = reader; JsonObject untyped = JsonSerializer.Deserialize(ref txTypeReader, options); - Type concreteTxType = DeriveTxType(untyped, options, out bool isDefaulted); + Type concreteTxType = DeriveTxType(untyped, options, out bool isDefaulted, out bool hasExplicitType); TransactionForRpc? result = (TransactionForRpc?)JsonSerializer.Deserialize(ref reader, concreteTxType, options); - result?.IsTypeDefaulted = isDefaulted; + if (result is not null) + { + result.IsTypeDefaulted = isDefaulted; + result.HasExplicitType = hasExplicitType; + } return result; } - private Type DeriveTxType(JsonObject untyped, JsonSerializerOptions options, out bool isDefaulted) + private Type DeriveTxType(JsonObject untyped, JsonSerializerOptions options, out bool isDefaulted, out bool hasExplicitType) { const string gasPriceFieldKey = nameof(LegacyTransactionForRpc.GasPrice); const string typeFieldKey = nameof(TransactionForRpc.Type); isDefaulted = false; + hasExplicitType = false; if (untyped.TryGetPropertyValue(typeFieldKey, out JsonNode? node)) { TxType? setType = node.Deserialize(options); if (setType is not null) { + hasExplicitType = true; return _txTypes.FirstOrDefault(p => p.TxType == setType)?.Type ?? throw new JsonException("Unknown transaction type"); } } diff --git a/src/Nethermind/Nethermind.JsonRpc.Test/Modules/Eth/EthRpcModuleTests.SignTransaction.cs b/src/Nethermind/Nethermind.JsonRpc.Test/Modules/Eth/EthRpcModuleTests.SignTransaction.cs index 5d78cf307d95..c14092f8a929 100644 --- a/src/Nethermind/Nethermind.JsonRpc.Test/Modules/Eth/EthRpcModuleTests.SignTransaction.cs +++ b/src/Nethermind/Nethermind.JsonRpc.Test/Modules/Eth/EthRpcModuleTests.SignTransaction.cs @@ -2,6 +2,7 @@ // SPDX-License-Identifier: LGPL-3.0-only using System; +using System.Text.Json; using System.Threading.Tasks; using FluentAssertions; using Nethermind.Core; @@ -88,10 +89,39 @@ public async Task SignTransaction_WhenBlobTxMissingCommitments_ReturnsInvalidInp string response = await SignTransaction(rpcTx); - response.Should().Contain("blobs, commitments and proofs must all be provided", + response.Should().Contain("commitments must be provided alongside blobs", "blob signing without commitments must surface a precise error so callers know what to add"); } + [TestCase(false, typeof(EIP1559TransactionForRpc), TestName = "WithoutExplicitType_PromotedToEip1559")] + [TestCase(true, typeof(LegacyTransactionForRpc), TestName = "WithExplicitLegacyType_StaysLegacy")] + public async Task SignTransaction_LegacyShapeJson_RespectsExplicitTypePinning(bool withExplicitType, Type expectedEchoType) + { + // Bypasses BuildTx because constructed C# instances always serialize the `type` field; + // we need raw JSON that omits it to drive HasExplicitType=false on the server. + string typeLine = withExplicitType ? "\"type\": \"0x0\"," : ""; + string txJson = $$""" + { + {{typeLine}} + "from": "{{UnlockedTestAccount}}", + "to": "0x2d44c0e097f6cd0f514edac633d82e01280b4a5c", + "value": "0x9184e72a", + "gas": "0x76c0", + "gasPrice": "0x9184e72a000", + "nonce": "0x0" + } + """; + JsonElement param = JsonSerializer.Deserialize(txJson); + + using Context ctx = await Context.Create(); + string serialized = await ctx.Test.TestEthRpc("eth_signTransaction", param); + JsonRpcResponse response = ctx.Test.JsonSerializer.Deserialize>(serialized)!; + response.Result.Should().NotBeNull("precondition: signing must succeed for valid input"); + + response.Result!.Tx.Should().BeOfType(expectedEchoType, + "no-type input must auto-promote to EIP-1559; explicit type must be preserved"); + } + [TestCase(TxType.Legacy, typeof(LegacyTransactionForRpc), TestName = "Legacy")] [TestCase(TxType.AccessList, typeof(AccessListTransactionForRpc), TestName = "AccessList")] [TestCase(TxType.EIP1559, typeof(EIP1559TransactionForRpc), TestName = "Eip1559")] diff --git a/src/Nethermind/Nethermind.JsonRpc/Modules/Eth/EthRpcModule.cs b/src/Nethermind/Nethermind.JsonRpc/Modules/Eth/EthRpcModule.cs index 5b0280893d43..f67860eb6a31 100644 --- a/src/Nethermind/Nethermind.JsonRpc/Modules/Eth/EthRpcModule.cs +++ b/src/Nethermind/Nethermind.JsonRpc/Modules/Eth/EthRpcModule.cs @@ -374,7 +374,7 @@ public virtual ResultWrapper eth_signTransaction(Transact if (!_wallet.IsUnlocked(from)) return ResultWrapper.Fail("authentication needed: password or unlock", ErrorCodes.InvalidInput); - rpcTx = PromoteToEip1559IfDefaultLegacy(rpcTx); + rpcTx = PromoteLegacyToEip1559IfTypeImplicit(rpcTx); Result txResult = rpcTx.ToTransaction(validateUserInput: true); if (!txResult.Success(out Transaction tx, out string error)) @@ -427,9 +427,12 @@ private static bool HasFeeFields(TransactionForRpc rpcTx) return rpcTx is LegacyTransactionForRpc legacy && legacy.GasPrice is not null; } - private static TransactionForRpc PromoteToEip1559IfDefaultLegacy(TransactionForRpc rpcTx) + private static TransactionForRpc PromoteLegacyToEip1559IfTypeImplicit(TransactionForRpc rpcTx) { - if (!rpcTx.IsTypeDefaulted) return rpcTx; + // When the JSON request omits "type", the spec-style behavior is to auto-promote a + // legacy-shape request (gasPrice only) to EIP-1559 with maxFeePerGas == maxPriorityFeePerGas == gasPrice. + // Explicit type pinning is preserved verbatim. + if (rpcTx.HasExplicitType) return rpcTx; if (rpcTx is AccessListTransactionForRpc or EIP1559TransactionForRpc) return rpcTx; if (rpcTx is not LegacyTransactionForRpc legacy) return rpcTx; @@ -452,6 +455,8 @@ private static TransactionForRpc PromoteToEip1559IfDefaultLegacy(TransactionForR ulong cap = _rpcConfig.RpcTxFeeCap; if (cap == 0) return null; + // Cap covers execution gas only (maxFeePerGas * gasLimit). Blob gas (4844) is intentionally + // excluded — keeps parity with the reference implementation. UInt256 perGas = tx.Type >= TxType.EIP1559 ? tx.MaxFeePerGas : tx.GasPrice; UInt256 totalFee = perGas * (UInt256)tx.GasLimit; UInt256 capWei = cap; @@ -475,8 +480,10 @@ private static string FormatWeiAsEther(UInt256 wei) { if (blobTx.Blobs is null || blobTx.Blobs.Length == 0) return "blob transaction requires non-empty blobs"; - if (blobTx.Commitments is null || blobTx.Proofs is null) - return "blobs, commitments and proofs must all be provided"; + if (blobTx.Commitments is null) + return "commitments must be provided alongside blobs"; + if (blobTx.Proofs is null) + return "proofs must be provided alongside blobs"; BlockHeader? head = _blockFinder.Head?.Header; ProofVersion version = head is null From 1a3624705b374e3bef6887b29837b4ee236d4ebb Mon Sep 17 00:00:00 2001 From: stavrosvl7 Date: Thu, 7 May 2026 23:26:29 +0300 Subject: [PATCH 04/18] claude review --- .../Eth/RpcTransaction/TransactionForRpc.cs | 15 +++++++++++++-- .../Modules/Eth/EthRpcModule.cs | 2 +- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/src/Nethermind/Nethermind.Facade/Eth/RpcTransaction/TransactionForRpc.cs b/src/Nethermind/Nethermind.Facade/Eth/RpcTransaction/TransactionForRpc.cs index 3fada11fc85e..4105fd062343 100644 --- a/src/Nethermind/Nethermind.Facade/Eth/RpcTransaction/TransactionForRpc.cs +++ b/src/Nethermind/Nethermind.Facade/Eth/RpcTransaction/TransactionForRpc.cs @@ -5,6 +5,7 @@ using System.Collections.Generic; using System.Linq; using System.Reflection; +using System.Runtime.CompilerServices; using System.Text.Json; using System.Text.Json.Nodes; using System.Text.Json.Serialization; @@ -12,6 +13,8 @@ using Nethermind.Core.Crypto; using Nethermind.Core.Specs; +[assembly: InternalsVisibleTo("Nethermind.JsonRpc")] + namespace Nethermind.Facade.Eth.RpcTransaction; /// @@ -62,10 +65,18 @@ public abstract class TransactionForRpc /// : this flag tracks whether the caller pinned a type, even /// when discriminator-based routing (e.g. presence of gasPrice) would have picked the /// same type. Consumers use this to decide whether to apply spec-style auto-promotion to - /// newer tx types. + /// newer tx types. Internal so it does not participate in public-property equivalency checks + /// or JSON serialization; cross-assembly access is granted via InternalsVisibleTo. /// + /// + /// Only set during JSON deserialization. Always false for programmatically constructed + /// instances — consumers that build a in code and then route + /// it through an RPC method that consults this flag will be treated as "no explicit type" and + /// may be auto-promoted. Test fixtures must therefore exercise the JSON-deserialization path + /// to validate type-pinning behavior. + /// [JsonIgnore] - public bool HasExplicitType { get; internal set; } + internal bool HasExplicitType { get; set; } [JsonConstructor] protected TransactionForRpc() { } diff --git a/src/Nethermind/Nethermind.JsonRpc/Modules/Eth/EthRpcModule.cs b/src/Nethermind/Nethermind.JsonRpc/Modules/Eth/EthRpcModule.cs index f67860eb6a31..22b098774235 100644 --- a/src/Nethermind/Nethermind.JsonRpc/Modules/Eth/EthRpcModule.cs +++ b/src/Nethermind/Nethermind.JsonRpc/Modules/Eth/EthRpcModule.cs @@ -433,7 +433,7 @@ private static TransactionForRpc PromoteLegacyToEip1559IfTypeImplicit(Transactio // legacy-shape request (gasPrice only) to EIP-1559 with maxFeePerGas == maxPriorityFeePerGas == gasPrice. // Explicit type pinning is preserved verbatim. if (rpcTx.HasExplicitType) return rpcTx; - if (rpcTx is AccessListTransactionForRpc or EIP1559TransactionForRpc) return rpcTx; + if (rpcTx is AccessListTransactionForRpc) return rpcTx; if (rpcTx is not LegacyTransactionForRpc legacy) return rpcTx; return new EIP1559TransactionForRpc From d7b98e2b30fb80170b1e948b0c43e07269f235f4 Mon Sep 17 00:00:00 2001 From: stavrosvl7 Date: Thu, 7 May 2026 23:37:00 +0300 Subject: [PATCH 05/18] refactoring --- .../Eth/RpcTransaction/TransactionForRpc.cs | 78 ++++++++++--------- .../Eth/EthRpcModuleTests.SignTransaction.cs | 14 ++++ .../Modules/Eth/EthRpcModule.cs | 25 +----- 3 files changed, 58 insertions(+), 59 deletions(-) diff --git a/src/Nethermind/Nethermind.Facade/Eth/RpcTransaction/TransactionForRpc.cs b/src/Nethermind/Nethermind.Facade/Eth/RpcTransaction/TransactionForRpc.cs index 4105fd062343..ab005df3137e 100644 --- a/src/Nethermind/Nethermind.Facade/Eth/RpcTransaction/TransactionForRpc.cs +++ b/src/Nethermind/Nethermind.Facade/Eth/RpcTransaction/TransactionForRpc.cs @@ -5,7 +5,6 @@ using System.Collections.Generic; using System.Linq; using System.Reflection; -using System.Runtime.CompilerServices; using System.Text.Json; using System.Text.Json.Nodes; using System.Text.Json.Serialization; @@ -13,8 +12,6 @@ using Nethermind.Core.Crypto; using Nethermind.Core.Specs; -[assembly: InternalsVisibleTo("Nethermind.JsonRpc")] - namespace Nethermind.Facade.Eth.RpcTransaction; /// @@ -53,30 +50,18 @@ public abstract class TransactionForRpc public long? Gas { get; set; } /// - /// True when the transaction type was inferred by rather than - /// explicitly provided in the JSON request. When set, can resolve - /// the type to match the target block's fork rules. - /// - [JsonIgnore] - internal bool IsTypeDefaulted { get; set; } - - /// - /// True when the JSON request contained an explicit type field. Distinct from - /// : this flag tracks whether the caller pinned a type, even - /// when discriminator-based routing (e.g. presence of gasPrice) would have picked the - /// same type. Consumers use this to decide whether to apply spec-style auto-promotion to - /// newer tx types. Internal so it does not participate in public-property equivalency checks - /// or JSON serialization; cross-assembly access is granted via InternalsVisibleTo. + /// True when the JSON request did not contain an explicit type field — the runtime + /// type was inferred from discriminator fields, the gasPrice routing rule, or the + /// default. Consumers use this to decide whether to apply spec-style auto-promotion to newer + /// tx types via . /// /// /// Only set during JSON deserialization. Always false for programmatically constructed - /// instances — consumers that build a in code and then route - /// it through an RPC method that consults this flag will be treated as "no explicit type" and - /// may be auto-promoted. Test fixtures must therefore exercise the JSON-deserialization path - /// to validate type-pinning behavior. + /// instances — code paths that need to assert promotion behavior must exercise the + /// JSON-deserialization path. /// [JsonIgnore] - internal bool HasExplicitType { get; set; } + internal bool IsTypeDefaulted { get; set; } [JsonConstructor] protected TransactionForRpc() { } @@ -95,10 +80,40 @@ public virtual Result ToTransaction(bool validateUserInput = false, private TxType ResolveType(IReleaseSpec? spec) { + // Pre-Berlin specs only know Legacy txs; downgrade any defaulted-type request to Legacy + // rather than producing a typed tx that the EVM at that height would reject outright. TxType type = Type ?? default; return spec is not null && !spec.IsEip2930Enabled && IsTypeDefaulted ? TxType.Legacy : type; } + /// + /// Returns an EIP-1559 form of this request when the JSON omitted the type field and + /// the request shape is plain Legacy (gasPrice only, no access list, no 1559/blob/setcode + /// fields). Otherwise returns this unchanged. The original gasPrice becomes + /// both maxFeePerGas and maxPriorityFeePerGas. + /// + public TransactionForRpc PromoteToEip1559IfTypeDefaulted() + { + if (!IsTypeDefaulted) return this; + // EIP-1559, Blob, and SetCode all derive from AccessListTransactionForRpc, so this single + // check excludes everything that's already a typed (≥0x01) tx. + if (this is AccessListTransactionForRpc) return this; + if (this is not LegacyTransactionForRpc legacy) return this; + + return new EIP1559TransactionForRpc + { + From = legacy.From, + To = legacy.To, + Value = legacy.Value, + Gas = legacy.Gas, + Nonce = legacy.Nonce, + Input = legacy.Input, + ChainId = legacy.ChainId, + MaxFeePerGas = legacy.GasPrice, + MaxPriorityFeePerGas = legacy.GasPrice, + }; + } + public abstract bool ShouldSetBaseFee(); internal class TransactionJsonConverter : JsonConverter @@ -161,44 +176,37 @@ internal static void RegisterTransactionType() where T : TransactionForRpc, I Utf8JsonReader txTypeReader = reader; JsonObject untyped = JsonSerializer.Deserialize(ref txTypeReader, options); - Type concreteTxType = DeriveTxType(untyped, options, out bool isDefaulted, out bool hasExplicitType); + Type concreteTxType = DeriveTxType(untyped, options, out bool isDefaulted); TransactionForRpc? result = (TransactionForRpc?)JsonSerializer.Deserialize(ref reader, concreteTxType, options); if (result is not null) { result.IsTypeDefaulted = isDefaulted; - result.HasExplicitType = hasExplicitType; } return result; } - private Type DeriveTxType(JsonObject untyped, JsonSerializerOptions options, out bool isDefaulted, out bool hasExplicitType) + private Type DeriveTxType(JsonObject untyped, JsonSerializerOptions options, out bool isDefaulted) { const string gasPriceFieldKey = nameof(LegacyTransactionForRpc.GasPrice); const string typeFieldKey = nameof(TransactionForRpc.Type); - isDefaulted = false; - hasExplicitType = false; if (untyped.TryGetPropertyValue(typeFieldKey, out JsonNode? node)) { TxType? setType = node.Deserialize(options); if (setType is not null) { - hasExplicitType = true; + isDefaulted = false; return _txTypes.FirstOrDefault(p => p.TxType == setType)?.Type ?? throw new JsonException("Unknown transaction type"); } } + // No explicit "type" field — every branch below is "defaulted". + isDefaulted = true; return untyped.ContainsKey(gasPriceFieldKey) ? typeof(LegacyTransactionForRpc) : _txTypes.FirstOrDefault(p => p.DiscriminatorProperties.Any(untyped.ContainsKey))?.Type - ?? GetDefaultType(out isDefaulted); - - static Type GetDefaultType(out bool isDefaulted) - { - isDefaulted = true; - return typeof(EIP1559TransactionForRpc); - } + ?? typeof(EIP1559TransactionForRpc); } public override void Write(Utf8JsonWriter writer, TransactionForRpc value, JsonSerializerOptions options) => JsonSerializer.Serialize(writer, value, value.GetType(), options); diff --git a/src/Nethermind/Nethermind.JsonRpc.Test/Modules/Eth/EthRpcModuleTests.SignTransaction.cs b/src/Nethermind/Nethermind.JsonRpc.Test/Modules/Eth/EthRpcModuleTests.SignTransaction.cs index c14092f8a929..ec306f624764 100644 --- a/src/Nethermind/Nethermind.JsonRpc.Test/Modules/Eth/EthRpcModuleTests.SignTransaction.cs +++ b/src/Nethermind/Nethermind.JsonRpc.Test/Modules/Eth/EthRpcModuleTests.SignTransaction.cs @@ -28,9 +28,11 @@ public partial class EthRpcModuleTests [TestCase(TxType.AccessList, "gasPrice", FeeFieldsMissingMessage, TestName = "AccessListFeesMissing")] [TestCase(TxType.EIP1559, "maxFeePerGas", FeeFieldsMissingMessage, TestName = "Eip1559MaxFeePerGasMissing")] [TestCase(TxType.EIP1559, "maxPriorityFeePerGas", FeeFieldsMissingMessage, TestName = "Eip1559MaxPriorityFeePerGasMissing")] + [TestCase(TxType.SetCode, "maxFeePerGas", FeeFieldsMissingMessage, TestName = "SetCodeMaxFeePerGasMissing")] [TestCase(TxType.Legacy, "nonce", "nonce not specified", TestName = "LegacyNonceMissing")] [TestCase(TxType.AccessList, "nonce", "nonce not specified", TestName = "AccessListNonceMissing")] [TestCase(TxType.EIP1559, "nonce", "nonce not specified", TestName = "Eip1559NonceMissing")] + [TestCase(TxType.SetCode, "nonce", "nonce not specified", TestName = "SetCodeNonceMissing")] public async Task SignTransaction_WhenRequiredFieldMissing_ReturnsInvalidInput(TxType type, string omitField, string expectedMessage) { TransactionForRpc rpcTx = BuildTx(type, omitField); @@ -125,6 +127,7 @@ public async Task SignTransaction_LegacyShapeJson_RespectsExplicitTypePinning(bo [TestCase(TxType.Legacy, typeof(LegacyTransactionForRpc), TestName = "Legacy")] [TestCase(TxType.AccessList, typeof(AccessListTransactionForRpc), TestName = "AccessList")] [TestCase(TxType.EIP1559, typeof(EIP1559TransactionForRpc), TestName = "Eip1559")] + [TestCase(TxType.SetCode, typeof(SetCodeTransactionForRpc), TestName = "SetCode")] public async Task SignTransaction_WhenValid_RawRoundTripsAndTxEcho(TxType type, Type expectedEchoType) { TransactionForRpc rpcTx = BuildTx(type); @@ -185,6 +188,17 @@ private static TransactionForRpc BuildTx(TxType type, string? omitField = null, MaxFeePerGas = omitField == "maxFeePerGas" ? null : (UInt256?)gasPrice, MaxPriorityFeePerGas = omitField == "maxPriorityFeePerGas" ? null : (UInt256?)0x3b9aca00, }, + TxType.SetCode => new SetCodeTransactionForRpc + { + From = from, + To = to, + Value = value, + Gas = omitField == "gas" ? null : gas, + Nonce = omitField == "nonce" ? null : nonce, + MaxFeePerGas = omitField == "maxFeePerGas" ? null : (UInt256?)gasPrice, + MaxPriorityFeePerGas = omitField == "maxPriorityFeePerGas" ? null : (UInt256?)0x3b9aca00, + AuthorizationList = new AuthorizationListForRpc(), + }, TxType.AccessList => new AccessListTransactionForRpc { From = from, diff --git a/src/Nethermind/Nethermind.JsonRpc/Modules/Eth/EthRpcModule.cs b/src/Nethermind/Nethermind.JsonRpc/Modules/Eth/EthRpcModule.cs index 22b098774235..7320fe2515d7 100644 --- a/src/Nethermind/Nethermind.JsonRpc/Modules/Eth/EthRpcModule.cs +++ b/src/Nethermind/Nethermind.JsonRpc/Modules/Eth/EthRpcModule.cs @@ -374,7 +374,7 @@ public virtual ResultWrapper eth_signTransaction(Transact if (!_wallet.IsUnlocked(from)) return ResultWrapper.Fail("authentication needed: password or unlock", ErrorCodes.InvalidInput); - rpcTx = PromoteLegacyToEip1559IfTypeImplicit(rpcTx); + rpcTx = rpcTx.PromoteToEip1559IfTypeDefaulted(); Result txResult = rpcTx.ToTransaction(validateUserInput: true); if (!txResult.Success(out Transaction tx, out string error)) @@ -427,29 +427,6 @@ private static bool HasFeeFields(TransactionForRpc rpcTx) return rpcTx is LegacyTransactionForRpc legacy && legacy.GasPrice is not null; } - private static TransactionForRpc PromoteLegacyToEip1559IfTypeImplicit(TransactionForRpc rpcTx) - { - // When the JSON request omits "type", the spec-style behavior is to auto-promote a - // legacy-shape request (gasPrice only) to EIP-1559 with maxFeePerGas == maxPriorityFeePerGas == gasPrice. - // Explicit type pinning is preserved verbatim. - if (rpcTx.HasExplicitType) return rpcTx; - if (rpcTx is AccessListTransactionForRpc) return rpcTx; - if (rpcTx is not LegacyTransactionForRpc legacy) return rpcTx; - - return new EIP1559TransactionForRpc - { - From = legacy.From, - To = legacy.To, - Value = legacy.Value, - Gas = legacy.Gas, - Nonce = legacy.Nonce, - Input = legacy.Input, - ChainId = legacy.ChainId, - MaxFeePerGas = legacy.GasPrice, - MaxPriorityFeePerGas = legacy.GasPrice, - }; - } - private ResultWrapper? CheckTxFeeCap(Transaction tx) { ulong cap = _rpcConfig.RpcTxFeeCap; From 60db3316493e25802e19e3f737c0f4cebcd525aa Mon Sep 17 00:00:00 2001 From: stavrosvl7 Date: Thu, 7 May 2026 23:49:14 +0300 Subject: [PATCH 06/18] refactoring again --- .../Eth/RpcTransaction/TransactionForRpc.cs | 31 +++++++++++++------ .../TransactionForRpcDeserializationTests.cs | 3 +- 2 files changed, 23 insertions(+), 11 deletions(-) diff --git a/src/Nethermind/Nethermind.Facade/Eth/RpcTransaction/TransactionForRpc.cs b/src/Nethermind/Nethermind.Facade/Eth/RpcTransaction/TransactionForRpc.cs index ab005df3137e..9aa00ea8fd04 100644 --- a/src/Nethermind/Nethermind.Facade/Eth/RpcTransaction/TransactionForRpc.cs +++ b/src/Nethermind/Nethermind.Facade/Eth/RpcTransaction/TransactionForRpc.cs @@ -50,14 +50,14 @@ public abstract class TransactionForRpc public long? Gas { get; set; } /// - /// True when the JSON request did not contain an explicit type field — the runtime - /// type was inferred from discriminator fields, the gasPrice routing rule, or the - /// default. Consumers use this to decide whether to apply spec-style auto-promotion to newer - /// tx types via . + /// True when the runtime type was selected by a fallback rather than an explicit signal — + /// either the legacy gasPrice-only routing rule or the absolute default. Discriminator + /// fields (accessList, maxFeePerGas, etc.) and an explicit type field + /// count as signals and leave this flag false. /// /// /// Only set during JSON deserialization. Always false for programmatically constructed - /// instances — code paths that need to assert promotion behavior must exercise the + /// instances — code paths that need to assert defaulting behavior must exercise the /// JSON-deserialization path. /// [JsonIgnore] @@ -201,12 +201,23 @@ private Type DeriveTxType(JsonObject untyped, JsonSerializerOptions options, out } } - // No explicit "type" field — every branch below is "defaulted". + // No explicit "type" field. gasPrice-only and the absolute fallback are "defaulted"; + // a discriminator field (accessList, maxFeePerGas, ...) is a strong signal and is not. + if (untyped.ContainsKey(gasPriceFieldKey)) + { + isDefaulted = true; + return typeof(LegacyTransactionForRpc); + } + + Type? viaDiscriminator = _txTypes.FirstOrDefault(p => p.DiscriminatorProperties.Any(untyped.ContainsKey))?.Type; + if (viaDiscriminator is not null) + { + isDefaulted = false; + return viaDiscriminator; + } + isDefaulted = true; - return untyped.ContainsKey(gasPriceFieldKey) - ? typeof(LegacyTransactionForRpc) - : _txTypes.FirstOrDefault(p => p.DiscriminatorProperties.Any(untyped.ContainsKey))?.Type - ?? typeof(EIP1559TransactionForRpc); + return typeof(EIP1559TransactionForRpc); } public override void Write(Utf8JsonWriter writer, TransactionForRpc value, JsonSerializerOptions options) => JsonSerializer.Serialize(writer, value, value.GetType(), options); diff --git a/src/Nethermind/Nethermind.JsonRpc.Test/Data/TransactionForRpcDeserializationTests.cs b/src/Nethermind/Nethermind.JsonRpc.Test/Data/TransactionForRpcDeserializationTests.cs index 9d03baadb2d0..9086b6a8e518 100644 --- a/src/Nethermind/Nethermind.JsonRpc.Test/Data/TransactionForRpcDeserializationTests.cs +++ b/src/Nethermind/Nethermind.JsonRpc.Test/Data/TransactionForRpcDeserializationTests.cs @@ -112,8 +112,9 @@ static TestCaseData Make(TxType expected, string json, IReleaseSpec? spec) => yield return Make(TxType.AccessList, """{"accessList":[]}""", Istanbul.Instance); yield return Make(TxType.EIP1559, """{"maxFeePerGas":"0x0"}""", Istanbul.Instance); - // gasPrice → Legacy, not defaulted + // gasPrice → Legacy: defaulted, but downgrade is a no-op so result is Legacy on any spec yield return Make(TxType.Legacy, """{"gasPrice":"0x1"}""", London.Instance); + yield return Make(TxType.Legacy, """{"gasPrice":"0x1"}""", Istanbul.Instance); // No spec (null) → keeps defaulted EIP1559 yield return Make(TxType.EIP1559, """{}""", null); From 77aba5fe96c9f7f5533bb6f532cfb378c620d8b2 Mon Sep 17 00:00:00 2001 From: stavrosvl7 Date: Thu, 7 May 2026 23:58:33 +0300 Subject: [PATCH 07/18] minor fix --- .../Eth/RpcTransaction/TransactionForRpc.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/Nethermind/Nethermind.Facade/Eth/RpcTransaction/TransactionForRpc.cs b/src/Nethermind/Nethermind.Facade/Eth/RpcTransaction/TransactionForRpc.cs index 9aa00ea8fd04..d38dbf3dd499 100644 --- a/src/Nethermind/Nethermind.Facade/Eth/RpcTransaction/TransactionForRpc.cs +++ b/src/Nethermind/Nethermind.Facade/Eth/RpcTransaction/TransactionForRpc.cs @@ -96,9 +96,10 @@ public TransactionForRpc PromoteToEip1559IfTypeDefaulted() { if (!IsTypeDefaulted) return this; // EIP-1559, Blob, and SetCode all derive from AccessListTransactionForRpc, so this single - // check excludes everything that's already a typed (≥0x01) tx. + // check excludes the absolute-default path (which produces EIP1559) and any future + // typed subclass; the remainder is guaranteed to be the gasPrice-routed Legacy. if (this is AccessListTransactionForRpc) return this; - if (this is not LegacyTransactionForRpc legacy) return this; + LegacyTransactionForRpc legacy = (LegacyTransactionForRpc)this; return new EIP1559TransactionForRpc { From b76e0dacc664f8a661ed439989f6fc35d0fb571b Mon Sep 17 00:00:00 2001 From: stavrosvl7 Date: Fri, 8 May 2026 00:00:53 +0300 Subject: [PATCH 08/18] revert --- .../Eth/RpcTransaction/TransactionForRpc.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Nethermind/Nethermind.Facade/Eth/RpcTransaction/TransactionForRpc.cs b/src/Nethermind/Nethermind.Facade/Eth/RpcTransaction/TransactionForRpc.cs index d38dbf3dd499..bdf03bbdd59a 100644 --- a/src/Nethermind/Nethermind.Facade/Eth/RpcTransaction/TransactionForRpc.cs +++ b/src/Nethermind/Nethermind.Facade/Eth/RpcTransaction/TransactionForRpc.cs @@ -96,10 +96,10 @@ public TransactionForRpc PromoteToEip1559IfTypeDefaulted() { if (!IsTypeDefaulted) return this; // EIP-1559, Blob, and SetCode all derive from AccessListTransactionForRpc, so this single - // check excludes the absolute-default path (which produces EIP1559) and any future - // typed subclass; the remainder is guaranteed to be the gasPrice-routed Legacy. + // check excludes the absolute-default path (which produces EIP1559) and any future typed + // subclass that lands above Legacy. if (this is AccessListTransactionForRpc) return this; - LegacyTransactionForRpc legacy = (LegacyTransactionForRpc)this; + if (this is not LegacyTransactionForRpc legacy) return this; return new EIP1559TransactionForRpc { From 0518dc3fb8af8aa93b5137ef3f0c1b85aadd9b50 Mon Sep 17 00:00:00 2001 From: stavrosvl7 Date: Fri, 8 May 2026 00:07:20 +0300 Subject: [PATCH 09/18] trim comments --- .../Eth/RpcTransaction/TransactionForRpc.cs | 29 ++++--------------- 1 file changed, 5 insertions(+), 24 deletions(-) diff --git a/src/Nethermind/Nethermind.Facade/Eth/RpcTransaction/TransactionForRpc.cs b/src/Nethermind/Nethermind.Facade/Eth/RpcTransaction/TransactionForRpc.cs index bdf03bbdd59a..dceb91d6978a 100644 --- a/src/Nethermind/Nethermind.Facade/Eth/RpcTransaction/TransactionForRpc.cs +++ b/src/Nethermind/Nethermind.Facade/Eth/RpcTransaction/TransactionForRpc.cs @@ -49,17 +49,8 @@ public abstract class TransactionForRpc [JsonIgnore(Condition = JsonIgnoreCondition.Never)] public long? Gas { get; set; } - /// - /// True when the runtime type was selected by a fallback rather than an explicit signal — - /// either the legacy gasPrice-only routing rule or the absolute default. Discriminator - /// fields (accessList, maxFeePerGas, etc.) and an explicit type field - /// count as signals and leave this flag false. - /// - /// - /// Only set during JSON deserialization. Always false for programmatically constructed - /// instances — code paths that need to assert defaulting behavior must exercise the - /// JSON-deserialization path. - /// + // True when type came from a fallback (gasPrice-only or absolute default), not from an + // explicit `type` field or a discriminator. Set only during JSON deserialization. [JsonIgnore] internal bool IsTypeDefaulted { get; set; } @@ -80,24 +71,15 @@ public virtual Result ToTransaction(bool validateUserInput = false, private TxType ResolveType(IReleaseSpec? spec) { - // Pre-Berlin specs only know Legacy txs; downgrade any defaulted-type request to Legacy - // rather than producing a typed tx that the EVM at that height would reject outright. + // Pre-Berlin only knows Legacy; defaulted-type requests downgrade to avoid EVM rejection. TxType type = Type ?? default; return spec is not null && !spec.IsEip2930Enabled && IsTypeDefaulted ? TxType.Legacy : type; } - /// - /// Returns an EIP-1559 form of this request when the JSON omitted the type field and - /// the request shape is plain Legacy (gasPrice only, no access list, no 1559/blob/setcode - /// fields). Otherwise returns this unchanged. The original gasPrice becomes - /// both maxFeePerGas and maxPriorityFeePerGas. - /// public TransactionForRpc PromoteToEip1559IfTypeDefaulted() { if (!IsTypeDefaulted) return this; - // EIP-1559, Blob, and SetCode all derive from AccessListTransactionForRpc, so this single - // check excludes the absolute-default path (which produces EIP1559) and any future typed - // subclass that lands above Legacy. + // AccessList and its descendants (EIP1559/Blob/SetCode) are already typed — only plain Legacy promotes. if (this is AccessListTransactionForRpc) return this; if (this is not LegacyTransactionForRpc legacy) return this; @@ -202,14 +184,13 @@ private Type DeriveTxType(JsonObject untyped, JsonSerializerOptions options, out } } - // No explicit "type" field. gasPrice-only and the absolute fallback are "defaulted"; - // a discriminator field (accessList, maxFeePerGas, ...) is a strong signal and is not. if (untyped.ContainsKey(gasPriceFieldKey)) { isDefaulted = true; return typeof(LegacyTransactionForRpc); } + // Discriminator field is a strong signal — not a default. Type? viaDiscriminator = _txTypes.FirstOrDefault(p => p.DiscriminatorProperties.Any(untyped.ContainsKey))?.Type; if (viaDiscriminator is not null) { From 7bd835c3d015aa1345a1c41f1e5086cbe15b61cf Mon Sep 17 00:00:00 2001 From: stavrosvl7 Date: Fri, 8 May 2026 00:19:29 +0300 Subject: [PATCH 10/18] claude review --- .../Nethermind.JsonRpc/Data/SignTransactionResult.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Nethermind/Nethermind.JsonRpc/Data/SignTransactionResult.cs b/src/Nethermind/Nethermind.JsonRpc/Data/SignTransactionResult.cs index 3321acc63741..fd7c916f4ca3 100644 --- a/src/Nethermind/Nethermind.JsonRpc/Data/SignTransactionResult.cs +++ b/src/Nethermind/Nethermind.JsonRpc/Data/SignTransactionResult.cs @@ -9,8 +9,8 @@ namespace Nethermind.JsonRpc.Data; public class SignTransactionResult { [JsonPropertyName("raw")] - public byte[] Raw { get; init; } = null!; + public required byte[] Raw { get; init; } [JsonPropertyName("tx")] - public TransactionForRpc Tx { get; init; } = null!; + public required TransactionForRpc Tx { get; init; } } From 149d1c66243f9c62bc9d98bc6c569a7a2b10c902 Mon Sep 17 00:00:00 2001 From: stavrosvl7 Date: Sat, 9 May 2026 02:00:09 +0300 Subject: [PATCH 11/18] oleksii review --- .../Eth/RpcTransaction/BlobTransactionForRpc.cs | 7 +++++++ src/Nethermind/Nethermind.JsonRpc/JsonRpcConfig.cs | 2 +- .../Nethermind.JsonRpc/Modules/Eth/EthRpcModule.cs | 10 +++------- 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/src/Nethermind/Nethermind.Facade/Eth/RpcTransaction/BlobTransactionForRpc.cs b/src/Nethermind/Nethermind.Facade/Eth/RpcTransaction/BlobTransactionForRpc.cs index ccfab9d4bcec..294e22e1fd0e 100644 --- a/src/Nethermind/Nethermind.Facade/Eth/RpcTransaction/BlobTransactionForRpc.cs +++ b/src/Nethermind/Nethermind.Facade/Eth/RpcTransaction/BlobTransactionForRpc.cs @@ -39,6 +39,13 @@ public BlobTransactionForRpc(Transaction transaction, in TransactionForRpcContex { MaxFeePerBlobGas = transaction.MaxFeePerBlobGas ?? 0; BlobVersionedHashes = transaction.BlobVersionedHashes ?? []; + + if (transaction.NetworkWrapper is ShardBlobNetworkWrapper wrapper) + { + Blobs = wrapper.Blobs; + Commitments = wrapper.Commitments; + Proofs = wrapper.Proofs; + } } public override Result ToTransaction(bool validateUserInput = false, long? gasCap = null, IReleaseSpec? spec = null) diff --git a/src/Nethermind/Nethermind.JsonRpc/JsonRpcConfig.cs b/src/Nethermind/Nethermind.JsonRpc/JsonRpcConfig.cs index 2b4737012fbb..a9de8d82d0fd 100644 --- a/src/Nethermind/Nethermind.JsonRpc/JsonRpcConfig.cs +++ b/src/Nethermind/Nethermind.JsonRpc/JsonRpcConfig.cs @@ -68,7 +68,7 @@ public string[] EnabledModules public long? MaxBatchResponseBodySize { get; set; } = 32.MiB; public long? MaxSimulateBlocksCap { get; set; } = 256; public int EstimateErrorMargin { get; set; } = 150; - public ulong RpcTxFeeCap { get; set; } = 1_000_000_000_000_000_000UL; + public ulong RpcTxFeeCap { get; set; } = (ulong)1.Ether; public string[] CorsOrigins { get; set; } = ["*"]; public int WebSocketsProcessingConcurrency { get; set; } = 1; public int IpcProcessingConcurrency { get; set; } = 1; diff --git a/src/Nethermind/Nethermind.JsonRpc/Modules/Eth/EthRpcModule.cs b/src/Nethermind/Nethermind.JsonRpc/Modules/Eth/EthRpcModule.cs index 7320fe2515d7..2ff758a3ecad 100644 --- a/src/Nethermind/Nethermind.JsonRpc/Modules/Eth/EthRpcModule.cs +++ b/src/Nethermind/Nethermind.JsonRpc/Modules/Eth/EthRpcModule.cs @@ -445,13 +445,9 @@ private static bool HasFeeFields(TransactionForRpc rpcTx) ErrorCodes.InvalidInput); } - private static string FormatWeiAsEther(UInt256 wei) - { - UInt256 ether = wei / Unit.Ether; - UInt256 centiTotal = wei / (Unit.Ether / 100); - UInt256.Mod(centiTotal, (UInt256)100, out UInt256 centi); - return $"{ether}.{(ulong)centi:D2}"; - } + private static string FormatWeiAsEther(UInt256 wei) => + // Invariant culture + F2 to match Geth's "X.XX ether" exactly across all locales. + (wei.ToDecimal(null) / (decimal)Unit.Ether).ToString("F2", System.Globalization.CultureInfo.InvariantCulture); private string? TryAttachBlobSidecar(Transaction tx, BlobTransactionForRpc blobTx) { From 3c2657b96591078a5549657085b71bec95e90048 Mon Sep 17 00:00:00 2001 From: stavrosvl7 Date: Sat, 9 May 2026 02:03:06 +0300 Subject: [PATCH 12/18] remove comment --- src/Nethermind/Nethermind.JsonRpc/Modules/Eth/EthRpcModule.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Nethermind/Nethermind.JsonRpc/Modules/Eth/EthRpcModule.cs b/src/Nethermind/Nethermind.JsonRpc/Modules/Eth/EthRpcModule.cs index 2ff758a3ecad..0952e52fe1ce 100644 --- a/src/Nethermind/Nethermind.JsonRpc/Modules/Eth/EthRpcModule.cs +++ b/src/Nethermind/Nethermind.JsonRpc/Modules/Eth/EthRpcModule.cs @@ -446,7 +446,6 @@ private static bool HasFeeFields(TransactionForRpc rpcTx) } private static string FormatWeiAsEther(UInt256 wei) => - // Invariant culture + F2 to match Geth's "X.XX ether" exactly across all locales. (wei.ToDecimal(null) / (decimal)Unit.Ether).ToString("F2", System.Globalization.CultureInfo.InvariantCulture); private string? TryAttachBlobSidecar(Transaction tx, BlobTransactionForRpc blobTx) From e05bc2423c5461a408eceaa3c243313267a560c0 Mon Sep 17 00:00:00 2001 From: stavrosvl7 Date: Sat, 9 May 2026 15:11:41 +0300 Subject: [PATCH 13/18] lukasz review --- .../Modules/Eth/EthRpcModule.cs | 58 +++++++++---------- 1 file changed, 29 insertions(+), 29 deletions(-) diff --git a/src/Nethermind/Nethermind.JsonRpc/Modules/Eth/EthRpcModule.cs b/src/Nethermind/Nethermind.JsonRpc/Modules/Eth/EthRpcModule.cs index fe11518a01d1..93b86fa5d223 100644 --- a/src/Nethermind/Nethermind.JsonRpc/Modules/Eth/EthRpcModule.cs +++ b/src/Nethermind/Nethermind.JsonRpc/Modules/Eth/EthRpcModule.cs @@ -415,17 +415,9 @@ public virtual ResultWrapper eth_signTransaction(Transact }); } - private static bool HasFeeFields(TransactionForRpc rpcTx) - { - if (rpcTx is EIP1559TransactionForRpc eip1559 - && eip1559.MaxFeePerGas is not null - && eip1559.MaxPriorityFeePerGas is not null) - { - return true; - } - - return rpcTx is LegacyTransactionForRpc legacy && legacy.GasPrice is not null; - } + private static bool HasFeeFields(TransactionForRpc rpcTx) => + rpcTx is EIP1559TransactionForRpc { MaxFeePerGas: not null, MaxPriorityFeePerGas: not null } + or LegacyTransactionForRpc { GasPrice: not null }; private ResultWrapper? CheckTxFeeCap(Transaction tx) { @@ -435,14 +427,18 @@ private static bool HasFeeFields(TransactionForRpc rpcTx) // Cap covers execution gas only (maxFeePerGas * gasLimit). Blob gas (4844) is intentionally // excluded — keeps parity with the reference implementation. UInt256 perGas = tx.Type >= TxType.EIP1559 ? tx.MaxFeePerGas : tx.GasPrice; - UInt256 totalFee = perGas * (UInt256)tx.GasLimit; UInt256 capWei = cap; - if (totalFee <= capWei) return null; + // Reject overflow as cap-exceeded: a wraparound multiplication would otherwise let huge + // fee values silently slip through. + if (UInt256.MultiplyOverflow(perGas, (UInt256)tx.GasLimit, out UInt256 totalFee) || totalFee > capWei) + { + return ResultWrapper.Fail( + $"tx fee ({FormatWeiAsEther(totalFee)} ether) exceeds the configured cap ({FormatWeiAsEther(capWei)} ether)", + ErrorCodes.InvalidInput); + } - return ResultWrapper.Fail( - $"tx fee ({FormatWeiAsEther(totalFee)} ether) exceeds the configured cap ({FormatWeiAsEther(capWei)} ether)", - ErrorCodes.InvalidInput); + return null; } private static string FormatWeiAsEther(UInt256 wei) => @@ -450,19 +446,14 @@ private static string FormatWeiAsEther(UInt256 wei) => private string? TryAttachBlobSidecar(Transaction tx, BlobTransactionForRpc blobTx) { - if (blobTx.Blobs is null || blobTx.Blobs.Length == 0) - return "blob transaction requires non-empty blobs"; - if (blobTx.Commitments is null) - return "commitments must be provided alongside blobs"; - if (blobTx.Proofs is null) - return "proofs must be provided alongside blobs"; - - BlockHeader? head = _blockFinder.Head?.Header; - ProofVersion version = head is null - ? ProofVersion.V0 - : _specProvider.GetSpec(head).BlobProofVersion; - - ShardBlobNetworkWrapper wrapper = new(blobTx.Blobs, blobTx.Commitments, blobTx.Proofs, version); + if (ValidateBlobSidecarFields(blobTx) is { } error) + return error; + + ProofVersion version = _blockFinder.Head?.Header is { } head + ? _specProvider.GetSpec(head).BlobProofVersion + : ProofVersion.V0; + + ShardBlobNetworkWrapper wrapper = new(blobTx.Blobs!, blobTx.Commitments!, blobTx.Proofs!, version); IBlobProofsManager manager = IBlobProofsManager.For(version); if (!manager.ValidateLengths(wrapper)) return "blob sidecar lengths invalid (blobs/commitments/proofs counts or individual byte sizes)"; @@ -473,6 +464,15 @@ private static string FormatWeiAsEther(UInt256 wei) => return null; } + private static string? ValidateBlobSidecarFields(BlobTransactionForRpc blobTx) => + blobTx switch + { + { Blobs: null or { Length: 0 } } => "blob transaction requires non-empty blobs", + { Commitments: null } => "commitments must be provided alongside blobs", + { Proofs: null } => "proofs must be provided alongside blobs", + _ => null + }; + private async Task> SendTx(Transaction tx, TxHandlingOptions txHandlingOptions = TxHandlingOptions.None) { From 441e37aedd3e3858881b09980fd464a8a282605e Mon Sep 17 00:00:00 2001 From: stavrosvl7 Date: Sat, 9 May 2026 15:22:57 +0300 Subject: [PATCH 14/18] lukasz review --- .../Nethermind.JsonRpc/Modules/Eth/EthRpcModule.cs | 6 +----- src/Nethermind/Nethermind.Wallet/IWallet.cs | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/src/Nethermind/Nethermind.JsonRpc/Modules/Eth/EthRpcModule.cs b/src/Nethermind/Nethermind.JsonRpc/Modules/Eth/EthRpcModule.cs index 93b86fa5d223..299bbe216f91 100644 --- a/src/Nethermind/Nethermind.JsonRpc/Modules/Eth/EthRpcModule.cs +++ b/src/Nethermind/Nethermind.JsonRpc/Modules/Eth/EthRpcModule.cs @@ -395,11 +395,7 @@ public virtual ResultWrapper eth_signTransaction(Transact return ResultWrapper.Fail(attachError, ErrorCodes.InvalidInput); } - try - { - _wallet.Sign(tx, chainId); - } - catch (SecurityException) + if (!_wallet.TrySign(tx, chainId)) { return ResultWrapper.Fail("authentication needed: password or unlock", ErrorCodes.InvalidInput); } diff --git a/src/Nethermind/Nethermind.Wallet/IWallet.cs b/src/Nethermind/Nethermind.Wallet/IWallet.cs index e6cc0a51bd96..980a184add16 100644 --- a/src/Nethermind/Nethermind.Wallet/IWallet.cs +++ b/src/Nethermind/Nethermind.Wallet/IWallet.cs @@ -28,6 +28,20 @@ void Sign(Transaction tx, ulong chainId) ?? throw new CryptographicException($"Failed to sign tx {tx.Hash} using the {tx.SenderAddress} address."); tx.Signature.V = tx.Type == TxType.Legacy ? tx.Signature.V + 8 + 2 * chainId : (ulong)(tx.Signature.RecoveryId + 27); } + + bool TrySign(Transaction tx, ulong chainId) + { + try + { + Sign(tx, chainId); + return true; + } + catch (SecurityException) + { + return false; + } + } + Signature SignMessage(byte[] message, Address address) { string m = Encoding.UTF8.GetString(message); From b956806f4756eaa8047b459435fa1e88a72557ba Mon Sep 17 00:00:00 2001 From: stavrosvl7 Date: Sat, 9 May 2026 16:12:01 +0300 Subject: [PATCH 15/18] Revert "lukasz review" This reverts commit 441e37aedd3e3858881b09980fd464a8a282605e. --- .../Nethermind.JsonRpc/Modules/Eth/EthRpcModule.cs | 6 +++++- src/Nethermind/Nethermind.Wallet/IWallet.cs | 14 -------------- 2 files changed, 5 insertions(+), 15 deletions(-) diff --git a/src/Nethermind/Nethermind.JsonRpc/Modules/Eth/EthRpcModule.cs b/src/Nethermind/Nethermind.JsonRpc/Modules/Eth/EthRpcModule.cs index 299bbe216f91..93b86fa5d223 100644 --- a/src/Nethermind/Nethermind.JsonRpc/Modules/Eth/EthRpcModule.cs +++ b/src/Nethermind/Nethermind.JsonRpc/Modules/Eth/EthRpcModule.cs @@ -395,7 +395,11 @@ public virtual ResultWrapper eth_signTransaction(Transact return ResultWrapper.Fail(attachError, ErrorCodes.InvalidInput); } - if (!_wallet.TrySign(tx, chainId)) + try + { + _wallet.Sign(tx, chainId); + } + catch (SecurityException) { return ResultWrapper.Fail("authentication needed: password or unlock", ErrorCodes.InvalidInput); } diff --git a/src/Nethermind/Nethermind.Wallet/IWallet.cs b/src/Nethermind/Nethermind.Wallet/IWallet.cs index 980a184add16..e6cc0a51bd96 100644 --- a/src/Nethermind/Nethermind.Wallet/IWallet.cs +++ b/src/Nethermind/Nethermind.Wallet/IWallet.cs @@ -28,20 +28,6 @@ void Sign(Transaction tx, ulong chainId) ?? throw new CryptographicException($"Failed to sign tx {tx.Hash} using the {tx.SenderAddress} address."); tx.Signature.V = tx.Type == TxType.Legacy ? tx.Signature.V + 8 + 2 * chainId : (ulong)(tx.Signature.RecoveryId + 27); } - - bool TrySign(Transaction tx, ulong chainId) - { - try - { - Sign(tx, chainId); - return true; - } - catch (SecurityException) - { - return false; - } - } - Signature SignMessage(byte[] message, Address address) { string m = Encoding.UTF8.GetString(message); From 0f3764498f40a482fdbdaf84d24cf99138f2490d Mon Sep 17 00:00:00 2001 From: stavrosvl7 Date: Sat, 9 May 2026 16:18:13 +0300 Subject: [PATCH 16/18] different wallet fixes --- .../Nethermind.JsonRpc/Modules/Eth/EthRpcModule.cs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/Nethermind/Nethermind.JsonRpc/Modules/Eth/EthRpcModule.cs b/src/Nethermind/Nethermind.JsonRpc/Modules/Eth/EthRpcModule.cs index 93b86fa5d223..0eec07342f30 100644 --- a/src/Nethermind/Nethermind.JsonRpc/Modules/Eth/EthRpcModule.cs +++ b/src/Nethermind/Nethermind.JsonRpc/Modules/Eth/EthRpcModule.cs @@ -39,6 +39,7 @@ using System.Collections.Generic; using System.Linq; using System.Security; +using System.Security.Cryptography; using System.Text; using System.Text.Json; using System.Text.Json.Nodes; @@ -399,7 +400,11 @@ public virtual ResultWrapper eth_signTransaction(Transact { _wallet.Sign(tx, chainId); } - catch (SecurityException) + // Each wallet implementation signals "could not sign" with its own exception type: + // SecurityException for keystore wallets when the account is locked, InvalidOperationException + // for ClefWallet on remote-signer rejection, CryptographicException via the default + // Sign(Transaction, ulong) when Sign(Hash256, Address) returns null (e.g. NullWallet). + catch (Exception ex) when (ex is SecurityException or InvalidOperationException or CryptographicException) { return ResultWrapper.Fail("authentication needed: password or unlock", ErrorCodes.InvalidInput); } From 7fcaa57feb19dcc68d5b915c3982f4c38daaecb0 Mon Sep 17 00:00:00 2001 From: stavrosvl7 Date: Sat, 9 May 2026 16:38:22 +0300 Subject: [PATCH 17/18] security fixes --- .../Modules/Eth/EthRpcModuleTests.SignTransaction.cs | 3 +++ src/Nethermind/Nethermind.JsonRpc/IJsonRpcConfig.cs | 5 ++++- src/Nethermind/Nethermind.JsonRpc/JsonRpcConfig.cs | 4 +++- .../Nethermind.JsonRpc/Modules/Eth/EthRpcModule.cs | 5 +++++ 4 files changed, 15 insertions(+), 2 deletions(-) diff --git a/src/Nethermind/Nethermind.JsonRpc.Test/Modules/Eth/EthRpcModuleTests.SignTransaction.cs b/src/Nethermind/Nethermind.JsonRpc.Test/Modules/Eth/EthRpcModuleTests.SignTransaction.cs index ec306f624764..dced4746b6a3 100644 --- a/src/Nethermind/Nethermind.JsonRpc.Test/Modules/Eth/EthRpcModuleTests.SignTransaction.cs +++ b/src/Nethermind/Nethermind.JsonRpc.Test/Modules/Eth/EthRpcModuleTests.SignTransaction.cs @@ -116,6 +116,7 @@ public async Task SignTransaction_LegacyShapeJson_RespectsExplicitTypePinning(bo JsonElement param = JsonSerializer.Deserialize(txJson); using Context ctx = await Context.Create(); + ctx.Test.RpcConfig.EnableEthSignTransaction = true; string serialized = await ctx.Test.TestEthRpc("eth_signTransaction", param); JsonRpcResponse response = ctx.Test.JsonSerializer.Deserialize>(serialized)!; response.Result.Should().NotBeNull("precondition: signing must succeed for valid input"); @@ -152,12 +153,14 @@ public async Task SignTransaction_WhenValid_RawRoundTripsAndTxEcho(TxType type, private async Task SignTransaction(TransactionForRpc rpcTx) { using Context ctx = await Context.Create(); + ctx.Test.RpcConfig.EnableEthSignTransaction = true; return await ctx.Test.TestEthRpc("eth_signTransaction", rpcTx); } private async Task SignTransactionForResult(TransactionForRpc rpcTx) { using Context ctx = await Context.Create(); + ctx.Test.RpcConfig.EnableEthSignTransaction = true; string serialized = await ctx.Test.TestEthRpc("eth_signTransaction", rpcTx); JsonRpcResponse response = ctx.Test.JsonSerializer.Deserialize>(serialized)!; response.Result.Should().NotBeNull("precondition: signing must succeed for valid input"); diff --git a/src/Nethermind/Nethermind.JsonRpc/IJsonRpcConfig.cs b/src/Nethermind/Nethermind.JsonRpc/IJsonRpcConfig.cs index d27b8f33c63f..7e7934fb5f55 100644 --- a/src/Nethermind/Nethermind.JsonRpc/IJsonRpcConfig.cs +++ b/src/Nethermind/Nethermind.JsonRpc/IJsonRpcConfig.cs @@ -140,7 +140,7 @@ public interface IJsonRpcConfig : IConfig [ConfigItem( Description = "An array of the method names not to log.", - DefaultValue = "[engine_newPayloadV1,engine_newPayloadV2,engine_newPayloadV3,engine_forkchoiceUpdatedV1,engine_forkchoiceUpdatedV2,flashbots_validateBuilderSubmissionV3]")] + DefaultValue = "[engine_newPayloadV1,engine_newPayloadV2,engine_newPayloadV3,engine_forkchoiceUpdatedV1,engine_forkchoiceUpdatedV2,flashbots_validateBuilderSubmissionV3,eth_signTransaction]")] public string[]? MethodsLoggingFiltering { get; set; } [ConfigItem(Description = "The Engine API host.", DefaultValue = "127.0.0.1")] @@ -172,6 +172,9 @@ public interface IJsonRpcConfig : IConfig [ConfigItem(Description = "Maximum total tx fee (gasPrice * gasLimit, in wei) the node will sign in eth_signTransaction. 0 disables the cap. Default 1 ETH.", DefaultValue = "1000000000000000000")] ulong RpcTxFeeCap { get; set; } + [ConfigItem(Description = "Whether to enable eth_signTransaction. Disabled by default; enable only on nodes that explicitly manage unlocked accounts.", DefaultValue = "false")] + bool EnableEthSignTransaction { get; set; } + [ConfigItem(Description = "The JSON-RPC server CORS origins.", DefaultValue = "*")] string[] CorsOrigins { get; set; } diff --git a/src/Nethermind/Nethermind.JsonRpc/JsonRpcConfig.cs b/src/Nethermind/Nethermind.JsonRpc/JsonRpcConfig.cs index a9de8d82d0fd..16e28834a6fa 100644 --- a/src/Nethermind/Nethermind.JsonRpc/JsonRpcConfig.cs +++ b/src/Nethermind/Nethermind.JsonRpc/JsonRpcConfig.cs @@ -58,7 +58,8 @@ public string[] EnabledModules "engine_newPayloadV3", "engine_forkchoiceUpdatedV1", "engine_forkchoiceUpdatedV2", - "flashbots_validateBuilderSubmissionV3" + "flashbots_validateBuilderSubmissionV3", + "eth_signTransaction" }; public string EngineHost { get; set; } = "127.0.0.1"; public int? EnginePort { get; set; } = null; @@ -69,6 +70,7 @@ public string[] EnabledModules public long? MaxSimulateBlocksCap { get; set; } = 256; public int EstimateErrorMargin { get; set; } = 150; public ulong RpcTxFeeCap { get; set; } = (ulong)1.Ether; + public bool EnableEthSignTransaction { get; set; } public string[] CorsOrigins { get; set; } = ["*"]; public int WebSocketsProcessingConcurrency { get; set; } = 1; public int IpcProcessingConcurrency { get; set; } = 1; diff --git a/src/Nethermind/Nethermind.JsonRpc/Modules/Eth/EthRpcModule.cs b/src/Nethermind/Nethermind.JsonRpc/Modules/Eth/EthRpcModule.cs index 0eec07342f30..4a897b797c10 100644 --- a/src/Nethermind/Nethermind.JsonRpc/Modules/Eth/EthRpcModule.cs +++ b/src/Nethermind/Nethermind.JsonRpc/Modules/Eth/EthRpcModule.cs @@ -359,6 +359,9 @@ public virtual async Task> eth_sendRawTransaction(byte[] public virtual ResultWrapper eth_signTransaction(TransactionForRpc rpcTx) { + if (!_rpcConfig.EnableEthSignTransaction) + return ResultWrapper.Fail("eth_signTransaction is disabled", ErrorCodes.MethodNotFound); + if (rpcTx.Gas is null) return ResultWrapper.Fail("gas not specified", ErrorCodes.InvalidInput); @@ -411,6 +414,8 @@ public virtual ResultWrapper eth_signTransaction(Transact tx.Hash = tx.CalculateHash(); + if (_logger.IsInfo) _logger.Info($"eth_signTransaction signed tx {tx.Hash} from {tx.SenderAddress}"); + byte[] raw = TxDecoder.Instance.Encode(tx, RlpBehaviors.SkipTypedWrapping | RlpBehaviors.InMempoolForm).Bytes; return ResultWrapper.Success(new SignTransactionResult From a7d0f5be662e0685cb19a0af1ad0bb16f0c40d08 Mon Sep 17 00:00:00 2001 From: stavrosvl7 Date: Sat, 9 May 2026 20:51:00 +0300 Subject: [PATCH 18/18] fix overflow --- .../Nethermind.JsonRpc/Modules/Eth/EthRpcModule.cs | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/Nethermind/Nethermind.JsonRpc/Modules/Eth/EthRpcModule.cs b/src/Nethermind/Nethermind.JsonRpc/Modules/Eth/EthRpcModule.cs index 4a897b797c10..efb9e4da9817 100644 --- a/src/Nethermind/Nethermind.JsonRpc/Modules/Eth/EthRpcModule.cs +++ b/src/Nethermind/Nethermind.JsonRpc/Modules/Eth/EthRpcModule.cs @@ -441,14 +441,13 @@ private static bool HasFeeFields(TransactionForRpc rpcTx) => // Reject overflow as cap-exceeded: a wraparound multiplication would otherwise let huge // fee values silently slip through. - if (UInt256.MultiplyOverflow(perGas, (UInt256)tx.GasLimit, out UInt256 totalFee) || totalFee > capWei) - { - return ResultWrapper.Fail( - $"tx fee ({FormatWeiAsEther(totalFee)} ether) exceeds the configured cap ({FormatWeiAsEther(capWei)} ether)", - ErrorCodes.InvalidInput); - } + bool overflow = UInt256.MultiplyOverflow(perGas, (UInt256)tx.GasLimit, out UInt256 totalFee); + if (!overflow && totalFee <= capWei) return null; + string feeStr = overflow ? "overflow" : FormatWeiAsEther(totalFee); + return ResultWrapper.Fail( + $"tx fee ({feeStr} ether) exceeds the configured cap ({FormatWeiAsEther(capWei)} ether)", + ErrorCodes.InvalidInput); - return null; } private static string FormatWeiAsEther(UInt256 wei) =>