Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Comment thread
svlachakis marked this conversation as resolved.

[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public byte[][]? Proofs { get; set; }

[JsonConstructor]
public BlobTransactionForRpc() { }

Expand All @@ -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<Transaction> ToTransaction(bool validateUserInput = false, long? gasCap = null, IReleaseSpec? spec = null)
Expand Down Expand Up @@ -71,4 +84,31 @@ public override Result<Transaction> ToTransaction(bool validateUserInput = false

public new static BlobTransactionForRpc FromTransaction(Transaction tx, in TransactionForRpcContext extraData)
=> new(tx, extraData);

/// <summary>
/// Validates the blob sidecar fields and attaches a <see cref="ShardBlobNetworkWrapper"/>
/// to the given <see cref="Transaction"/>. Returns an error string on failure, or
/// <c>null</c> on success.
/// </summary>
public string? TryAttachSidecar(Transaction tx, ProofVersion version)
{
string? fieldError = this 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
};
if (fieldError is not null) return fieldError;

ShardBlobNetworkWrapper wrapper = new(Blobs!, Commitments!, 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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -49,11 +49,8 @@ public abstract class TransactionForRpc
[JsonIgnore(Condition = JsonIgnoreCondition.Never)]
public long? Gas { get; set; }

/// <summary>
/// True when the transaction type was inferred by <see cref="TransactionJsonConverter"/> rather than
/// explicitly provided in the JSON request. When set, <see cref="ToTransaction"/> can resolve
/// the type to match the target block's fork rules.
/// </summary>
// 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; }

Expand All @@ -74,10 +71,55 @@ public virtual Result<Transaction> 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;
}

/// <summary>
/// Validates fields required for signing (gas, fee, nonce), promotes type-defaulted
/// transactions to EIP-1559, and returns the resulting <see cref="Transaction"/>.
/// </summary>
public Result<Transaction> ToSignableTransaction()
{
if (Gas is null)
return Result<Transaction>.Fail("gas not specified");

if (!HasFeeFields(this))
return Result<Transaction>.Fail("missing gasPrice or maxFeePerGas/maxPriorityFeePerGas");

// All concrete tx subtypes (AccessList, EIP1559, Blob, SetCode) derive from LegacyTransactionForRpc.
if (this is not LegacyTransactionForRpc { Nonce: not null })
return Result<Transaction>.Fail("nonce not specified");

return PromoteToEip1559IfTypeDefaulted().ToTransaction(validateUserInput: true);
}

private static bool HasFeeFields(TransactionForRpc rpcTx) =>
rpcTx is EIP1559TransactionForRpc { MaxFeePerGas: not null, MaxPriorityFeePerGas: not null }
or LegacyTransactionForRpc { GasPrice: not null };

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<TransactionForRpc>
Expand Down Expand Up @@ -143,35 +185,44 @@ internal static void RegisterTransactionType<T>() 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;
}
Comment on lines +188 to +191
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

why downgrade?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The downgrade is a pre-Berlin compatibility guard. ResolveType checks IsTypeDefaulted == true && !spec.IsEip2930Enabled and forces the type to Legacy, since pre-Berlin EVMs reject typed (≥ 0x01) txs outright. The flag is set whenever the JSON request omitted the type field - covers both the gasPrice-routing path (which is already Legacy → no-op downgrade) and the absolute fallback (EIP1559 default → actually downgraded to Legacy). Explicit type: "0x2" or discriminator-routed types (accessList/maxFeePerGas) keep IsTypeDefaulted == false and pass through unchanged. Pre-Berlin chains are mostly dead, but for eth_call/eth_estimateGas against historical heights this keeps the result spec-correct.

return result;
}

private Type DeriveTxType(JsonObject untyped, JsonSerializerOptions options, out bool isDefaulted)
{
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<TxType?>(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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading
Loading