Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -712,8 +712,11 @@ public void Warmup_does_not_update_SpentGas()
Assert.That(tx.SpentGas, Is.EqualTo(sentinel), "Warmup must not modify tx.SpentGas");
}

// Warmup runs in a throwaway scope with real fee and nonce semantics: a same-sender
// successor must see the bumped nonce and debited balance or it warms the wrong state
// (a deploy chain would compute wrong CREATE addresses).
[Test]
public void Warmup_does_not_modify_sender_nonce()
public void Warmup_increments_sender_nonce_in_the_warm_scope()
{
Transaction tx = Build.A.Transaction.SignedAndResolved(_ethereumEcdsa, TestItem.PrivateKeyA, eip155Enabled)
.WithGasLimit(100000).TestObject;
Expand All @@ -723,11 +726,11 @@ public void Warmup_does_not_modify_sender_nonce()

_transactionProcessor.Warmup(tx, new BlockExecutionContext(block.Header, _specProvider.GetSpec(block.Header)), NullTxTracer.Instance);

Assert.That(_stateProvider.GetNonce(TestItem.AddressA), Is.EqualTo(nonceBefore), "Warmup must not increment sender nonce");
Assert.That(_stateProvider.GetNonce(TestItem.AddressA), Is.EqualTo(nonceBefore + 1), "Warmup must bump the nonce so same-sender successors warm the right state");
}

[Test]
public void Warmup_does_not_deduct_sender_balance()
public void Warmup_deducts_sender_balance_in_the_warm_scope()
{
Transaction tx = Build.A.Transaction.SignedAndResolved(_ethereumEcdsa, TestItem.PrivateKeyA, eip155Enabled)
.WithGasLimit(100000).TestObject;
Expand All @@ -737,7 +740,7 @@ public void Warmup_does_not_deduct_sender_balance()

_transactionProcessor.Warmup(tx, new BlockExecutionContext(block.Header, _specProvider.GetSpec(block.Header)), NullTxTracer.Instance);

Assert.That(_stateProvider.GetBalance(TestItem.AddressA), Is.EqualTo(balanceBefore), "Warmup must not deduct sender balance (should use SystemTransactionProcessor path)");
Assert.That(_stateProvider.GetBalance(TestItem.AddressA), Is.LessThan(balanceBefore), "Warmup must debit gas so same-sender successors warm the right state");
}

}
158 changes: 158 additions & 0 deletions src/Nethermind/Nethermind.Evm.Test/TransactionProcessorWarmupTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited
// SPDX-License-Identifier: LGPL-3.0-only

using System;
using Nethermind.Blockchain;
using Nethermind.Core;
using Nethermind.Core.Extensions;
using Nethermind.Core.Specs;
using Nethermind.Core.Test;
using Nethermind.Core.Test.Builders;
using Nethermind.Crypto;
using Nethermind.Evm.State;
using Nethermind.Evm.Tracing;
using Nethermind.Evm.TransactionProcessing;
using Nethermind.Int256;
using Nethermind.Logging;
using Nethermind.Specs;
using Nethermind.Specs.Forks;
using NUnit.Framework;

namespace Nethermind.Evm.Test;

