diff --git a/yarn-project/end-to-end/src/e2e_p2p/fee_asset_price_oracle_gossip.test.ts b/yarn-project/end-to-end/src/e2e_p2p/fee_asset_price_oracle_gossip.test.ts index 04b436680edb..c384797a938c 100644 --- a/yarn-project/end-to-end/src/e2e_p2p/fee_asset_price_oracle_gossip.test.ts +++ b/yarn-project/end-to-end/src/e2e_p2p/fee_asset_price_oracle_gossip.test.ts @@ -63,6 +63,8 @@ describe('e2e_p2p_network', () => { slashingRoundSizeInEpochs: 2, slashingQuorum: 5, listenAddress: '127.0.0.1', + enableProposerPipelining: true, + inboxLag: 2, }, }); diff --git a/yarn-project/ethereum/src/contracts/fee_asset_price_oracle.test.ts b/yarn-project/ethereum/src/contracts/fee_asset_price_oracle.test.ts index 7d4f5d78d6b2..4e56319a2a5b 100644 --- a/yarn-project/ethereum/src/contracts/fee_asset_price_oracle.test.ts +++ b/yarn-project/ethereum/src/contracts/fee_asset_price_oracle.test.ts @@ -1,8 +1,14 @@ +import { jest } from '@jest/globals'; +import { type MockProxy, mock } from 'jest-mock-extended'; + +import type { ViemClient } from '../types.js'; import { + FeeAssetPriceOracle, MAX_FEE_ASSET_PRICE_MODIFIER_BPS, sqrtPriceX96ToEthPerFeeAssetE12, validateFeeAssetPriceModifier, } from './fee_asset_price_oracle.js'; +import { RollupContract } from './rollup.js'; describe('Uniswap Price Oracle', () => { describe('sqrtPriceX96ToEthPerFeeAssetE12', () => { @@ -52,6 +58,70 @@ describe('Uniswap Price Oracle', () => { }); }); + describe('computePriceModifier', () => { + let client: MockProxy; + let rollupContract: MockProxy; + let oracle: FeeAssetPriceOracle; + const oraclePriceE12 = 10n ** 7n; + + beforeEach(() => { + client = mock(); + rollupContract = mock(); + oracle = new FeeAssetPriceOracle(client, rollupContract); + + // Inject a stub uniswap oracle so we can drive the oracle price without touching the + // real Uniswap V4 StateView contract. + jest.spyOn(oracle, 'getUniswapOracle').mockResolvedValue({ + getMeanEthPerFeeAssetE12: () => Promise.resolve(oraclePriceE12), + } as never); + }); + + it('reads ethPerFeeAsset from L1 when no predicted price is provided', async () => { + const onChainPriceE12 = 9_950_000n; + rollupContract.getEthPerFeeAsset.mockResolvedValue(onChainPriceE12); + + const modifier = await oracle.computePriceModifier(); + + expect(rollupContract.getEthPerFeeAsset).toHaveBeenCalledTimes(1); + expect(modifier).toBe(oracle.computePriceModifierBps(onChainPriceE12, oraclePriceE12)); + }); + + it('uses the supplied predicted price and skips the L1 read when provided', async () => { + const predictedPriceE12 = 10_050_000n; + const modifier = await oracle.computePriceModifier(predictedPriceE12); + + expect(rollupContract.getEthPerFeeAsset).not.toHaveBeenCalled(); + expect(modifier).toBe(oracle.computePriceModifierBps(predictedPriceE12, oraclePriceE12)); + }); + + it('emits the modifier that drives the predicted parent toward (but not exactly to) the target', async () => { + // Pick a target within the ±100 bps cap so the test exercises truncation rather than the clamp. + // P=10_100_000, T=10_150_000: + // raw bps = floor((T - P) * 10_000 / P) = floor(50_000 * 10_000 / 10_100_000) = 49 + // child = floor(P * (10_000 + 49) / 10_000) = 10_149_490 + // Note 10_149_490 != 10_150_000 — bps truncation leaves the child ~510 LSB (~0.5 bp) shy + // of the target. The e2e test depends on this sub-bp gap: convergence is monotonic and + // within sub-bp of target, not exact equality. + const predictedParentE12 = 10_100_000n; + const targetE12 = 10_150_000n; + jest.spyOn(oracle, 'getUniswapOracle').mockResolvedValue({ + getMeanEthPerFeeAssetE12: () => Promise.resolve(targetE12), + } as never); + + const modifier = await oracle.computePriceModifier(predictedParentE12); + expect(modifier).toBe(49n); + + const child = RollupContract.computeChildFeeHeader( + { excessMana: 0n, manaUsed: 0n, ethPerFeeAsset: predictedParentE12, congestionCost: 0n, proverCost: 0n }, + 0n, + modifier, + 100n, + ); + expect(child.ethPerFeeAsset).toBe(10_149_490n); + expect(child.ethPerFeeAsset).not.toBe(targetE12); + }); + }); + describe('validateFeeAssetPriceModifier', () => { it('accepts 0 modifier', () => { expect(validateFeeAssetPriceModifier(0n)).toBe(true); diff --git a/yarn-project/ethereum/src/contracts/fee_asset_price_oracle.ts b/yarn-project/ethereum/src/contracts/fee_asset_price_oracle.ts index 540d954628d4..6b5b9b51da99 100644 --- a/yarn-project/ethereum/src/contracts/fee_asset_price_oracle.ts +++ b/yarn-project/ethereum/src/contracts/fee_asset_price_oracle.ts @@ -63,28 +63,33 @@ export class FeeAssetPriceOracle { * * Returns 0 if not on mainnet or if the oracle query fails. * + * @param currentPriceE12 - Optional override for the parent checkpoint's eth-per-fee-asset + * (E12 scale). When omitted, the latest published value is read from L1. Pipelined + * proposers should supply the predicted parent value so the modifier they emit aligns + * with the parent fee header L1 will see when the previous pipelined checkpoint lands. * @returns The price modifier in basis points (positive to increase price, negative to decrease) */ - async computePriceModifier(): Promise { + async computePriceModifier(currentPriceE12?: bigint): Promise { const uniswapOracle = await this.getUniswapOracle(); if (!uniswapOracle) { return 0n; } try { - // Get current on-chain price (ETH per fee asset, E12) - const currentPriceE12 = await this.rollupContract.getEthPerFeeAsset(); + // Get current on-chain price (ETH per fee asset, E12), preferring the caller-supplied value. + const resolvedCurrentPriceE12 = currentPriceE12 ?? (await this.rollupContract.getEthPerFeeAsset()); // Get oracle price (median of last N blocks, ETH per fee asset, E12) const oraclePriceE12 = await uniswapOracle.getMeanEthPerFeeAssetE12(); // Compute modifier in basis points - const modifier = this.computePriceModifierBps(currentPriceE12, oraclePriceE12); + const modifier = this.computePriceModifierBps(resolvedCurrentPriceE12, oraclePriceE12); this.log.debug('Computed price modifier', { - currentPriceE12: currentPriceE12.toString(), + currentPriceE12: resolvedCurrentPriceE12.toString(), oraclePriceE12: oraclePriceE12.toString(), modifierBps: modifier.toString(), + currentPriceFromCaller: currentPriceE12 !== undefined, }); return modifier; diff --git a/yarn-project/sequencer-client/src/publisher/sequencer-publisher.ts b/yarn-project/sequencer-client/src/publisher/sequencer-publisher.ts index b5f49d000293..47819bcb1221 100644 --- a/yarn-project/sequencer-client/src/publisher/sequencer-publisher.ts +++ b/yarn-project/sequencer-client/src/publisher/sequencer-publisher.ts @@ -287,10 +287,14 @@ export class SequencerPublisher { /** * Gets the fee asset price modifier from the oracle. - * Returns 0n if the oracle query fails. + * + * @param predictedParentEthPerFeeAssetE12 - Optional predicted parent eth-per-fee-asset (E12). + * Pipelined proposers should pass the value from the predicted parent fee header so the + * modifier matches the parent L1 will use when applying it. + * @returns The fee asset price modifier in basis points, or 0n if the oracle query fails. */ - public getFeeAssetPriceModifier(): Promise { - return this.feeAssetPriceOracle.computePriceModifier(); + public getFeeAssetPriceModifier(predictedParentEthPerFeeAssetE12?: bigint): Promise { + return this.feeAssetPriceOracle.computePriceModifier(predictedParentEthPerFeeAssetE12); } public getSenderAddress() { diff --git a/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.ts b/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.ts index 3c5317dca265..c0228cae85c0 100644 --- a/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.ts +++ b/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.ts @@ -587,8 +587,11 @@ export class CheckpointProposalJob implements Traceable { .filter(c => c.checkpointNumber < this.checkpointNumber) .map(c => c.checkpointOutHash); - // Get the fee asset price modifier from the oracle - const feeAssetPriceModifier = await this.publisher.getFeeAssetPriceModifier(); + // Anchor the modifier to the predicted parent fee header: L1 will apply it against + // that, not against the latest published checkpoint (which lags by one under pipelining). + const predictedParentEthPerFeeAssetE12 = + this.pipelinedParentSimulationOverridesPlan?.pendingCheckpointState?.feeHeader?.ethPerFeeAsset; + const feeAssetPriceModifier = await this.publisher.getFeeAssetPriceModifier(predictedParentEthPerFeeAssetE12); // Create a long-lived forked world state for the checkpoint builder await using fork = await this.worldState.fork(this.syncedToBlockNumber, { closeDelayMs: 12_000 });