Skip to content

Commit 0f70752

Browse files
hudem1claudeLukaszRozmej
authored
feat: Support eth_fillTransaction (#12204)
* feat: Support eth_fillTransaction * fix(rpc): address eth_fillTransaction review feedback - Require an explicit `from`; reject the silent zero-address default. - Reject a `chainId` that doesn't match the node (geth parity). - Derive blob fields before gas estimation so blob txs estimate correctly. - Default maxFeePerBlobGas to 2x the blob base fee and fail when it can't be computed, instead of emitting "0x0" (below the EIP-4844 minimum of 1). - Add regression tests for the from and chainId guards. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(rpc): fill eth_fillTransaction fields via polymorphism Replace the per-type if/switch cascade in eth_fillTransaction with virtual FillFeeDefaults / PrepareForGasEstimation methods on the TransactionForRpc hierarchy, driven by a small TxFillContext of node-computed defaults. Each tx type now populates the fee model it uses without the RPC layer branching on the concrete type. Also harden the blob path (moved into BlobTransactionForRpc): bound the blob count before the KZG work and validate caller-supplied blobVersionedHashes against the derived commitments instead of silently overwriting them. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(rpc): drop redundant JsonPropertyName on FillTransactionResult The camelCase naming policy already serializes Tx as "tx". Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(rpc): consolidate eth_fillTransaction tests and cover blob validation Fold the success and error cases into TestCaseSource-driven tests, and add coverage for the blob-count bound and the blob-hash mismatch rejection. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore: remove comment * refactor(rpc): unify eth_fillTransaction fill hooks into one Result-based method Merge PrepareForGasEstimation and FillFeeDefaults into a single FillDefaults(in TxFillContext) returning Result, called before gas estimation to mirror geth's setDefaults order; estimation now runs with the effective fees, failing like geth when the sender cannot cover them. Split the unsupported-type and missing-from guards into distinct errors, and trim comments to the non-obvious invariants. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(rpc): type eth_fillTransaction input as LegacyTransactionForRpc Add LegacyTransactionJsonConverter, a second polymorphic converter rooted at LegacyTransactionForRpc (the base of every user-signable tx type), so RPC methods taking transactions as input can declare that type and reject output-only types such as Optimism deposit transactions at deserialization. The base dispatch strips the converter (cached options copy) when deserializing the exact LegacyTransactionForRpc concrete type to avoid recursion, and registration happens via ModuleInitializer so the converter is present in every host before the first parameter bind. This removes the runtime type guard from eth_fillTransaction. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(rpc): satisfy analyzers in LegacyTransactionJsonConverter Use a collection expression for the options cache (IDE0028) and suppress CA2255 with justification: registration must precede the first bind of a LegacyTransactionForRpc-declared member in any host, which a static constructor cannot guarantee. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(rpc): root signable-tx converter at an abstract type Replace the concrete-rooted, globally-registered LegacyTransactionJsonConverter with SignableTransactionForRpc, an abstract intermediate base between TransactionForRpc and LegacyTransactionForRpc that carries the polymorphic converter as a [JsonConverter] attribute. Because the root is abstract it is never a concrete leaf, so the converter no longer self-collides and needs no WithoutSelf; attributing it drops the global options mutation and the ModuleInitializer. Output-only types (e.g. Optimism deposits) derive from TransactionForRpc directly, so declaring an input parameter as SignableTransactionForRpc excludes them via the type system. Applied to the transaction-input eth_* methods (fillTransaction, sendTransaction, signTransaction, call, estimateGas, createAccessList). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Lukasz Rozmej <lukasz.rozmej@gmail.com>
1 parent 96ed40d commit 0f70752

15 files changed

Lines changed: 477 additions & 17 deletions

File tree

src/Nethermind/Nethermind.Facade/Eth/RpcTransaction/BlobTransactionForRpc.cs

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,49 @@ public override Result<Transaction> ToTransaction(bool validateUserInput = false
9090
return tx;
9191
}
9292

93+
public override Result FillDefaults(in TxFillContext context)
94+
{
95+
Result baseResult = base.FillDefaults(context);
96+
if (!baseResult) return baseResult;
97+
98+
if (MaxFeePerBlobGas is null)
99+
{
100+
// Fail rather than default to 0x0, which is below the EIP-4844 minimum blob base fee of 1.
101+
if (context.BlobBaseFee is null) return Result.Fail("unable to calculate the current blob base fee");
102+
MaxFeePerBlobGas = context.BlobBaseFee.Value * 2;
103+
}
104+
105+
return DeriveSidecar(context.Spec);
106+
}
107+
108+
private Result DeriveSidecar(IReleaseSpec spec)
109+
{
110+
if (Blobs is not { Length: > 0 }) return Result.Success;
111+
if (Commitments is not null && Proofs is not null && BlobVersionedHashes is { Length: > 0 }) return Result.Success;
112+
113+
// Bound the blob count before the expensive KZG work.
114+
ValidationResult blobCountValidation = BlobFieldsTxValidator.ValidateBlobGasLimits(Blobs.Length, spec);
115+
if (!blobCountValidation) return Result.Fail(blobCountValidation.Error!);
116+
117+
IBlobProofsManager proofsManager = IBlobProofsManager.For(spec.BlobProofVersion);
118+
ShardBlobNetworkWrapper wrapper = proofsManager.AllocateWrapper(Blobs);
119+
proofsManager.ComputeProofsAndCommitments(wrapper);
120+
121+
if (BlobVersionedHashes is { Length: > 0 })
122+
{
123+
if (!proofsManager.ValidateHashes(wrapper, BlobVersionedHashes))
124+
return Result.Fail("blob versioned hashes do not match the supplied blobs");
125+
}
126+
else
127+
{
128+
BlobVersionedHashes = proofsManager.ComputeHashes(wrapper);
129+
}
130+
131+
Commitments = wrapper.Commitments;
132+
Proofs = wrapper.Proofs;
133+
return Result.Success;
134+
}
135+
93136
public new static BlobTransactionForRpc FromTransaction(Transaction tx, in TransactionForRpcContext extraData)
94137
=> new(tx, extraData);
95138

src/Nethermind/Nethermind.Facade/Eth/RpcTransaction/EIP1559TransactionForRpc.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,13 @@ public override Result<Transaction> ToTransaction(bool validateUserInput = false
6666
public override bool ShouldSetBaseFee() =>
6767
base.ShouldSetBaseFee() || MaxFeePerGas.IsPositive() || MaxPriorityFeePerGas.IsPositive();
6868

69+
public override Result FillDefaults(in TxFillContext context)
70+
{
71+
MaxPriorityFeePerGas ??= context.MaxPriorityFeePerGas;
72+
MaxFeePerGas ??= context.BaseFee * 2 + MaxPriorityFeePerGas.Value;
73+
return Result.Success;
74+
}
75+
6976
public new static EIP1559TransactionForRpc FromTransaction(Transaction tx, in TransactionForRpcContext extraData)
7077
=> new(tx, extraData);
7178
}

src/Nethermind/Nethermind.Facade/Eth/RpcTransaction/LegacyTransactionForRpc.cs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414

1515
namespace Nethermind.Facade.Eth.RpcTransaction;
1616

17-
public class LegacyTransactionForRpc : TransactionForRpc, ITxTyped, IFromTransaction<LegacyTransactionForRpc>
17+
public class LegacyTransactionForRpc : SignableTransactionForRpc, ITxTyped, IFromTransaction<LegacyTransactionForRpc>
1818
{
1919
public static TxType TxType => TxType.Legacy;
2020

@@ -134,6 +134,12 @@ public override Result<Transaction> ToTransaction(bool validateUserInput = false
134134

135135
public override bool ShouldSetBaseFee() => GasPrice.IsPositive();
136136

137+
public override Result FillDefaults(in TxFillContext context)
138+
{
139+
GasPrice ??= context.GasPrice;
140+
return Result.Success;
141+
}
142+
137143
public static LegacyTransactionForRpc FromTransaction(Transaction tx, in TransactionForRpcContext extraData) =>
138144
new(tx, extraData);
139145
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited
2+
// SPDX-License-Identifier: LGPL-3.0-only
3+
4+
using System;
5+
using System.Text.Json;
6+
using System.Text.Json.Serialization;
7+
using Nethermind.Core;
8+
9+
namespace Nethermind.Facade.Eth.RpcTransaction;
10+
11+
/// <summary>
12+
/// Abstract root of the user-signable transaction types (legacy, access list, EIP-1559, blob,
13+
/// set-code). Declaring an RPC input parameter as this type restricts it, via the type system, to
14+
/// transactions a user can submit: output-only types such as Optimism deposits derive from
15+
/// <see cref="TransactionForRpc"/> directly and are rejected during deserialization.
16+
/// </summary>
17+
/// <remarks>
18+
/// The root is abstract on purpose. Because it is never a concrete leaf, the polymorphic converter
19+
/// never has to deserialize its own type and needs no self-exclusion — unlike a converter rooted at
20+
/// a concrete type. The converter is attached via <see cref="JsonConverterAttribute"/> rather than
21+
/// registered globally, so it applies only where a parameter is declared as this type.
22+
/// </remarks>
23+
[JsonConverter(typeof(SignableTransactionJsonConverter))]
24+
public abstract class SignableTransactionForRpc : TransactionForRpc
25+
{
26+
protected SignableTransactionForRpc() { }
27+
28+
protected SignableTransactionForRpc(Transaction transaction, in TransactionForRpcContext extraData)
29+
: base(transaction, extraData) { }
30+
31+
internal sealed class SignableTransactionJsonConverter : JsonConverter<SignableTransactionForRpc>
32+
{
33+
public override SignableTransactionForRpc? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) =>
34+
// The base converter matches only TransactionForRpc, so its concrete-type deserialization never re-enters this one.
35+
JsonSerializer.Deserialize<TransactionForRpc>(ref reader, options) switch
36+
{
37+
null => null,
38+
SignableTransactionForRpc signable => signable,
39+
{ Type: var type } => throw new JsonException($"transaction type {type} is not supported as an input")
40+
};
41+
42+
public override void Write(Utf8JsonWriter writer, SignableTransactionForRpc value, JsonSerializerOptions options) =>
43+
JsonSerializer.Serialize(writer, value, value.GetType(), options);
44+
}
45+
}

src/Nethermind/Nethermind.Facade/Eth/RpcTransaction/TransactionForRpc.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,13 @@ public TransactionForRpc PromoteToEip1559IfTypeDefaulted()
120120
};
121121
}
122122

