Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
3091707
feat(node): add fee prediction API for upcoming L2 slots
spalladino Mar 27, 2026
80bb627
Merge remote-tracking branch 'origin/next' into palla/fee-prediction
spalladino Apr 7, 2026
4425ae5
fix(wallet-sdk): use Limit mana estimate for conservative fee prediction
spalladino Apr 7, 2026
8d872f8
fix(wallet-sdk): narrow fee prediction fallback to method-not-found e…
spalladino Apr 7, 2026
7d7975b
fix(ethereum): add getEffectivePendingCheckpoint to handle prune scen…
spalladino Apr 7, 2026
2e8fc2c
test(sequencer-client): add roll-forward regression test for fee pred…
spalladino Apr 7, 2026
17446ed
fix(sequencer-client): decay ethPerFeeAsset per slot for conservative…
spalladino Apr 7, 2026
5f7e700
fix(sequencer-client): reduce prediction window from LAG+1 to LAG ent…
spalladino Apr 7, 2026
cbb04c3
fix(txe): return FEE_ORACLE_LAG entries from TXEFeeProvider
spalladino Apr 7, 2026
192de2f
fix(cli-wallet): use predicted fees instead of current fees
spalladino Apr 7, 2026
24d1871
fix(ethereum): remove duplicate compressFeeHeader method
spalladino Apr 7, 2026
5b222d5
refactor(stdlib): extract FeeProvider interface from GlobalVariableBu…
spalladino Apr 7, 2026
6feb805
fix(sequencer-client): account for next L1 block slot in fee predicti…
spalladino Apr 7, 2026
320d19b
refactor(sequencer-client): move FeeProviderImpl to separate file
spalladino Apr 7, 2026
bb44515
Merge remote-tracking branch 'origin/merge-train/spartan' into palla/…
spalladino Apr 8, 2026
3b165d6
feat(wallet-sdk): add congestionEstimate option to fee prediction
spalladino Apr 8, 2026
d178979
fix(sequencer-client): clamp ethPerFeeAsset decay to MIN_ETH_PER_FEE_…
spalladino Apr 8, 2026
a610b4f
refactor: use named opts for fee predictor ctor args
spalladino Apr 8, 2026
38ece88
chore: fix tests
spalladino Apr 8, 2026
d9aabdf
Merge remote-tracking branch 'origin/merge-train/spartan' into palla/…
spalladino Apr 10, 2026
2520c6e
fix: add missing feeProvider arg to AztecNodeService constructor in test
spalladino Apr 10, 2026
66825b1
feat(bot): use predicted min fees instead of current min fees in factory
spalladino Apr 10, 2026
24ed743
fix: pin fee predictor queries to L1 block and evaluate prune at pred…
spalladino Apr 10, 2026
e5270dc
fix: mock getPredictedMinFees in fee_settings e2e test
spalladino Apr 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion yarn-project/aztec-node/src/aztec-node/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import type { L2LogsSource, MerkleTreeReadOperations, WorldStateSynchronizer } f
import type { L1ToL2MessageSource } from '@aztec/stdlib/messaging';
import { mockTx } from '@aztec/stdlib/testing';
import { MerkleTreeId, PublicDataTreeLeaf, PublicDataTreeLeafPreimage } from '@aztec/stdlib/trees';
import type { FeeProvider } from '@aztec/stdlib/tx';
import {
BlockHeader,
GlobalVariables,
Expand Down Expand Up @@ -73,6 +74,7 @@ class TestAztecNodeService extends AztecNodeService {
describe('aztec node', () => {
let p2p: MockProxy<P2P>;
let globalVariablesBuilder: MockProxy<GlobalVariableBuilder>;
let feeProvider: MockProxy<FeeProvider>;
let merkleTreeOps: MockProxy<MerkleTreeReadOperations>;
let worldState: MockProxy<WorldStateSynchronizer>;
let l2BlockSource: MockProxy<L2BlockSource>;
Expand Down Expand Up @@ -108,7 +110,8 @@ describe('aztec node', () => {
p2p = mock<P2P>();

globalVariablesBuilder = mock<GlobalVariableBuilder>();
globalVariablesBuilder.getCurrentMinFees.mockResolvedValue(new GasFees(0, BlockNumber.ZERO));
feeProvider = mock<FeeProvider>();
feeProvider.getCurrentMinFees.mockResolvedValue(new GasFees(0, BlockNumber.ZERO));

merkleTreeOps = mock<MerkleTreeReadOperations>();
merkleTreeOps.findLeafIndices.mockImplementation((treeId: MerkleTreeId, _value: any[]) => {
Expand Down Expand Up @@ -196,6 +199,7 @@ describe('aztec node', () => {
12345,
rollupVersion.toNumber(),
globalVariablesBuilder,
feeProvider,
epochCache,
getPackageVersion() ?? '',
new TestCircuitVerifier(),
Expand Down Expand Up @@ -738,6 +742,7 @@ describe('aztec node', () => {
12345,
rollupVersion.toNumber(),
globalVariablesBuilder,
feeProvider,
epochCache,
getPackageVersion() ?? '',
new TestCircuitVerifier(),
Expand Down Expand Up @@ -927,6 +932,7 @@ describe('aztec node', () => {
12345,
rollupVersion.toNumber(),
globalVariablesBuilder,
feeProvider,
epochCache,
getPackageVersion() ?? '',
new TestCircuitVerifier(),
Expand Down Expand Up @@ -997,6 +1003,7 @@ describe('aztec node', () => {
12345,
rollupVersion.toNumber(),
globalVariablesBuilder,
mock<FeeProvider>(),
epochCache,
getPackageVersion() ?? '',
new TestCircuitVerifier(),
Expand Down
30 changes: 21 additions & 9 deletions yarn-project/aztec-node/src/aztec-node/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,12 @@ import {
import { ProtocolContractAddress } from '@aztec/protocol-contracts';
import { type ProverNode, type ProverNodeDeps, createProverNode } from '@aztec/prover-node';
import { createKeyStoreForProver } from '@aztec/prover-node/config';
import { GlobalVariableBuilder, SequencerClient, type SequencerPublisher } from '@aztec/sequencer-client';
import {
FeeProviderImpl,
GlobalVariableBuilder,
SequencerClient,
type SequencerPublisher,
} from '@aztec/sequencer-client';
import { PublicProcessorFactory } from '@aztec/simulator/server';
import {
AttestationsBlockWatcher,
Expand All @@ -60,7 +65,7 @@ import type {
NodeInfo,
ProtocolContractAddresses,
} from '@aztec/stdlib/contract';
import { GasFees } from '@aztec/stdlib/gas';
import { GasFees, type ManaUsageEstimate } from '@aztec/stdlib/gas';
import { computePublicDataTreeLeafSlot } from '@aztec/stdlib/hash';
import {
type AztecNode,
Expand Down Expand Up @@ -88,6 +93,7 @@ import type { NullifierLeafPreimage, PublicDataTreeLeafPreimage } from '@aztec/s
import { MerkleTreeId, NullifierMembershipWitness, PublicDataWitness } from '@aztec/stdlib/trees';
import {
type BlockHeader,
type FeeProvider,
type GlobalVariableBuilder as GlobalVariableBuilderInterface,
type IndexedTxEffect,
PublicSimulationOutput,
Expand Down Expand Up @@ -154,6 +160,7 @@ export class AztecNodeService implements AztecNode, AztecNodeAdmin, AztecNodeDeb
protected readonly l1ChainId: number,
protected readonly version: number,
protected readonly globalVariableBuilder: GlobalVariableBuilderInterface,
protected readonly feeProvider: FeeProvider,
protected readonly epochCache: EpochCacheInterface,
protected readonly packageVersion: string,
private peerProofVerifier: ClientProtocolCircuitVerifier,
Expand Down Expand Up @@ -475,13 +482,16 @@ export class AztecNodeService implements AztecNode, AztecNodeAdmin, AztecNodeDeb
})
.catch(err => log.error('Failed to start p2p services after archiver sync', err));

const globalVariableBuilder = new GlobalVariableBuilder(dateProvider, publicClient, {
const globalVariableBuilderConfig = {
l1Contracts: config.l1Contracts,
ethereumSlotDuration: config.ethereumSlotDuration,
rollupVersion: BigInt(config.rollupVersion),
l1GenesisTime,
slotDuration: Number(slotDuration),
});
};

const globalVariableBuilder = new GlobalVariableBuilder(dateProvider, publicClient, globalVariableBuilderConfig);
const feeProvider = new FeeProviderImpl(dateProvider, publicClient, globalVariableBuilderConfig);

// Validator enabled, create/start relevant service
let sequencer: SequencerClient | undefined;
Expand Down Expand Up @@ -609,6 +619,7 @@ export class AztecNodeService implements AztecNode, AztecNodeAdmin, AztecNodeDeb
ethereumChain.chainInfo.id,
config.rollupVersion,
globalVariableBuilder,
feeProvider,
epochCache,
packageVersion,
peerProofVerifier,
Expand Down Expand Up @@ -761,12 +772,13 @@ export class AztecNodeService implements AztecNode, AztecNodeAdmin, AztecNodeDeb
return this.blockSource.getCheckpointsDataForEpoch(epochNumber);
}

/**
* Method to fetch the current min L2 fees.
* @returns The current min L2 fees.
*/
public async getCurrentMinFees(): Promise<GasFees> {
return await this.globalVariableBuilder.getCurrentMinFees();
return await this.feeProvider.getCurrentMinFees();
}

/** Returns predicted min fees for the current slot and next N slots. */
public async getPredictedMinFees(manaUsage?: ManaUsageEstimate): Promise<GasFees[]> {
return await this.feeProvider.getPredictedMinFees(manaUsage);
}

public async getMaxPriorityFees(): Promise<GasFees> {
Expand Down
12 changes: 11 additions & 1 deletion yarn-project/aztec.js/src/contract/interaction_options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { Fr } from '@aztec/foundation/curves/bn254';
import type { FieldsOf } from '@aztec/foundation/types';
import type { AuthWitness } from '@aztec/stdlib/auth-witness';
import { AztecAddress } from '@aztec/stdlib/aztec-address';
import type { GasSettings } from '@aztec/stdlib/gas';
import type { GasSettings, ManaUsageEstimate } from '@aztec/stdlib/gas';
import {
type Capsule,
OFFCHAIN_MESSAGE_IDENTIFIER,
Expand Down Expand Up @@ -42,6 +42,14 @@ export type FeePaymentMethodOption = {
export type GasSettingsOption = {
/** The gas settings */
gasSettings?: Partial<FieldsOf<GasSettings>>;
/**
* Assumed network congestion level for fee prediction. Controls how aggressively the wallet
* estimates future fees: None assumes empty blocks, Target assumes steady-state usage,
* and Limit assumes blocks at maximum capacity. Higher estimates produce higher fee predictions,
* reducing the risk of underpriced transactions during congestion spikes.
* Defaults to Limit (worst case) when not specified.
*/
congestionEstimate?: ManaUsageEstimate;
};

/** Fee options as set by a user. */
Expand Down Expand Up @@ -253,6 +261,7 @@ export function toSendOptions<W extends InteractionWaitOptions = undefined>(
...options.fee?.paymentMethod?.getGasSettings(),
...options.fee?.gasSettings,
},
congestionEstimate: options.fee?.congestionEstimate,
},
wait: options.wait, // Pass through wait option
};
Expand All @@ -273,6 +282,7 @@ export function toSimulateOptions(options: SimulateInteractionOptions): Simulate
...options.fee?.paymentMethod?.getGasSettings(),
...options.fee?.gasSettings,
},
congestionEstimate: options.fee?.congestionEstimate,
estimateGas: options.fee?.estimateGas,
estimatedGasPadding: options.fee?.estimatedGasPadding,
},
Expand Down
3 changes: 2 additions & 1 deletion yarn-project/aztec.js/src/wallet/wallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
import { AuthWitness } from '@aztec/stdlib/auth-witness';
import type { AztecAddress } from '@aztec/stdlib/aztec-address';
import { type ContractInstanceWithAddress, ContractInstanceWithAddressSchema } from '@aztec/stdlib/contract';
import { Gas } from '@aztec/stdlib/gas';
import { Gas, ManaUsageEstimate } from '@aztec/stdlib/gas';
import { LogId } from '@aztec/stdlib/logs';
import { AbiDecodedSchema, type ApiSchemaFor, optional, schemas, zodFor } from '@aztec/stdlib/schemas';
import type { ExecutionPayload, InTx } from '@aztec/stdlib/tx';
Expand Down Expand Up @@ -300,6 +300,7 @@ export const GasSettingsOptionSchema = z.object({
maxPriorityFeePerGas: optional(z.object({ feePerDaGas: schemas.BigInt, feePerL2Gas: schemas.BigInt })),
}),
),
congestionEstimate: optional(z.nativeEnum(ManaUsageEstimate)),
});

export const WalletSimulationFeeOptionSchema = GasSettingsOptionSchema.extend({
Expand Down
19 changes: 16 additions & 3 deletions yarn-project/bot/src/factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ import { PrivateTokenContract } from '@aztec/noir-contracts.js/PrivateToken';
import { TokenContract } from '@aztec/noir-contracts.js/Token';
import { TestContract } from '@aztec/noir-test-contracts.js/Test';
import type { ContractInstanceWithAddress } from '@aztec/stdlib/contract';
import { GasFees, GasSettings } from '@aztec/stdlib/gas';
import { GasFees, GasSettings, ManaUsageEstimate } from '@aztec/stdlib/gas';
import type { AztecNode, AztecNodeAdmin } from '@aztec/stdlib/interfaces/client';
import { deriveSigningKey } from '@aztec/stdlib/keys';
import { EmbeddedWallet } from '@aztec/wallets/embedded';
Expand Down Expand Up @@ -531,7 +531,7 @@ export class BotFactory {
const claim = await this.getOrCreateBridgeClaim(sender!);
const paymentMethod = new FeeJuicePaymentMethodWithClaim(sender!, claim);
const { estimatedGas } = await deploy.simulate({ ...deployOpts, fee: { estimateGas: true, paymentMethod } });
const maxFeesPerGas = (await this.aztecNode.getCurrentMinFees()).mul(1 + this.config.minFeePadding);
const maxFeesPerGas = (await this.getMinFees()).mul(1 + this.config.minFeePadding);
const gasSettings = GasSettings.from({
...estimatedGas!,
maxFeesPerGas,
Expand Down Expand Up @@ -589,7 +589,7 @@ export class BotFactory {
this.log.info(
`Fee juice balance ${balance} below threshold ${FEE_JUICE_TOP_UP_THRESHOLD}, bridging from L1 until ${FEE_JUICE_TOP_UP_TARGET}`,
);
const maxFeesPerGas = (await this.aztecNode.getCurrentMinFees()).mul(1 + this.config.minFeePadding);
const maxFeesPerGas = (await this.getMinFees()).mul(1 + this.config.minFeePadding);
const minimalInteraction = isStandardTokenContract(token)
? token.methods.transfer_in_public(account, account, 0n, 0)
: token.methods.transfer(0n, account, account);
Expand Down Expand Up @@ -729,6 +729,19 @@ export class BotFactory {
return claim as L2AmountClaim;
}

/** Returns worst-case min fees across predicted slots, with fallback to current min fees. */
private async getMinFees(): Promise<GasFees> {
try {
const predicted = await this.aztecNode.getPredictedMinFees(ManaUsageEstimate.Limit);
if (predicted.length === 0) {
return this.aztecNode.getCurrentMinFees();
}
return predicted.reduce((worst, fees) => (fees.feePerL2Gas > worst.feePerL2Gas ? fees : worst));
} catch {
return this.aztecNode.getCurrentMinFees();
}
}

private async withNoMinTxsPerBlock<T>(fn: () => Promise<T>): Promise<T> {
if (!this.aztecNodeAdmin || !this.config.flushSetupTransactions) {
this.log.verbose(`No node admin client or flushing not requested (not setting minTxsPerBlock to 0)`);
Expand Down
26 changes: 24 additions & 2 deletions yarn-project/cli-wallet/src/utils/options/fees.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { Fr } from '@aztec/foundation/curves/bn254';
import type { LogFn } from '@aztec/foundation/log';
import type { FieldsOf } from '@aztec/foundation/types';
import { AztecAddress } from '@aztec/stdlib/aztec-address';
import { Gas, GasFees, GasSettings } from '@aztec/stdlib/gas';
import { Gas, GasFees, GasSettings, ManaUsageEstimate } from '@aztec/stdlib/gas';
import type { FeeOptions } from '@aztec/wallet-sdk/base-wallet';

import { Option } from 'commander';
Expand Down Expand Up @@ -257,7 +257,8 @@ export class CLIFeeArgs {
) {}

async toUserFeeOptions(node: AztecNode, wallet: Wallet, from: AztecAddress): Promise<ParsedFeeOptions> {
const maxFeesPerGas = (await node.getCurrentMinFees()).mul(1 + MIN_FEE_PADDING);
const minFees = await this.getMinFees(node);
const maxFeesPerGas = minFees.mul(1 + MIN_FEE_PADDING);
const gasSettings = GasSettings.fallback({ ...this.gasSettings, maxFeesPerGas });
const paymentMethod = await this.paymentMethod(wallet, from, gasSettings);
return {
Expand All @@ -266,6 +267,27 @@ export class CLIFeeArgs {
};
}

/**
* Returns the worst-case min fee across predicted future slots.
* Falls back to getCurrentMinFees if the node doesn't support getPredictedMinFees.
*/
private async getMinFees(node: AztecNode): Promise<GasFees> {
try {
const predicted = await node.getPredictedMinFees(ManaUsageEstimate.Limit);
if (predicted.length === 0) {
return node.getCurrentMinFees();
}
return predicted.reduce((worst, fees) => (fees.feePerL2Gas > worst.feePerL2Gas ? fees : worst));
} catch (err: any) {
// Fallback for old nodes that don't support getPredictedMinFees.
// Only fall back on method-not-found errors (JSON-RPC code -32601); rethrow others.
if (err?.cause?.code === -32601 || err?.message?.includes('Method not found')) {
return node.getCurrentMinFees();
}
throw err;
}
}

static parse(args: RawCliFeeArgs, log: LogFn, db?: WalletDB): CLIFeeArgs {
const estimateOnly = !!args.estimateGasOnly;
return new CLIFeeArgs(
Expand Down
6 changes: 6 additions & 0 deletions yarn-project/end-to-end/src/e2e_fees/fee_settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,11 @@ describe('e2e_fees fee settings', () => {
};

const prepareTxsWithMockedMinFees = async (noPaddingMinFees: GasFees, defaultPaddingMinFees: GasFees) => {
// Mock getPredictedMinFees (used by the wallet) and getCurrentMinFees (used by bumpL2Fees and other callers).
const getPredictedMinFeesSpy = jest
.spyOn(aztecNode, 'getPredictedMinFees')
.mockResolvedValueOnce([noPaddingMinFees])
.mockResolvedValueOnce([defaultPaddingMinFees]);
const getCurrentMinFeesSpy = jest
.spyOn(aztecNode, 'getCurrentMinFees')
.mockResolvedValueOnce(noPaddingMinFees)
Expand All @@ -102,6 +107,7 @@ describe('e2e_fees fee settings', () => {
const txWithDefaultPadding = await proveTx(undefined);
return { txWithNoPadding, txWithDefaultPadding };
} finally {
getPredictedMinFeesSpy.mockRestore();
getCurrentMinFeesSpy.mockRestore();
}
};
Expand Down
71 changes: 69 additions & 2 deletions yarn-project/ethereum/src/contracts/rollup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,7 @@ import { EthCheatCodes } from '../test/eth_cheat_codes.js';
import type { Anvil } from '../test/start_anvil.js';
import { startAnvil } from '../test/start_anvil.js';
import type { ViemClient } from '../types.js';
import type { FeeHeader } from './rollup.js';
import { RollupContract } from './rollup.js';
import { type FeeHeader, RollupContract, TempCheckpointLogField } from './rollup.js';

describe('compressFeeHeader', () => {
/** Creates a zero fee header with the given overrides. */
Expand Down Expand Up @@ -355,4 +354,72 @@ describe('Rollup', () => {
expect(slashingProposer).toBeDefined();
});
});

describe('compressFeeHeader', () => {
it('compressed fee header can be read back by L1 getFeeHeader', async () => {
const feeHeader: FeeHeader = {
manaUsed: 12345n,
excessMana: 67890n,
ethPerFeeAsset: 1_000_000_000_000n,
congestionCost: 99999n,
proverCost: 55555n,
};

// Ensure pending checkpoint is 0 so getFeeHeader(0) is in range
await cheatCodes.store(
EthAddress.fromString(rollupAddress),
RollupContract.chainTipsStorageSlot,
RollupContract.packChainTips(0n, 0n),
);

const checkpointNumber = CheckpointNumber(0);
const slot = await rollup.getTempCheckpointLogStorageSlot(checkpointNumber, TempCheckpointLogField.FeeHeader);
await cheatCodes.store(EthAddress.fromString(rollupAddress), slot, RollupContract.compressFeeHeader(feeHeader));

const result = await rollup.getFeeHeader(0n);
expect(result.manaUsed).toBe(feeHeader.manaUsed);
expect(result.excessMana).toBe(feeHeader.excessMana);
expect(result.ethPerFeeAsset).toBe(feeHeader.ethPerFeeAsset);
expect(result.congestionCost).toBe(feeHeader.congestionCost);
expect(result.proverCost).toBe(feeHeader.proverCost);
});
});

describe('packChainTips', () => {
it('packed tips can be read back as pending and proven checkpoint numbers', async () => {
const pending = 200n;
const proven = 150n;

await cheatCodes.store(
EthAddress.fromString(rollupAddress),
RollupContract.chainTipsStorageSlot,
RollupContract.packChainTips(pending, proven),
);

expect(await rollup.getCheckpointNumber()).toBe(CheckpointNumber.fromBigInt(pending));
expect(await rollup.getProvenCheckpointNumber()).toBe(CheckpointNumber.fromBigInt(proven));
});
});

describe('getTempCheckpointLogStorageSlot', () => {
it('writing to the slot number field is readable via getCheckpoint', async () => {
// First restore tips so checkpoint 0 is pending
await cheatCodes.store(
EthAddress.fromString(rollupAddress),
RollupContract.chainTipsStorageSlot,
RollupContract.packChainTips(0n, 0n),
);

const slotNumberStorageSlot = await rollup.getTempCheckpointLogStorageSlot(
CheckpointNumber(0),
TempCheckpointLogField.SlotNumber,
);

const testSlotNumber = 42n;
await cheatCodes.store(EthAddress.fromString(rollupAddress), slotNumberStorageSlot, testSlotNumber);

const checkpoint = await rollup.getCheckpoint(CheckpointNumber(0));
expect(BigInt(checkpoint.slotNumber)).toBe(testSlotNumber);
});
});
});
Loading
Loading