Skip to content

fix: price public simulation and wallet fee quotes at the same slot - #25357

Draft
spalladino wants to merge 3 commits into
merge-train/spartan-v5from
spl/a-25344-fee-slot-floor
Draft

fix: price public simulation and wallet fee quotes at the same slot#25357
spalladino wants to merge 3 commits into
merge-train/spartan-v5from
spl/a-25344-fee-slot-floor

Conversation

@spalladino

Copy link
Copy Markdown
Contributor

Fixes #25344.

The symptom

On a fresh sandbox, a wallet asks the node what fee to pay, pads it by 50%, and sends the transaction to simulatePublicCalls. Sometimes the simulation rejects it:

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

The wallet paid what the node told it to pay, and the node's own simulation said it wasn't enough.

Background: where the fee number comes from

Both the wallet quote and the simulation ask the L1 rollup contract the same question: "what is the minimum mana fee for a block in slot X?" (Rollup.getManaMinFeeAt). The answer depends on an L1 gas oracle that is updated when checkpoints are proposed, and the new value only kicks in a couple of slots later. So the answer is a step function of the slot: for slots before the change it is one value, for slots from the change on it is another. While the sandbox's anvil base fee is decaying (first minutes after start), each step is a large drop — in the issue, 3,415,500,000 → 920,600,000.

Because the answer depends on the slot, the two sides only agree if they ask about the same slot. They didn't.

Bug 1: the simulator could target a slot that was already taken

The fee quote (FeeProviderImpl) picks its slot as max(slot of the latest checkpoint on L1 + 1, next slot by the node clock) — anchored to L1.

The simulator (NodePublicCallsSimulator.computeTargetSlot) picked max(next slot by the node clock + 1, slot of the locally proposed checkpoint + 1) — anchored to the node clock, and the second term disappears once the archiver promotes the proposed checkpoint to checkpointed.

Worked example (72s slots, oracle steps at slot 15):

  • The sandbox builds checkpoint 14, sends it to L1, anvil mines it. L1 now says: latest checkpoint is at slot 14. The node's clock has not been bumped yet — the automine sequencer only advances it at the very end of its publish routine.
  • Fee quote: latest L1 checkpoint is at slot 14, so the next block is slot 15 at the earliest → fee for slot 15 → cheap (post-step) → wallet declares 1.5× that.
  • Simulator: node clock says next slot is 13, plus one → slot 14. No proposed checkpoint any more (already promoted). → fee for slot 14 → expensive (pre-step).
  • expensive > 1.5 × cheap → the assert fires.

Slot 14 is nonsense for the simulator to target: a checkpoint already exists there on L1. The next block can only land in slot 15 or later. The simulator didn't know because it trusted its clock over the chain.

Fix: computeTargetSlot gets a third term in its max: slot of the latest checkpointed checkpoint + 1. It never lowers a correct answer (when the clock is ahead, as it normally is, the clock term is already larger). It only binds when the clock is behind the chain, which is exactly the broken case. In the example: max(14, 15) = 15, same as the quote.

A related archiver race is closed too: the simulator reads the chain tips (cached) and the proposed checkpoint (straight from the store) in parallel, and a checkpoint promotion committing in between could pair stale tips with a post-promotion read, misclassifying "new checkpoint" as "mid-checkpoint". The tips cache is now repointed before the write commits (L2TipsCache.refreshAfter), and the simulator verifies its snapshot (re-reads tips, retries if they moved).

Bug 2: the quote ignored the fee frozen in an in-progress checkpoint

All blocks in a checkpoint share the same header fields, including gasFees. When a checkpoint is in progress (some blocks proposed, more to come), the next block must use the fee the checkpoint opened with. The simulator correctly does this — it copies the header of the latest proposed block. But the wallet quote only looked at the forward-looking L1 projection, which never includes that frozen value.

Example: a checkpoint opens at slot 22 with fee 3,415 (frozen for all its blocks). The oracle steps down; the quote for slot 23 is 920, padded to 1,380. The next block will be charged 3,415 — a correctly-priced transaction fails simulation. This cannot happen in the sandbox (one block per checkpoint) but can on any network with multi-block checkpoints when fees are falling fast.

Fix: AztecNode.getPredictedMinFees now returns [fee the simulation would charge right now, ...L1 projections]. Wallets already take the maximum of the list and pad it, so a quoted transaction now clears the simulation's fee check by construction, whether the difference comes from a lagging clock or a frozen checkpoint fee. getCurrentMinFees and tx admission (isValidTx, p2p validators) are unchanged and stay forward-looking, so a transaction that is affordable in the next checkpoint is still accepted into the pool.

