Skip to content

Commit 3fdb9af

Browse files
authored
feat: fee asset price oracle in node (#19965)
The pr uses the seeded uniswap pool as a pricing feed for Aztec token. When the sequencer is to propose a checkpoint, he will use the uniswap oracle and fetch the price for the last 5 blocks. He takes the average of these 5 blocks and sees that as the price of the asset, he then computes the fee asset modifier (up to 100 bps up or down) based on that value and the current value. It is included as part of the checkpoint. Other validators will sign the payload if the fee asset modifier is valid (`-100 <= x <= 100`) but does not take actual price into account for now. Notable changes: - Needed to have a serialize and deserialize for bigger signed ints - Include the fee asset price modifier in checkpoint and consensus payload - new fee asset price oracle
2 parents 65b21a6 + 1cb8102 commit 3fdb9af

37 files changed

Lines changed: 1269 additions & 65 deletions
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.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ describe('data_retrieval', () => {
3333
const retrievedCheckpoint: RetrievedCheckpoint = {
3434
checkpointNumber: CheckpointNumber(1),
3535
archiveRoot,
36+
feeAssetPriceModifier: 0n,
3637
header: CheckpointHeader.random(),
3738
checkpointBlobData,
3839
l1: new L1PublishedData(1n, 1000n, '0x1234'),
@@ -94,6 +95,7 @@ describe('data_retrieval', () => {
9495
const retrievedCheckpoint: RetrievedCheckpoint = {
9596
checkpointNumber: CheckpointNumber(1),
9697
archiveRoot,
98+
feeAssetPriceModifier: 0n,
9799
header: CheckpointHeader.random(),
98100
checkpointBlobData,
99101
l1: new L1PublishedData(1n, 1000n, '0x1234'),

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: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,16 @@ describe('validateCheckpointAttestations', () => {
2222

2323
const constants = { epochDuration: 10 };
2424

25-
const makeCheckpoint = async (signers: Secp256k1Signer[], committee: EthAddress[], slot?: number) => {
26-
const checkpoint = await Checkpoint.random(CheckpointNumber(1), { slotNumber: SlotNumber(slot ?? 1) });
25+
const makeCheckpoint = async (
26+
signers: Secp256k1Signer[],
27+
committee: EthAddress[],
28+
slot?: number,
29+
feeAssetPriceModifier?: bigint,
30+
) => {
31+
const checkpoint = await Checkpoint.random(CheckpointNumber(1), {
32+
slotNumber: SlotNumber(slot ?? 1),
33+
feeAssetPriceModifier,
34+
});
2735
return makeSignedPublishedCheckpoint(checkpoint, signers, committee);
2836
};
2937

@@ -79,6 +87,16 @@ describe('validateCheckpointAttestations', () => {
7987
setCommittee(committee);
8088
});
8189

90+
it('uses feeAssetPriceModifier when recovering attestors', async () => {
91+
const checkpoint = await makeCheckpoint(signers.slice(0, 4), committee, 1, 1n);
92+
93+
const attestationInfos = getAttestationInfoFromPublishedCheckpoint(checkpoint);
94+
expect(attestationInfos.filter(a => a.status === 'recovered-from-signature').length).toBe(4);
95+
96+
const result = await validateCheckpointAttestations(checkpoint, epochCache, constants, logger);
97+
expect(result.valid).toBe(true);
98+
});
99+
82100
it('requests committee for the correct epoch', async () => {
83101
const checkpoint = await makeCheckpoint(signers, committee, 28);
84102
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)