diff --git a/src/Nethermind/Nethermind.Facade/Eth/RpcTransaction/BlobTransactionForRpc.cs b/src/Nethermind/Nethermind.Facade/Eth/RpcTransaction/BlobTransactionForRpc.cs index 71c95c5a358b..294e22e1fd0e 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() { } @@ -33,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.Facade/Eth/RpcTransaction/TransactionForRpc.cs b/src/Nethermind/Nethermind.Facade/Eth/RpcTransaction/TransactionForRpc.cs index 9dd271e248d1..dceb91d6978a 100644 --- a/src/Nethermind/Nethermind.Facade/Eth/RpcTransaction/TransactionForRpc.cs +++ b/src/Nethermind/Nethermind.Facade/Eth/RpcTransaction/TransactionForRpc.cs @@ -49,11 +49,8 @@ public abstract class TransactionForRpc [JsonIgnore(Condition = JsonIgnoreCondition.Never)] 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. - /// + // 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; } @@ -74,10 +71,32 @@ public virtual Result ToTransaction(bool validateUserInput = false, private TxType ResolveType(IReleaseSpec? spec) { + // 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; } + public TransactionForRpc PromoteToEip1559IfTypeDefaulted() + { + if (!IsTypeDefaulted) return this; + // 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; + + 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 @@ -143,7 +162,10 @@ internal static void RegisterTransactionType() where T : TransactionForRpc, I Type concreteTxType = DeriveTxType(untyped, options, out bool isDefaulted); TransactionForRpc? result = (TransactionForRpc?)JsonSerializer.Deserialize(ref reader, concreteTxType, options); - result?.IsTypeDefaulted = isDefaulted; + if (result is not null) + { + result.IsTypeDefaulted = isDefaulted; + } return result; } @@ -151,27 +173,33 @@ private Type DeriveTxType(JsonObject untyped, JsonSerializerOptions options, out { const string gasPriceFieldKey = nameof(LegacyTransactionForRpc.GasPrice); const string typeFieldKey = nameof(TransactionForRpc.Type); - isDefaulted = false; if (untyped.TryGetPropertyValue(typeFieldKey, out JsonNode? node)) { TxType? setType = node.Deserialize(options); if (setType is not null) { + isDefaulted = false; return _txTypes.FirstOrDefault(p => p.TxType == setType)?.Type ?? throw new JsonException("Unknown transaction type"); } } - return untyped.ContainsKey(gasPriceFieldKey) - ? typeof(LegacyTransactionForRpc) - : _txTypes.FirstOrDefault(p => p.DiscriminatorProperties.Any(untyped.ContainsKey))?.Type - ?? GetDefaultType(out isDefaulted); - - static Type GetDefaultType(out bool isDefaulted) + if (untyped.ContainsKey(gasPriceFieldKey)) { isDefaulted = true; - return typeof(EIP1559TransactionForRpc); + 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) + { + isDefaulted = false; + return viaDiscriminator; } + + isDefaulted = true; + 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); 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..dced4746b6a3 --- /dev/null +++ b/src/Nethermind/Nethermind.JsonRpc.Test/Modules/Eth/EthRpcModuleTests.SignTransaction.cs @@ -0,0 +1,226 @@ +// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited +// SPDX-License-Identifier: LGPL-3.0-only + +using System; +using System.Text.Json; +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.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); + 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(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(); + 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"); + + 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")] + [TestCase(TxType.SetCode, typeof(SetCodeTransactionForRpc), TestName = "SetCode")] + 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(); + 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"); + 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; + UInt256 gasPrice = 0x9184e72a000; + + 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?)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, + 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?)gasPrice, + }, + }; + } +} diff --git a/src/Nethermind/Nethermind.JsonRpc/Data/SignTransactionResult.cs b/src/Nethermind/Nethermind.JsonRpc/Data/SignTransactionResult.cs new file mode 100644 index 000000000000..fd7c916f4ca3 --- /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 required byte[] Raw { get; init; } + + [JsonPropertyName("tx")] + public required TransactionForRpc Tx { get; init; } +} diff --git a/src/Nethermind/Nethermind.JsonRpc/IJsonRpcConfig.cs b/src/Nethermind/Nethermind.JsonRpc/IJsonRpcConfig.cs index 6ab58d241091..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")] @@ -169,6 +169,12 @@ 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 = "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 4a477e4cb7c6..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; @@ -68,6 +69,8 @@ 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; } = (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 86844628d80d..efb9e4da9817 100644 --- a/src/Nethermind/Nethermind.JsonRpc/Modules/Eth/EthRpcModule.cs +++ b/src/Nethermind/Nethermind.JsonRpc/Modules/Eth/EthRpcModule.cs @@ -39,11 +39,13 @@ 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; 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 +357,131 @@ 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); + + 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); + + Address from = legacy.From ?? Address.Zero; + if (!_wallet.IsUnlocked(from)) + return ResultWrapper.Fail("authentication needed: password or unlock", ErrorCodes.InvalidInput); + + rpcTx = rpcTx.PromoteToEip1559IfTypeDefaulted(); + + 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); + } + // 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); + } + + 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 + { + Raw = raw, + Tx = TransactionForRpc.FromTransaction(tx) + }); + } + + 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) + { + 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 capWei = cap; + + // Reject overflow as cap-exceeded: a wraparound multiplication would otherwise let huge + // fee values silently slip through. + 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); + + } + + private static string FormatWeiAsEther(UInt256 wei) => + (wei.ToDecimal(null) / (decimal)Unit.Ether).ToString("F2", System.Globalization.CultureInfo.InvariantCulture); + + private string? TryAttachBlobSidecar(Transaction tx, BlobTransactionForRpc blobTx) + { + 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)"; + 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; + } + + 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) { diff --git a/src/Nethermind/Nethermind.JsonRpc/Modules/Eth/IEthRpcModule.cs b/src/Nethermind/Nethermind.JsonRpc/Modules/Eth/IEthRpcModule.cs index acf3a71853ad..44999cc26abc 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,