This costs one extra L1 read per quote at a checkpoint boundary. It is deliberately not cached: the L1 fee for a fixed slot moves on a gas-oracle update or a proving-cost change without the L2 tips moving, so any cache keyed on chain state could serve exactly the stale quote this PR exists to prevent.

Tests

  • aztec-node/src/aztec-node/fee_quote_vs_simulation.integration.test.ts (new): real anvil, real L1 contracts, RollupContract, EpochCache, GlobalVariableBuilder, FeeProviderImpl, AztecNodeService/NodePublicCallsSimulator, and a real PublicProcessor on a real world-state fork; only the archiver is mocked. Steps the oracle 1000 gwei → 1 gwei (~1000× fee step), plants the L1 pending checkpoint at the slot before the step via storage cheats, and reproduces both bugs with the exact assertion from the issue before the fix (maxFeesPerGas.feePerL2Gas=614770000002 and gasFees.feePerL2Gas=360937510100000).
    • lagging node clock → simulator now targets the same slot as the quote;
    • mid-checkpoint frozen fee → the quote leads with the frozen fee;
    • nothing lagging → quote and simulation agree, prepended value equals the provider's.
  • Unit tests for the floor (including the by-hash lookup), the snapshot retry, getPredictedMinFees/getCurrentMinFees/isValidTx behaviour, and tips-cache consistency tests in the archiver.
  • RollupCheatCodes.setPendingCheckpoint extracted from fee_predictor.test.ts for reuse.

…ed fee in predicted min fees

A wallet quote and the node's public simulation both ask L1 for a mana min fee, but they could ask
about different slots, and the L1 gas oracle steps between slots. A tx priced against the quote then
failed its own simulation with `maxFeesPerGas.feePerL2Gas must be greater than or equal to
gasFees.feePerL2Gas`.

- Floor the simulator's target slot at the checkpointed tip's slot + 1. The next block can never land
  in a slot a checkpointed checkpoint already took, so this only binds when the node clock lags the
  chain — exactly the automine sandbox race between promoting a checkpoint and advancing the clock.
- Add `NodePublicCallsSimulator.getNextBlockMinFees()`, sharing one `resolveNextBlockGlobals()` helper
  with `simulate()`, and prepend it to `AztecNodeService.getPredictedMinFees`. Clients take the worst
  entry, so a quote now also covers the frozen fee an in-progress checkpoint would charge.
- Make the archiver's L2 tips cache pending before the writer transaction commits
  (`L2TipsCache.refreshAfter`), so a reader cannot pair stale tips with a post-commit proposed-checkpoint
  read and misclassify the next block as mid-checkpoint.
- Move the fee-predictor test's pending-checkpoint storage cheat into `RollupCheatCodes.setPendingCheckpoint`.
…lock

Follow-up to the fee-quote/simulation alignment, addressing review feedback.

- A reader holding an already-resolved tips promise could still observe a committed promotion in
  `getProposedCheckpointData` and misclassify the next block. `resolveNextBlockGlobals` now reads tips,
  then the proposed checkpoint, then tips again, retrying up to three times while the two tips reads
  disagree and throwing the retryable torn-snapshot error if they never settle. Because the tips cache
  is pointed at post-commit state before the write commits, a matching pair proves both halves came
  from the same chain state.
- Look the checkpointed tip block up by its hash rather than its number, so a checkpoint unwind cannot
  answer with a different block, and treat a miss as a torn snapshot instead of silently dropping the
  slot floor.
- Memoise `getNextBlockMinFees` for one L1 slot against the chain state it was derived from, so wallet
  quotes no longer hit L1 on every call. The archiver reads still happen every time, and `simulate`
  never reads the memo.
- `L2TipsCache.refreshAfter` returns the reload promise and every updater call site awaits it, so a
  failed post-commit reload surfaces to the writer instead of becoming an unhandled rejection.
Follow-up to the previous commit, addressing review feedback.

- Drop the memo from `getNextBlockMinFees`. Its key was built from L2 tips and the target slot, but the
  L1 mana min fee for a fixed slot also moves on a gas-oracle update, a proving-cost change, or an L1
  reorg, none of which move the tips. A stale entry would quote a fee below what the simulation charges,
  which is the failure this method exists to prevent. The `planNextBlock` / `buildGlobalVariablesForPlan`
  split stays; only the cache is gone.
- Compare the checkpointed checkpoint's hash in `haveSameTips`, so a checkpoint replaced at the same
  number no longer passes the snapshot consistency check.
- Reword the `planNextBlock` ordering invariant: the double read is what makes the pair trustworthy for
  any block source, and the standard archiver's tips cache strengthens that rather than the interface
  guaranteeing it.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci-draft Run CI on draft PRs.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant