fix: price public simulation and wallet fee quotes at the same slot - #25357
Draft
spalladino wants to merge 3 commits into
Draft
fix: price public simulation and wallet fee quotes at the same slot#25357spalladino wants to merge 3 commits into
spalladino wants to merge 3 commits into
Conversation
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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: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 asmax(slot of the latest checkpoint on L1 + 1, next slot by the node clock)— anchored to L1.The simulator (
NodePublicCallsSimulator.computeTargetSlot) pickedmax(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):
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:
computeTargetSlotgets a third term in itsmax: 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.getPredictedMinFeesnow 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.getCurrentMinFeesand 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 realPublicProcessoron 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).getPredictedMinFees/getCurrentMinFees/isValidTxbehaviour, and tips-cache consistency tests in the archiver.RollupCheatCodes.setPendingCheckpointextracted fromfee_predictor.test.tsfor reuse.