Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ describe('e2e_p2p_network', () => {
slashingRoundSizeInEpochs: 2,
slashingQuorum: 5,
listenAddress: '127.0.0.1',
enableProposerPipelining: true,
inboxLag: 2,
},
});

Expand Down
70 changes: 70 additions & 0 deletions yarn-project/ethereum/src/contracts/fee_asset_price_oracle.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -52,6 +58,70 @@ describe('Uniswap Price Oracle', () => {
});
});

describe('computePriceModifier', () => {
let client: MockProxy<ViemClient>;
let rollupContract: MockProxy<RollupContract>;
let oracle: FeeAssetPriceOracle;
const oraclePriceE12 = 10n ** 7n;

beforeEach(() => {
client = mock<ViemClient>();
rollupContract = mock<RollupContract>();
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);
Expand Down
15 changes: 10 additions & 5 deletions yarn-project/ethereum/src/contracts/fee_asset_price_oracle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<bigint> {
async computePriceModifier(currentPriceE12?: bigint): Promise<bigint> {
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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<bigint> {
return this.feeAssetPriceOracle.computePriceModifier();
public getFeeAssetPriceModifier(predictedParentEthPerFeeAssetE12?: bigint): Promise<bigint> {
return this.feeAssetPriceOracle.computePriceModifier(predictedParentEthPerFeeAssetE12);
}

public getSenderAddress() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
Loading