Skip to content

Commit c7c25f5

Browse files
committed
feat: initial go at fee asset price oracle
1 parent 4973446 commit c7c25f5

36 files changed

+1255
-62
lines changed
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
// Copyright 2024 Aztec Labs.
3+
pragma solidity >=0.8.27;
4+
5+
import {Test} from "forge-std/Test.sol";
6+
import {Math} from "@oz/utils/math/Math.sol";
7+
8+
interface IStateView {
9+
function getSlot0(bytes32 poolId)
10+
external
11+
view
12+
returns (uint160 sqrtPriceX96, int24 tick, uint24 protocolFee, uint24 lpFee);
13+
}
14+
15+
contract UniswapLookupScript is Test {
16+
function lookAtUniswap() public {
17+
// Uniswap V4 StateView contract on mainnet
18+
IStateView stateView = IStateView(0x7fFE42C4a5DEeA5b0feC41C94C136Cf115597227);
19+
20+
address currency0 = address(0); // Native ETH
21+
address currency1 = 0xA27EC0006e59f245217Ff08CD52A7E8b169E62D2; // Fee asset token
22+
uint24 fee = 500; // 0.05%
23+
int24 tickSpacing = 10;
24+
address hooks = 0xd53006d1e3110fD319a79AEEc4c527a0d265E080;
25+
26+
// Compute pool ID: keccak256(abi.encode(currency0, currency1, fee, tickSpacing, hooks))
27+
bytes32 poolId = keccak256(abi.encode(currency0, currency1, fee, tickSpacing, hooks));
28+
emit log_named_bytes32("Pool ID", poolId);
29+
30+
// Query the real pool state
31+
(uint160 sqrtPriceX96, int24 tick, uint24 protocolFee, uint24 lpFee) = stateView.getSlot0(poolId);
32+
33+
emit log_named_uint("sqrtPriceX96", sqrtPriceX96);
34+
emit log_named_int("tick", tick);
35+
emit log_named_uint("protocolFee", protocolFee);
36+
emit log_named_uint("lpFee", lpFee);
37+
38+
// Convert to ethPerFeeAssetE12
39+
// ethPerFeeAssetE12 = 1e12 * 2^192 / sqrtPriceX96^2
40+
uint256 Q192 = 2 ** 192;
41+
uint256 sqrtPriceSquared = uint256(sqrtPriceX96) * uint256(sqrtPriceX96);
42+
uint256 ethPerFeeAssetE12 = (1e12 * Q192) / sqrtPriceSquared;
43+
44+
emit log_named_decimal_uint("ethPerFeeAssetE12 (computed)", ethPerFeeAssetE12, 12);
45+
46+
// Compute what sqrtPriceX96 would give us a price 0.5% higher
47+
uint256 targetPriceHalfPercentHigher = (ethPerFeeAssetE12 * 1005) / 1000;
48+
emit log_named_decimal_uint("Target price (0.5% higher)", targetPriceHalfPercentHigher, 12);
49+
50+
// sqrtPriceX96^2 = 1e12 * 2^192 / targetPrice
51+
uint256 targetSqrtSquared = (1e12 * Q192) / targetPriceHalfPercentHigher;
52+
uint256 targetSqrtPriceX96 = Math.sqrt(targetSqrtSquared);
53+
emit log_named_uint("Target sqrtPriceX96", targetSqrtPriceX96);
54+
55+
// Verify: compute the price back from the sqrt
56+
uint256 verifyPrice = (1e12 * Q192) / (targetSqrtPriceX96 * targetSqrtPriceX96);
57+
assertEq(verifyPrice, targetPriceHalfPercentHigher);
58+
}
59+
}