123+
/// <summary>
124+
/// Fills the type-specific fields the caller left unset from node-computed defaults: each
125+
/// transaction type populates the fee model it uses, and blob transactions additionally derive
126+
/// the KZG sidecar from the supplied blobs.
127+
/// </summary>
128+
public virtual Result FillDefaults(in TxFillContext context) => Result.Success;
129+
123130
public abstract bool ShouldSetBaseFee();
124131

125132
internal class TransactionJsonConverter : JsonConverter<TransactionForRpc>
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited
2+
// SPDX-License-Identifier: LGPL-3.0-only
3+
4+
using Nethermind.Core.Specs;
5+
using Nethermind.Int256;
6+
7+
namespace Nethermind.Facade.Eth.RpcTransaction;
8+
9+
/// <summary>
10+
/// Node-computed defaults handed to <see cref="TransactionForRpc.FillDefaults"/> so each transaction
11+
/// type populates the fields it uses without the RPC layer branching on the concrete type.
12+
/// </summary>
13+
public readonly struct TxFillContext
14+
{
15+
public required UInt256 GasPrice { get; init; }
16+
17+
public required UInt256 MaxPriorityFeePerGas { get; init; }
18+
19+
public required UInt256 BaseFee { get; init; }
20+
21+
/// <summary>Current blob base fee, or <c>null</c> when it cannot be computed.</summary>
22+
public required UInt256? BlobBaseFee { get; init; }
23+
24+
public required IReleaseSpec Spec { get; init; }
25+
}

