Skip to content

[BUG] Simulation validates maxFeesPerGas against a stale slot's gasFees, rejecting correctly-priced transactions while the fee is moving #25344

Description

@shiqicao

What are you trying to do?

Run a test suite against a freshly started sandbox. Simulations are rejected with

maxFeesPerGas.feePerL2Gas must be greater than or equal to gasFees.feePerL2Gas,
but got maxFeesPerGas.feePerL2Gas=1058030306 and gasFees.feePerL2Gas=3415500000

even though the wallet priced the transaction correctly at the moment it read the fee.

The two sides read different slots.

Wallet sideFeeProvider.computeCurrentMinFees() deliberately prices the next slot:

const earliestTimestamp = await this.rollupContract.getTimestampForSlot(lastCheckpoint.slotNumber + 1n);
const nextEthTimestamp = getNextL1SlotTimestamp(...);
const timestamp = earliestTimestamp > nextEthTimestamp ? earliestTimestamp : nextEthTimestamp;
return new GasFees(0, await this.rollupContract.getManaMinFeeAt(timestamp, true));

getPredictedMinFees returns [currentMinFees, ...predicted], and BaseWallet.completeFeeOptions
declares max(...) * (1 + minFeePadding) with minFeePadding = 0.5.

Node side — when a simulation is not at a checkpoint boundary,
NodePublicCallsSimulator copies the globals of a block that has already been proposed:

const { globalVariables: newGlobalVariables, targetCheckpoint } = atCheckpointBoundary
  ? await this.buildGlobalVariablesForNewCheckpoint(...)   // fresh globals for the next slot
  : { globalVariables: await this.copyGlobalVariablesFromLatestProposedBlock(...) };

// copyGlobalVariablesFromLatestProposedBlock:
return GlobalVariables.from({ ...latestBlockData.header.globalVariables, blockNumber });

Where the assert fires

Those stale globals are threaded straight through to the assert:

AztecNodeService.simulatePublicCalls
  -> NodePublicCallsSimulator.simulate
       globals = copyGlobalVariablesFromLatestProposedBlock(...)   // node_public_calls_simulator.ts:157,281
  -> PublicTxSimulator (constructed with those globals)            // public_tx_simulator.ts:88
  -> PublicTxContext.create(..., this.globalVariables, ...)        // public_tx_simulator.ts:117-121
  -> PublicTxContext.generateAvmCircuitPublicInputs()              // public_tx_context.ts:309
       computeEffectiveGasFees(this.globalVariables.gasFees, this.gasSettings)   // public_tx_context.ts:396
  -> assert(maxFeesPerGas.feePerL2Gas >= gasFees.feePerL2Gas)      // stdlib/src/fees/transaction_fee.ts:21

this.globalVariables.gasFees is the copied, already-proposed slot's fee; this.gasSettings carries
the wallet's next-slot price. The assert compares the two directly.

There is a second site on the same globalVariables, reached only when a transaction has a teardown
phase: PublicTxContext.getTransactionFeeUnsafe() (public_tx_context.ts:293) calls
computeTransactionFee(this.globalVariables.gasFees, ...), which calls computeEffectiveGasFees
internally. Our failing case is a plain public #[view] read with no teardown, so it fires at the
generateAvmCircuitPublicInputs site.

The surfaced symptom is a rejected simulation, logged by the node as:

WARN: simulator:public-processor  Failed to process tx 0x1814...: maxFeesPerGas.feePerL2Gas ...
WARN: node:public-calls-simulator Simulated tx 0x1814... fails: AssertionError

While the mana min fee is falling, the older slot is the dearer one, so a correctly priced
transaction is rejected. minFeePadding only covers a 1.5x fall.

Code Reference

Reproduced against a local sandbox. Start it without deploying anything first, so the tests run
while the L1 base fee is still decaying — this is what CI does:

anvil --silent --port 8545 &
aztec start --local-network &
# as soon as the node is up, run a suite that performs several simulations

Sampling getCurrentMinFees() and getPredictedMinFees(Limit) once a second through the run
(declared = max(predicted) * 1.5, i.e. what the wallet puts on the transaction):

t current declared current/declared
0.1s 75,696,000,000 128,926,212,123 0.587
30.9s 3,415,500,000 5,817,272,728 0.587
39.0s 621,200,000 1,058,030,305 0.587
48.4s 172,600,000 293,939,397 0.587
58.8s 10,200,000 17,272,731 0.591

The failure quoted above is maxFeesPerGas=1058030306 against gasFees=3415500000 — the declared
value from t=39.0s compared against the fee from t=30.9s, roughly 8 seconds and 5.5x apart.

Two things worth noting from the trace:

  1. current / declared is 0.587 at all 90 samples. The declared cap is always ~1.7x the fee
    current at read time, so there is no instant at which a freshly read value would be rejected.
    The wallet is not underpricing; the comparison is against a different slot.
  2. The fee is falling (75.7e9 to 10.2e6, ~7,400x in 59s) as anvil's L1 base fee decays. Congestion
    is not involved — the mana min fee is dominated by fees.baseFee in
    FeeLib.getManaMinFeeComponentsAt.

No flag avoids it: skipTxValidation, skipFeeEnforcement, and both together were each tested and
all leave computeEffectiveGasFees in force. Declaring an unreachable maxFeesPerGas (2^70) is the
only client-side workaround, which is not something a real caller can do.

Aztec Version

5.0.1

OS

Linux

Node Version

v24.18.0

Additional Context

This does not affect a node that has been running for a while. Once the L1 base fee has decayed,
the mana min fee sits on its floor (the getProvingCostPerMana() term, the only component not
scaled by fees.baseFee) and stops moving — 10,200,000 in the trace above. Both slots then price
identically and the assertion always holds. It only bites while the fee is moving quickly, which in
practice means the first minute or so after a fresh anvil starts, since anvil begins at 1 gwei and
sheds ~12.5% per empty block.

That makes it very easy to miss locally, where a sandbox is typically started once and used for a
while, and easy to hit in CI, where each job gets a brand-new chain and starts testing immediately.
It presents as an intermittent failure — we measured roughly 40% of runs on identical commits —
because it depends on whether a given simulation lands mid-checkpoint while the fee is still
dropping.

The atCheckpointBoundary branch already does the right thing: it builds fresh globals for the slot
the next block will land in, which is the slot the wallet priced. The mismatch is only in the
copy-from-latest-proposed-block branch.


Originating prompt:

open a bug against aztec, also mention this bug won't affect if the node runs a while

🤖 Generated with Claude Code

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions