diff --git a/yarn-project/aztec-node/src/aztec-node/server.test.ts b/yarn-project/aztec-node/src/aztec-node/server.test.ts index e7f4cf6ca714..eb1ac40d46bd 100644 --- a/yarn-project/aztec-node/src/aztec-node/server.test.ts +++ b/yarn-project/aztec-node/src/aztec-node/server.test.ts @@ -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, @@ -73,6 +74,7 @@ class TestAztecNodeService extends AztecNodeService { describe('aztec node', () => { let p2p: MockProxy; let globalVariablesBuilder: MockProxy; + let feeProvider: MockProxy; let merkleTreeOps: MockProxy; let worldState: MockProxy; let l2BlockSource: MockProxy; @@ -108,7 +110,8 @@ describe('aztec node', () => { p2p = mock(); globalVariablesBuilder = mock(); - globalVariablesBuilder.getCurrentMinFees.mockResolvedValue(new GasFees(0, BlockNumber.ZERO)); + feeProvider = mock(); + feeProvider.getCurrentMinFees.mockResolvedValue(new GasFees(0, BlockNumber.ZERO)); merkleTreeOps = mock(); merkleTreeOps.findLeafIndices.mockImplementation((treeId: MerkleTreeId, _value: any[]) => { @@ -196,6 +199,7 @@ describe('aztec node', () => { 12345, rollupVersion.toNumber(), globalVariablesBuilder, + feeProvider, epochCache, getPackageVersion() ?? '', new TestCircuitVerifier(), @@ -738,6 +742,7 @@ describe('aztec node', () => { 12345, rollupVersion.toNumber(), globalVariablesBuilder, + feeProvider, epochCache, getPackageVersion() ?? '', new TestCircuitVerifier(), @@ -927,6 +932,7 @@ describe('aztec node', () => { 12345, rollupVersion.toNumber(), globalVariablesBuilder, + feeProvider, epochCache, getPackageVersion() ?? '', new TestCircuitVerifier(), @@ -997,6 +1003,7 @@ describe('aztec node', () => { 12345, rollupVersion.toNumber(), globalVariablesBuilder, + mock(), epochCache, getPackageVersion() ?? '', new TestCircuitVerifier(), diff --git a/yarn-project/aztec-node/src/aztec-node/server.ts b/yarn-project/aztec-node/src/aztec-node/server.ts index 0814ee01ccbc..f84cb20400e0 100644 --- a/yarn-project/aztec-node/src/aztec-node/server.ts +++ b/yarn-project/aztec-node/src/aztec-node/server.ts @@ -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, @@ -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, @@ -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, @@ -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, @@ -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; @@ -609,6 +619,7 @@ export class AztecNodeService implements AztecNode, AztecNodeAdmin, AztecNodeDeb ethereumChain.chainInfo.id, config.rollupVersion, globalVariableBuilder, + feeProvider, epochCache, packageVersion, peerProofVerifier, @@ -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 { - 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 { + return await this.feeProvider.getPredictedMinFees(manaUsage); } public async getMaxPriorityFees(): Promise { diff --git a/yarn-project/aztec.js/src/contract/interaction_options.ts b/yarn-project/aztec.js/src/contract/interaction_options.ts index 626381d6f665..f47bd8d556b2 100644 --- a/yarn-project/aztec.js/src/contract/interaction_options.ts +++ b/yarn-project/aztec.js/src/contract/interaction_options.ts @@ -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, @@ -42,6 +42,14 @@ export type FeePaymentMethodOption = { export type GasSettingsOption = { /** The gas settings */ gasSettings?: Partial>; + /** + * 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. */ @@ -253,6 +261,7 @@ export function toSendOptions( ...options.fee?.paymentMethod?.getGasSettings(), ...options.fee?.gasSettings, }, + congestionEstimate: options.fee?.congestionEstimate, }, wait: options.wait, // Pass through wait option }; @@ -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, }, diff --git a/yarn-project/aztec.js/src/wallet/wallet.ts b/yarn-project/aztec.js/src/wallet/wallet.ts index fa2c7bd297a2..9fd080aaec98 100644 --- a/yarn-project/aztec.js/src/wallet/wallet.ts +++ b/yarn-project/aztec.js/src/wallet/wallet.ts @@ -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'; @@ -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({ diff --git a/yarn-project/bot/src/factory.ts b/yarn-project/bot/src/factory.ts index 4be39d482dfd..09c84eefa2f1 100644 --- a/yarn-project/bot/src/factory.ts +++ b/yarn-project/bot/src/factory.ts @@ -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'; @@ -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, @@ -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); @@ -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 { + 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(fn: () => Promise): Promise { if (!this.aztecNodeAdmin || !this.config.flushSetupTransactions) { this.log.verbose(`No node admin client or flushing not requested (not setting minTxsPerBlock to 0)`); diff --git a/yarn-project/cli-wallet/src/utils/options/fees.ts b/yarn-project/cli-wallet/src/utils/options/fees.ts index 321af9e0e16a..82cea4f5aca4 100644 --- a/yarn-project/cli-wallet/src/utils/options/fees.ts +++ b/yarn-project/cli-wallet/src/utils/options/fees.ts @@ -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'; @@ -257,7 +257,8 @@ export class CLIFeeArgs { ) {} async toUserFeeOptions(node: AztecNode, wallet: Wallet, from: AztecAddress): Promise { - 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 { @@ -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 { + 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( diff --git a/yarn-project/end-to-end/src/e2e_fees/fee_settings.test.ts b/yarn-project/end-to-end/src/e2e_fees/fee_settings.test.ts index ec2936ac7dd6..7bae88d7a310 100644 --- a/yarn-project/end-to-end/src/e2e_fees/fee_settings.test.ts +++ b/yarn-project/end-to-end/src/e2e_fees/fee_settings.test.ts @@ -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) @@ -102,6 +107,7 @@ describe('e2e_fees fee settings', () => { const txWithDefaultPadding = await proveTx(undefined); return { txWithNoPadding, txWithDefaultPadding }; } finally { + getPredictedMinFeesSpy.mockRestore(); getCurrentMinFeesSpy.mockRestore(); } }; diff --git a/yarn-project/ethereum/src/contracts/rollup.test.ts b/yarn-project/ethereum/src/contracts/rollup.test.ts index d250f5a7eee5..a9ef1863314d 100644 --- a/yarn-project/ethereum/src/contracts/rollup.test.ts +++ b/yarn-project/ethereum/src/contracts/rollup.test.ts @@ -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. */ @@ -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); + }); + }); }); diff --git a/yarn-project/ethereum/src/contracts/rollup.ts b/yarn-project/ethereum/src/contracts/rollup.ts index 105b0a888db5..df763e2a73ee 100644 --- a/yarn-project/ethereum/src/contracts/rollup.ts +++ b/yarn-project/ethereum/src/contracts/rollup.ts @@ -128,6 +128,17 @@ export type L1FeeData = { blobFee: bigint; }; +/** Field offsets within the CompressedTempCheckpointLog struct in Solidity storage. */ +export enum TempCheckpointLogField { + HeaderHash = 0, + BlobCommitmentsHash = 1, + OutHash = 2, + AttestationsHash = 3, + PayloadDigest = 4, + SlotNumber = 5, + FeeHeader = 6, +} + /** Components of the minimum fee per mana, as returned by the L1 rollup contract. */ export type ManaMinFeeComponents = { sequencerCost: bigint; @@ -514,8 +525,9 @@ export class RollupContract { return this.rollup.read.getCheckpointReward(); } - async getCheckpointNumber(): Promise { - return CheckpointNumber.fromBigInt(await this.rollup.read.getPendingCheckpointNumber()); + async getCheckpointNumber(options?: { blockNumber?: bigint }): Promise { + await checkBlockTag(options?.blockNumber, this.client); + return CheckpointNumber.fromBigInt(await this.rollup.read.getPendingCheckpointNumber(options)); } async getProvenCheckpointNumber(options?: { blockNumber?: bigint }): Promise { @@ -523,18 +535,31 @@ export class RollupContract { return CheckpointNumber.fromBigInt(await this.rollup.read.getProvenCheckpointNumber(options)); } - async getSlotNumber(): Promise { - return SlotNumber.fromBigInt(await this.rollup.read.getCurrentSlot()); + async getSlotNumber(options?: { blockNumber?: bigint }): Promise { + await checkBlockTag(options?.blockNumber, this.client); + return SlotNumber.fromBigInt(await this.rollup.read.getCurrentSlot(options)); } - async getL1FeesAt(timestamp: bigint): Promise { - const result = await this.rollup.read.getL1FeesAt([timestamp]); + async getL1FeesAt(timestamp: bigint, options?: { blockNumber?: bigint }): Promise { + await checkBlockTag(options?.blockNumber, this.client); + const result = await this.rollup.read.getL1FeesAt([timestamp], options); return { baseFee: result.baseFee, blobFee: result.blobFee, }; } + async getFeeHeader(checkpointNumber: bigint): Promise { + const result = await this.rollup.read.getFeeHeader([checkpointNumber]); + return { + excessMana: result.excessMana, + manaUsed: result.manaUsed, + ethPerFeeAsset: result.ethPerFeeAsset, + congestionCost: result.congestionCost, + proverCost: result.proverCost, + }; + } + getEthPerFeeAsset(): Promise { return this.rollup.read.getEthPerFeeAsset(); } @@ -609,8 +634,9 @@ export class RollupContract { return EthAddress.fromString(result); } - async getCheckpoint(checkpointNumber: CheckpointNumber): Promise { - const result = await this.rollup.read.getCheckpoint([BigInt(checkpointNumber)]); + async getCheckpoint(checkpointNumber: CheckpointNumber, options?: { blockNumber?: bigint }): Promise { + await checkBlockTag(options?.blockNumber, this.client); + const result = await this.rollup.read.getCheckpoint([BigInt(checkpointNumber)], options); return { archive: Fr.fromString(result.archive), headerHash: Buffer32.fromString(result.headerHash), @@ -643,6 +669,30 @@ export class RollupContract { ); } + /** + * Returns the effective pending checkpoint, accounting for potential prunes. + * When a prune can happen, the L1 contract uses the proven checkpoint instead of the pending one. + * This mirrors the behavior of getEffectivePendingCheckpointNumber in STFLib.sol. + * @param atTimestamp - The timestamp to evaluate pruneability at. Defaults to the current L1 block timestamp. + * @param options - Optional L1 block number to pin the queries to. + */ + getEffectivePendingCheckpoint(atTimestamp?: bigint, options?: { blockNumber?: bigint }) { + return retry( + async () => { + const timestamp = atTimestamp ?? (await this.client.getBlock()).timestamp; + const canPrune = await this.canPruneAtTime(timestamp, options); + if (canPrune) { + const provenCheckpointNumber = await this.getProvenCheckpointNumber(options); + return await this.getCheckpoint(provenCheckpointNumber, options); + } + const pendingCheckpointNumber = await this.getCheckpointNumber(options); + return await this.getCheckpoint(pendingCheckpointNumber, options); + }, + 'getting effective pending checkpoint', + makeBackoff([0.5, 0.5, 0.5]), + ); + } + async getTips(): Promise<{ pending: CheckpointNumber; proven: CheckpointNumber }> { const { pending, proven } = await this.rollup.read.getTips(); return { @@ -1248,4 +1298,39 @@ export class RollupContract { }, })); } + + /** Packs pending and proven checkpoint numbers into the chain tips storage format. */ + static packChainTips(pendingCheckpointNumber: bigint, provenCheckpointNumber: bigint): bigint { + return (pendingCheckpointNumber << 128n) | (provenCheckpointNumber & ((1n << 128n) - 1n)); + } + + /** Storage slot for the chain tips (offset 0 within the STF storage struct). */ + static get chainTipsStorageSlot(): bigint { + return BigInt(RollupContract.stfStorageSlot); + } + + /** + * Computes the storage slot for a field within a tempCheckpointLog entry. + * @param checkpointNumber - The checkpoint number + * @param field - The field within the CompressedTempCheckpointLog struct + */ + async getTempCheckpointLogStorageSlot( + checkpointNumber: CheckpointNumber, + field: TempCheckpointLogField, + ): Promise { + const fieldOffset = BigInt(field); + const [epochDuration, proofSubmissionEpochs] = await Promise.all([ + this.getEpochDuration(), + this.getProofSubmissionEpochs(), + ]); + const roundaboutSize = BigInt(epochDuration) * (BigInt(proofSubmissionEpochs) + 1n) + 1n; + const tempCheckpointLogsBase = BigInt(RollupContract.stfStorageSlot) + 2n; + const circularIndex = BigInt(checkpointNumber) % roundaboutSize; + const entryBase = BigInt( + keccak256( + encodeAbiParameters([{ type: 'uint256' }, { type: 'uint256' }], [circularIndex, tempCheckpointLogsBase]), + ), + ); + return entryBase + fieldOffset; + } } diff --git a/yarn-project/foundation/src/branded-types/checkpoint_number.ts b/yarn-project/foundation/src/branded-types/checkpoint_number.ts index 547b23c32fab..1d864b78e03c 100644 --- a/yarn-project/foundation/src/branded-types/checkpoint_number.ts +++ b/yarn-project/foundation/src/branded-types/checkpoint_number.ts @@ -85,6 +85,11 @@ CheckpointNumber.isValid = function (value: unknown): value is CheckpointNumber return typeof value === 'number' && Number.isInteger(value) && value >= 0; }; +/** Increments a CheckpointNumber by a given value. */ +CheckpointNumber.add = function (n: CheckpointNumber, increment: number): CheckpointNumber { + return CheckpointNumber(n + increment); +}; + /** The zero checkpoint value. */ CheckpointNumber.ZERO = CheckpointNumber(0); diff --git a/yarn-project/sequencer-client/src/global_variable_builder/README.md b/yarn-project/sequencer-client/src/global_variable_builder/README.md new file mode 100644 index 000000000000..de19b3c71a4b --- /dev/null +++ b/yarn-project/sequencer-client/src/global_variable_builder/README.md @@ -0,0 +1,44 @@ +# Fee Prediction + +The `FeePredictor` predicts min fees for upcoming L2 slots based on the current L1 +oracle state and an assumed mana usage pattern. + +## Prediction Window + +The prediction covers `LAG = 2` entries (the next available slot plus 1 more). +A new oracle update can activate at `startSlot + LAG`, so only the first LAG entries +are guaranteed stable. Beyond that, L1 fees could change unpredictably. + +Each entry uses: + +- The **actual L1 fees** the oracle would return for that slot's timestamp (pre or post, + depending on whether the slot is before or after `slotOfChange`). +- A **configurable congestion assumption** (`ManaUsageEstimate`): none (0 mana), target + (steady state), or limit (worst case, 2x target). + +The client picks `max(predicted[0..LAG-1])` as their `maxFeesPerGas`. + +``` +predicted[0] → fee at next slot (current oracle + current congestion) +predicted[1] → fee at next slot + 1 (congestion may change based on usage estimate) +``` + +## Why LAG and not LIFETIME? + +The L1 gas oracle has two timing constants: + +- **LAG = 2 slots**: when new fees are queued, they activate LAG slots later +- **LIFETIME = 5 slots**: after an oracle update, the next update is rejected until + `slotOfChange + (LIFETIME - LAG)` slots have passed + +If the oracle cooldown has already elapsed (i.e., no recent update), a new update can +be enqueued at any moment. Its new values would activate LAG slots later. This means +predictions beyond LAG slots could be invalidated by an oracle update that happens +right after we query. + +With a LIFETIME-sized window, we'd give a false sense of coverage: the prediction +would look like it covers 6 slots, but slots beyond LAG could be wrong if an update +is enqueued after the prediction is computed. + +By limiting to LAG entries, we guarantee that all predicted L1 fees are the ones that +will actually be used — no oracle update can change them within this window. diff --git a/yarn-project/sequencer-client/src/global_variable_builder/fee_predictor.test.ts b/yarn-project/sequencer-client/src/global_variable_builder/fee_predictor.test.ts new file mode 100644 index 000000000000..72624b4b0ff1 --- /dev/null +++ b/yarn-project/sequencer-client/src/global_variable_builder/fee_predictor.test.ts @@ -0,0 +1,354 @@ +import { getPublicClient } from '@aztec/ethereum/client'; +import { DefaultL1ContractsConfig } from '@aztec/ethereum/config'; +import type { FeeHeader } from '@aztec/ethereum/contracts'; +import { MAX_FEE_ASSET_PRICE_MODIFIER_BPS, RollupContract, TempCheckpointLogField } from '@aztec/ethereum/contracts'; +import { deployAztecL1Contracts } from '@aztec/ethereum/deploy-aztec-l1-contracts'; +import { type Anvil, EthCheatCodes, RollupCheatCodes, startAnvil } from '@aztec/ethereum/test'; +import type { ViemClient } from '@aztec/ethereum/types'; +import { CheckpointNumber } from '@aztec/foundation/branded-types'; +import { Fr } from '@aztec/foundation/curves/bn254'; +import { EthAddress } from '@aztec/foundation/eth-address'; +import { createLogger } from '@aztec/foundation/log'; +import { DateProvider } from '@aztec/foundation/timer'; +import { FEE_ORACLE_LAG, type GasFees, ManaUsageEstimate, computeExcessMana } from '@aztec/stdlib/gas'; + +import { foundry } from 'viem/chains'; + +import { FeePredictor } from './fee_predictor.js'; + +describe('FeePredictor', () => { + let anvil: Anvil; + let rpcUrl: string; + let publicClient: ViemClient; + let cheatCodes: EthCheatCodes; + let rollupCheatCodes: RollupCheatCodes; + let rollup: RollupContract; + + let slotDuration: number; + let ethereumSlotDuration: number; + let l1GenesisTime: bigint; + let feePredictorConfig: { slotDuration: number; l1GenesisTime: bigint; ethereumSlotDuration: number }; + let dateProvider: DateProvider; + + beforeAll(async () => { + const privateKeyRaw = '0x8b3a350cf5c34c9194ca85829a2df0ec3153be0318b5e2d3348e872092edffba'; + + ({ anvil, rpcUrl } = await startAnvil()); + + publicClient = getPublicClient({ l1RpcUrls: [rpcUrl], l1ChainId: 31337 }); + cheatCodes = new EthCheatCodes([rpcUrl], new DateProvider()); + + const deployed = await deployAztecL1Contracts(rpcUrl, privateKeyRaw, foundry.id, { + ...DefaultL1ContractsConfig, + vkTreeRoot: Fr.random(), + protocolContractsHash: Fr.random(), + genesisArchiveRoot: Fr.random(), + realVerifier: false, + }); + + rollup = new RollupContract(publicClient, deployed.l1ContractAddresses.rollupAddress.toString()); + rollupCheatCodes = RollupCheatCodes.create([rpcUrl], deployed.l1ContractAddresses, new DateProvider()); + + slotDuration = await rollup.getSlotDuration(); + ethereumSlotDuration = DefaultL1ContractsConfig.ethereumSlotDuration; + l1GenesisTime = await rollup.getL1GenesisTime(); + feePredictorConfig = { slotDuration, l1GenesisTime, ethereumSlotDuration }; + dateProvider = new DateProvider(); + }, 60_000); + + afterAll(async () => { + await cheatCodes.setIntervalMining(0); + await anvil?.stop().catch(err => createLogger('cleanup').error(`Error stopping anvil`, err)); + }); + + function getTimestamp(slot: bigint): bigint { + return l1GenesisTime + slot * BigInt(slotDuration); + } + + /** Decays ethPerFeeAsset by MAX_FEE_ASSET_PRICE_MODIFIER_BPS per step, matching the predictor's conservative estimate. */ + function decayEthPerFeeAsset(ethPerFeeAsset: bigint, steps: number): bigint { + let value = ethPerFeeAsset; + for (let i = 0; i < steps; i++) { + value = (value * (10000n - MAX_FEE_ASSET_PRICE_MODIFIER_BPS)) / 10000n; + } + return value; + } + + /** Writes a fee header to the pending checkpoint's storage via cheat codes. */ + async function writePendingFeeHeader(feeHeader: FeeHeader) { + const rollupAddress = EthAddress.fromString(rollup.address); + const pendingCheckpointNumber = await rollup.getCheckpointNumber(); + const feeHeaderSlot = await rollup.getTempCheckpointLogStorageSlot( + pendingCheckpointNumber, + TempCheckpointLogField.FeeHeader, + ); + await cheatCodes.store(rollupAddress, feeHeaderSlot, RollupContract.compressFeeHeader(feeHeader)); + } + + /** Writes a fee header and slot number for the given checkpoint, then bumps the pending tip. */ + async function advanceCheckpoint(checkpointNumber: CheckpointNumber, feeHeader: FeeHeader, slotNumber: bigint) { + const rollupAddress = EthAddress.fromString(rollup.address); + const feeHeaderSlot = await rollup.getTempCheckpointLogStorageSlot( + checkpointNumber, + TempCheckpointLogField.FeeHeader, + ); + await cheatCodes.store(rollupAddress, feeHeaderSlot, RollupContract.compressFeeHeader(feeHeader)); + + const slotNumberSlot = await rollup.getTempCheckpointLogStorageSlot( + checkpointNumber, + TempCheckpointLogField.SlotNumber, + ); + await cheatCodes.store(rollupAddress, slotNumberSlot, slotNumber & ((1n << 32n) - 1n)); + + const currentTips = await cheatCodes.load(rollupAddress, RollupContract.chainTipsStorageSlot); + const provenCheckpointNumber = currentTips & ((1n << 128n) - 1n); + await cheatCodes.store( + rollupAddress, + RollupContract.chainTipsStorageSlot, + RollupContract.packChainTips(BigInt(checkpointNumber), provenCheckpointNumber), + ); + } + + async function getPredictionStartSlot(): Promise { + const lastCheckpoint = await rollup.getPendingCheckpoint(); + const currentSlot = await rollup.getSlotNumber(); + const afterCheckpoint = BigInt(lastCheckpoint.slotNumber) + 1n; + return afterCheckpoint > BigInt(currentSlot) ? afterCheckpoint : BigInt(currentSlot); + } + + it('slot 0 matches L1 getManaMinFeeAt for all ManaUsageEstimate values', async () => { + const startSlot = await getPredictionStartSlot(); + const l1Fee = await rollup.getManaMinFeeAt(getTimestamp(startSlot), true); + + for (const manaUsage of Object.values(ManaUsageEstimate)) { + const predictor = new FeePredictor(rollup, publicClient, dateProvider, feePredictorConfig); + const predicted = await predictor.getPredictedMinFees(manaUsage); + expect(predicted[0].feePerL2Gas).toBe(l1Fee); + } + }); + + it('all slots match L1 with ManaUsageEstimate.None and zero congestion', async () => { + const predictor = new FeePredictor(rollup, publicClient, dateProvider, feePredictorConfig); + const predicted = await predictor.getPredictedMinFees(ManaUsageEstimate.None); + + const startSlot = await getPredictionStartSlot(); + const pendingCheckpointNumber = await rollup.getCheckpointNumber(); + const currentFeeHeader = (await rollup.getCheckpoint(pendingCheckpointNumber)).feeHeader; + + for (let i = 0; i < predicted.length; i++) { + // Write the decayed ethPerFeeAsset to L1 so getManaMinFeeAt matches the predictor's conservative estimate. + if (i > 0) { + await writePendingFeeHeader({ + ...currentFeeHeader, + ethPerFeeAsset: decayEthPerFeeAsset(currentFeeHeader.ethPerFeeAsset, i), + }); + } + const l1Fee = await rollup.getManaMinFeeAt(getTimestamp(startSlot + BigInt(i)), true); + expect(predicted[i].feePerL2Gas).toBe(l1Fee); + } + + // Restore original fee header. + await writePendingFeeHeader(currentFeeHeader); + }); + + it('each slot uses correct L1 fees across oracle transition', async () => { + await rollupCheatCodes.advanceSlots(FEE_ORACLE_LAG + 1); + await cheatCodes.setNextBlockBaseFeePerGas(1_000_000_000n); + await cheatCodes.mine(); + await rollupCheatCodes.updateL1GasFeeOracle(); + + await rollupCheatCodes.advanceSlots(FEE_ORACLE_LAG + 1); + await cheatCodes.setNextBlockBaseFeePerGas(200_000_000_000n); + await cheatCodes.mine(); + await rollupCheatCodes.updateL1GasFeeOracle(); + + const predictor = new FeePredictor(rollup, publicClient, dateProvider, feePredictorConfig); + const predicted = await predictor.getPredictedMinFees(ManaUsageEstimate.None); + + const startSlot = await getPredictionStartSlot(); + const pendingCheckpointNumber = await rollup.getCheckpointNumber(); + const currentFeeHeader = (await rollup.getCheckpoint(pendingCheckpointNumber)).feeHeader; + + for (let i = 0; i < predicted.length; i++) { + // Write the decayed ethPerFeeAsset to L1 so getManaMinFeeAt matches the predictor's conservative estimate. + if (i > 0) { + await writePendingFeeHeader({ + ...currentFeeHeader, + ethPerFeeAsset: decayEthPerFeeAsset(currentFeeHeader.ethPerFeeAsset, i), + }); + } + const l1Fee = await rollup.getManaMinFeeAt(getTimestamp(startSlot + BigInt(i)), true); + expect(predicted[i].feePerL2Gas).toBe(l1Fee); + } + + // Restore original fee header. + await writePendingFeeHeader(currentFeeHeader); + }); + + it('L1 base fee change is reflected in slot 0 prediction', async () => { + await rollupCheatCodes.advanceSlots(FEE_ORACLE_LAG + 1); + await cheatCodes.setNextBlockBaseFeePerGas(100_000_000_000n); + await cheatCodes.mine(); + await rollupCheatCodes.updateL1GasFeeOracle(); + await cheatCodes.mine(); + await rollupCheatCodes.advanceSlots(3); + + const predictor = new FeePredictor(rollup, publicClient, dateProvider, feePredictorConfig); + const predicted = await predictor.getPredictedMinFees(ManaUsageEstimate.None); + + const startSlot = await getPredictionStartSlot(); + const l1Fee = await rollup.getManaMinFeeAt(getTimestamp(startSlot), true); + expect(predicted[0].feePerL2Gas).toBe(l1Fee); + }); + + it('returns exactly FEE_ORACLE_LAG entries', async () => { + const predictor = new FeePredictor(rollup, publicClient, dateProvider, feePredictorConfig); + const predicted = await predictor.getPredictedMinFees(ManaUsageEstimate.Target); + expect(predicted.length).toBe(FEE_ORACLE_LAG); + }); + + it.each([ + { name: 'None', estimate: ManaUsageEstimate.None }, + { name: 'Target', estimate: ManaUsageEstimate.Target }, + { name: 'Limit', estimate: ManaUsageEstimate.Limit }, + ])( + 'predictions match L1 across all slots when advancing with ManaUsageEstimate.$name', + async ({ estimate }) => { + const constantBaseFee = 50_000_000_000n; + + // Pin L1 fees to a constant by updating the oracle twice (sets both pre and post). + await rollupCheatCodes.advanceSlots(FEE_ORACLE_LAG + 1); + await cheatCodes.setNextBlockBaseFeePerGas(constantBaseFee); + await rollupCheatCodes.updateL1GasFeeOracle(); + await rollupCheatCodes.advanceSlots(FEE_ORACLE_LAG + 1); + await cheatCodes.setNextBlockBaseFeePerGas(constantBaseFee); + await rollupCheatCodes.updateL1GasFeeOracle(); + await rollupCheatCodes.advanceSlots(3); + await cheatCodes.mine(); + + const manaTarget = await rollup.getManaTarget(); + const manaLimit = await rollup.getManaLimit(); + const assumedManaUsed = + estimate === ManaUsageEstimate.None ? 0n : estimate === ManaUsageEstimate.Target ? manaTarget : manaLimit; + + const predictor = new FeePredictor(rollup, publicClient, dateProvider, feePredictorConfig); + const predicted = await predictor.getPredictedMinFees(estimate); + + const startSlot = await getPredictionStartSlot(); + const pendingCheckpointNumber = await rollup.getCheckpointNumber(); + const currentFeeHeader = (await rollup.getCheckpoint(pendingCheckpointNumber)).feeHeader; + + let prevExcessMana = currentFeeHeader.excessMana; + let prevManaUsed = currentFeeHeader.manaUsed; + + for (let i = 0; i < predicted.length; i++) { + const slotI = startSlot + BigInt(i); + const timestampI = getTimestamp(slotI); + + const l1Fee = await rollup.getManaMinFeeAt(timestampI, true); + expect(predicted[i].feePerL2Gas).toBe(l1Fee); + + // Advance: simulate proposing a checkpoint at this slot with the assumed mana usage + // and the decayed ethPerFeeAsset matching the predictor's conservative estimate. + const newExcessMana = computeExcessMana(prevExcessMana, prevManaUsed, manaTarget); + const newCheckpointNumber = CheckpointNumber.add(pendingCheckpointNumber, i + 1); + const newFeeHeader: FeeHeader = { + excessMana: newExcessMana, + manaUsed: assumedManaUsed, + ethPerFeeAsset: decayEthPerFeeAsset(currentFeeHeader.ethPerFeeAsset, i + 1), + congestionCost: 0n, + proverCost: 0n, + }; + + await advanceCheckpoint(newCheckpointNumber, newFeeHeader, slotI); + + if (i < predicted.length - 1) { + const nextTimestamp = getTimestamp(slotI + 1n); + await cheatCodes.warp(Number(nextTimestamp)); + await cheatCodes.setNextBlockBaseFeePerGas(constantBaseFee); + await cheatCodes.mine(); + } + + prevExcessMana = newExcessMana; + prevManaUsed = assumedManaUsed; + } + }, + 60_000, + ); + + it('predictions match L1 across successive slots over time', async () => { + const constantBaseFee = 50_000_000_000n; + + // Pin L1 fees to a constant by updating the oracle twice (sets both pre and post). + await rollupCheatCodes.advanceSlots(FEE_ORACLE_LAG + 1); + await cheatCodes.setNextBlockBaseFeePerGas(constantBaseFee); + await rollupCheatCodes.updateL1GasFeeOracle(); + await rollupCheatCodes.advanceSlots(FEE_ORACLE_LAG + 1); + await cheatCodes.setNextBlockBaseFeePerGas(constantBaseFee); + await rollupCheatCodes.updateL1GasFeeOracle(); + await rollupCheatCodes.advanceSlots(3); + await cheatCodes.mine(); + + const manaTarget = await rollup.getManaTarget(); + + const pendingCheckpointNumber = await rollup.getCheckpointNumber(); + const currentFeeHeader = (await rollup.getCheckpoint(pendingCheckpointNumber)).feeHeader; + + let prevExcessMana = currentFeeHeader.excessMana; + let prevManaUsed = currentFeeHeader.manaUsed; + let ethPerFeeAsset = currentFeeHeader.ethPerFeeAsset; + let nextCheckpointOffset = 1; + + // Store previous predictions to verify their future entries against L1 when those slots arrive. + const pastPredictions: { predicted: GasFees[]; startSlot: bigint }[] = []; + + // Step through 6 successive slots, creating a fresh predictor each time. + for (let step = 0; step < 6; step++) { + const predictor = new FeePredictor(rollup, publicClient, dateProvider, feePredictorConfig); + const predicted = await predictor.getPredictedMinFees(ManaUsageEstimate.None); + + expect(predicted.length).toBe(FEE_ORACLE_LAG); + + // Slot 0 of each fresh predictor must match L1 exactly. + const startSlot = await getPredictionStartSlot(); + const l1Fee = await rollup.getManaMinFeeAt(getTimestamp(startSlot), true); + expect(predicted[0].feePerL2Gas).toBe(l1Fee); + + // Verify future entries of past predictions that now cover the current slot. + for (const past of pastPredictions) { + const offset = Number(startSlot - past.startSlot); + if (offset > 0 && offset < past.predicted.length) { + expect(past.predicted[offset].feePerL2Gas).toBe(l1Fee); + } + } + + pastPredictions.push({ predicted, startSlot }); + + // Advance: simulate proposing a checkpoint at the current slot with zero mana usage + // and the decayed ethPerFeeAsset matching the predictor's conservative estimate. + const newExcessMana = computeExcessMana(prevExcessMana, prevManaUsed, manaTarget); + const decayedEthPerFeeAsset = decayEthPerFeeAsset(ethPerFeeAsset, 1); + const newCheckpointNumber = CheckpointNumber.add(pendingCheckpointNumber, nextCheckpointOffset); + const newFeeHeader: FeeHeader = { + excessMana: newExcessMana, + manaUsed: 0n, + ethPerFeeAsset: decayedEthPerFeeAsset, + congestionCost: 0n, + proverCost: 0n, + }; + + await advanceCheckpoint(newCheckpointNumber, newFeeHeader, startSlot); + + // Warp to the next slot. + const nextTimestamp = getTimestamp(startSlot + 1n); + await cheatCodes.warp(Number(nextTimestamp)); + await cheatCodes.setNextBlockBaseFeePerGas(constantBaseFee); + await cheatCodes.mine(); + + prevExcessMana = newExcessMana; + prevManaUsed = 0n; + ethPerFeeAsset = decayedEthPerFeeAsset; + nextCheckpointOffset++; + } + }, 60_000); +}); diff --git a/yarn-project/sequencer-client/src/global_variable_builder/fee_predictor.ts b/yarn-project/sequencer-client/src/global_variable_builder/fee_predictor.ts new file mode 100644 index 000000000000..7e51eeeca31f --- /dev/null +++ b/yarn-project/sequencer-client/src/global_variable_builder/fee_predictor.ts @@ -0,0 +1,172 @@ +import { type L1FeeData, MAX_FEE_ASSET_PRICE_MODIFIER_BPS, type RollupContract } from '@aztec/ethereum/contracts'; +import { SlotNumber } from '@aztec/foundation/branded-types'; +import { times } from '@aztec/foundation/collection'; +import type { DateProvider } from '@aztec/foundation/timer'; +import { getSlotAtNextL1Block, getTimestampForSlot } from '@aztec/stdlib/epoch-helpers'; +import { + FEE_ORACLE_LAG, + GasFees, + MIN_ETH_PER_FEE_ASSET, + ManaUsageEstimate, + computeExcessMana, + computeManaMinFee, +} from '@aztec/stdlib/gas'; + +/** Cached rollup state for fee prediction. Refreshed once per L1 block. */ +type FeeOracleState = { + lastSlot: SlotNumber; + excessMana: bigint; + ethPerFeeAsset: bigint; + manaTarget: bigint; + manaLimit: bigint; + provingCostPerManaEth: bigint; + epochDuration: bigint; + /** Pre-resolved L1 fees for each slot in the prediction window. */ + l1FeesBySlot: L1FeeData[]; +}; + +/** + * Predicts min fees for LAG upcoming slots based on the L1 oracle state. + * A new oracle update can activate at startSlot + LAG, so only the first LAG entries + * are guaranteed stable. Caches L1 queries per L1 block and recomputes predictions + * for each mana usage estimate. + */ +export class FeePredictor { + private cachedState: Promise | undefined; + private cachedL1BlockNumber: bigint | undefined; + + private readonly slotDuration: number; + private readonly l1GenesisTime: bigint; + private readonly ethereumSlotDuration: number; + + constructor( + private readonly rollupContract: RollupContract, + private readonly publicClient: { getBlockNumber: (opts?: { cacheTime?: number }) => Promise }, + private readonly dateProvider: DateProvider, + config: { slotDuration: number; l1GenesisTime: bigint; ethereumSlotDuration: number }, + ) { + this.slotDuration = config.slotDuration; + this.l1GenesisTime = config.l1GenesisTime; + this.ethereumSlotDuration = config.ethereumSlotDuration; + } + + /** Returns predicted min fees for each slot in the prediction window. */ + async getPredictedMinFees(manaUsage: ManaUsageEstimate): Promise { + const state = await this.getState(); + return this.computePredictions(state, manaUsage); + } + + /** Fetches and caches rollup state. Refreshes when L1 block number advances. */ + private async getState(): Promise { + const blockNumber = await this.publicClient.getBlockNumber({ cacheTime: 0 }); + if (this.cachedL1BlockNumber === undefined || blockNumber > this.cachedL1BlockNumber) { + this.cachedL1BlockNumber = blockNumber; + this.cachedState = this.fetchState(blockNumber); + } + return this.cachedState!; + } + + private async fetchState(blockNumber: bigint): Promise { + // Pin all non-constant queries to this L1 block number for a consistent snapshot. + const opts = { blockNumber }; + + // Cached constants don't need pinning + const [manaTarget, manaLimit, provingCostPerManaEth, epochDuration] = await Promise.all([ + this.rollupContract.getManaTarget(), + this.rollupContract.getManaLimit(), + this.rollupContract.getProvingCostPerMana(), + this.rollupContract.getEpochDuration(), + ]); + + // First, compute the earliest possible nextSlot independently of the checkpoint, so we can + // evaluate pruneability at the prediction start timestamp instead of the current L1 block time. + // This avoids an epoch-boundary edge case where the effective parent differs between now and nextSlot. + const slotConfig = { slotDuration: this.slotDuration, l1GenesisTime: this.l1GenesisTime }; + const currentSlot = await this.rollupContract.getSlotNumber(opts); + + const slotAtNextL1Block = getSlotAtNextL1Block(BigInt(this.dateProvider.nowInSeconds()), { + l1GenesisTime: this.l1GenesisTime, + slotDuration: this.slotDuration, + ethereumSlotDuration: this.ethereumSlotDuration, + }); + const preliminaryNextSlot = SlotNumber(Math.max(currentSlot, slotAtNextL1Block)); + const nextSlotTimestamp = getTimestampForSlot(preliminaryNextSlot, slotConfig); + + // Resolve the effective checkpoint at the prediction start timestamp + const lastCheckpoint = await this.rollupContract.getEffectivePendingCheckpoint(nextSlotTimestamp, opts); + const lastSlot = lastCheckpoint.slotNumber; + // Refine nextSlot: also account for the slot after the last checkpoint + const nextSlot = SlotNumber(Math.max(SlotNumber.add(lastSlot, 1), preliminaryNextSlot)); + const feeHeader = lastCheckpoint.feeHeader; + + const slotCount = FEE_ORACLE_LAG; + const timestamps = times(slotCount, i => getTimestampForSlot(SlotNumber.add(nextSlot, i), slotConfig)); + const l1FeesBySlot = await Promise.all(timestamps.map(ts => this.rollupContract.getL1FeesAt(ts, opts))); + + return { + lastSlot, + excessMana: computeExcessMana(feeHeader.excessMana, feeHeader.manaUsed, manaTarget), + ethPerFeeAsset: feeHeader.ethPerFeeAsset, + manaTarget, + manaLimit, + provingCostPerManaEth, + epochDuration: BigInt(epochDuration), + l1FeesBySlot, + }; + } + + /** Computes per-slot fee predictions given cached state and a mana usage assumption. */ + private computePredictions(state: FeeOracleState, manaUsage: ManaUsageEstimate): GasFees[] { + const assumedManaUsed = this.getAssumedManaUsed(state, manaUsage); + + const result: GasFees[] = []; + let { excessMana } = state; + let { ethPerFeeAsset } = state; + + // Slot 0: current state (next available slot after last checkpoint) + result.push(this.computeGasFees(state, excessMana, ethPerFeeAsset, state.l1FeesBySlot[0])); + + // Slots 1..LAG-1: advance excessMana with the assumed mana usage per checkpoint, + // and decay ethPerFeeAsset by MAX_FEE_ASSET_PRICE_MODIFIER_BPS per slot for conservative estimates. + // Lower ethPerFeeAsset means higher fees in fee asset terms. + for (let i = 1; i < state.l1FeesBySlot.length; i++) { + excessMana = computeExcessMana(excessMana, assumedManaUsed, state.manaTarget); + const decayed = (ethPerFeeAsset * (10000n - MAX_FEE_ASSET_PRICE_MODIFIER_BPS)) / 10000n; + ethPerFeeAsset = decayed < MIN_ETH_PER_FEE_ASSET ? MIN_ETH_PER_FEE_ASSET : decayed; + result.push(this.computeGasFees(state, excessMana, ethPerFeeAsset, state.l1FeesBySlot[i])); + } + + return result; + } + + private getAssumedManaUsed(state: FeeOracleState, manaUsage: ManaUsageEstimate): bigint { + switch (manaUsage) { + case ManaUsageEstimate.None: + return 0n; + case ManaUsageEstimate.Target: + return state.manaTarget; + case ManaUsageEstimate.Limit: + return state.manaLimit; + } + } + + private computeGasFees( + state: FeeOracleState, + excessMana: bigint, + ethPerFeeAsset: bigint, + l1Fees: L1FeeData, + ): GasFees { + return new GasFees( + 0, + computeManaMinFee({ + l1BaseFee: l1Fees.baseFee, + l1BlobFee: l1Fees.blobFee, + manaTarget: state.manaTarget, + epochDuration: state.epochDuration, + provingCostPerManaEth: state.provingCostPerManaEth, + excessMana, + ethPerFeeAsset, + }), + ); + } +} diff --git a/yarn-project/sequencer-client/src/global_variable_builder/fee_provider.ts b/yarn-project/sequencer-client/src/global_variable_builder/fee_provider.ts new file mode 100644 index 000000000000..519146884a84 --- /dev/null +++ b/yarn-project/sequencer-client/src/global_variable_builder/fee_provider.ts @@ -0,0 +1,75 @@ +import { RollupContract } from '@aztec/ethereum/contracts'; +import type { ViemPublicClient } from '@aztec/ethereum/types'; +import { SlotNumber } from '@aztec/foundation/branded-types'; +import type { DateProvider } from '@aztec/foundation/timer'; +import { getNextL1SlotTimestamp } from '@aztec/stdlib/epoch-helpers'; +import { GasFees, ManaUsageEstimate } from '@aztec/stdlib/gas'; +import type { FeeProvider } from '@aztec/stdlib/tx'; + +import { FeePredictor } from './fee_predictor.js'; +import type { GlobalVariableBuilderConfig } from './global_builder.js'; + +/** Provides current and predicted fee information based on on-chain state. */ +export class FeeProviderImpl implements FeeProvider { + private currentMinFees: Promise = Promise.resolve(new GasFees(0, 0)); + private currentL1BlockNumber: bigint | undefined = undefined; + + private readonly rollupContract: RollupContract; + private readonly feePredictor: FeePredictor; + private readonly ethereumSlotDuration: number; + private readonly l1GenesisTime: bigint; + + constructor( + private readonly dateProvider: DateProvider, + private readonly publicClient: ViemPublicClient, + config: GlobalVariableBuilderConfig, + ) { + this.ethereumSlotDuration = config.ethereumSlotDuration; + this.l1GenesisTime = config.l1GenesisTime; + + this.rollupContract = new RollupContract(this.publicClient, config.l1Contracts.rollupAddress); + this.feePredictor = new FeePredictor(this.rollupContract, this.publicClient, this.dateProvider, { + slotDuration: config.slotDuration, + l1GenesisTime: config.l1GenesisTime, + ethereumSlotDuration: config.ethereumSlotDuration, + }); + } + + /** + * Computes the "current" min fees, e.g., the price that you currently should pay to get include in the next block + * @returns Min fees for the next block + */ + private async computeCurrentMinFees(): Promise { + // Since this might be called in the middle of a slot where a block might have been published, + // we need to fetch the last block written, and estimate the earliest timestamp for the next block. + // The timestamp of that last block will act as a lower bound for the next block. + + const lastCheckpoint = await this.rollupContract.getPendingCheckpoint(); + const earliestTimestamp = await this.rollupContract.getTimestampForSlot( + SlotNumber.fromBigInt(BigInt(lastCheckpoint.slotNumber) + 1n), + ); + const nextEthTimestamp = getNextL1SlotTimestamp(this.dateProvider.nowInSeconds(), { + l1GenesisTime: this.l1GenesisTime, + ethereumSlotDuration: this.ethereumSlotDuration, + }); + const timestamp = earliestTimestamp > nextEthTimestamp ? earliestTimestamp : nextEthTimestamp; + + return new GasFees(0, await this.rollupContract.getManaMinFeeAt(timestamp, true)); + } + + public async getCurrentMinFees(): Promise { + // Get the current block number + const blockNumber = await this.publicClient.getBlockNumber(); + + // If the L1 block number has changed then chain a new promise to get the current min fees + if (this.currentL1BlockNumber === undefined || blockNumber > this.currentL1BlockNumber) { + this.currentL1BlockNumber = blockNumber; + this.currentMinFees = this.currentMinFees.then(() => this.computeCurrentMinFees()); + } + return this.currentMinFees; + } + + public getPredictedMinFees(manaUsage?: ManaUsageEstimate): Promise { + return this.feePredictor.getPredictedMinFees(manaUsage ?? ManaUsageEstimate.Target); + } +} diff --git a/yarn-project/sequencer-client/src/global_variable_builder/global_builder.ts b/yarn-project/sequencer-client/src/global_variable_builder/global_builder.ts index 929a1a3ebdff..84c9d08fb591 100644 --- a/yarn-project/sequencer-client/src/global_variable_builder/global_builder.ts +++ b/yarn-project/sequencer-client/src/global_variable_builder/global_builder.ts @@ -8,7 +8,6 @@ import type { ViemPublicClient } from '@aztec/ethereum/types'; import { BlockNumber, SlotNumber } from '@aztec/foundation/branded-types'; import { Fr } from '@aztec/foundation/curves/bn254'; import type { EthAddress } from '@aztec/foundation/eth-address'; -import { createLogger } from '@aztec/foundation/log'; import type { DateProvider } from '@aztec/foundation/timer'; import type { AztecAddress } from '@aztec/stdlib/aztec-address'; import { type L1RollupConstants, getNextL1SlotTimestamp, getTimestampForSlot } from '@aztec/stdlib/epoch-helpers'; @@ -30,10 +29,6 @@ export type GlobalVariableBuilderConfig = { * Simple global variables builder. */ export class GlobalVariableBuilder implements GlobalVariableBuilderInterface { - private log = createLogger('sequencer:global_variable_builder'); - private currentMinFees: Promise = Promise.resolve(new GasFees(0, 0)); - private currentL1BlockNumber: bigint | undefined = undefined; - private readonly rollupContract: RollupContract; private readonly ethereumSlotDuration: number; private readonly aztecSlotDuration: number; @@ -57,40 +52,6 @@ export class GlobalVariableBuilder implements GlobalVariableBuilderInterface { this.rollupContract = new RollupContract(this.publicClient, config.l1Contracts.rollupAddress); } - /** - * Computes the "current" min fees, e.g., the price that you currently should pay to get include in the next block - * @returns Min fees for the next block - */ - private async computeCurrentMinFees(): Promise { - // Since this might be called in the middle of a slot where a block might have been published, - // we need to fetch the last block written, and estimate the earliest timestamp for the next block. - // The timestamp of that last block will act as a lower bound for the next block. - - const lastCheckpoint = await this.rollupContract.getPendingCheckpoint(); - const earliestTimestamp = await this.rollupContract.getTimestampForSlot( - SlotNumber.fromBigInt(BigInt(lastCheckpoint.slotNumber) + 1n), - ); - const nextEthTimestamp = getNextL1SlotTimestamp(this.dateProvider.nowInSeconds(), { - l1GenesisTime: this.l1GenesisTime, - ethereumSlotDuration: this.ethereumSlotDuration, - }); - const timestamp = earliestTimestamp > nextEthTimestamp ? earliestTimestamp : nextEthTimestamp; - - return new GasFees(0, await this.rollupContract.getManaMinFeeAt(timestamp, true)); - } - - public async getCurrentMinFees(): Promise { - // Get the current block number - const blockNumber = await this.publicClient.getBlockNumber(); - - // If the L1 block number has changed then chain a new promise to get the current min fees - if (this.currentL1BlockNumber === undefined || blockNumber > this.currentL1BlockNumber) { - this.currentL1BlockNumber = blockNumber; - this.currentMinFees = this.currentMinFees.then(() => this.computeCurrentMinFees()); - } - return this.currentMinFees; - } - /** * Simple builder of global variables. * @param blockNumber - The block number to build global variables for. diff --git a/yarn-project/sequencer-client/src/global_variable_builder/index.ts b/yarn-project/sequencer-client/src/global_variable_builder/index.ts index a48ed6c244eb..084db54844e1 100644 --- a/yarn-project/sequencer-client/src/global_variable_builder/index.ts +++ b/yarn-project/sequencer-client/src/global_variable_builder/index.ts @@ -1 +1,3 @@ +export { FeeProviderImpl } from './fee_provider.js'; export { GlobalVariableBuilder, type GlobalVariableBuilderConfig } from './global_builder.js'; +export { FeePredictor } from './fee_predictor.js'; diff --git a/yarn-project/stdlib/src/gas/README.md b/yarn-project/stdlib/src/gas/README.md index 09343b074237..bda744329992 100644 --- a/yarn-project/stdlib/src/gas/README.md +++ b/yarn-project/stdlib/src/gas/README.md @@ -84,6 +84,37 @@ else → use post (new fees) **Net effect**: L1 fee changes reach L2 with a 2-slot delay and can update at most once every 5 slots. +### Worked Example + +Suppose the oracle is updated at slot 10 with new L1 fees. Here is the timeline: + +``` +Slot Oracle state Active fees Notes +──── ──────────────────── ──────────── ────────────────────────────────── + 10 pre=A, post=B, soc=12 A Update queued. slotOfChange = 10 + LAG = 12. + 11 (same) A Still before slotOfChange → pre (A). + 12 (same) B slot >= slotOfChange → post (B) activates. + 13 (same) B B remains active. + 14 (same) B B remains active. + 15 Update allowed again B Earliest next update: soc + (LIFETIME - LAG) + = 12 + 3 = 15. +``` + +Key observations: + +1. **Slots 10-11**: The old fees (A) are still in effect. Transactions submitted during + these slots see the old L1 cost. This is the **LAG** window — it gives pending + transactions 2 slots to land before fees change. + +2. **Slot 12**: The new fees (B) activate. Any checkpoint proposed at slot >= 12 uses B + for its sequencer/prover cost calculation. + +3. **Slots 12-14**: No new oracle update is accepted. The system is in a **cooldown** + period of `LIFETIME - LAG = 3` slots after the transition. + +4. **Slot 15**: A new oracle update can be queued (earliest `acceptableSlot`). If + triggered, the new values would activate at slot 15 + LAG = 17. + ## Fee Asset Price Fees are computed in ETH internally but converted to the fee asset (Fee Juice) via diff --git a/yarn-project/stdlib/src/gas/fee_math.test.ts b/yarn-project/stdlib/src/gas/fee_math.test.ts new file mode 100644 index 000000000000..e77dfb8a2ddd --- /dev/null +++ b/yarn-project/stdlib/src/gas/fee_math.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, it } from '@jest/globals'; + +import { + MINIMUM_CONGESTION_MULTIPLIER, + computeCongestionMultiplier, + computeExcessMana, + computeManaMinFee, + fakeExponential, +} from './fee_math.js'; + +describe('fakeExponential', () => { + it('returns factor when numerator is zero', () => { + expect(fakeExponential(1_000_000_000n, 0n, 100n)).toBe(1_000_000_000n); + }); + + it('returns factor when numerator is zero (large factor)', () => { + expect(fakeExponential(1_000_000_000_000n, 0n, 1_000n)).toBe(1_000_000_000_000n); + }); + + it('approximates e^1 correctly', () => { + // fakeExponential(1e9, d, d) should approximate 1e9 * e ≈ 2.718e9 + const result = fakeExponential(1_000_000_000n, 1000n, 1000n); + // e ≈ 2.71828, so result should be close to 2718281828 + expect(result).toBeGreaterThan(2_718_000_000n); + expect(result).toBeLessThan(2_719_000_000n); + }); + + it('approximates e^2 correctly', () => { + const result = fakeExponential(1_000_000_000n, 2000n, 1000n); + // e^2 ≈ 7.389, so result ≈ 7389e6 + expect(result).toBeGreaterThan(7_388_000_000n); + expect(result).toBeLessThan(7_390_000_000n); + }); + + it('returns zero when factor is zero', () => { + expect(fakeExponential(0n, 100n, 50n)).toBe(0n); + }); +}); + +describe('computeExcessMana', () => { + it('returns zero when mana used is below target', () => { + expect(computeExcessMana(0n, 50_000n, 100_000n)).toBe(0n); + }); + + it('returns zero when excess + used equals target', () => { + expect(computeExcessMana(0n, 100_000n, 100_000n)).toBe(0n); + }); + + it('accumulates excess when usage exceeds target', () => { + expect(computeExcessMana(0n, 200_000n, 100_000n)).toBe(100_000n); + }); + + it('adds to existing excess', () => { + expect(computeExcessMana(50_000n, 200_000n, 100_000n)).toBe(150_000n); + }); + + it('drains excess when usage is below target', () => { + expect(computeExcessMana(100_000n, 50_000n, 100_000n)).toBe(50_000n); + }); + + it('clamps to zero when drain exceeds excess', () => { + expect(computeExcessMana(10_000n, 0n, 100_000n)).toBe(0n); + }); +}); + +describe('computeCongestionMultiplier', () => { + it('returns MINIMUM_CONGESTION_MULTIPLIER when excess is zero', () => { + expect(computeCongestionMultiplier(0n, 100_000_000n)).toBe(MINIMUM_CONGESTION_MULTIPLIER); + }); + + it('increases with excess mana', () => { + const low = computeCongestionMultiplier(100_000n, 100_000_000n); + const high = computeCongestionMultiplier(200_000n, 100_000_000n); + expect(high).toBeGreaterThan(low); + expect(low).toBeGreaterThan(MINIMUM_CONGESTION_MULTIPLIER); + }); + + it('increases by ~12.5% per manaTarget of excess', () => { + // When excessMana = manaTarget, multiplier ≈ 1.125 * MINIMUM_CONGESTION_MULTIPLIER + const manaTarget = 100_000_000n; + const multiplier = computeCongestionMultiplier(manaTarget, manaTarget); + const ratio = Number(multiplier) / Number(MINIMUM_CONGESTION_MULTIPLIER); + expect(ratio).toBeGreaterThan(1.12); + expect(ratio).toBeLessThan(1.13); + }); +}); + +describe('computeManaMinFee', () => { + const baseParams = { + l1BaseFee: 30_000_000_000n, // 30 gwei + l1BlobFee: 1n, + manaTarget: 100_000_000n, + epochDuration: 16n, + provingCostPerManaEth: 0n, + excessMana: 0n, + ethPerFeeAsset: 1_000_000_000_000n, // 1:1 ETH:FeeAsset + }; + + it('returns zero when manaTarget is zero', () => { + expect(computeManaMinFee({ ...baseParams, manaTarget: 0n })).toBe(0n); + }); + + it('returns non-zero for reasonable parameters', () => { + const fee = computeManaMinFee(baseParams); + expect(fee).toBeGreaterThan(0n); + }); + + it('increases with L1 base fee', () => { + const low = computeManaMinFee(baseParams); + const high = computeManaMinFee({ ...baseParams, l1BaseFee: 60_000_000_000n }); + expect(high).toBeGreaterThan(low); + }); + + it('increases with congestion (excess mana)', () => { + const low = computeManaMinFee(baseParams); + const high = computeManaMinFee({ ...baseParams, excessMana: baseParams.manaTarget * 3n }); + expect(high).toBeGreaterThan(low); + }); + + it('increases with blob fee', () => { + const low = computeManaMinFee(baseParams); + const high = computeManaMinFee({ ...baseParams, l1BlobFee: 1_000_000_000n }); + expect(high).toBeGreaterThan(low); + }); + + it('has zero congestion cost when excess mana is zero', () => { + // With zero excess, congestionMultiplier = MINIMUM_CONGESTION_MULTIPLIER, + // so congestionCost = total * 1 - total = 0 + const fee = computeManaMinFee(baseParams); + // The fee should equal just sequencer + prover costs + const feeWithExcess = computeManaMinFee({ ...baseParams, excessMana: baseParams.manaTarget }); + expect(feeWithExcess).toBeGreaterThan(fee); + }); +}); diff --git a/yarn-project/stdlib/src/gas/fee_math.ts b/yarn-project/stdlib/src/gas/fee_math.ts new file mode 100644 index 000000000000..35ff2842c97a --- /dev/null +++ b/yarn-project/stdlib/src/gas/fee_math.ts @@ -0,0 +1,120 @@ +/** + * TypeScript port of fee computation logic from FeeLib.sol. + * Used to predict worst-case min fees over a future window of L2 slots. + */ + +// Constants matching FeeLib.sol +export const MINIMUM_CONGESTION_MULTIPLIER = 1_000_000_000n; +export const L1_GAS_PER_CHECKPOINT_PROPOSED = 300_000n; +export const L1_GAS_PER_EPOCH_VERIFIED = 3_600_000n; +export const BLOBS_PER_CHECKPOINT = 3n; +export const BLOB_GAS_PER_BLOB = 2n ** 17n; +export const MAGIC_CONGESTION_VALUE_MULTIPLIER = 854_700_854n; +export const MAGIC_CONGESTION_VALUE_DIVISOR = 100_000_000n; +export const ETH_PER_FEE_ASSET_PRECISION = 1_000_000_000_000n; +export const MIN_ETH_PER_FEE_ASSET = 100n; + +/** Number of L2 slots of lag before new oracle values activate. Defines the prediction window. */ +export const FEE_ORACLE_LAG = 2; + +/** Expected mana usage per checkpoint for fee prediction. */ +export enum ManaUsageEstimate { + /** No usage — fees decrease as congestion drains. */ + None = 'none', + /** Target usage — congestion stays roughly flat (steady state). */ + Target = 'target', + /** Limit usage (2x target) — worst case, congestion grows maximally. */ + Limit = 'limit', +} + +/** Parameters for computing the mana min fee at a given point in time. */ +export type ManaMinFeeParams = { + l1BaseFee: bigint; + l1BlobFee: bigint; + manaTarget: bigint; + epochDuration: bigint; + provingCostPerManaEth: bigint; + excessMana: bigint; + ethPerFeeAsset: bigint; +}; + +/** + * Taylor series approximation of `factor * e^(numerator/denominator)`. + * Direct port of FeeLib.sol fakeExponential (EIP-4844 style). + */ +export function fakeExponential(factor: bigint, numerator: bigint, denominator: bigint): bigint { + let i = 1n; + let output = 0n; + let numeratorAccumulator = factor * denominator; + while (numeratorAccumulator > 0n) { + output += numeratorAccumulator; + numeratorAccumulator = (numeratorAccumulator * numerator) / (denominator * i); + i += 1n; + } + return output / denominator; +} + +/** Computes excess mana for the next checkpoint, clamped to zero. */ +export function computeExcessMana(prevExcessMana: bigint, prevManaUsed: bigint, manaTarget: bigint): bigint { + const sum = prevExcessMana + prevManaUsed; + return sum > manaTarget ? sum - manaTarget : 0n; +} + +/** Computes the congestion multiplier from excess mana (1e9 = no congestion). */ +export function computeCongestionMultiplier(excessMana: bigint, manaTarget: bigint): bigint { + const denominator = (manaTarget * MAGIC_CONGESTION_VALUE_MULTIPLIER) / MAGIC_CONGESTION_VALUE_DIVISOR; + const cappedNumerator = excessMana < denominator * 100n ? excessMana : denominator * 100n; + return fakeExponential(MINIMUM_CONGESTION_MULTIPLIER, cappedNumerator, denominator); +} + +/** Ceiling division for positive bigints. */ +function ceilDiv(a: bigint, b: bigint): bigint { + return (a + b - 1n) / b; +} + +/** Converts an ETH value to fee asset value using ethPerFeeAsset (1e12 precision). */ +function toFeeAsset(ethValue: bigint, ethPerFeeAsset: bigint): bigint { + if (ethPerFeeAsset === 0n) { + return 0n; + } + return ceilDiv(ethValue * ETH_PER_FEE_ASSET_PRECISION, ethPerFeeAsset); +} + +/** + * Computes the full mana min fee (sequencer + prover + congestion) in fee asset terms. + * Mirrors FeeLib.getManaMinFeeComponentsAt + summedMinFee. + */ +export function computeManaMinFee(params: ManaMinFeeParams): bigint { + const { l1BaseFee, l1BlobFee, manaTarget, epochDuration, provingCostPerManaEth, excessMana, ethPerFeeAsset } = params; + + if (manaTarget === 0n) { + return 0n; + } + + // Sequencer cost per mana (in ETH) + const ethUsed = L1_GAS_PER_CHECKPOINT_PROPOSED * l1BaseFee + BLOBS_PER_CHECKPOINT * BLOB_GAS_PER_BLOB * l1BlobFee; + const sequencerCostEth = ceilDiv(ethUsed, manaTarget); + + // Prover cost per mana (in ETH): L1 verification gas + governance-set proving cost + const proverCostEth = + ceilDiv(ceilDiv(L1_GAS_PER_EPOCH_VERIFIED * l1BaseFee, epochDuration), manaTarget) + provingCostPerManaEth; + + // Total base cost in ETH (congestion is computed on this) + const totalEth = sequencerCostEth + proverCostEth; + + // Congestion multiplier and cost (in ETH) + const congestionMul = computeCongestionMultiplier(excessMana, manaTarget); + const congestionCostEth = (totalEth * congestionMul) / MINIMUM_CONGESTION_MULTIPLIER - totalEth; + + // Convert all components to fee asset + const clampedEthPerFeeAsset = ethPerFeeAsset < MIN_ETH_PER_FEE_ASSET ? MIN_ETH_PER_FEE_ASSET : ethPerFeeAsset; + const sequencerCost = toFeeAsset(sequencerCostEth, clampedEthPerFeeAsset); + const proverCost = toFeeAsset(proverCostEth, clampedEthPerFeeAsset); + const congestionCost = toFeeAsset(congestionCostEth, clampedEthPerFeeAsset); + + const total = sequencerCost + proverCost + congestionCost; + + // Cap at uint128 max (matching FeeLib.summedMinFee) + const UINT128_MAX = (1n << 128n) - 1n; + return total < UINT128_MAX ? total : UINT128_MAX; +} diff --git a/yarn-project/stdlib/src/gas/index.ts b/yarn-project/stdlib/src/gas/index.ts index d1709d11b792..0e75b6a99c9f 100644 --- a/yarn-project/stdlib/src/gas/index.ts +++ b/yarn-project/stdlib/src/gas/index.ts @@ -1,3 +1,4 @@ +export * from './fee_math.js'; export * from './gas.js'; export * from './gas_fees.js'; export * from './gas_settings.js'; diff --git a/yarn-project/stdlib/src/interfaces/aztec-node.test.ts b/yarn-project/stdlib/src/interfaces/aztec-node.test.ts index f95a63c03a68..df0be82080a2 100644 --- a/yarn-project/stdlib/src/interfaces/aztec-node.test.ts +++ b/yarn-project/stdlib/src/interfaces/aztec-node.test.ts @@ -190,6 +190,11 @@ describe('AztecNodeApiSchema', () => { expect(response).toEqual(GasFees.empty()); }); + it('getPredictedMinFees', async () => { + const response = await context.client.getPredictedMinFees(); + expect(response).toEqual([GasFees.empty()]); + }); + it('getMaxPriorityFees', async () => { const response = await context.client.getMaxPriorityFees(); expect(response).toEqual(GasFees.empty()); @@ -658,6 +663,9 @@ class MockAztecNode implements AztecNode { getCurrentMinFees(): Promise { return Promise.resolve(GasFees.empty()); } + getPredictedMinFees(): Promise { + return Promise.resolve([GasFees.empty()]); + } getMaxPriorityFees(): Promise { return Promise.resolve(GasFees.empty()); } diff --git a/yarn-project/stdlib/src/interfaces/aztec-node.ts b/yarn-project/stdlib/src/interfaces/aztec-node.ts index 183354098fd5..57d2f2efd944 100644 --- a/yarn-project/stdlib/src/interfaces/aztec-node.ts +++ b/yarn-project/stdlib/src/interfaces/aztec-node.ts @@ -37,6 +37,7 @@ import { type ProtocolContractAddresses, ProtocolContractAddressesSchema, } from '../contract/index.js'; +import { ManaUsageEstimate } from '../gas/fee_math.js'; import { GasFees } from '../gas/gas_fees.js'; import { SiloedTag, Tag, TxScopedL2Log } from '../logs/index.js'; import { type LogFilter, LogFilterSchema } from '../logs/log_filter.js'; @@ -272,6 +273,15 @@ export interface AztecNode */ getCurrentMinFees(): Promise; + /** + * Returns predicted min fees for the current slot and next N slots. + * Each entry accounts for the L1 gas oracle transition and congestion growth based on the + * given mana usage estimate. Defaults to target usage (steady state). + * @param manaUsage - Expected mana usage per checkpoint (none, target, or limit). + * @returns An array of GasFees, one per slot in the prediction window. + */ + getPredictedMinFees(manaUsage?: ManaUsageEstimate): Promise; + /** * Method to fetch the current max priority fee of txs in the mempool. * @returns The current max priority fees. @@ -578,6 +588,11 @@ export const AztecNodeApiSchema: ApiSchemaFor = { getCurrentMinFees: z.function().returns(GasFees.schema), + getPredictedMinFees: z + .function() + .args(optional(z.nativeEnum(ManaUsageEstimate))) + .returns(z.array(GasFees.schema)), + getMaxPriorityFees: z.function().returns(GasFees.schema), getNodeVersion: z.function().returns(z.string()), diff --git a/yarn-project/stdlib/src/tx/fee_provider.ts b/yarn-project/stdlib/src/tx/fee_provider.ts new file mode 100644 index 000000000000..4cd4d8f29b46 --- /dev/null +++ b/yarn-project/stdlib/src/tx/fee_provider.ts @@ -0,0 +1,10 @@ +import type { ManaUsageEstimate } from '../gas/fee_math.js'; +import type { GasFees } from '../gas/gas_fees.js'; + +/** Provides current and predicted fee information for transaction pricing. */ +export interface FeeProvider { + /** Returns the current minimum fees for inclusion in the next block. */ + getCurrentMinFees(): Promise; + /** Returns predicted min fees for each slot in the prediction window. */ + getPredictedMinFees(manaUsage?: ManaUsageEstimate): Promise; +} diff --git a/yarn-project/stdlib/src/tx/global_variable_builder.ts b/yarn-project/stdlib/src/tx/global_variable_builder.ts index f35e95e96534..ae585fe748dd 100644 --- a/yarn-project/stdlib/src/tx/global_variable_builder.ts +++ b/yarn-project/stdlib/src/tx/global_variable_builder.ts @@ -3,7 +3,6 @@ import type { EthAddress } from '@aztec/foundation/eth-address'; import type { SlotNumber } from '@aztec/foundation/schemas'; import type { AztecAddress } from '../aztec-address/index.js'; -import type { GasFees } from '../gas/gas_fees.js'; import type { UInt32 } from '../types/index.js'; import type { CheckpointGlobalVariables, GlobalVariables } from './global_variables.js'; @@ -11,8 +10,6 @@ import type { CheckpointGlobalVariables, GlobalVariables } from './global_variab * Interface for building global variables for Aztec blocks. */ export interface GlobalVariableBuilder { - getCurrentMinFees(): Promise; - /** * Builds global variables for a given block. * @param blockNumber - The block number to build global variables for. diff --git a/yarn-project/stdlib/src/tx/index.ts b/yarn-project/stdlib/src/tx/index.ts index 7f243c492049..19a112068474 100644 --- a/yarn-project/stdlib/src/tx/index.ts +++ b/yarn-project/stdlib/src/tx/index.ts @@ -24,6 +24,7 @@ export * from './validator/tx_validator.js'; export * from './validator/empty_validator.js'; export * from './validator/error_texts.js'; export * from './capsule.js'; +export * from './fee_provider.js'; export * from './global_variable_builder.js'; export * from './hashed_values.js'; export * from './indexed_tx_effect.js'; diff --git a/yarn-project/txe/src/state_machine/global_variable_builder.ts b/yarn-project/txe/src/state_machine/global_variable_builder.ts index 67d64df6cfea..03b904837064 100644 --- a/yarn-project/txe/src/state_machine/global_variable_builder.ts +++ b/yarn-project/txe/src/state_machine/global_variable_builder.ts @@ -1,16 +1,29 @@ import type { SimulationOverridesPlan } from '@aztec/ethereum/contracts'; import { BlockNumber, type SlotNumber } from '@aztec/foundation/branded-types'; +import { times } from '@aztec/foundation/collection'; import type { EthAddress } from '@aztec/foundation/eth-address'; import type { AztecAddress } from '@aztec/stdlib/aztec-address'; -import { GasFees } from '@aztec/stdlib/gas'; +import { FEE_ORACLE_LAG, GasFees } from '@aztec/stdlib/gas'; import { makeGlobalVariables } from '@aztec/stdlib/testing'; -import { type CheckpointGlobalVariables, type GlobalVariableBuilder, GlobalVariables } from '@aztec/stdlib/tx'; +import { + type CheckpointGlobalVariables, + type FeeProvider, + type GlobalVariableBuilder, + GlobalVariables, +} from '@aztec/stdlib/tx'; -export class TXEGlobalVariablesBuilder implements GlobalVariableBuilder { +/** Simple FeeProvider for TXE that returns zero fees. */ +export class TXEFeeProvider implements FeeProvider { public getCurrentMinFees(): Promise { return Promise.resolve(new GasFees(0, 0)); } + public getPredictedMinFees(): Promise { + return Promise.resolve(times(FEE_ORACLE_LAG, () => new GasFees(0, 0))); + } +} + +export class TXEGlobalVariablesBuilder implements GlobalVariableBuilder { public buildGlobalVariables( _blockNumber: BlockNumber, _coinbase: EthAddress, diff --git a/yarn-project/txe/src/state_machine/index.ts b/yarn-project/txe/src/state_machine/index.ts index ca44362beee2..01e9e496c0b0 100644 --- a/yarn-project/txe/src/state_machine/index.ts +++ b/yarn-project/txe/src/state_machine/index.ts @@ -13,7 +13,7 @@ import { getPackageVersion } from '@aztec/stdlib/update-checker'; import { TXEArchiver } from './archiver.js'; import { DummyP2P } from './dummy_p2p_client.js'; -import { TXEGlobalVariablesBuilder } from './global_variable_builder.js'; +import { TXEFeeProvider, TXEGlobalVariablesBuilder } from './global_variable_builder.js'; import { MockEpochCache } from './mock_epoch_cache.js'; import { TXESynchronizer } from './synchronizer.js'; @@ -56,6 +56,7 @@ export class TXEStateMachine { VERSION, CHAIN_ID, new TXEGlobalVariablesBuilder(), + new TXEFeeProvider(), new MockEpochCache(), getPackageVersion() ?? '', new TestCircuitVerifier(), diff --git a/yarn-project/wallet-sdk/src/base-wallet/base_wallet.test.ts b/yarn-project/wallet-sdk/src/base-wallet/base_wallet.test.ts index 33ea07257f65..ea67a33e072d 100644 --- a/yarn-project/wallet-sdk/src/base-wallet/base_wallet.test.ts +++ b/yarn-project/wallet-sdk/src/base-wallet/base_wallet.test.ts @@ -9,7 +9,7 @@ import { FunctionCall, FunctionSelector, FunctionType } from '@aztec/stdlib/abi' import { AztecAddress } from '@aztec/stdlib/aztec-address'; import { BlockHash } from '@aztec/stdlib/block'; import type { NodeInfo } from '@aztec/stdlib/contract'; -import { Gas, GasFees } from '@aztec/stdlib/gas'; +import { Gas, GasFees, ManaUsageEstimate } from '@aztec/stdlib/gas'; import { PrivateKernelTailCircuitPublicInputs } from '@aztec/stdlib/kernel'; import { BlockHeader, @@ -44,6 +44,10 @@ class BasicWallet extends BaseWallet { override getAccounts(): Promise[]> { throw new Error('Method not implemented.'); } + + public override getMinFees(estimate?: ManaUsageEstimate): Promise { + return super.getMinFees(estimate); + } } async function makeFunctionCall(type: FunctionType, isStatic: boolean, name: string): Promise { @@ -79,6 +83,7 @@ describe('BaseWallet', () => { const optimizedRv1 = new NestedProcessReturnValues([new Fr(200)]); const normalRv0 = new NestedProcessReturnValues([new Fr(300)]); + node.getPredictedMinFees.mockResolvedValue([new GasFees(2, 2)]); node.getCurrentMinFees.mockResolvedValue(new GasFees(2, 2)); node.getNodeInfo.mockResolvedValue({ ...mock(), l1ChainId: 1, rollupVersion: 1 }); pxe.getSyncedBlockHeader.mockResolvedValue(BlockHeader.empty()); @@ -182,6 +187,62 @@ describe('BaseWallet', () => { ]); }); + describe('getMinFees', () => { + let pxe: MockProxy; + let node: MockProxy; + let wallet: BasicWallet; + + beforeEach(() => { + pxe = mock(); + node = mock(); + wallet = new BasicWallet(pxe, node); + }); + + it('returns max fee across all predicted slots', async () => { + node.getPredictedMinFees.mockResolvedValue([new GasFees(1, 100), new GasFees(1, 300), new GasFees(1, 200)]); + + const result = await wallet.getMinFees(); + + expect(result.feePerL2Gas).toBe(300n); + }); + + it('passes ManaUsageEstimate to the node', async () => { + node.getPredictedMinFees.mockResolvedValue([new GasFees(1, 100)]); + + await wallet.getMinFees(ManaUsageEstimate.Limit); + + expect(node.getPredictedMinFees).toHaveBeenCalledWith(ManaUsageEstimate.Limit); + }); + + it('defaults to ManaUsageEstimate.Limit', async () => { + node.getPredictedMinFees.mockResolvedValue([new GasFees(1, 100)]); + + await wallet.getMinFees(); + + expect(node.getPredictedMinFees).toHaveBeenCalledWith(ManaUsageEstimate.Limit); + }); + + it('falls back to getCurrentMinFees on empty array', async () => { + node.getPredictedMinFees.mockResolvedValue([]); + node.getCurrentMinFees.mockResolvedValue(new GasFees(1, 500)); + + const result = await wallet.getMinFees(); + + expect(result.feePerL2Gas).toBe(500n); + expect(node.getCurrentMinFees).toHaveBeenCalled(); + }); + + it('falls back to getCurrentMinFees when getPredictedMinFees throws', async () => { + node.getPredictedMinFees.mockRejectedValue(new Error('Method not found')); + node.getCurrentMinFees.mockResolvedValue(new GasFees(1, 500)); + + const result = await wallet.getMinFees(); + + expect(result.feePerL2Gas).toBe(500n); + expect(node.getCurrentMinFees).toHaveBeenCalled(); + }); + }); + it('should extract offchain messages with anchor block timestamp on sendTx', async () => { pxe = mock(); node = mock(); @@ -214,6 +275,7 @@ describe('BaseWallet', () => { provenTx.toTx.mockResolvedValue(mockTx); // Mock dependencies for completeFeeOptions and createTxExecutionRequestFromPayloadAndFee + node.getPredictedMinFees.mockResolvedValue([new GasFees(2, 2)]); node.getCurrentMinFees.mockResolvedValue(new GasFees(2, 2)); node.getNodeInfo.mockResolvedValue({ ...mock(), l1ChainId: 1, rollupVersion: 1 }); pxe.getSyncedBlockHeader.mockResolvedValue(BlockHeader.empty()); diff --git a/yarn-project/wallet-sdk/src/base-wallet/base_wallet.ts b/yarn-project/wallet-sdk/src/base-wallet/base_wallet.ts index 3008f3fd0b86..25e928edffec 100644 --- a/yarn-project/wallet-sdk/src/base-wallet/base_wallet.ts +++ b/yarn-project/wallet-sdk/src/base-wallet/base_wallet.ts @@ -47,7 +47,7 @@ import { getContractClassFromArtifact, } from '@aztec/stdlib/contract'; import { SimulationError } from '@aztec/stdlib/errors'; -import { Gas, GasFees, GasSettings } from '@aztec/stdlib/gas'; +import { Gas, GasFees, GasSettings, ManaUsageEstimate } from '@aztec/stdlib/gas'; import { computeSiloedPrivateInitializationNullifier, computeSiloedPublicInitializationNullifier, @@ -55,11 +55,12 @@ import { import type { AztecNode } from '@aztec/stdlib/interfaces/client'; import { BlockHeader, + ExecutionPayload, type TxExecutionRequest, type TxProfileResult, type UtilityExecutionResult, + mergeExecutionPayloads, } from '@aztec/stdlib/tx'; -import { ExecutionPayload, mergeExecutionPayloads } from '@aztec/stdlib/tx'; import { inspect } from 'util'; @@ -101,6 +102,11 @@ export type CompleteFeeOptionsConfig = { gasSettings?: Partial>; /** If true, returns gas settings with high gas limits for estimation. If false, uses fallback limits. */ forEstimation?: boolean; + /** + * Assumed network congestion level for fee prediction. Controls how aggressively the wallet + * estimates future fees. Defaults to Limit (worst case) when not specified. + */ + congestionEstimate?: ManaUsageEstimate; }; /** @@ -224,9 +230,9 @@ export abstract class BaseWallet implements Wallet { * @param config - Fee completion config. */ protected async completeFeeOptions(config: CompleteFeeOptionsConfig): Promise { - const { from, feePayer, gasSettings, forEstimation } = config; + const { from, feePayer, gasSettings, forEstimation, congestionEstimate } = config; const maxFeesPerGas = - gasSettings?.maxFeesPerGas ?? (await this.aztecNode.getCurrentMinFees()).mul(1 + this.minFeePadding); + gasSettings?.maxFeesPerGas ?? (await this.getMinFees(congestionEstimate)).mul(1 + this.minFeePadding); let accountFeePaymentMethodOptions; // If from is an address, we need to determine the appropriate fee payment method options for the // account contract entrypoint to use @@ -262,6 +268,28 @@ export abstract class BaseWallet implements Wallet { }; } + /** + * Returns the worst-case min fee across predicted future slots. + * Falls back to getCurrentMinFees if the node doesn't support getPredictedMinFees. + * @param estimate - The mana usage estimate to use for fee prediction. Defaults to Limit for conservative estimation. + */ + protected async getMinFees(estimate: ManaUsageEstimate = ManaUsageEstimate.Limit): Promise { + try { + const predicted = await this.aztecNode.getPredictedMinFees(estimate); + if (predicted.length === 0) { + return this.aztecNode.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 this.aztecNode.getCurrentMinFees(); + } + throw err; + } + } + registerSender(address: AztecAddress, _alias: string = ''): Promise { return this.pxe.registerSender(address); } @@ -356,6 +384,7 @@ export abstract class BaseWallet implements Wallet { feePayer: executionPayload.feePayer, gasSettings: opts.fee?.gasSettings, forEstimation: true, + congestionEstimate: opts.fee?.congestionEstimate, }); const { optimizableCalls, remainingCalls } = extractOptimizablePublicStaticCalls(executionPayload); const remainingPayload = { ...executionPayload, calls: remainingCalls }; @@ -403,6 +432,7 @@ export abstract class BaseWallet implements Wallet { from: opts.from, feePayer: executionPayload.feePayer, gasSettings: opts.fee?.gasSettings, + congestionEstimate: opts.fee?.congestionEstimate, }); const txRequest = await this.createTxExecutionRequestFromPayloadAndFee(executionPayload, opts.from, feeOptions); return this.pxe.profileTx(txRequest, { @@ -420,6 +450,7 @@ export abstract class BaseWallet implements Wallet { from: opts.from, feePayer: executionPayload.feePayer, gasSettings: opts.fee?.gasSettings, + congestionEstimate: opts.fee?.congestionEstimate, }); const txRequest = await this.createTxExecutionRequestFromPayloadAndFee(executionPayload, opts.from, feeOptions); const provenTx = await this.pxe.proveTx(txRequest, this.scopesFrom(opts.from, opts.additionalScopes));