public class TransactionProcessorWarmupTests
{
private ISpecProvider _specProvider = null!;
private IEthereumEcdsa _ethereumEcdsa = null!;
private ITransactionProcessor _transactionProcessor = null!;
private IWorldState _stateProvider = null!;
private IDisposable _worldStateCloser = null!;

[SetUp]
public void Setup()
{
_specProvider = new TestSpecProvider(Prague.Instance);
_stateProvider = TestWorldStateFactory.CreateForTest();
_worldStateCloser = _stateProvider.BeginScope(IWorldState.PreGenesis);
EthereumCodeInfoRepository codeInfoRepository = new(_stateProvider);
EthereumVirtualMachine virtualMachine = new(new TestBlockhashProvider(_specProvider), _specProvider, LimboLogs.Instance);
_transactionProcessor = new EthereumTransactionProcessor(BlobBaseFeeCalculator.Instance, _specProvider, _stateProvider, virtualMachine, codeInfoRepository, LimboLogs.Instance);
_ethereumEcdsa = new EthereumEcdsa(_specProvider.ChainId);
}

[TearDown]
public void TearDown() => _worldStateCloser?.Dispose();

// Warmup must take the real execution path: no-op fee/nonce handling made same-sender
// warm sequences run with undebited balances and unbumped nonces, so deploy chains
// computed wrong CREATE addresses and warmed the wrong state.
[Test]
public void Warmup_InTheThrowawayScope_DebitsFeesAndBumpsTheNonce()
{
_stateProvider.CreateAccount(TestItem.AddressA, 1.Ether);
_stateProvider.Commit(_specProvider.GenesisSpec);
_stateProvider.CommitTree(0);

Transaction tx = Build.A.Transaction
.WithGasPrice(1)
.WithMaxFeePerGas(1)
.WithTo(TestItem.AddressB)
.WithValue(100.GWei)
.WithGasLimit(100_000)
.SignedAndResolved(_ethereumEcdsa, TestItem.PrivateKeyA)
.TestObject;
Block block = Build.A.Block
.WithNumber(long.MaxValue)
.WithTimestamp(MainnetSpecProvider.PragueBlockTimestamp)
.WithTransactions(tx)
.WithGasLimit(10_000_000)
.TestObject;
UInt256 balanceBefore = _stateProvider.GetBalance(TestItem.AddressA);

_transactionProcessor.SetBlockExecutionContext(new BlockExecutionContext(block.Header, _specProvider.GetSpec(block.Header)));
TransactionResult result = _transactionProcessor.Warmup(tx, NullTxTracer.Instance);

Assert.That(result.TransactionExecuted, Is.True, "precondition: the warm execution ran");
Assert.That(_stateProvider.GetNonce(TestItem.AddressA), Is.EqualTo(1UL),
"a warm execution must bump the nonce so a same-sender successor warms the right state");
Assert.That(_stateProvider.GetBalance(TestItem.AddressA), Is.EqualTo(balanceBefore - 100.GWei - 21_000),
"a warm execution must debit value and gas so successors see real balances");
Assert.That(tx.BlockGasUsed, Is.EqualTo(100_000UL),
"warmup must never mutate the shared transaction object: the getter must still fall back to the gas limit");
}
Comment thread
svlachakis marked this conversation as resolved.

// A sender funded earlier in the block by another sender's transaction has no balance in
// the parent state; per-sender warm groups cannot see that funding, so the warm pass must
// execute best-effort instead of losing the sender's warming to the balance check.
[Test]
public void Warmup_ForASenderWithoutParentStateBalance_StillExecutes()
{
_stateProvider.CreateAccount(TestItem.AddressA, UInt256.Zero);
_stateProvider.Commit(_specProvider.GenesisSpec);
_stateProvider.CommitTree(0);

Transaction tx = Build.A.Transaction
.WithGasPrice(1)
.WithMaxFeePerGas(1)
.WithTo(TestItem.AddressB)
.WithGasLimit(100_000)
.SignedAndResolved(_ethereumEcdsa, TestItem.PrivateKeyA)
.TestObject;
Block block = Build.A.Block
.WithNumber(long.MaxValue)
.WithTimestamp(MainnetSpecProvider.PragueBlockTimestamp)
.WithTransactions(tx)
.WithGasLimit(10_000_000)
.TestObject;

_transactionProcessor.SetBlockExecutionContext(new BlockExecutionContext(block.Header, _specProvider.GetSpec(block.Header)));
TransactionResult result = _transactionProcessor.Warmup(tx, NullTxTracer.Instance);

Assert.That(result.TransactionExecuted, Is.True,
"an underfunded warm sender must still warm its execution path");
Assert.That(_stateProvider.GetNonce(TestItem.AddressA), Is.EqualTo(1UL));
}

// The motivating bug: with no-op nonce handling, every deploy in a same-sender warm chain
// computed the same CREATE address and warmed the wrong storage. With real semantics each
// deploy must land where the real execution will put it.
[Test]
public void Warmup_ForASameSenderDeployChain_DeploysAtConsecutiveCreateAddresses()
{
_stateProvider.CreateAccount(TestItem.AddressA, 1.Ether);
_stateProvider.Commit(_specProvider.GenesisSpec);
_stateProvider.CommitTree(0);

Transaction firstDeploy = Build.A.Transaction
.WithGasPrice(1)
.WithMaxFeePerGas(1)
.WithTo(null)
.WithNonce(0)
.WithGasLimit(100_000)
.SignedAndResolved(_ethereumEcdsa, TestItem.PrivateKeyA)
.TestObject;
Transaction secondDeploy = Build.A.Transaction
.WithGasPrice(1)
.WithMaxFeePerGas(1)
.WithTo(null)
.WithNonce(1)
.WithGasLimit(100_000)
.SignedAndResolved(_ethereumEcdsa, TestItem.PrivateKeyA)
.TestObject;
Block block = Build.A.Block
.WithNumber(long.MaxValue)
.WithTimestamp(MainnetSpecProvider.PragueBlockTimestamp)
.WithTransactions(firstDeploy, secondDeploy)
.WithGasLimit(10_000_000)
.TestObject;

_transactionProcessor.SetBlockExecutionContext(new BlockExecutionContext(block.Header, _specProvider.GetSpec(block.Header)));
_transactionProcessor.Warmup(firstDeploy, NullTxTracer.Instance);
_transactionProcessor.Warmup(secondDeploy, NullTxTracer.Instance);

Assert.That(_stateProvider.AccountExists(ContractAddress.From(TestItem.AddressA, 0)), Is.True,
"the first deploy must land at the nonce-0 CREATE address");
Assert.That(_stateProvider.AccountExists(ContractAddress.From(TestItem.AddressA, 1)), Is.True,
"the successor must observe the bumped nonce and deploy at the nonce-1 CREATE address");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,9 @@ public TransactionResult Trace(Transaction transaction, ITxTracer txTracer)
=> transactionProcessor.Process(transaction, txTracer, ExecutionOptions.SkipValidationAndCommit);

/// <summary>
/// Call transaction, no validations, don't commit state.
/// Will NOT charge gas from sender account.
/// Call transaction with real fee and nonce semantics but no validations, don't commit
/// state. Runs in a throwaway scope: charging gas and bumping nonces there keeps
/// same-sender sequences warming the state the real execution will touch.
Comment thread
svlachakis marked this conversation as resolved.
Outdated
/// </summary>
public TransactionResult Warmup(Transaction transaction, ITxTracer txTracer)
=> transactionProcessor.Process(transaction, txTracer, ExecutionOptions.Warmup | ExecutionOptions.SkipValidation);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,10 @@ protected virtual SystemTransactionProcessor<TGasPolicy> CreateSystemTransaction
private TransactionResult ExecuteCore(Transaction tx, ITxTracer tracer, ExecutionOptions opts)
{
if (Logger.IsTrace) Logger.Trace($"Executing tx {tx.Hash}");
if (tx.IsSystem() || (opts & ~ExecutionOptions.Warmup) == ExecutionOptions.SkipValidation)
// Warmup must take the real execution path: the system processor's no-op fee/nonce
Comment thread
svlachakis marked this conversation as resolved.
Outdated
// handling made same-sender warm sequences run with undebited balances and unbumped
// nonces, so deploy chains computed wrong CREATE addresses and warmed the wrong state.
if (tx.IsSystem() || opts == ExecutionOptions.SkipValidation)
{
return GetOrCreateSystemTransactionProcessor().Execute(tx, tracer, opts);
}
Expand Down Expand Up @@ -1149,6 +1152,16 @@ protected virtual TransactionResult BuyGas(Transaction tx, IReleaseSpec spec, IT

if (balance < balanceCheck)
{
// A warm sender may be funded earlier in the block by another sender's
// transaction, which per-sender warm groups cannot see; charge best-effort
// instead of losing that sender's warming entirely.
if (opts.HasFlag(ExecutionOptions.Warmup))
{
UInt256 warmCharge = UInt256.Min(senderReservedGasPayment, balance);
if (!warmCharge.IsZero) WorldState.SubtractFromBalance(tx.SenderAddress, warmCharge, spec);
return TransactionResult.Ok;
}

TraceLogInvalidTx(tx, $"INSUFFICIENT_SENDER_BALANCE: ({tx.SenderAddress})_BALANCE = {balance}");
return InsufficientFundsForGas(tx, balance, balanceCheck);
}
Expand Down Expand Up @@ -1605,7 +1618,18 @@ private bool TryChargeCodeDeposit(

protected virtual void PayValue(Transaction tx, IReleaseSpec spec, ExecutionOptions opts)
{
if (!tx.ValueRef.IsZero) WorldState.SubtractFromBalance(tx.SenderAddress!, in tx.ValueRef, spec);
if (tx.ValueRef.IsZero) return;

// Same best-effort rule as BuyGas: a warm sender funded earlier in the block has no
// parent-state balance to move, and failing here would abort its warming.
if (opts.HasFlag(ExecutionOptions.Warmup))
{
UInt256 charge = UInt256.Min(tx.Value, WorldState.GetBalance(tx.SenderAddress!));
if (!charge.IsZero) WorldState.SubtractFromBalance(tx.SenderAddress!, in charge, spec);
return;
}

WorldState.SubtractFromBalance(tx.SenderAddress!, in tx.ValueRef, spec);
}

protected virtual void PayFees(Transaction tx, BlockHeader header, IReleaseSpec spec, ITxTracer tracer, in TransactionSubstate substate, ulong spentGas, in UInt256 premiumPerGas, in UInt256 blobBaseFee, int statusCode)
Expand Down
Loading