yarn-project/archiver/src/l1/calldata_retriever.test.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -332,7 +332,7 @@ describe('CalldataRetriever', () => {
332332
const attestations = makeViemCommitteeAttestations();
333333
const archiveRoot = Fr.random();
334334
const archive = archiveRoot.toString() as Hex;
335-
const feeAssetPriceModifier = BigInt(0);
335+
const feeAssetPriceModifier = BigInt(-1);
336336

337337
// Create propose calldata with known values
338338
const proposeCalldata = encodeFunctionData({
@@ -355,8 +355,9 @@ describe('CalldataRetriever', () => {
355355
publicClient.getTransaction.mockResolvedValue(tx);
356356

357357
// Compute the expected payloadDigest using ConsensusPayload (same logic as the validator)
358+
// Note: feeAssetPriceModifier is 0n in makeProposeCalldata
358359
const checkpointHeader = CheckpointHeader.fromViem(header);
359-
const consensusPayload = new ConsensusPayload(checkpointHeader, archiveRoot);
360+
const consensusPayload = new ConsensusPayload(checkpointHeader, archiveRoot, feeAssetPriceModifier);
360361
const payloadToSign = consensusPayload.getPayloadToSign(SignatureDomainSeparator.checkpointAttestation);
361362
const expectedPayloadDigest = keccak256(payloadToSign);
362363

yarn-project/archiver/src/l1/calldata_retriever.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ export class CalldataRetriever {
8484
header: CheckpointHeader;
8585
attestations: CommitteeAttestation[];
8686
blockHash: string;
87+
feeAssetPriceModifier: bigint;
8788
}> {
8889
this.logger.trace(`Fetching checkpoint ${checkpointNumber} from rollup tx ${txHash}`, {
8990
willValidateHashes: !!expectedHashes.attestationsHash || !!expectedHashes.payloadDigest,
@@ -403,6 +404,7 @@ export class CalldataRetriever {
403404
header: CheckpointHeader;
404405
attestations: CommitteeAttestation[];
405406
blockHash: string;
407+
feeAssetPriceModifier: bigint;
406408
} {
407409
const { functionName: rollupFunctionName, args: rollupArgs } = decodeFunctionData({
408410
abi: RollupAbi,
@@ -458,7 +460,8 @@ export class CalldataRetriever {
458460
if (expectedHashes.payloadDigest) {
459461
// Use ConsensusPayload to compute the digest - this ensures we match the exact logic
460462
// used by the network for signing and verification
461-
const consensusPayload = new ConsensusPayload(header, archiveRoot);
463+
const feeAssetPriceModifier = decodedArgs.oracleInput.feeAssetPriceModifier;
464+
const consensusPayload = new ConsensusPayload(header, archiveRoot, feeAssetPriceModifier);
462465
const payloadToSign = consensusPayload.getPayloadToSign(SignatureDomainSeparator.checkpointAttestation);
463466
const computedPayloadDigest = keccak256(payloadToSign);
464467

@@ -495,6 +498,7 @@ export class CalldataRetriever {
495498
header,
496499
attestations,
497500
blockHash,
501+
feeAssetPriceModifier: decodedArgs.oracleInput.feeAssetPriceModifier,
498502
};
499503
}
500504
}

yarn-project/archiver/src/l1/data_retrieval.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import { CalldataRetriever } from './calldata_retriever.js';
3838
export type RetrievedCheckpoint = {
3939
checkpointNumber: CheckpointNumber;
4040
archiveRoot: Fr;
41+
feeAssetPriceModifier: bigint;
4142
header: CheckpointHeader;
4243
checkpointBlobData: CheckpointBlobData;
4344
l1: L1PublishedData;
@@ -49,6 +50,7 @@ export type RetrievedCheckpoint = {
4950
export async function retrievedToPublishedCheckpoint({
5051
checkpointNumber,
5152
archiveRoot,
53+
feeAssetPriceModifier,
5254
header: checkpointHeader,
5355
checkpointBlobData,
5456
l1,
@@ -128,6 +130,7 @@ export async function retrievedToPublishedCheckpoint({
128130
header: checkpointHeader,
129131
blocks: l2Blocks,
130132
number: checkpointNumber,
133+
feeAssetPriceModifier: feeAssetPriceModifier,
131134
});
132135

133136
return PublishedCheckpoint.from({ checkpoint, l1, attestations });

yarn-project/archiver/src/modules/validation.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,18 @@ describe('validateCheckpointAttestations', () => {
7979
setCommittee(committee);
8080
});
8181

82+
it('uses feeAssetPriceModifier when recovering attestors', async () => {
83+
const checkpoint = await makeCheckpoint(signers.slice(0, 4), committee);
84+
// Non-zero modifier should be included in the signed payload.
85+
checkpoint.checkpoint.feeAssetPriceModifier = 1n;
86+
87+
const attestationInfos = getAttestationInfoFromPublishedCheckpoint(checkpoint);
88+
expect(attestationInfos.filter(a => a.status === 'recovered-from-signature').length).toBe(4);
89+
90+
const result = await validateCheckpointAttestations(checkpoint, epochCache, constants, logger);
91+
expect(result.valid).toBe(true);
92+
});
93+
8294
it('requests committee for the correct epoch', async () => {
8395
const checkpoint = await makeCheckpoint(signers, committee, 28);
8496
await validateCheckpointAttestations(checkpoint, epochCache, constants, logger);
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import type { Logger } from '@aztec/aztec.js/log';
2+
import { EthCheatCodes } from '@aztec/aztec/testing';
3+
import { createExtendedL1Client } from '@aztec/ethereum/client';
4+
import { RollupContract, STATE_VIEW_ADDRESS } from '@aztec/ethereum/contracts';
5+
import { retryUntil } from '@aztec/foundation/retry';
6+
import { DateProvider } from '@aztec/foundation/timer';
7+
8+
import { jest } from '@jest/globals';
9+
import type { Anvil } from '@viem/anvil';
10+
import { mnemonicToAccount } from 'viem/accounts';
11+
import { foundry } from 'viem/chains';
12+
13+
import { MNEMONIC } from './fixtures/fixtures.js';
14+
import { getLogger, setup, startAnvil } from './fixtures/utils.js';
15+
import { MockStateView, diffInBps } from './shared/mock_state_view.js';
16+
17+
describe('FeeAssetPriceOracle E2E', () => {
18+
jest.setTimeout(300_000);
19+
20+
let logger: Logger;
21+
let teardown: () => Promise<void>;
22+
let anvil: Anvil;
23+
let rollup: RollupContract;
24+
let mockStateView: MockStateView;
25+
let ethCheatCodes: EthCheatCodes;
26+
27+
// Beware, if you use "mainnet" here it will be completely broken due to blobs...
28+
const chain = foundry;
29+
30+
beforeAll(async () => {
31+
logger = getLogger();
32+
33+
const anvilResult = await startAnvil({ chainId: chain.id });
34+
anvil = anvilResult.anvil;
35+
const rpcUrl = anvilResult.rpcUrl;
36+
37+
// Set ETHEREUM_HOSTS so setup() uses our pre-started Anvil
38+
process.env.ETHEREUM_HOSTS = rpcUrl;
39+
40+
// Deploy mock StateView BEFORE the full setup, so the oracle can read from it
41+
ethCheatCodes = new EthCheatCodes([rpcUrl], new DateProvider());
42+
const account = mnemonicToAccount(MNEMONIC, { addressIndex: 999 });
43+
const walletClient = createExtendedL1Client([rpcUrl], account, chain);
44+
45+
await ethCheatCodes.setBalance(account.address, 100n * 10n ** 18n);
46+
47+
mockStateView = await MockStateView.deploy(ethCheatCodes, walletClient, STATE_VIEW_ADDRESS);
48+
logger.info(`Deployed mock StateView at ${STATE_VIEW_ADDRESS}`);
49+
50+
// The initial oracle price (default value) is 1e7
51+
await mockStateView.setEthPerFeeAsset(10n ** 7n);
52+
53+
await ethCheatCodes.mineEmptyBlock();
54+
await ethCheatCodes.mine(10);
55+
await ethCheatCodes.mineEmptyBlock();
56+
57+
const context = await setup(0, { l1ChainId: chain.id, minTxsPerBlock: 0 }, {}, chain);
58+
teardown = context.teardown;
59+
60+
const l1Client = context.deployL1ContractsValues.l1Client;
61+
rollup = new RollupContract(l1Client, context.deployL1ContractsValues.l1ContractAddresses.rollupAddress);
62+
});
63+
64+
afterAll(async () => {
65+
await teardown?.();
66+
await anvil?.stop().catch(err => logger.error('Failed to stop anvil', err));
67+
delete process.env.ETHEREUM_HOSTS;
68+
});
69+
70+
it('on-chain price converges toward oracle price over multiple checkpoints', async () => {
71+
// Move the price up 2.5% (2 moves of 1% and another smaller)
72+
// Wait until we are within 1 bps or the price
73+
// Then move the price down 0.5%
74+
// Wait until 1 bps of the price
75+
// Profit
76+
77+
const targetOraclePrice = (BigInt(10n ** 7n) * 1025n) / 1000n;
78+
await mockStateView.setEthPerFeeAsset(targetOraclePrice);
79+
logger.info(`Set uniswap price to ${targetOraclePrice}`);
80+
81+
// Get initial on-chain price
82+
const initialOnChainPrice = await rollup.getEthPerFeeAsset();
83+
logger.info(`Initial on-chain price: ${initialOnChainPrice}, target oracle price: ${targetOraclePrice}`);
84+
85+
await retryUntil(
86+
async () => {
87+
const currentPrice = await rollup.getEthPerFeeAsset();
88+
logger.info(`Current on-chain price: ${currentPrice}, waiting for: ${targetOraclePrice}`);
89+
return diffInBps(currentPrice, targetOraclePrice) == 0n;
90+
},
91+
'price convergence toward oracle',
92+
120, // timeout in seconds
93+
5, // check interval in seconds
94+
);
95+
96+
const priceAfterFirstAlignment = await rollup.getEthPerFeeAsset();
97+
const targetOraclePrice2 = (BigInt(priceAfterFirstAlignment) * 995n) / 1000n;
98+
await mockStateView.setEthPerFeeAsset(targetOraclePrice2);
99+
logger.info(`Set uniswap price to ${targetOraclePrice}`);
100+
101+
await retryUntil(
102+
async () => {
103+
const currentPrice = await rollup.getEthPerFeeAsset();
104+
logger.info(`Current on-chain price: ${currentPrice}, waiting for: ${targetOraclePrice2}`);
105+
return diffInBps(currentPrice, targetOraclePrice2) == 0n;
106+
},
107+
'price convergence toward oracle',
108+
120, // timeout in seconds
109+
5, // check interval in seconds
110+
);
111+
112+
const finalPrice = await rollup.getEthPerFeeAsset();
113+
logger.info(`Final on-chain price: ${finalPrice}`);
114+
115+
// Verify the price moved toward the oracle price
116+
expect(finalPrice).toBeGreaterThan(initialOnChainPrice);
117+
expect(diffInBps(finalPrice, targetOraclePrice2)).toBe(0n);
118+
});
119+
});

0 commit comments

Comments
 (0)