11import type { AztecAddress } from '@aztec/aztec.js/addresses' ;
22import type { AztecNode } from '@aztec/aztec.js/node' ;
33import { CheatCodes } from '@aztec/aztec/testing' ;
4+ import type { BlockNumber } from '@aztec/foundation/branded-types' ;
45import { Fr } from '@aztec/foundation/curves/bn254' ;
56import { retryUntil } from '@aztec/foundation/retry' ;
67import { TestContract } from '@aztec/noir-test-contracts.js/Test' ;
7- import type { GasSettings } from '@aztec/stdlib/gas' ;
8+ import type { GasFees , GasSettings } from '@aztec/stdlib/gas' ;
89import { TX_ERROR_INSUFFICIENT_FEE_PER_GAS } from '@aztec/stdlib/tx' ;
910
11+ import { jest } from '@jest/globals' ;
1012import { inspect } from 'util' ;
1113
1214import type { TestWallet } from '../test-wallet/test_wallet.js' ;
@@ -20,13 +22,18 @@ describe('e2e_fees fee settings', () => {
2022 let wallet : TestWallet ;
2123 let gasSettings : Partial < GasSettings > ;
2224 let testContract : TestContract ;
25+ let testContractDeployBlock : BlockNumber ;
2326 const t = new FeesTest ( 'fee_juice' , 1 ) ;
2427
2528 beforeAll ( async ( ) => {
2629 await t . setup ( ) ;
2730 ( { aliceAddress, wallet, gasSettings, cheatCodes, aztecNode } = t ) ;
2831
29- ( { contract : testContract } = await TestContract . deploy ( wallet ) . send ( { from : aliceAddress } ) ) ;
32+ const deployedTestContract = await TestContract . deploy ( wallet ) . send ( {
33+ from : aliceAddress ,
34+ } ) ;
35+ testContract = deployedTestContract . contract ;
36+ testContractDeployBlock = deployedTestContract . receipt . blockNumber ! ;
3037 gasSettings = { ...gasSettings , maxFeesPerGas : undefined } ;
3138 } ) ;
3239
@@ -39,21 +46,39 @@ describe('e2e_fees fee settings', () => {
3946 const before = await aztecNode . getCurrentMinFees ( ) ;
4047 t . logger . info ( `Initial L2 min fees are ${ inspect ( before ) } ` , { minFees : before . toInspect ( ) } ) ;
4148 await cheatCodes . rollup . bumpProvingCostPerMana ( current => ( current * 120n ) / 100n ) ;
42- await retryUntil (
49+ return await retryUntil (
4350 async ( ) => {
4451 const after = await aztecNode . getCurrentMinFees ( ) ;
4552 t . logger . info ( `L2 min fees are now ${ inspect ( after ) } ` , {
4653 minFeesBefore : before . toInspect ( ) ,
4754 minFeesAfter : after . toInspect ( ) ,
4855 } ) ;
49- return after . feePerL2Gas > before . feePerL2Gas ;
56+ return after . feePerL2Gas > before . feePerL2Gas ? after : undefined ;
5057 } ,
5158 'L2 min fee increase' ,
5259 5 ,
5360 1 ,
5461 ) ;
5562 } ;
5663
64+ // Pick a baseline from the post-checkpoint chain state. The prove step itself is
65+ // made deterministic by prepareTxsWithMockedMinFees below.
66+ const getCurrentMinFeesAfterCheckpoint = async ( checkpointedBlock : BlockNumber ) => {
67+ return await retryUntil (
68+ async ( ) => {
69+ const currentCheckpointedBlock = await aztecNode . getCheckpointedBlockNumber ( ) ;
70+ if ( currentCheckpointedBlock < checkpointedBlock ) {
71+ return undefined ;
72+ }
73+
74+ return await aztecNode . getCurrentMinFees ( ) ;
75+ } ,
76+ `L2 min fees after block ${ checkpointedBlock } is checkpointed` ,
77+ 60 ,
78+ 1 ,
79+ ) ;
80+ } ;
81+
5782 const proveTx = async ( minFeePadding : number | undefined ) => {
5883 t . logger . info ( `Preparing tx to be sent with min fee padding ${ minFeePadding } ` ) ;
5984 wallet . setMinFeePadding ( minFeePadding ) ;
@@ -66,18 +91,61 @@ describe('e2e_fees fee settings', () => {
6691 return tx ;
6792 } ;
6893
94+ const prepareTxsWithMockedMinFees = async ( noPaddingMinFees : GasFees , defaultPaddingMinFees : GasFees ) => {
95+ const getCurrentMinFeesSpy = jest
96+ . spyOn ( aztecNode , 'getCurrentMinFees' )
97+ . mockResolvedValueOnce ( noPaddingMinFees )
98+ . mockResolvedValueOnce ( defaultPaddingMinFees ) ;
99+
100+ try {
101+ const txWithNoPadding = await proveTx ( 0 ) ;
102+ const txWithDefaultPadding = await proveTx ( undefined ) ;
103+ return { txWithNoPadding, txWithDefaultPadding } ;
104+ } finally {
105+ getCurrentMinFeesSpy . mockRestore ( ) ;
106+ }
107+ } ;
108+
69109 it ( 'handles min fee spikes with default padding' , async ( ) => {
70- // Prepare two txs using the current L2 min fees: one with no padding and one with default padding
71- const txWithNoPadding = await proveTx ( 0 ) ;
72- const txWithDefaultPadding = await proveTx ( undefined ) ;
110+ const stableMinFees = await getCurrentMinFeesAfterCheckpoint ( testContractDeployBlock ) ;
111+ const { txWithNoPadding, txWithDefaultPadding } = await prepareTxsWithMockedMinFees ( stableMinFees , stableMinFees ) ;
112+
113+ expect ( txWithNoPadding . data . constants . txContext . gasSettings . maxFeesPerGas . equals ( stableMinFees ) ) . toBe ( true ) ;
114+ expect (
115+ txWithDefaultPadding . data . constants . txContext . gasSettings . maxFeesPerGas . equals ( stableMinFees . mul ( 1.5 ) ) ,
116+ ) . toBe ( true ) ;
73117
74118 // Now bump the L2 fees before we actually send them
75- await bumpL2Fees ( ) ;
119+ const bumpedMinFees = await bumpL2Fees ( ) ;
120+ expect ( stableMinFees . feePerL2Gas ) . toBeLessThan ( bumpedMinFees . feePerL2Gas ) ;
121+ expect ( stableMinFees . mul ( 1.5 ) . feePerL2Gas ) . toBeGreaterThan ( bumpedMinFees . feePerL2Gas ) ;
76122
77123 // And check that the no-padding does not get mined, but the default padding is good enough
78- t . logger . info ( `Sendings txs` ) ;
124+ t . logger . info ( `Sending txs` ) ;
79125 await expect ( txWithNoPadding . send ( ) ) . rejects . toThrow ( TX_ERROR_INSUFFICIENT_FEE_PER_GAS ) ;
80126 await expect ( txWithDefaultPadding . send ( ) ) . resolves . toBeDefined ( ) ;
81127 } ) ;
128+
129+ it ( 'reproduces the stale fee snapshot race deterministically' , async ( ) => {
130+ const lowerMinFees = await getCurrentMinFeesAfterCheckpoint ( testContractDeployBlock ) ;
131+ const higherMinFees = lowerMinFees . mul ( 2 ) ;
132+
133+ const { txWithNoPadding, txWithDefaultPadding } = await prepareTxsWithMockedMinFees ( higherMinFees , lowerMinFees ) ;
134+
135+ expect ( txWithNoPadding . data . constants . txContext . gasSettings . maxFeesPerGas . equals ( higherMinFees ) ) . toBe ( true ) ;
136+ expect (
137+ txWithDefaultPadding . data . constants . txContext . gasSettings . maxFeesPerGas . equals ( lowerMinFees . mul ( 1.5 ) ) ,
138+ ) . toBe ( true ) ;
139+
140+ const bumpedMinFees = await bumpL2Fees ( ) ;
141+ expect ( lowerMinFees . feePerL2Gas ) . toBeLessThan ( bumpedMinFees . feePerL2Gas ) ;
142+ expect ( higherMinFees . feePerL2Gas ) . toBeGreaterThan ( bumpedMinFees . feePerL2Gas ) ;
143+ expect ( lowerMinFees . mul ( 1.5 ) . feePerL2Gas ) . toBeGreaterThan ( bumpedMinFees . feePerL2Gas ) ;
144+
145+ // This is the original flake: the "no padding" tx only succeeds because it was
146+ // accidentally prepared against an earlier, higher fee snapshot than the padded tx.
147+ await expect ( txWithNoPadding . send ( ) ) . resolves . toBeDefined ( ) ;
148+ await expect ( txWithDefaultPadding . send ( ) ) . resolves . toBeDefined ( ) ;
149+ } ) ;
82150 } ) ;
83151} ) ;
0 commit comments