src/Nethermind/Nethermind.JsonRpc.Test/JsonRpcServiceTests.cs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -235,7 +235,7 @@ public void CanHandleOptionalArguments()
235235
{
236236
IEthRpcModule ethRpcModule = Substitute.For<IEthRpcModule>();
237237
HexBytes expected = ToHexBytes("0x01");
238-
ethRpcModule.eth_call(Arg.Any<TransactionForRpc>()).ReturnsForAnyArgs(_ => ResultWrapper<HexBytes>.Success(expected));
238+
ethRpcModule.eth_call(Arg.Any<SignableTransactionForRpc>()).ReturnsForAnyArgs(_ => ResultWrapper<HexBytes>.Success(expected));
239239
HexBytes result = RpcTest.AssertSuccess<HexBytes>(TestRequest(ethRpcModule, "eth_call", new LegacyTransactionForRpc()));
240240
Assert.That(result, Is.EqualTo(expected));
241241
}
@@ -244,7 +244,7 @@ public void CanHandleOptionalArguments()
244244
public void Value_type_result_failure_without_error_data_does_not_emit_default_data()
245245
{
246246
IEthRpcModule ethRpcModule = Substitute.For<IEthRpcModule>();
247-
ethRpcModule.eth_call(Arg.Any<TransactionForRpc>()).ReturnsForAnyArgs(_ => ResultWrapper<HexBytes>.Fail("out of gas", ErrorCodes.ExecutionError));
247+
ethRpcModule.eth_call(Arg.Any<SignableTransactionForRpc>()).ReturnsForAnyArgs(_ => ResultWrapper<HexBytes>.Fail("out of gas", ErrorCodes.ExecutionError));
248248

249249
ResultWrapper<HexBytes> response = AssertWrapperResponse<HexBytes>(TestRequest(ethRpcModule, "eth_call", new LegacyTransactionForRpc()));
250250

@@ -433,7 +433,7 @@ public void Eth_call_is_working_with_nullable_last_argument(object?[] parameters
433433
{
434434
IEthRpcModule ethRpcModule = Substitute.For<IEthRpcModule>();
435435
HexBytes expected = ToHexBytes("0x");
436-
ethRpcModule.eth_call(Arg.Any<TransactionForRpc>(), Arg.Any<BlockParameter?>()).ReturnsForAnyArgs(_ => ResultWrapper<HexBytes>.Success(expected));
436+
ethRpcModule.eth_call(Arg.Any<SignableTransactionForRpc>(), Arg.Any<BlockParameter?>()).ReturnsForAnyArgs(_ => ResultWrapper<HexBytes>.Success(expected));
437437

438438
HexBytes result = RpcTest.AssertSuccess<HexBytes>(TestRequest(ethRpcModule, "eth_call", parameters));
439439
Assert.That(result, Is.EqualTo(expected));
@@ -445,7 +445,7 @@ public void Raw_utf8_params_keep_explicit_nullable_trailing_defaults()
445445
IEthRpcModule ethRpcModule = Substitute.For<IEthRpcModule>();
446446
ethRpcModule
447447
.eth_call(
448-
Arg.Any<TransactionForRpc>(),
448+
Arg.Any<SignableTransactionForRpc>(),
449449
Arg.Any<BlockParameter?>(),
450450
Arg.Any<Dictionary<Address, AccountOverride>?>(),
451451
Arg.Any<BlockOverride?>())

0 commit comments

Comments
 (0)