From 1cb810297493010d5d1c0af388365534785b4580 Mon Sep 17 00:00:00 2001 From: LHerskind <16536249+LHerskind@users.noreply.github.com> Date: Fri, 13 Feb 2026 09:18:03 +0000 Subject: [PATCH] feat: initial go at fee asset price oracle --- l1-contracts/script/UniswapLookup.s.sol | 59 ++++ .../src/l1/calldata_retriever.test.ts | 5 +- .../archiver/src/l1/calldata_retriever.ts | 6 +- .../archiver/src/l1/data_retrieval.test.ts | 2 + .../archiver/src/l1/data_retrieval.ts | 3 + .../archiver/src/modules/validation.test.ts | 22 +- .../src/e2e_fee_asset_price_oracle.test.ts | 119 ++++++++ .../fee_asset_price_oracle_gossip.test.ts | 229 +++++++++++++++ yarn-project/end-to-end/src/shared/index.ts | 1 + .../end-to-end/src/shared/mock_state_view.ts | 188 +++++++++++++ .../contracts/fee_asset_price_oracle.test.ts | 80 ++++++ .../src/contracts/fee_asset_price_oracle.ts | 262 ++++++++++++++++++ yarn-project/ethereum/src/contracts/index.ts | 1 + .../foundation/src/serialize/buffer_reader.ts | 15 + .../foundation/src/serialize/serialize.ts | 32 +++ .../src/mem_pools/attestation_pool/mocks.ts | 3 +- .../light/lightweight_checkpoint_builder.ts | 10 +- .../src/publisher/sequencer-publisher.ts | 32 ++- .../sequencer/checkpoint_proposal_job.test.ts | 6 +- .../src/sequencer/checkpoint_proposal_job.ts | 5 + .../src/test/mock_checkpoint_builder.ts | 13 +- .../sequencer-client/src/test/utils.ts | 6 +- .../src/watchers/epoch_prune_watcher.ts | 1 + .../stdlib/src/checkpoint/checkpoint.ts | 38 ++- .../stdlib/src/interfaces/block-builder.ts | 1 + .../stdlib/src/interfaces/validator.ts | 1 + .../stdlib/src/p2p/checkpoint_attestation.ts | 7 +- .../stdlib/src/p2p/checkpoint_proposal.ts | 48 +++- .../stdlib/src/p2p/consensus_payload.ts | 37 ++- yarn-project/stdlib/src/tests/mocks.ts | 25 +- .../src/block_proposal_handler.ts | 1 + .../src/checkpoint_builder.ts | 7 + .../src/duties/validation_service.test.ts | 1 + .../src/duties/validation_service.ts | 11 +- .../src/validator.integration.test.ts | 3 + .../validator-client/src/validator.test.ts | 42 +++ .../validator-client/src/validator.ts | 12 + 37 files changed, 1269 insertions(+), 65 deletions(-) create mode 100644 l1-contracts/script/UniswapLookup.s.sol create mode 100644 yarn-project/end-to-end/src/e2e_fee_asset_price_oracle.test.ts create mode 100644 yarn-project/end-to-end/src/e2e_p2p/fee_asset_price_oracle_gossip.test.ts create mode 100644 yarn-project/end-to-end/src/shared/mock_state_view.ts create mode 100644 yarn-project/ethereum/src/contracts/fee_asset_price_oracle.test.ts create mode 100644 yarn-project/ethereum/src/contracts/fee_asset_price_oracle.ts diff --git a/l1-contracts/script/UniswapLookup.s.sol b/l1-contracts/script/UniswapLookup.s.sol new file mode 100644 index 000000000000..eb03143720ef --- /dev/null +++ b/l1-contracts/script/UniswapLookup.s.sol @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2024 Aztec Labs. +pragma solidity >=0.8.27; + +import {Test} from "forge-std/Test.sol"; +import {Math} from "@oz/utils/math/Math.sol"; + +interface IStateView { + function getSlot0(bytes32 poolId) + external + view + returns (uint160 sqrtPriceX96, int24 tick, uint24 protocolFee, uint24 lpFee); +} + +contract UniswapLookupScript is Test { + function lookAtUniswap() public { + // Uniswap V4 StateView contract on mainnet + IStateView stateView = IStateView(0x7fFE42C4a5DEeA5b0feC41C94C136Cf115597227); + + address currency0 = address(0); // Native ETH + address currency1 = 0xA27EC0006e59f245217Ff08CD52A7E8b169E62D2; // Fee asset token + uint24 fee = 500; // 0.05% + int24 tickSpacing = 10; + address hooks = 0xd53006d1e3110fD319a79AEEc4c527a0d265E080; + + // Compute pool ID: keccak256(abi.encode(currency0, currency1, fee, tickSpacing, hooks)) + bytes32 poolId = keccak256(abi.encode(currency0, currency1, fee, tickSpacing, hooks)); + emit log_named_bytes32("Pool ID", poolId); + + // Query the real pool state + (uint160 sqrtPriceX96, int24 tick, uint24 protocolFee, uint24 lpFee) = stateView.getSlot0(poolId); + + emit log_named_uint("sqrtPriceX96", sqrtPriceX96); + emit log_named_int("tick", tick); + emit log_named_uint("protocolFee", protocolFee); + emit log_named_uint("lpFee", lpFee); + + // Convert to ethPerFeeAssetE12 + // ethPerFeeAssetE12 = 1e12 * 2^192 / sqrtPriceX96^2 + uint256 Q192 = 2 ** 192; + uint256 sqrtPriceSquared = uint256(sqrtPriceX96) * uint256(sqrtPriceX96); + uint256 ethPerFeeAssetE12 = (1e12 * Q192) / sqrtPriceSquared; + + emit log_named_decimal_uint("ethPerFeeAssetE12 (computed)", ethPerFeeAssetE12, 12); + + // Compute what sqrtPriceX96 would give us a price 0.5% higher + uint256 targetPriceHalfPercentHigher = (ethPerFeeAssetE12 * 1005) / 1000; + emit log_named_decimal_uint("Target price (0.5% higher)", targetPriceHalfPercentHigher, 12); + + // sqrtPriceX96^2 = 1e12 * 2^192 / targetPrice + uint256 targetSqrtSquared = (1e12 * Q192) / targetPriceHalfPercentHigher; + uint256 targetSqrtPriceX96 = Math.sqrt(targetSqrtSquared); + emit log_named_uint("Target sqrtPriceX96", targetSqrtPriceX96); + + // Verify: compute the price back from the sqrt + uint256 verifyPrice = (1e12 * Q192) / (targetSqrtPriceX96 * targetSqrtPriceX96); + assertEq(verifyPrice, targetPriceHalfPercentHigher); + } +} diff --git a/yarn-project/archiver/src/l1/calldata_retriever.test.ts b/yarn-project/archiver/src/l1/calldata_retriever.test.ts index 9dd7f5e7187a..dd4239ff6fd2 100644 --- a/yarn-project/archiver/src/l1/calldata_retriever.test.ts +++ b/yarn-project/archiver/src/l1/calldata_retriever.test.ts @@ -332,7 +332,7 @@ describe('CalldataRetriever', () => { const attestations = makeViemCommitteeAttestations(); const archiveRoot = Fr.random(); const archive = archiveRoot.toString() as Hex; - const feeAssetPriceModifier = BigInt(0); + const feeAssetPriceModifier = BigInt(-1); // Create propose calldata with known values const proposeCalldata = encodeFunctionData({ @@ -355,8 +355,9 @@ describe('CalldataRetriever', () => { publicClient.getTransaction.mockResolvedValue(tx); // Compute the expected payloadDigest using ConsensusPayload (same logic as the validator) + // Note: feeAssetPriceModifier is 0n in makeProposeCalldata const checkpointHeader = CheckpointHeader.fromViem(header); - const consensusPayload = new ConsensusPayload(checkpointHeader, archiveRoot); + const consensusPayload = new ConsensusPayload(checkpointHeader, archiveRoot, feeAssetPriceModifier); const payloadToSign = consensusPayload.getPayloadToSign(SignatureDomainSeparator.checkpointAttestation); const expectedPayloadDigest = keccak256(payloadToSign); diff --git a/yarn-project/archiver/src/l1/calldata_retriever.ts b/yarn-project/archiver/src/l1/calldata_retriever.ts index 84f227b9617b..b6225aba7d1d 100644 --- a/yarn-project/archiver/src/l1/calldata_retriever.ts +++ b/yarn-project/archiver/src/l1/calldata_retriever.ts @@ -84,6 +84,7 @@ export class CalldataRetriever { header: CheckpointHeader; attestations: CommitteeAttestation[]; blockHash: string; + feeAssetPriceModifier: bigint; }> { this.logger.trace(`Fetching checkpoint ${checkpointNumber} from rollup tx ${txHash}`, { willValidateHashes: !!expectedHashes.attestationsHash || !!expectedHashes.payloadDigest, @@ -403,6 +404,7 @@ export class CalldataRetriever { header: CheckpointHeader; attestations: CommitteeAttestation[]; blockHash: string; + feeAssetPriceModifier: bigint; } { const { functionName: rollupFunctionName, args: rollupArgs } = decodeFunctionData({ abi: RollupAbi, @@ -458,7 +460,8 @@ export class CalldataRetriever { if (expectedHashes.payloadDigest) { // Use ConsensusPayload to compute the digest - this ensures we match the exact logic // used by the network for signing and verification - const consensusPayload = new ConsensusPayload(header, archiveRoot); + const feeAssetPriceModifier = decodedArgs.oracleInput.feeAssetPriceModifier; + const consensusPayload = new ConsensusPayload(header, archiveRoot, feeAssetPriceModifier); const payloadToSign = consensusPayload.getPayloadToSign(SignatureDomainSeparator.checkpointAttestation); const computedPayloadDigest = keccak256(payloadToSign); @@ -495,6 +498,7 @@ export class CalldataRetriever { header, attestations, blockHash, + feeAssetPriceModifier: decodedArgs.oracleInput.feeAssetPriceModifier, }; } } diff --git a/yarn-project/archiver/src/l1/data_retrieval.test.ts b/yarn-project/archiver/src/l1/data_retrieval.test.ts index c064f5acbf52..341d6b8b7fc6 100644 --- a/yarn-project/archiver/src/l1/data_retrieval.test.ts +++ b/yarn-project/archiver/src/l1/data_retrieval.test.ts @@ -33,6 +33,7 @@ describe('data_retrieval', () => { const retrievedCheckpoint: RetrievedCheckpoint = { checkpointNumber: CheckpointNumber(1), archiveRoot, + feeAssetPriceModifier: 0n, header: CheckpointHeader.random(), checkpointBlobData, l1: new L1PublishedData(1n, 1000n, '0x1234'), @@ -94,6 +95,7 @@ describe('data_retrieval', () => { const retrievedCheckpoint: RetrievedCheckpoint = { checkpointNumber: CheckpointNumber(1), archiveRoot, + feeAssetPriceModifier: 0n, header: CheckpointHeader.random(), checkpointBlobData, l1: new L1PublishedData(1n, 1000n, '0x1234'), diff --git a/yarn-project/archiver/src/l1/data_retrieval.ts b/yarn-project/archiver/src/l1/data_retrieval.ts index 7156b9b0c919..54b6dd62207d 100644 --- a/yarn-project/archiver/src/l1/data_retrieval.ts +++ b/yarn-project/archiver/src/l1/data_retrieval.ts @@ -38,6 +38,7 @@ import { CalldataRetriever } from './calldata_retriever.js'; export type RetrievedCheckpoint = { checkpointNumber: CheckpointNumber; archiveRoot: Fr; + feeAssetPriceModifier: bigint; header: CheckpointHeader; checkpointBlobData: CheckpointBlobData; l1: L1PublishedData; @@ -49,6 +50,7 @@ export type RetrievedCheckpoint = { export async function retrievedToPublishedCheckpoint({ checkpointNumber, archiveRoot, + feeAssetPriceModifier, header: checkpointHeader, checkpointBlobData, l1, @@ -128,6 +130,7 @@ export async function retrievedToPublishedCheckpoint({ header: checkpointHeader, blocks: l2Blocks, number: checkpointNumber, + feeAssetPriceModifier: feeAssetPriceModifier, }); return PublishedCheckpoint.from({ checkpoint, l1, attestations }); diff --git a/yarn-project/archiver/src/modules/validation.test.ts b/yarn-project/archiver/src/modules/validation.test.ts index bbb6a122f0a8..aa11589bb5d5 100644 --- a/yarn-project/archiver/src/modules/validation.test.ts +++ b/yarn-project/archiver/src/modules/validation.test.ts @@ -22,8 +22,16 @@ describe('validateCheckpointAttestations', () => { const constants = { epochDuration: 10 }; - const makeCheckpoint = async (signers: Secp256k1Signer[], committee: EthAddress[], slot?: number) => { - const checkpoint = await Checkpoint.random(CheckpointNumber(1), { slotNumber: SlotNumber(slot ?? 1) }); + const makeCheckpoint = async ( + signers: Secp256k1Signer[], + committee: EthAddress[], + slot?: number, + feeAssetPriceModifier?: bigint, + ) => { + const checkpoint = await Checkpoint.random(CheckpointNumber(1), { + slotNumber: SlotNumber(slot ?? 1), + feeAssetPriceModifier, + }); return makeSignedPublishedCheckpoint(checkpoint, signers, committee); }; @@ -79,6 +87,16 @@ describe('validateCheckpointAttestations', () => { setCommittee(committee); }); + it('uses feeAssetPriceModifier when recovering attestors', async () => { + const checkpoint = await makeCheckpoint(signers.slice(0, 4), committee, 1, 1n); + + const attestationInfos = getAttestationInfoFromPublishedCheckpoint(checkpoint); + expect(attestationInfos.filter(a => a.status === 'recovered-from-signature').length).toBe(4); + + const result = await validateCheckpointAttestations(checkpoint, epochCache, constants, logger); + expect(result.valid).toBe(true); + }); + it('requests committee for the correct epoch', async () => { const checkpoint = await makeCheckpoint(signers, committee, 28); await validateCheckpointAttestations(checkpoint, epochCache, constants, logger); diff --git a/yarn-project/end-to-end/src/e2e_fee_asset_price_oracle.test.ts b/yarn-project/end-to-end/src/e2e_fee_asset_price_oracle.test.ts new file mode 100644 index 000000000000..3708d78c9a60 --- /dev/null +++ b/yarn-project/end-to-end/src/e2e_fee_asset_price_oracle.test.ts @@ -0,0 +1,119 @@ +import type { Logger } from '@aztec/aztec.js/log'; +import { EthCheatCodes } from '@aztec/aztec/testing'; +import { createExtendedL1Client } from '@aztec/ethereum/client'; +import { RollupContract, STATE_VIEW_ADDRESS } from '@aztec/ethereum/contracts'; +import { retryUntil } from '@aztec/foundation/retry'; +import { DateProvider } from '@aztec/foundation/timer'; + +import { jest } from '@jest/globals'; +import type { Anvil } from '@viem/anvil'; +import { mnemonicToAccount } from 'viem/accounts'; +import { foundry } from 'viem/chains'; + +import { MNEMONIC } from './fixtures/fixtures.js'; +import { getLogger, setup, startAnvil } from './fixtures/utils.js'; +import { MockStateView, diffInBps } from './shared/mock_state_view.js'; + +describe('FeeAssetPriceOracle E2E', () => { + jest.setTimeout(300_000); + + let logger: Logger; + let teardown: () => Promise; + let anvil: Anvil; + let rollup: RollupContract; + let mockStateView: MockStateView; + let ethCheatCodes: EthCheatCodes; + + // Beware, if you use "mainnet" here it will be completely broken due to blobs... + const chain = foundry; + + beforeAll(async () => { + logger = getLogger(); + + const anvilResult = await startAnvil({ chainId: chain.id }); + anvil = anvilResult.anvil; + const rpcUrl = anvilResult.rpcUrl; + + // Set ETHEREUM_HOSTS so setup() uses our pre-started Anvil + process.env.ETHEREUM_HOSTS = rpcUrl; + + // Deploy mock StateView BEFORE the full setup, so the oracle can read from it + ethCheatCodes = new EthCheatCodes([rpcUrl], new DateProvider()); + const account = mnemonicToAccount(MNEMONIC, { addressIndex: 999 }); + const walletClient = createExtendedL1Client([rpcUrl], account, chain); + + await ethCheatCodes.setBalance(account.address, 100n * 10n ** 18n); + + mockStateView = await MockStateView.deploy(ethCheatCodes, walletClient, STATE_VIEW_ADDRESS); + logger.info(`Deployed mock StateView at ${STATE_VIEW_ADDRESS}`); + + // The initial oracle price (default value) is 1e7 + await mockStateView.setEthPerFeeAsset(10n ** 7n); + + await ethCheatCodes.mineEmptyBlock(); + await ethCheatCodes.mine(10); + await ethCheatCodes.mineEmptyBlock(); + + const context = await setup(0, { l1ChainId: chain.id, minTxsPerBlock: 0 }, {}, chain); + teardown = context.teardown; + + const l1Client = context.deployL1ContractsValues.l1Client; + rollup = new RollupContract(l1Client, context.deployL1ContractsValues.l1ContractAddresses.rollupAddress); + }); + + afterAll(async () => { + await teardown?.(); + await anvil?.stop().catch(err => logger.error('Failed to stop anvil', err)); + delete process.env.ETHEREUM_HOSTS; + }); + + it('on-chain price converges toward oracle price over multiple checkpoints', async () => { + // Move the price up 2.5% (2 moves of 1% and another smaller) + // Wait until we are within 1 bps or the price + // Then move the price down 0.5% + // Wait until 1 bps of the price + // Profit + + const targetOraclePrice = (BigInt(10n ** 7n) * 1025n) / 1000n; + await mockStateView.setEthPerFeeAsset(targetOraclePrice); + logger.info(`Set uniswap price to ${targetOraclePrice}`); + + // Get initial on-chain price + const initialOnChainPrice = await rollup.getEthPerFeeAsset(); + logger.info(`Initial on-chain price: ${initialOnChainPrice}, target oracle price: ${targetOraclePrice}`); + + await retryUntil( + async () => { + const currentPrice = await rollup.getEthPerFeeAsset(); + logger.info(`Current on-chain price: ${currentPrice}, waiting for: ${targetOraclePrice}`); + return diffInBps(currentPrice, targetOraclePrice) == 0n; + }, + 'price convergence toward oracle', + 120, // timeout in seconds + 5, // check interval in seconds + ); + + const priceAfterFirstAlignment = await rollup.getEthPerFeeAsset(); + const targetOraclePrice2 = (BigInt(priceAfterFirstAlignment) * 995n) / 1000n; + await mockStateView.setEthPerFeeAsset(targetOraclePrice2); + logger.info(`Set uniswap price to ${targetOraclePrice}`); + + await retryUntil( + async () => { + const currentPrice = await rollup.getEthPerFeeAsset(); + logger.info(`Current on-chain price: ${currentPrice}, waiting for: ${targetOraclePrice2}`); + return diffInBps(currentPrice, targetOraclePrice2) == 0n; + }, + 'price convergence toward oracle', + 120, // timeout in seconds + 5, // check interval in seconds + ); + + const finalPrice = await rollup.getEthPerFeeAsset(); + logger.info(`Final on-chain price: ${finalPrice}`); + + // Verify the price moved toward the oracle price + expect(finalPrice).toBeGreaterThan(initialOnChainPrice); + expect(diffInBps(finalPrice, targetOraclePrice2)).toBe(0n); + }); +}); 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 new file mode 100644 index 000000000000..8b385c374b6e --- /dev/null +++ b/yarn-project/end-to-end/src/e2e_p2p/fee_asset_price_oracle_gossip.test.ts @@ -0,0 +1,229 @@ +import type { Archiver } from '@aztec/archiver'; +import type { AztecNodeService } from '@aztec/aztec-node'; +import { createExtendedL1Client } from '@aztec/ethereum/client'; +import { RollupContract, STATE_VIEW_ADDRESS } from '@aztec/ethereum/contracts'; +import { CheckpointNumber } from '@aztec/foundation/branded-types'; +import { Signature } from '@aztec/foundation/eth-signature'; +import { retryUntil } from '@aztec/foundation/retry'; +import { sleep } from '@aztec/foundation/sleep'; +import type { ProverNode } from '@aztec/prover-node'; +import type { SequencerClient } from '@aztec/sequencer-client'; +import { tryStop } from '@aztec/stdlib/interfaces/server'; +import { CheckpointAttestation, ConsensusPayload } from '@aztec/stdlib/p2p'; + +import { jest } from '@jest/globals'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { mnemonicToAccount } from 'viem/accounts'; +import { foundry } from 'viem/chains'; + +import { MNEMONIC, shouldCollectMetrics } from '../fixtures/fixtures.js'; +import { ATTESTER_PRIVATE_KEYS_START_INDEX, createNodes, createProverNode } from '../fixtures/setup_p2p_test.js'; +import { type AlertConfig, GrafanaClient } from '../quality_of_service/grafana_client.js'; +import { MockStateView, diffInBps } from '../shared/mock_state_view.js'; +import { P2PNetworkTest, SHORTENED_BLOCK_TIME_CONFIG_NO_PRUNES } from './p2p_network.js'; + +const CHECK_ALERTS = process.env.CHECK_ALERTS === 'true'; + +// Don't set this to a higher value than 9 because each node will use a different L1 publisher account and anvil seeds +const NUM_VALIDATORS = 4; +const BOOT_NODE_UDP_PORT = 4500; + +const DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'gossip-')); + +jest.setTimeout(1000 * 60 * 10); + +const qosAlerts: AlertConfig[] = [ + { + alert: 'SequencerTimeToCollectAttestations', + expr: 'aztec_sequencer_time_to_collect_attestations > 3500', + labels: { severity: 'error' }, + for: '10m', + annotations: {}, + }, +]; + +describe('e2e_p2p_network', () => { + let t: P2PNetworkTest; + let nodes: AztecNodeService[]; + let proverNode: ProverNode; + + beforeEach(async () => { + t = await P2PNetworkTest.create({ + testName: 'e2e_p2p_network_fee_asset_price_oracle', + numberOfNodes: 0, + numberOfValidators: NUM_VALIDATORS, + basePort: BOOT_NODE_UDP_PORT, + metricsPort: shouldCollectMetrics(), + startProverNode: false, // we'll start our own using p2p + initialConfig: { + ...SHORTENED_BLOCK_TIME_CONFIG_NO_PRUNES, + aztecSlotDuration: 24, + aztecEpochDuration: 4, + slashingRoundSizeInEpochs: 2, + slashingQuorum: 5, + listenAddress: '127.0.0.1', + }, + }); + + await t.setup(); + await t.applyBaseSetup(); + }); + + afterEach(async () => { + await tryStop(proverNode); + await t.stopNodes(nodes); + await t.teardown(); + for (let i = 0; i < NUM_VALIDATORS; i++) { + fs.rmSync(`${DATA_DIR}-${i}`, { recursive: true, force: true, maxRetries: 3 }); + } + }); + + afterAll(async () => { + if (CHECK_ALERTS) { + const checker = new GrafanaClient(t.logger); + await checker.runAlertCheck(qosAlerts); + } + }); + + it('should rollup txs from all peers', async () => { + // create the bootstrap node for the network + if (!t.bootstrapNodeEnr) { + throw new Error('Bootstrap node ENR is not available'); + } + + const rollup = RollupContract.getFromL1ContractsValues(t.ctx.deployL1ContractsValues); + + const account = mnemonicToAccount(MNEMONIC, { addressIndex: 999 }); + const walletClient = createExtendedL1Client(t.ctx.aztecNodeConfig.l1RpcUrls, account, foundry); + + await t.ctx.ethCheatCodes.setBalance(account.address, 100n * 10n ** 18n); + + const mockStateView = await MockStateView.deploy(t.ctx.ethCheatCodes, walletClient, STATE_VIEW_ADDRESS); + + // The initial oracle price (default value) is 1e7 + await mockStateView.setEthPerFeeAsset(10n ** 7n); + + await t.ctx.ethCheatCodes.mineEmptyBlock(); + await t.ctx.ethCheatCodes.mine(10); + await t.ctx.ethCheatCodes.mineEmptyBlock(); + + t.ctx.aztecNodeConfig.validatorReexecute = true; + + t.logger.info('Creating validator nodes'); + nodes = await createNodes( + t.ctx.aztecNodeConfig, + t.ctx.dateProvider!, + t.bootstrapNodeEnr, + NUM_VALIDATORS, + BOOT_NODE_UDP_PORT, + t.prefilledPublicData, + DATA_DIR, + // To collect metrics - run in aztec-packages `docker compose --profile metrics up` and set COLLECT_METRICS=true + shouldCollectMetrics(), + ); + + t.logger.warn(`Creating prover node`); + proverNode = await createProverNode( + { ...t.ctx.aztecNodeConfig, minTxsPerBlock: 0 }, + BOOT_NODE_UDP_PORT + NUM_VALIDATORS + 1, + t.bootstrapNodeEnr, + ATTESTER_PRIVATE_KEYS_START_INDEX + NUM_VALIDATORS + 1, + { dateProvider: t.ctx.dateProvider! }, + t.prefilledPublicData, + `${DATA_DIR}-prover`, + shouldCollectMetrics(), + ); + await proverNode.start(); + + // wait a bit for peers to discover each other + await sleep(8000); + + // We need to `createNodes` before we setup account, because + // those nodes actually form the committee, and so we cannot build + // blocks without them (since targetCommitteeSize is set to the number of nodes) + await t.setupAccount(); + + // Wait until the other nodes sync to the block from which we sent the tx + const targetBlock = await t.ctx.aztecNode.getBlockNumber(); + t.logger.warn(`Waiting for all nodes to sync to block number ${targetBlock}`); + await retryUntil( + async () => { + const blockNumbers = await Promise.all(nodes.map(node => node.getBlockNumber())); + const checkpointNumber = (await t.monitor.run()).checkpointNumber; + t.logger.info(`Current block numbers ${blockNumbers} (checkpoint number on L1 is ${checkpointNumber})`); + return blockNumbers.every(bn => bn >= targetBlock); + }, + `nodes to sync to block number ${targetBlock}`, + 30, + 0.5, + ); + + for (const node of nodes) { + await node.setConfig({ minTxsPerBlock: 0 }); + } + + const targetOraclePrice = (BigInt(10n ** 7n) * 1025n) / 1000n; + await mockStateView.setEthPerFeeAsset(targetOraclePrice); + t.logger.info(`Set uniswap price to ${targetOraclePrice}`); + + // Get initial on-chain price + const initialOnChainPrice = await rollup.getEthPerFeeAsset(); + t.logger.info(`Initial on-chain price: ${initialOnChainPrice}, target oracle price: ${targetOraclePrice}`); + + // Gather signers from attestations downloaded from L1 + const blockNumber = await nodes[0].getBlockNumber(); + const dataStore = (nodes[0] as AztecNodeService).getBlockSource() as Archiver; + const [publishedCheckpoint] = await dataStore.getCheckpoints(CheckpointNumber.fromBlockNumber(blockNumber), 1); + const payload = ConsensusPayload.fromCheckpoint(publishedCheckpoint.checkpoint); + const attestations = publishedCheckpoint.attestations + .filter(a => !a.signature.isEmpty()) + .map(a => new CheckpointAttestation(payload, a.signature, Signature.empty())); + const signers = await Promise.all(attestations.map(att => att.getSender()!.toString())); + t.logger.info(`Attestation signers`, { signers }); + + // Check that the signers found are part of the proposer nodes to ensure the archiver fetched them right + const validatorAddresses = nodes.flatMap(node => + ((node as AztecNodeService).getSequencer() as SequencerClient).validatorAddresses?.map(a => a.toString()), + ); + t.logger.info(`Validator addresses`, { addresses: validatorAddresses }); + for (const signer of signers) { + expect(validatorAddresses).toContain(signer); + } + + await retryUntil( + async () => { + const currentPrice = await rollup.getEthPerFeeAsset(); + t.logger.info(`Current on-chain price: ${currentPrice}, waiting for: ${targetOraclePrice}`); + return diffInBps(currentPrice, targetOraclePrice) == 0n; + }, + 'price convergence toward oracle', + 120, // timeout in seconds + 5, // check interval in seconds + ); + + const priceAfterFirstAlignment = await rollup.getEthPerFeeAsset(); + const targetOraclePrice2 = (BigInt(priceAfterFirstAlignment) * 995n) / 1000n; + await mockStateView.setEthPerFeeAsset(targetOraclePrice2); + t.logger.info(`Set uniswap price to ${targetOraclePrice}`); + + await retryUntil( + async () => { + const currentPrice = await rollup.getEthPerFeeAsset(); + t.logger.info(`Current on-chain price: ${currentPrice}, waiting for: ${targetOraclePrice2}`); + return diffInBps(currentPrice, targetOraclePrice2) == 0n; + }, + 'price convergence toward oracle', + 120, // timeout in seconds + 5, // check interval in seconds + ); + + const finalPrice = await rollup.getEthPerFeeAsset(); + t.logger.info(`Final on-chain price: ${finalPrice}`); + + // Verify the price moved toward the oracle price + expect(finalPrice).toBeGreaterThan(initialOnChainPrice); + expect(diffInBps(finalPrice, targetOraclePrice2)).toBe(0n); + }); +}); diff --git a/yarn-project/end-to-end/src/shared/index.ts b/yarn-project/end-to-end/src/shared/index.ts index da82734ffeea..7c6f63351f18 100644 --- a/yarn-project/end-to-end/src/shared/index.ts +++ b/yarn-project/end-to-end/src/shared/index.ts @@ -1 +1,2 @@ export { uniswapL1L2TestSuite } from './uniswap_l1_l2.js'; +export { MockStateView, diffInBps } from './mock_state_view.js'; diff --git a/yarn-project/end-to-end/src/shared/mock_state_view.ts b/yarn-project/end-to-end/src/shared/mock_state_view.ts new file mode 100644 index 000000000000..d2ec2c58c639 --- /dev/null +++ b/yarn-project/end-to-end/src/shared/mock_state_view.ts @@ -0,0 +1,188 @@ +import { EthAddress } from '@aztec/aztec.js/addresses'; +import { type Logger, createLogger } from '@aztec/aztec.js/log'; +import type { EthCheatCodes } from '@aztec/aztec/testing'; +import type { ExtendedViemWalletClient } from '@aztec/ethereum/types'; + +import { getContract } from 'viem'; + +/** + * Mock StateView contract for testing the Uniswap price oracle. + * + * Wraps a compiled Solidity contract that mimics the Uniswap V4 StateView's getSlot0 function. + * The mock allows setting return values dynamically for testing different price scenarios. + * + * Solidity source: + * ```solidity + * // SPDX-License-Identifier: Apache-2.0 + * pragma solidity >=0.8.27; + * + * contract MockStateView { + * uint160 public sqrtPriceX96; + * int24 public tick; + * uint24 public protocolFee; + * uint24 public lpFee; + * + * function setReturnValues( + * uint160 _sqrtPriceX96, + * int24 _tick, + * uint24 _protocolFee, + * uint24 _lpFee + * ) external { + * sqrtPriceX96 = _sqrtPriceX96; + * tick = _tick; + * protocolFee = _protocolFee; + * lpFee = _lpFee; + * } + * + * function getSlot0(bytes32 poolId) external view returns (uint160, int24, uint24, uint24) { + * return (sqrtPriceX96, tick, protocolFee, lpFee); + * } + * } + * ``` + */ +export class MockStateView { + private static readonly BYTECODE: `0x${string}` = + '0x608060405234801561000f575f5ffd5b5060043610610060575f3560e01c80633eaf5d9f14610064578063704ce43e146100825780638db791d2146100a0578063b0e21e8a146100be578063b52e4bdd146100dc578063c815641c146100f8575b5f5ffd5b61006c61012b565b60405161007991906102ab565b60405180910390f35b61008a61013d565b60405161009791906102e1565b60405180910390f35b6100a8610151565b6040516100b59190610328565b60405180910390f35b6100c6610175565b6040516100d391906102e1565b60405180910390f35b6100f660048036038101906100f191906103c3565b610189565b005b610112600480360381019061010d919061045a565b61022b565b6040516101229493929190610485565b60405180910390f35b5f60149054906101000a900460020b81565b5f601a9054906101000a900462ffffff1681565b5f5f9054906101000a900473ffffffffffffffffffffffffffffffffffffffff1681565b5f60179054906101000a900462ffffff1681565b835f5f6101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff160217905550825f60146101000a81548162ffffff021916908360020b62ffffff160217905550815f60176101000a81548162ffffff021916908362ffffff160217905550805f601a6101000a81548162ffffff021916908362ffffff16021790555050505050565b5f5f5f5f5f5f9054906101000a900473ffffffffffffffffffffffffffffffffffffffff165f60149054906101000a900460020b5f60179054906101000a900462ffffff165f601a9054906101000a900462ffffff1693509350935093509193509193565b5f8160020b9050919050565b6102a581610290565b82525050565b5f6020820190506102be5f83018461029c565b92915050565b5f62ffffff82169050919050565b6102db816102c4565b82525050565b5f6020820190506102f45f8301846102d2565b92915050565b5f73ffffffffffffffffffffffffffffffffffffffff82169050919050565b610322816102fa565b82525050565b5f60208201905061033b5f830184610319565b92915050565b5f5ffd5b61034e816102fa565b8114610358575f5ffd5b50565b5f8135905061036981610345565b92915050565b61037881610290565b8114610382575f5ffd5b50565b5f813590506103938161036f565b92915050565b6103a2816102c4565b81146103ac575f5ffd5b50565b5f813590506103bd81610399565b92915050565b5f5f5f5f608085870312156103db576103da610341565b5b5f6103e88782880161035b565b94505060206103f987828801610385565b935050604061040a878288016103af565b925050606061041b878288016103af565b91505092959194509250565b5f819050919050565b61043981610427565b8114610443575f5ffd5b50565b5f8135905061045481610430565b92915050565b5f6020828403121561046f5761046e610341565b5b5f61047c84828501610446565b91505092915050565b5f6080820190506104985f830187610319565b6104a5602083018661029c565b6104b260408301856102d2565b6104bf60608301846102d2565b9594505050505056fea2646970667358221220f8b1bfff284535bc078368ed34bd5e78981644845f3c9c1f5a4b8448c976805464736f6c634300081f0033'; + private static readonly ABI = [ + { + type: 'function', + name: 'setReturnValues', + inputs: [ + { name: '_sqrtPriceX96', type: 'uint160' }, + { name: '_tick', type: 'int24' }, + { name: '_protocolFee', type: 'uint24' }, + { name: '_lpFee', type: 'uint24' }, + ], + outputs: [], + stateMutability: 'nonpayable', + }, + { + inputs: [ + { + internalType: 'bytes32', + name: 'poolId', + type: 'bytes32', + }, + ], + name: 'getSlot0', + outputs: [ + { + internalType: 'uint160', + name: '', + type: 'uint160', + }, + { + internalType: 'int24', + name: '', + type: 'int24', + }, + { + internalType: 'uint24', + name: '', + type: 'uint24', + }, + { + internalType: 'uint24', + name: '', + type: 'uint24', + }, + ], + stateMutability: 'view', + type: 'function', + }, + ] as const; + + private constructor( + private readonly address: EthAddress, + private readonly walletClient: ExtendedViemWalletClient, + private readonly log: Logger = createLogger('mock-state-view'), + ) {} + + /** + * Deploys the mock StateView contract at the specified address using etch. + * @param ethCheatCodes - Cheat codes for etching bytecode + * @param walletClient - Wallet client for sending transactions + * @param address - Address to deploy the mock at (typically the real StateView address) + */ + static async deploy( + ethCheatCodes: EthCheatCodes, + walletClient: ExtendedViemWalletClient, + address: EthAddress, + ): Promise { + await ethCheatCodes.etch(address, MockStateView.BYTECODE); + return new MockStateView(address, walletClient); + } + + /** + * Sets the price using the ethPerFeeAssetE12 format (same as rollup contract). + * Computes the corresponding sqrtPriceX96 internally. + * + * Math (from fee_asset_price_oracle.ts): + * ethPerFeeAssetE12 = 1e12 * 2^192 / sqrtPriceX96^2 + * + * Inverted: + * sqrtPriceX96^2 = 1e12 * 2^192 / ethPerFeeAssetE12 + * sqrtPriceX96 = sqrt(1e12 * 2^192 / ethPerFeeAssetE12) + * + * @param ethPerFeeAssetE12 - The price in ETH per fee asset, scaled by 1e12 + */ + async setEthPerFeeAsset(ethPerFeeAssetE12: bigint) { + const sqrtPriceX96 = this.ethPerFeeAssetE12ToSqrtPriceX96(ethPerFeeAssetE12); + return await this.setSqrtPriceX96(sqrtPriceX96); + } + + /** + * Sets the sqrtPriceX96 value directly (Uniswap's price encoding). + * @param sqrtPriceX96 - The sqrtPriceX96 value + * @param tick - The tick value (default 10) + * @param protocolFee - The protocol fee (default 0) + * @param lpFee - The LP fee (default 500) + */ + async setSqrtPriceX96(sqrtPriceX96: bigint, tick: number = 10, protocolFee: number = 0, lpFee: number = 500) { + const contract = getContract({ + address: this.address.toString() as `0x${string}`, + abi: MockStateView.ABI, + client: this.walletClient, + }); + + const hash = await contract.write.setReturnValues([sqrtPriceX96, tick, protocolFee, lpFee]); + this.log.info(`Set sqrtPriceX96 to ${sqrtPriceX96}`); + return await this.walletClient.waitForTransactionReceipt({ hash }); + } + + /** + * Converts ethPerFeeAssetE12 to sqrtPriceX96 (inverse of sqrtPriceX96ToEthPerFeeAssetE12). + * + * Math: + * sqrtPriceX96 = sqrt(1e12 * 2^192 / ethPerFeeAssetE12) + */ + ethPerFeeAssetE12ToSqrtPriceX96(ethPerFeeAssetE12: bigint): bigint { + if (ethPerFeeAssetE12 === 0n) { + throw new Error('Cannot convert zero ethPerFeeAssetE12'); + } + const Q192 = 2n ** 192n; + const sqrtPriceSquared = (10n ** 12n * Q192) / ethPerFeeAssetE12; + return this.bigintSqrt(sqrtPriceSquared); + } + + /** Integer square root using Newton's method */ + bigintSqrt(n: bigint): bigint { + if (n < 0n) { + throw new Error('Cannot compute sqrt of negative number'); + } + if (n === 0n) { + return 0n; + } + let x = n; + let y = (x + 1n) / 2n; + while (y < x) { + x = y; + y = (x + n / x) / 2n; + } + return x; + } +} + +export function diffInBps(a: bigint, b: bigint): bigint { + return ((a - b) * 10000n) / b; +} 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 new file mode 100644 index 000000000000..7d4f5d78d6b2 --- /dev/null +++ b/yarn-project/ethereum/src/contracts/fee_asset_price_oracle.test.ts @@ -0,0 +1,80 @@ +import { + MAX_FEE_ASSET_PRICE_MODIFIER_BPS, + sqrtPriceX96ToEthPerFeeAssetE12, + validateFeeAssetPriceModifier, +} from './fee_asset_price_oracle.js'; + +describe('Uniswap Price Oracle', () => { + describe('sqrtPriceX96ToEthPerFeeAssetE12', () => { + it('converts 1:1 rate correctly', () => { + // If 1 fee asset = 1 ETH, then feeAssetPerEth = 1 + // sqrtPriceX96 = sqrt(1) * 2^96 = 2^96 + const Q96 = 2n ** 96n; + const ethPerFeeAssetE12 = sqrtPriceX96ToEthPerFeeAssetE12(Q96); + // ethPerFeeAsset = 1, scaled by 1e12 + expect(ethPerFeeAssetE12).toBe(10n ** 12n); + }); + + it('converts typical exchange rate correctly', () => { + // If 1000 fee asset = 1 ETH, then feeAssetPerEth = 1000 + // sqrtPriceX96 = sqrt(1000) * 2^96 + // ethPerFeeAsset = 0.001 (scaled by 1e12 = 1e9) + const sqrt1000 = 31622776601683793319n; // sqrt(1000) * 1e18 + const sqrtPriceX96 = (sqrt1000 * 2n ** 96n) / 10n ** 18n; + + const ethPerFeeAssetE12 = sqrtPriceX96ToEthPerFeeAssetE12(sqrtPriceX96); + + const expectedEthPerFeeAssetE12 = 10n ** 9n; + const tolerance = expectedEthPerFeeAssetE12 / 100n; // 1% tolerance + + expect(ethPerFeeAssetE12).toBeGreaterThan(expectedEthPerFeeAssetE12 - tolerance); + expect(ethPerFeeAssetE12).toBeLessThan(expectedEthPerFeeAssetE12 + tolerance); + }); + + it('handles very high fee asset prices (few fee assets per ETH)', () => { + // If 0.1 fee asset = 1 ETH (fee asset is very valuable) + // feeAssetPerEth = 0.1, sqrtPriceX96 = sqrt(0.1) * 2^96 + // ethPerFeeAsset = 10 (scaled by 1e12 = 10e12) + const sqrt01 = 316227766016837933n; // sqrt(0.1) * 1e18 + const sqrtPriceX96 = (sqrt01 * 2n ** 96n) / 10n ** 18n; + + const ethPerFeeAssetE12 = sqrtPriceX96ToEthPerFeeAssetE12(sqrtPriceX96); + + const expectedEthPerFeeAssetE12 = 10n * 10n ** 12n; + const tolerance = expectedEthPerFeeAssetE12 / 100n; // 1% tolerance + + expect(ethPerFeeAssetE12).toBeGreaterThan(expectedEthPerFeeAssetE12 - tolerance); + expect(ethPerFeeAssetE12).toBeLessThan(expectedEthPerFeeAssetE12 + tolerance); + }); + + it('throws when input is 0', () => { + expect(() => sqrtPriceX96ToEthPerFeeAssetE12(0n)).toThrow('Cannot convert zero sqrtPriceX96'); + }); + }); + + describe('validateFeeAssetPriceModifier', () => { + it('accepts 0 modifier', () => { + expect(validateFeeAssetPriceModifier(0n)).toBe(true); + }); + + it('accepts positive modifier within range', () => { + expect(validateFeeAssetPriceModifier(50n)).toBe(true); + expect(validateFeeAssetPriceModifier(MAX_FEE_ASSET_PRICE_MODIFIER_BPS)).toBe(true); + }); + + it('accepts negative modifier within range', () => { + expect(validateFeeAssetPriceModifier(-50n)).toBe(true); + expect(validateFeeAssetPriceModifier(-MAX_FEE_ASSET_PRICE_MODIFIER_BPS)).toBe(true); + }); + + it('rejects modifier above max', () => { + expect(validateFeeAssetPriceModifier(MAX_FEE_ASSET_PRICE_MODIFIER_BPS + 1n)).toBe(false); + expect(validateFeeAssetPriceModifier(1000n)).toBe(false); + }); + + it('rejects modifier below min', () => { + expect(validateFeeAssetPriceModifier(-MAX_FEE_ASSET_PRICE_MODIFIER_BPS - 1n)).toBe(false); + expect(validateFeeAssetPriceModifier(-1000n)).toBe(false); + }); + }); +}); diff --git a/yarn-project/ethereum/src/contracts/fee_asset_price_oracle.ts b/yarn-project/ethereum/src/contracts/fee_asset_price_oracle.ts new file mode 100644 index 000000000000..b0df729c8989 --- /dev/null +++ b/yarn-project/ethereum/src/contracts/fee_asset_price_oracle.ts @@ -0,0 +1,262 @@ +import { memoize } from '@aztec/foundation/decorators'; +import { EthAddress } from '@aztec/foundation/eth-address'; +import { type Logger, createLogger } from '@aztec/foundation/log'; + +import { type Hex, encodeAbiParameters, getContract, keccak256, parseAbiParameters } from 'viem'; + +import type { ViemClient } from '../types.js'; +import { RollupContract } from './rollup.js'; + +/** Maximum price modifier per checkpoint in basis points. ±100 bps = ±1% */ +export const MAX_FEE_ASSET_PRICE_MODIFIER_BPS = 100n; + +/** + * Validates that a fee asset price modifier is within the allowed range. + * Validators should call this before attesting to a checkpoint proposal. + * + * @param modifier - The fee asset price modifier in basis points + * @returns true if the modifier is valid (between -100 and +100 bps) + */ +export function validateFeeAssetPriceModifier(modifier: bigint): boolean { + return modifier >= -MAX_FEE_ASSET_PRICE_MODIFIER_BPS && modifier <= MAX_FEE_ASSET_PRICE_MODIFIER_BPS; +} + +/** + * Oracle for computing fee asset price modifiers based on Uniswap V4 pool prices. + * Only active on Ethereum mainnet - returns 0 on other chains. + */ +export class FeeAssetPriceOracle { + constructor( + private client: ViemClient, + private readonly rollupContract: RollupContract, + private log: Logger = createLogger('fee-asset-price-oracle'), + ) {} + + @memoize + async getUniswapOracle(): Promise { + if ((await this.client.getCode({ address: STATE_VIEW_ADDRESS.toString() })) === '0x') { + this.log.warn('Uniswap V4 StateView contract not found, skipping fee asset price oracle'); + return undefined; + } + this.log.info('Uniswap V4 StateView contract found, initializing fee asset price oracle'); + return new UniswapPriceOracle(this.client, this.log); + } + + /** + * Computes the fee asset price modifier to be used in the next checkpoint proposal. + * + * The modifier adjusts the on-chain fee asset price toward the oracle price, + * clamped to ±1% (±100 basis points) per checkpoint. + * + * Returns 0 if not on mainnet or if the oracle query fails. + * + * @returns The price modifier in basis points (positive to increase price, negative to decrease) + */ + async computePriceModifier(): 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 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); + + this.log.debug('Computed price modifier', { + currentPriceE12: currentPriceE12.toString(), + oraclePriceE12: oraclePriceE12.toString(), + modifierBps: modifier.toString(), + }); + + return modifier; + } catch (err) { + this.log.warn(`Failed to compute price modifier, using 0: ${err}`); + return 0n; + } + } + + /** + * Gets the current oracle price (ETH per fee asset, scaled by 1e12). + * Returns undefined if not on mainnet or if the oracle query fails. + */ + async getOraclePrice(): Promise { + const uniswapOracle = await this.getUniswapOracle(); + if (!uniswapOracle) { + return undefined; + } + + try { + return await uniswapOracle.getMeanEthPerFeeAssetE12(); + } catch (err) { + this.log.warn(`Failed to get oracle price: ${err}`); + return undefined; + } + } + + /** + * Computes the basis points modifier needed to move from current price toward target price. + * + * @param currentPrice - Current ETH per fee asset (E12 scale) + * @param targetPrice - Target ETH per fee asset (E12 scale) + * @returns Basis points modifier clamped to ±100 (±1%) + */ + computePriceModifierBps(currentPrice: bigint, targetPrice: bigint): bigint { + if (currentPrice === 0n) { + return MAX_FEE_ASSET_PRICE_MODIFIER_BPS; + } + + // Calculate percentage difference in basis points + // modifierBps = ((targetPrice - currentPrice) / currentPrice) * 10000 + const diff = targetPrice - currentPrice; + const rawModifierBps = (diff * 10_000n) / currentPrice; + + // Clamp to ±MAX_FEE_ASSET_PRICE_MODIFIER_BPS + if (rawModifierBps > MAX_FEE_ASSET_PRICE_MODIFIER_BPS) { + return MAX_FEE_ASSET_PRICE_MODIFIER_BPS; + } + if (rawModifierBps < -MAX_FEE_ASSET_PRICE_MODIFIER_BPS) { + return -MAX_FEE_ASSET_PRICE_MODIFIER_BPS; + } + return rawModifierBps; + } +} + +/** Mainnet Uniswap V4 StateView contract address */ +export const STATE_VIEW_ADDRESS = EthAddress.fromString('0x7ffe42c4a5deea5b0fec41c94c136cf115597227'); + +const PRECISION_Q192 = 10n ** 12n * 2n ** 192n; + +/** + * Converts Uniswap's sqrtPriceX96 directly to ETH per FeeAsset (E12). + * + * For an ETH/FeeAsset pool where ETH is currency0 and FeeAsset is currency1: + * - Uniswap's sqrtPriceX96 = sqrt(FeeAsset/ETH) * 2^96 + * - We need: ETH/FeeAsset with 1e12 precision + * + * Math: + * price = (sqrtPriceX96 / 2^96)^2 = sqrtPriceX96^2 / 2^192 (FeeAsset per ETH) + * ethPerFeeAsset = 1 / price = 2^192 / sqrtPriceX96^2 + * ethPerFeeAssetE12 = ethPerFeeAsset * 1e12 = 1e12 * 2^192 / sqrtPriceX96^2 + */ +export function sqrtPriceX96ToEthPerFeeAssetE12(sqrtPriceX96: bigint): bigint { + if (sqrtPriceX96 === 0n) { + throw new Error('Cannot convert zero sqrtPriceX96'); + } + return PRECISION_Q192 / (sqrtPriceX96 * sqrtPriceX96); +} +/** + * Uniswap V4 StateView ABI - only the functions we need + */ +const StateViewAbi = [ + { + type: 'function', + name: 'getSlot0', + inputs: [{ name: 'poolId', type: 'bytes32', internalType: 'PoolId' }], + outputs: [ + { name: 'sqrtPriceX96', type: 'uint160', internalType: 'uint160' }, + { name: 'tick', type: 'int24', internalType: 'int24' }, + { name: 'protocolFee', type: 'uint24', internalType: 'uint24' }, + { name: 'lpFee', type: 'uint24', internalType: 'uint24' }, + ], + stateMutability: 'view', + }, +] as const; + +/** + * Client for querying the ETH/FeeAsset price from Uniswap V4. + * Returns prices in ETH per FeeAsset format (E12) to match the rollup contract. + */ +class UniswapPriceOracle { + private readonly stateView; + private readonly poolId: Hex; + private readonly log: Logger; + + constructor( + private readonly client: ViemClient, + log?: Logger, + ) { + this.log = log ?? createLogger('uniswap-price-oracle'); + this.stateView = getContract({ + address: STATE_VIEW_ADDRESS.toString(), + abi: StateViewAbi, + client, + }); + this.poolId = this.computePoolId(); + this.log.debug(`Initialized UniswapPriceOracle with poolId: ${this.poolId}`); + } + + /** + * Computes the PoolId from the pool configuration by hashing its components. + * PoolId = keccak256(abi.encode(currency0, currency1, fee, tickSpacing, hooks)) + * For mainnet, the value is expected to be: 0xce2899b16743cfd5a954d8122d5e07f410305b1aebee39fd73d9f3b9ebf10c2f + * Derived anyway to make it simpler to change if needed. + */ + @memoize + computePoolId(): Hex { + /** ETH/FeeAsset pool configuration (hardcoded for mainnet) */ + const encoded = encodeAbiParameters(parseAbiParameters('address, address, uint24, int24, address'), [ + EthAddress.ZERO.toString(), + EthAddress.fromString('0xA27EC0006e59f245217Ff08CD52A7E8b169E62D2').toString(), + 500, // 0.05% + 10, + EthAddress.fromString('0xd53006d1e3110fD319a79AEEc4c527a0d265E080').toString(), + ]); + return keccak256(encoded); + } + + /** + * Gets the price as ETH per FeeAsset, scaled by 1e12. + * This is the format expected by the rollup contract. + * + * @param blockNumber - Optional block number to query at (defaults to latest) + */ + async getEthPerFeeAssetE12(blockNumber?: bigint): Promise { + const [sqrtPriceX96] = await this.stateView.read.getSlot0( + [this.poolId], + blockNumber !== undefined ? { blockNumber } : undefined, + ); + return sqrtPriceX96ToEthPerFeeAssetE12(sqrtPriceX96); + } + + /** + * Gets the median price over the last N blocks as ETH per FeeAsset (E12). + * Using median helps protect against single-block manipulation. + * + * @param numBlocks - Number of recent blocks to sample (default: 5) + * @returns Median price as ETH per FeeAsset, scaled by 1e12 + */ + async getMeanEthPerFeeAssetE12(numBlocks: number = 5): Promise { + const currentBlock = await this.client.getBlockNumber(); + const prices: bigint[] = []; + + for (let i = 0; i < numBlocks; i++) { + const blockNumber = currentBlock - BigInt(i); + if (blockNumber < 0n) { + break; + } + + try { + const price = await this.getEthPerFeeAssetE12(blockNumber); + prices.push(price); + } catch (err) { + this.log.warn(`Failed to get price at block ${blockNumber}: ${err}`); + // Continue with fewer samples + } + } + + const filteredPrices = prices.filter(price => price !== 0n); + + if (filteredPrices.length === 0) { + throw new Error('Failed to get any price samples from Uniswap oracle'); + } + + const mean = filteredPrices.reduce((a, b) => a + b, 0n) / BigInt(filteredPrices.length); + return mean; + } +} diff --git a/yarn-project/ethereum/src/contracts/index.ts b/yarn-project/ethereum/src/contracts/index.ts index 5792f1323441..fc0ff334c857 100644 --- a/yarn-project/ethereum/src/contracts/index.ts +++ b/yarn-project/ethereum/src/contracts/index.ts @@ -1,6 +1,7 @@ export * from './empire_base.js'; export * from './errors.js'; export * from './fee_asset_handler.js'; +export * from './fee_asset_price_oracle.js'; export * from './fee_juice.js'; export * from './governance.js'; export * from './governance_proposer.js'; diff --git a/yarn-project/foundation/src/serialize/buffer_reader.ts b/yarn-project/foundation/src/serialize/buffer_reader.ts index 8887573effe8..8cc8071db595 100644 --- a/yarn-project/foundation/src/serialize/buffer_reader.ts +++ b/yarn-project/foundation/src/serialize/buffer_reader.ts @@ -1,3 +1,4 @@ +import { toBigIntBE } from '../bigint-buffer/index.js'; import type { Tuple } from './types.js'; /** @@ -130,6 +131,20 @@ export class BufferReader { return result; } + /** + * Reads a 256-bit signed integer (two's complement) from the buffer at the current index position. + * Updates the index position by 32 bytes after reading the number. + * + * @returns The read 256 bit signed value as a bigint. + */ + public readInt256(): bigint { + this.#rangeCheck(32); + const unsigned = toBigIntBE(this.buffer.subarray(this.index, this.index + 32)); + this.index += 32; + const signBit = 1n << 255n; + return unsigned >= signBit ? unsigned - (1n << 256n) : unsigned; + } + /** Alias for readUInt256 */ public readBigInt(): bigint { return this.readUInt256(); diff --git a/yarn-project/foundation/src/serialize/serialize.ts b/yarn-project/foundation/src/serialize/serialize.ts index 0a2dbed33503..5c6260be40fd 100644 --- a/yarn-project/foundation/src/serialize/serialize.ts +++ b/yarn-project/foundation/src/serialize/serialize.ts @@ -269,6 +269,22 @@ export function serializeBigInt(n: bigint, width = 32) { return toBufferBE(n, width); } +/** + * Serialize a signed BigInt value into a Buffer of specified width using two's complement. + * @param n - The signed BigInt value to be serialized. + * @param width - The width (in bytes) of the output Buffer, optional with default value 32. + * @returns A Buffer containing the serialized signed BigInt value in big-endian format. + */ +export function serializeSignedBigInt(n: bigint, width = 32) { + const widthBits = BigInt(width * 8); + const max = 1n << (widthBits - 1n); + if (n < -max || n >= max) { + throw new Error(`Signed BigInt ${n.toString()} does not fit into ${width} bytes`); + } + const unsigned = n < 0n ? (1n << widthBits) + n : n; + return toBufferBE(unsigned, width); +} + /** * Deserialize a big integer from a buffer, given an offset and width. * Reads the specified number of bytes from the buffer starting at the offset, converts it to a big integer, and returns the deserialized result along with the number of bytes read (advanced). @@ -282,6 +298,22 @@ export function deserializeBigInt(buf: Buffer, offset = 0, width = 32) { return { elem: toBigIntBE(buf.subarray(offset, offset + width)), adv: width }; } +/** + * Deserialize a signed BigInt from a buffer (two's complement). + * @param buf - The buffer containing the signed big integer to be deserialized. + * @param offset - The position in the buffer where the integer starts. Defaults to 0. + * @param width - The number of bytes to read from the buffer for the integer. Defaults to 32. + * @returns An object containing the deserialized signed bigint value ('elem') and bytes advanced ('adv'). + */ +export function deserializeSignedBigInt(buf: Buffer, offset = 0, width = 32) { + const { elem, adv } = deserializeBigInt(buf, offset, width); + const widthBits = BigInt(width * 8); + const signBit = 1n << (widthBits - 1n); + const fullRange = 1n << widthBits; + const signed = elem >= signBit ? elem - fullRange : elem; + return { elem: signed, adv }; +} + /** * Serializes a Date object into a Buffer containing its timestamp as a big integer value. * The resulting Buffer has a fixed width of 8 bytes, representing a 64-bit big-endian integer. diff --git a/yarn-project/p2p/src/mem_pools/attestation_pool/mocks.ts b/yarn-project/p2p/src/mem_pools/attestation_pool/mocks.ts index a63087feaa2f..419ed432028a 100644 --- a/yarn-project/p2p/src/mem_pools/attestation_pool/mocks.ts +++ b/yarn-project/p2p/src/mem_pools/attestation_pool/mocks.ts @@ -34,9 +34,10 @@ export const mockCheckpointAttestation = ( slot: number = 0, archive: Fr = Fr.random(), header?: CheckpointHeader, + feeAssetPriceModifier: bigint = 0n, ): CheckpointAttestation => { header = header ?? CheckpointHeader.random({ slotNumber: SlotNumber(slot) }); - const payload = new ConsensusPayload(header, archive); + const payload = new ConsensusPayload(header, archive, feeAssetPriceModifier); const attestationHash = getHashedSignaturePayloadEthSignedMessage( payload, diff --git a/yarn-project/prover-client/src/light/lightweight_checkpoint_builder.ts b/yarn-project/prover-client/src/light/lightweight_checkpoint_builder.ts index d193f436c7e5..188a2af864ee 100644 --- a/yarn-project/prover-client/src/light/lightweight_checkpoint_builder.ts +++ b/yarn-project/prover-client/src/light/lightweight_checkpoint_builder.ts @@ -44,6 +44,7 @@ export class LightweightCheckpointBuilder { constructor( public readonly checkpointNumber: CheckpointNumber, public readonly constants: CheckpointGlobalVariables, + public feeAssetPriceModifier: bigint, public readonly l1ToL2Messages: Fr[], private readonly previousCheckpointOutHashes: Fr[], public readonly db: MerkleTreeWriteOperations, @@ -54,7 +55,7 @@ export class LightweightCheckpointBuilder { instanceId: `checkpoint-${checkpointNumber}`, }); this.spongeBlob = SpongeBlob.init(); - this.logger.debug('Starting new checkpoint', { constants, l1ToL2Messages }); + this.logger.debug('Starting new checkpoint', { constants, l1ToL2Messages, feeAssetPriceModifier }); } static async startNewCheckpoint( @@ -64,6 +65,7 @@ export class LightweightCheckpointBuilder { previousCheckpointOutHashes: Fr[], db: MerkleTreeWriteOperations, bindings?: LoggerBindings, + feeAssetPriceModifier: bigint = 0n, ): Promise { // Insert l1-to-l2 messages into the tree. await db.appendLeaves( @@ -74,6 +76,7 @@ export class LightweightCheckpointBuilder { return new LightweightCheckpointBuilder( checkpointNumber, constants, + feeAssetPriceModifier, l1ToL2Messages, previousCheckpointOutHashes, db, @@ -90,6 +93,7 @@ export class LightweightCheckpointBuilder { static async resumeCheckpoint( checkpointNumber: CheckpointNumber, constants: CheckpointGlobalVariables, + feeAssetPriceModifier: bigint, l1ToL2Messages: Fr[], previousCheckpointOutHashes: Fr[], db: MerkleTreeWriteOperations, @@ -99,6 +103,7 @@ export class LightweightCheckpointBuilder { const builder = new LightweightCheckpointBuilder( checkpointNumber, constants, + feeAssetPriceModifier, l1ToL2Messages, previousCheckpointOutHashes, db, @@ -268,13 +273,14 @@ export class LightweightCheckpointBuilder { totalManaUsed, }); - return new Checkpoint(newArchive, header, blocks, this.checkpointNumber); + return new Checkpoint(newArchive, header, blocks, this.checkpointNumber, this.feeAssetPriceModifier); } clone() { const clone = new LightweightCheckpointBuilder( this.checkpointNumber, this.constants, + this.feeAssetPriceModifier, [...this.l1ToL2Messages], [...this.previousCheckpointOutHashes], this.db, diff --git a/yarn-project/sequencer-client/src/publisher/sequencer-publisher.ts b/yarn-project/sequencer-client/src/publisher/sequencer-publisher.ts index 892ad2081c0f..cdf317576111 100644 --- a/yarn-project/sequencer-client/src/publisher/sequencer-publisher.ts +++ b/yarn-project/sequencer-client/src/publisher/sequencer-publisher.ts @@ -4,6 +4,7 @@ import type { EpochCache } from '@aztec/epoch-cache'; import type { L1ContractsConfig } from '@aztec/ethereum/config'; import { type EmpireSlashingProposerContract, + FeeAssetPriceOracle, type GovernanceProposerContract, type IEmpireBase, MULTI_CALL_3_ADDRESS, @@ -60,6 +61,8 @@ type L1ProcessArgs = { attestationsAndSigners: CommitteeAttestationsAndSigners; /** Attestations and signers signature */ attestationsAndSignersSignature: Signature; + /** The fee asset price modifier in basis points (from oracle) */ + feeAssetPriceModifier: bigint; }; export const Actions = [ @@ -123,6 +126,10 @@ export class SequencerPublisher { /** L1 fee analyzer for fisherman mode */ private l1FeeAnalyzer?: L1FeeAnalyzer; + + /** Fee asset price oracle for computing price modifiers from Uniswap V4 */ + private feeAssetPriceOracle: FeeAssetPriceOracle; + // A CALL to a cold address is 2700 gas public static MULTICALL_OVERHEAD_GAS_GUESS = 5000n; @@ -188,12 +195,27 @@ export class SequencerPublisher { createLogger('sequencer:publisher:fee-analyzer'), ); } + + // Initialize fee asset price oracle + this.feeAssetPriceOracle = new FeeAssetPriceOracle( + this.l1TxUtils.client, + this.rollupContract, + createLogger('sequencer:publisher:price-oracle'), + ); } public getRollupContract(): RollupContract { return this.rollupContract; } + /** + * Gets the fee asset price modifier from the oracle. + * Returns 0n if the oracle query fails. + */ + public getFeeAssetPriceModifier(): Promise { + return this.feeAssetPriceOracle.computePriceModifier(); + } + public getSenderAddress() { return this.l1TxUtils.getSenderAddress(); } @@ -642,7 +664,7 @@ export class SequencerPublisher { header: checkpoint.header.toViem(), archive: toHex(checkpoint.archive.root.toBuffer()), oracleInput: { - feeAssetPriceModifier: 0n, + feeAssetPriceModifier: checkpoint.feeAssetPriceModifier, }, }, attestationsAndSigners.getPackedAttestations(), @@ -920,12 +942,13 @@ export class SequencerPublisher { const blobFields = checkpoint.toBlobFields(); const blobs = getBlobsPerL1Block(blobFields); - const proposeTxArgs = { + const proposeTxArgs: L1ProcessArgs = { header: checkpointHeader, archive: checkpoint.archive.root.toBuffer(), blobs, attestationsAndSigners, attestationsAndSignersSignature, + feeAssetPriceModifier: checkpoint.feeAssetPriceModifier, }; let ts: bigint; @@ -1113,8 +1136,7 @@ export class SequencerPublisher { header: encodedData.header.toViem(), archive: toHex(encodedData.archive), oracleInput: { - // We are currently not modifying these. See #9963 - feeAssetPriceModifier: 0n, + feeAssetPriceModifier: encodedData.feeAssetPriceModifier, }, }, encodedData.attestationsAndSigners.getPackedAttestations(), @@ -1140,7 +1162,7 @@ export class SequencerPublisher { readonly header: ViemHeader; readonly archive: `0x${string}`; readonly oracleInput: { - readonly feeAssetPriceModifier: 0n; + readonly feeAssetPriceModifier: bigint; }; }, ViemCommitteeAttestations, diff --git a/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.test.ts b/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.test.ts index 58b0d8ca3db3..f71618d90b37 100644 --- a/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.test.ts +++ b/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.test.ts @@ -237,12 +237,12 @@ describe('CheckpointProposalJob', () => { }, ); validatorClient.createCheckpointProposal.mockImplementation( - async (checkpointHeader, archiveRoot, lastBlockInfo) => { + async (checkpointHeader, archiveRoot, feeAssetPriceModifier, lastBlockInfo) => { if (!lastBlockInfo) { - return new CheckpointProposal(checkpointHeader, archiveRoot, mockedSig); + return new CheckpointProposal(checkpointHeader, archiveRoot, feeAssetPriceModifier, mockedSig); } const txHashes = await Promise.all((lastBlockInfo.txs ?? []).map((tx: Tx) => tx.getTxHash())); - return new CheckpointProposal(checkpointHeader, archiveRoot, mockedSig, { + return new CheckpointProposal(checkpointHeader, archiveRoot, feeAssetPriceModifier, mockedSig, { blockHeader: lastBlockInfo.blockHeader, indexWithinCheckpoint: lastBlockInfo.indexWithinCheckpoint, txHashes, 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 e593152ca6b4..e7b84bb9cddc 100644 --- a/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.ts +++ b/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.ts @@ -191,6 +191,9 @@ export class CheckpointProposalJob implements Traceable { ); const previousCheckpointOutHashes = previousCheckpoints.map(c => c.getCheckpointOutHash()); + // Get the fee asset price modifier from the oracle + const feeAssetPriceModifier = await this.publisher.getFeeAssetPriceModifier(); + // Create a long-lived forked world state for the checkpoint builder using fork = await this.worldState.fork(this.syncedToBlockNumber, { closeDelayMs: 12_000 }); @@ -198,6 +201,7 @@ export class CheckpointProposalJob implements Traceable { const checkpointBuilder = await this.checkpointsBuilder.startCheckpoint( this.checkpointNumber, checkpointGlobalVariables, + feeAssetPriceModifier, l1ToL2Messages, previousCheckpointOutHashes, fork, @@ -275,6 +279,7 @@ export class CheckpointProposalJob implements Traceable { const proposal = await this.validatorClient.createCheckpointProposal( checkpoint.header, checkpoint.archive.root, + feeAssetPriceModifier, lastBlock, this.proposer, checkpointProposalOptions, diff --git a/yarn-project/sequencer-client/src/test/mock_checkpoint_builder.ts b/yarn-project/sequencer-client/src/test/mock_checkpoint_builder.ts index 3da2a1e51ff5..60cc606570f8 100644 --- a/yarn-project/sequencer-client/src/test/mock_checkpoint_builder.ts +++ b/yarn-project/sequencer-client/src/test/mock_checkpoint_builder.ts @@ -205,6 +205,7 @@ export class MockCheckpointsBuilder implements ICheckpointsBuilder { constants: CheckpointGlobalVariables; l1ToL2Messages: Fr[]; previousCheckpointOutHashes: Fr[]; + feeAssetPriceModifier: bigint; }> = []; public openCheckpointCalls: Array<{ checkpointNumber: CheckpointNumber; @@ -212,6 +213,7 @@ export class MockCheckpointsBuilder implements ICheckpointsBuilder { l1ToL2Messages: Fr[]; previousCheckpointOutHashes: Fr[]; existingBlocks: L2Block[]; + feeAssetPriceModifier: bigint; }> = []; public updateConfigCalls: Array> = []; @@ -257,11 +259,18 @@ export class MockCheckpointsBuilder implements ICheckpointsBuilder { startCheckpoint( checkpointNumber: CheckpointNumber, constants: CheckpointGlobalVariables, + feeAssetPriceModifier: bigint, l1ToL2Messages: Fr[], previousCheckpointOutHashes: Fr[], _fork: MerkleTreeWriteOperations, ): Promise { - this.startCheckpointCalls.push({ checkpointNumber, constants, l1ToL2Messages, previousCheckpointOutHashes }); + this.startCheckpointCalls.push({ + checkpointNumber, + constants, + l1ToL2Messages, + previousCheckpointOutHashes, + feeAssetPriceModifier, + }); if (!this.checkpointBuilder) { // Auto-create a builder if none was set @@ -274,6 +283,7 @@ export class MockCheckpointsBuilder implements ICheckpointsBuilder { openCheckpoint( checkpointNumber: CheckpointNumber, constants: CheckpointGlobalVariables, + feeAssetPriceModifier: bigint, l1ToL2Messages: Fr[], previousCheckpointOutHashes: Fr[], _fork: MerkleTreeWriteOperations, @@ -285,6 +295,7 @@ export class MockCheckpointsBuilder implements ICheckpointsBuilder { l1ToL2Messages, previousCheckpointOutHashes, existingBlocks, + feeAssetPriceModifier, }); if (!this.checkpointBuilder) { diff --git a/yarn-project/sequencer-client/src/test/utils.ts b/yarn-project/sequencer-client/src/test/utils.ts index ce670d41f6d9..4da8e08e3a6a 100644 --- a/yarn-project/sequencer-client/src/test/utils.ts +++ b/yarn-project/sequencer-client/src/test/utils.ts @@ -119,10 +119,11 @@ export function createCheckpointProposal( block: L2Block, checkpointSignature: Signature, blockSignature?: Signature, + feeAssetPriceModifier: bigint = 0n, ): CheckpointProposal { const txHashes = block.body.txEffects.map(tx => tx.txHash); const checkpointHeader = createCheckpointHeaderFromBlock(block); - return new CheckpointProposal(checkpointHeader, block.archive.root, checkpointSignature, { + return new CheckpointProposal(checkpointHeader, block.archive.root, feeAssetPriceModifier, checkpointSignature, { blockHeader: block.header, indexWithinCheckpoint: block.indexWithinCheckpoint, txHashes, @@ -139,9 +140,10 @@ export function createCheckpointAttestation( block: L2Block, signature: Signature, sender: EthAddress, + feeAssetPriceModifier: bigint = 0n, ): CheckpointAttestation { const checkpointHeader = createCheckpointHeaderFromBlock(block); - const payload = new ConsensusPayload(checkpointHeader, block.archive.root); + const payload = new ConsensusPayload(checkpointHeader, block.archive.root, feeAssetPriceModifier); const attestation = new CheckpointAttestation(payload, signature, signature); // Set sender directly for testing (bypasses signature recovery) (attestation as any).sender = sender; diff --git a/yarn-project/slasher/src/watchers/epoch_prune_watcher.ts b/yarn-project/slasher/src/watchers/epoch_prune_watcher.ts index e51d16efb951..77da4ac6d957 100644 --- a/yarn-project/slasher/src/watchers/epoch_prune_watcher.ts +++ b/yarn-project/slasher/src/watchers/epoch_prune_watcher.ts @@ -181,6 +181,7 @@ export class EpochPruneWatcher extends (EventEmitter as new () => WatcherEmitter const checkpointBuilder = await this.checkpointsBuilder.startCheckpoint( checkpointNumber, constants, + 0n, // feeAssetPriceModifier is not used for validation of the checkpoint content l1ToL2Messages, previousCheckpointOutHashes, fork, diff --git a/yarn-project/stdlib/src/checkpoint/checkpoint.ts b/yarn-project/stdlib/src/checkpoint/checkpoint.ts index 889a51bedd3a..9e633345f89c 100644 --- a/yarn-project/stdlib/src/checkpoint/checkpoint.ts +++ b/yarn-project/stdlib/src/checkpoint/checkpoint.ts @@ -8,7 +8,7 @@ import { } from '@aztec/foundation/branded-types'; import { sum } from '@aztec/foundation/collection'; import { Fr } from '@aztec/foundation/curves/bn254'; -import { BufferReader, serializeToBuffer } from '@aztec/foundation/serialize'; +import { BufferReader, serializeSignedBigInt, serializeToBuffer } from '@aztec/foundation/serialize'; import type { FieldsOf } from '@aztec/foundation/types'; import { z } from 'zod'; @@ -17,6 +17,7 @@ import { L2Block } from '../block/l2_block.js'; import { MAX_BLOCKS_PER_CHECKPOINT } from '../deserialization/index.js'; import { computeCheckpointOutHash } from '../messaging/out_hash.js'; import { CheckpointHeader } from '../rollup/checkpoint_header.js'; +import { schemas } from '../schemas/schemas.js'; import { AppendOnlyTreeSnapshot } from '../trees/append_only_tree_snapshot.js'; import type { CheckpointInfo } from './checkpoint_info.js'; @@ -32,6 +33,8 @@ export class Checkpoint { public blocks: L2Block[], /** Number of the checkpoint. */ public number: CheckpointNumber, + /** Fee asset price modifier in basis points (from oracle). Defaults to 0 (no change). */ + public feeAssetPriceModifier: bigint = 0n, ) {} get slot(): SlotNumber { @@ -45,8 +48,12 @@ export class Checkpoint { header: CheckpointHeader.schema, blocks: z.array(L2Block.schema), number: CheckpointNumberSchema, + feeAssetPriceModifier: schemas.BigInt, }) - .transform(({ archive, header, blocks, number }) => new Checkpoint(archive, header, blocks, number)); + .transform( + ({ archive, header, blocks, number, feeAssetPriceModifier }) => + new Checkpoint(archive, header, blocks, number, feeAssetPriceModifier), + ); } static from(fields: FieldsOfCheckpoint) { @@ -54,21 +61,28 @@ export class Checkpoint { } static getFields(fields: FieldsOfCheckpoint) { - return [fields.archive, fields.header, fields.blocks, fields.number] as const; + return [fields.archive, fields.header, fields.blocks, fields.number, fields.feeAssetPriceModifier] as const; } static fromBuffer(buf: Buffer | BufferReader) { const reader = BufferReader.asReader(buf); - return new Checkpoint( - reader.readObject(AppendOnlyTreeSnapshot), - reader.readObject(CheckpointHeader), - reader.readVector(L2Block, MAX_BLOCKS_PER_CHECKPOINT), - CheckpointNumber(reader.readNumber()), - ); + const archive = reader.readObject(AppendOnlyTreeSnapshot); + const header = reader.readObject(CheckpointHeader); + const blocks = reader.readVector(L2Block, MAX_BLOCKS_PER_CHECKPOINT); + const number = CheckpointNumber(reader.readNumber()); + const feeAssetPriceModifier = reader.readInt256(); + return new Checkpoint(archive, header, blocks, number, feeAssetPriceModifier); } public toBuffer() { - return serializeToBuffer(this.archive, this.header, this.blocks.length, this.blocks, this.number); + return serializeToBuffer( + this.archive, + this.header, + this.blocks.length, + this.blocks, + this.number, + serializeSignedBigInt(this.feeAssetPriceModifier), + ); } public toBlobFields(): Fr[] { @@ -129,11 +143,13 @@ export class Checkpoint { numBlocks = 1, startBlockNumber = 1, previousArchive, + feeAssetPriceModifier = 0n, ...options }: { numBlocks?: number; startBlockNumber?: number; previousArchive?: AppendOnlyTreeSnapshot; + feeAssetPriceModifier?: bigint; } & Partial[0]> & Partial[1]> = {}, ) { @@ -153,6 +169,6 @@ export class Checkpoint { blocks.push(block); } - return new Checkpoint(AppendOnlyTreeSnapshot.random(), header, blocks, checkpointNumber); + return new Checkpoint(AppendOnlyTreeSnapshot.random(), header, blocks, checkpointNumber, feeAssetPriceModifier); } } diff --git a/yarn-project/stdlib/src/interfaces/block-builder.ts b/yarn-project/stdlib/src/interfaces/block-builder.ts index 916c63801f33..b5b5ea9a4c1a 100644 --- a/yarn-project/stdlib/src/interfaces/block-builder.ts +++ b/yarn-project/stdlib/src/interfaces/block-builder.ts @@ -98,6 +98,7 @@ export interface ICheckpointsBuilder { startCheckpoint( checkpointNumber: CheckpointNumber, constants: CheckpointGlobalVariables, + feeAssetPriceModifier: bigint, l1ToL2Messages: Fr[], previousCheckpointOutHashes: Fr[], fork: MerkleTreeWriteOperations, diff --git a/yarn-project/stdlib/src/interfaces/validator.ts b/yarn-project/stdlib/src/interfaces/validator.ts index 48cb4a1b8dd4..ae6c5faf1e25 100644 --- a/yarn-project/stdlib/src/interfaces/validator.ts +++ b/yarn-project/stdlib/src/interfaces/validator.ts @@ -121,6 +121,7 @@ export interface Validator { createCheckpointProposal( checkpointHeader: CheckpointHeader, archive: Fr, + feeAssetPriceModifier: bigint, lastBlockInfo: CreateCheckpointProposalLastBlockData | undefined, proposerAddress: EthAddress | undefined, options: CheckpointProposalOptions, diff --git a/yarn-project/stdlib/src/p2p/checkpoint_attestation.ts b/yarn-project/stdlib/src/p2p/checkpoint_attestation.ts index 202e97f35b5f..815c23f83ded 100644 --- a/yarn-project/stdlib/src/p2p/checkpoint_attestation.ts +++ b/yarn-project/stdlib/src/p2p/checkpoint_attestation.ts @@ -96,7 +96,12 @@ export class CheckpointAttestation extends Gossipable { // Create a temporary CheckpointProposal to recover the proposer address. // We need to use CheckpointProposal because it has a different getPayloadToSign() // implementation than ConsensusPayload (uses serializeToBuffer vs ABI encoding). - const proposal = new CheckpointProposal(this.payload.header, this.payload.archive, this.proposerSignature); + const proposal = new CheckpointProposal( + this.payload.header, + this.payload.archive, + this.payload.feeAssetPriceModifier, + this.proposerSignature, + ); // Cache the proposer for later use this.proposer = proposal.getSender(); } diff --git a/yarn-project/stdlib/src/p2p/checkpoint_proposal.ts b/yarn-project/stdlib/src/p2p/checkpoint_proposal.ts index 728897cad1cf..e9c89e3d1880 100644 --- a/yarn-project/stdlib/src/p2p/checkpoint_proposal.ts +++ b/yarn-project/stdlib/src/p2p/checkpoint_proposal.ts @@ -5,7 +5,7 @@ import { tryRecoverAddress } from '@aztec/foundation/crypto/secp256k1-signer'; import { Fr } from '@aztec/foundation/curves/bn254'; import type { EthAddress } from '@aztec/foundation/eth-address'; import { Signature } from '@aztec/foundation/eth-signature'; -import { BufferReader, serializeToBuffer } from '@aztec/foundation/serialize'; +import { BufferReader, serializeSignedBigInt, serializeToBuffer } from '@aztec/foundation/serialize'; import { DutyType, type SigningContext } from '@aztec/validator-ha-signer/types'; import type { L2BlockInfo } from '../block/l2_block_info.js'; @@ -81,7 +81,10 @@ export class CheckpointProposal extends Gossipable { /** Archive root after this checkpoint is applied */ public readonly archive: Fr, - /** The proposer's signature over the checkpoint payload (checkpointHeader + archive) */ + /** The fee asset price modifier in basis points (from oracle) */ + public readonly feeAssetPriceModifier: bigint, + + /** The proposer's signature over the checkpoint payload (checkpointHeader + archive + feeAssetPriceModifier) */ public readonly signature: Signature, /** Optional last block info, including its own signature for BlockProposal extraction */ @@ -160,20 +163,31 @@ export class CheckpointProposal extends Gossipable { /** * Get the payload to sign for this checkpoint proposal. - * The signature is over the checkpoint header + archive root (for consensus). + * The signature is over the checkpoint header + archive root + feeAssetPriceModifier (for consensus). */ getPayloadToSign(domainSeparator: SignatureDomainSeparator): Buffer { - return serializeToBuffer([domainSeparator, this.checkpointHeader, this.archive]); + return serializeToBuffer([ + domainSeparator, + this.checkpointHeader, + this.archive, + serializeSignedBigInt(this.feeAssetPriceModifier), + ]); } static async createProposalFromSigner( checkpointHeader: CheckpointHeader, archiveRoot: Fr, + feeAssetPriceModifier: bigint, lastBlockInfo: CheckpointLastBlockData | undefined, payloadSigner: (payload: Buffer32, context: SigningContext) => Promise, ): Promise { // Sign the checkpoint payload with CHECKPOINT_PROPOSAL duty type - const tempProposal = new CheckpointProposal(checkpointHeader, archiveRoot, Signature.empty(), undefined); + const tempProposal = new CheckpointProposal( + checkpointHeader, + archiveRoot, + feeAssetPriceModifier, + Signature.empty(), + ); const checkpointHash = getHashedSignaturePayload(tempProposal, SignatureDomainSeparator.checkpointProposal); const checkpointContext: SigningContext = { @@ -184,7 +198,7 @@ export class CheckpointProposal extends Gossipable { const checkpointSignature = await payloadSigner(checkpointHash, checkpointContext); if (!lastBlockInfo) { - return new CheckpointProposal(checkpointHeader, archiveRoot, checkpointSignature); + return new CheckpointProposal(checkpointHeader, archiveRoot, feeAssetPriceModifier, checkpointSignature); } const lastBlockProposal = await BlockProposal.createProposalFromSigner( @@ -197,7 +211,7 @@ export class CheckpointProposal extends Gossipable { payloadSigner, ); - return new CheckpointProposal(checkpointHeader, archiveRoot, checkpointSignature, { + return new CheckpointProposal(checkpointHeader, archiveRoot, feeAssetPriceModifier, checkpointSignature, { blockHeader: lastBlockInfo.blockHeader, indexWithinCheckpoint: lastBlockInfo.indexWithinCheckpoint, txHashes: lastBlockInfo.txHashes, @@ -237,7 +251,12 @@ export class CheckpointProposal extends Gossipable { } toBuffer(): Buffer { - const buffer: any[] = [this.checkpointHeader, this.archive, this.signature]; + const buffer: any[] = [ + this.checkpointHeader, + this.archive, + serializeSignedBigInt(this.feeAssetPriceModifier), + this.signature, + ]; if (this.lastBlock) { buffer.push(1); // hasLastBlock = true @@ -264,6 +283,7 @@ export class CheckpointProposal extends Gossipable { const checkpointHeader = reader.readObject(CheckpointHeader); const archive = reader.readObject(Fr); + const feeAssetPriceModifier = reader.readInt256(); const signature = reader.readObject(Signature); const hasLastBlock = reader.readNumber(); @@ -286,7 +306,7 @@ export class CheckpointProposal extends Gossipable { } } - return new CheckpointProposal(checkpointHeader, archive, signature, { + return new CheckpointProposal(checkpointHeader, archive, feeAssetPriceModifier, signature, { blockHeader, indexWithinCheckpoint, txHashes, @@ -295,7 +315,7 @@ export class CheckpointProposal extends Gossipable { }); } - return new CheckpointProposal(checkpointHeader, archive, signature); + return new CheckpointProposal(checkpointHeader, archive, feeAssetPriceModifier, signature); } getSize(): number { @@ -303,6 +323,7 @@ export class CheckpointProposal extends Gossipable { this.checkpointHeader.toBuffer().length + this.archive.size + this.signature.getSize() + + 8 /* feeAssetPriceModifier */ + 4; /* hasLastBlock flag */ if (this.lastBlock) { @@ -320,11 +341,11 @@ export class CheckpointProposal extends Gossipable { } static empty(): CheckpointProposal { - return new CheckpointProposal(CheckpointHeader.empty(), Fr.ZERO, Signature.empty()); + return new CheckpointProposal(CheckpointHeader.empty(), Fr.ZERO, 0n, Signature.empty()); } static random(): CheckpointProposal { - return new CheckpointProposal(CheckpointHeader.random(), Fr.random(), Signature.random(), { + return new CheckpointProposal(CheckpointHeader.random(), Fr.random(), 0n, Signature.random(), { blockHeader: BlockHeader.random(), indexWithinCheckpoint: IndexWithinCheckpoint(Math.floor(Math.random() * 5)), txHashes: [TxHash.random(), TxHash.random()], @@ -337,6 +358,7 @@ export class CheckpointProposal extends Gossipable { checkpointHeader: this.checkpointHeader.toInspect(), archive: this.archive.toString(), signature: this.signature.toString(), + feeAssetPriceModifier: this.feeAssetPriceModifier.toString(), lastBlock: this.lastBlock ? { blockHeader: this.lastBlock.blockHeader.toInspect(), @@ -353,7 +375,7 @@ export class CheckpointProposal extends Gossipable { * Used when the lastBlock has been extracted and stored separately. */ toCore(): CheckpointProposalCore { - return new CheckpointProposal(this.checkpointHeader, this.archive, this.signature); + return new CheckpointProposal(this.checkpointHeader, this.archive, this.feeAssetPriceModifier, this.signature); } } diff --git a/yarn-project/stdlib/src/p2p/consensus_payload.ts b/yarn-project/stdlib/src/p2p/consensus_payload.ts index b7341e9ffe2b..35b65afba951 100644 --- a/yarn-project/stdlib/src/p2p/consensus_payload.ts +++ b/yarn-project/stdlib/src/p2p/consensus_payload.ts @@ -1,6 +1,6 @@ import { Fr } from '@aztec/foundation/curves/bn254'; import { schemas } from '@aztec/foundation/schemas'; -import { BufferReader, serializeToBuffer } from '@aztec/foundation/serialize'; +import { BufferReader, serializeSignedBigInt, serializeToBuffer } from '@aztec/foundation/serialize'; import { hexToBuffer } from '@aztec/foundation/string'; import type { FieldsOf } from '@aztec/foundation/types'; @@ -21,6 +21,8 @@ export class ConsensusPayload implements Signable { public readonly header: CheckpointHeader, /** The archive root after the block is added */ public readonly archive: Fr, + /** The fee asset price modifier in basis points (from oracle) */ + public readonly feeAssetPriceModifier: bigint = 0n, ) {} static get schema() { @@ -28,12 +30,13 @@ export class ConsensusPayload implements Signable { .object({ header: CheckpointHeader.schema, archive: schemas.Fr, + feeAssetPriceModifier: schemas.BigInt, }) - .transform(obj => new ConsensusPayload(obj.header, obj.archive)); + .transform(obj => new ConsensusPayload(obj.header, obj.archive, obj.feeAssetPriceModifier)); } static getFields(fields: FieldsOf) { - return [fields.header, fields.archive] as const; + return [fields.header, fields.archive, fields.feeAssetPriceModifier] as const; } getPayloadToSign(domainSeparator: SignatureDomainSeparator): Buffer { @@ -50,41 +53,50 @@ export class ConsensusPayload implements Signable { const headerHash = this.header.hash().toString(); const encodedData = encodeAbiParameters(abi, [ domainSeparator, - [archiveRoot, [0n] /* @todo See #9963 */, headerHash], + [archiveRoot, [this.feeAssetPriceModifier], headerHash], ] as const); return hexToBuffer(encodedData); } toBuffer(): Buffer { - return serializeToBuffer([this.header, this.archive]); + return serializeToBuffer([this.header, this.archive, serializeSignedBigInt(this.feeAssetPriceModifier)]); } public equals(other: ConsensusPayload | CheckpointProposal | CheckpointProposalCore): boolean { const otherHeader = 'checkpointHeader' in other ? other.checkpointHeader : other.header; - return this.header.equals(otherHeader) && this.archive.equals(other.archive); + const otherModifier = 'feeAssetPriceModifier' in other ? other.feeAssetPriceModifier : 0n; + return ( + this.header.equals(otherHeader) && + this.archive.equals(other.archive) && + this.feeAssetPriceModifier === otherModifier + ); } static fromBuffer(buf: Buffer | BufferReader): ConsensusPayload { const reader = BufferReader.asReader(buf); - const payload = new ConsensusPayload(reader.readObject(CheckpointHeader), reader.readObject(Fr)); + const payload = new ConsensusPayload( + reader.readObject(CheckpointHeader), + reader.readObject(Fr), + reader.readInt256(), + ); return payload; } static fromFields(fields: FieldsOf): ConsensusPayload { - return new ConsensusPayload(fields.header, fields.archive); + return new ConsensusPayload(fields.header, fields.archive, fields.feeAssetPriceModifier); } static fromCheckpoint(checkpoint: Checkpoint): ConsensusPayload { - return new ConsensusPayload(checkpoint.header, checkpoint.archive.root); + return new ConsensusPayload(checkpoint.header, checkpoint.archive.root, checkpoint.feeAssetPriceModifier); } static empty(): ConsensusPayload { - return new ConsensusPayload(CheckpointHeader.empty(), Fr.ZERO); + return new ConsensusPayload(CheckpointHeader.empty(), Fr.ZERO, 0n); } static random(): ConsensusPayload { - return new ConsensusPayload(CheckpointHeader.random(), Fr.random()); + return new ConsensusPayload(CheckpointHeader.random(), Fr.random(), 0n); } /** @@ -104,10 +116,11 @@ export class ConsensusPayload implements Signable { return { header: this.header.toInspect(), archive: this.archive.toString(), + feeAssetPriceModifier: this.feeAssetPriceModifier.toString(), }; } toString() { - return `header: ${this.header.toString()}, archive: ${this.archive.toString()}}`; + return `header: ${this.header.toString()}, archive: ${this.archive.toString()}, feeAssetPriceModifier: ${this.feeAssetPriceModifier}}`; } } diff --git a/yarn-project/stdlib/src/tests/mocks.ts b/yarn-project/stdlib/src/tests/mocks.ts index 473e905623d4..e50c78fa0ce7 100644 --- a/yarn-project/stdlib/src/tests/mocks.ts +++ b/yarn-project/stdlib/src/tests/mocks.ts @@ -503,6 +503,7 @@ export interface MakeConsensusPayloadOptions { archive?: Fr; txHashes?: TxHash[]; txs?: Tx[]; + feeAssetPriceModifier?: bigint; } export interface MakeBlockProposalOptions { @@ -519,6 +520,7 @@ export interface MakeCheckpointProposalOptions { signer?: Secp256k1Signer; checkpointHeader?: CheckpointHeader; archiveRoot?: Fr; + feeAssetPriceModifier?: bigint; /** Options for the lastBlock - if undefined, no lastBlock is included */ lastBlock?: { blockHeader?: BlockHeader; @@ -534,11 +536,12 @@ const makeAndSignConsensusPayload = ( options?: MakeConsensusPayloadOptions, ) => { const header = options?.header ?? makeCheckpointHeader(1); - const { signer = Secp256k1Signer.random(), archive = Fr.random() } = options ?? {}; + const { signer = Secp256k1Signer.random(), archive = Fr.random(), feeAssetPriceModifier = 0n } = options ?? {}; const payload = ConsensusPayload.fromFields({ header, archive, + feeAssetPriceModifier, }); const hash = getHashedSignaturePayloadEthSignedMessage(payload, domainSeparator); @@ -582,6 +585,7 @@ export const makeCheckpointProposal = (options?: MakeCheckpointProposalOptions): const blockHeader = options?.lastBlock?.blockHeader ?? makeBlockHeader(1); const checkpointHeader = options?.checkpointHeader ?? makeCheckpointHeader(1); const archiveRoot = options?.archiveRoot ?? Fr.random(); + const feeAssetPriceModifier = options?.feeAssetPriceModifier ?? 0n; const signer = options?.signer ?? Secp256k1Signer.random(); // Build lastBlock info if provided @@ -594,8 +598,12 @@ export const makeCheckpointProposal = (options?: MakeCheckpointProposalOptions): } : undefined; - return CheckpointProposal.createProposalFromSigner(checkpointHeader, archiveRoot, lastBlockInfo, payload => - Promise.resolve(signer.signMessage(payload)), + return CheckpointProposal.createProposalFromSigner( + checkpointHeader, + archiveRoot, + feeAssetPriceModifier, + lastBlockInfo, + payload => Promise.resolve(signer.signMessage(payload)), ); }; @@ -605,6 +613,7 @@ export const makeCheckpointProposal = (options?: MakeCheckpointProposalOptions): export type MakeCheckpointAttestationOptions = { header?: CheckpointHeader; archive?: Fr; + feeAssetPriceModifier?: bigint; attesterSigner?: Secp256k1Signer; proposerSigner?: Secp256k1Signer; signer?: Secp256k1Signer; @@ -616,9 +625,10 @@ export type MakeCheckpointAttestationOptions = { export const makeCheckpointAttestation = (options: MakeCheckpointAttestationOptions = {}): CheckpointAttestation => { const header = options.header ?? makeCheckpointHeader(1); const archive = options.archive ?? Fr.random(); + const feeAssetPriceModifier = options.feeAssetPriceModifier ?? 0n; const { signer, attesterSigner = signer, proposerSigner = signer } = options; - const payload = new ConsensusPayload(header, archive); + const payload = new ConsensusPayload(header, archive, feeAssetPriceModifier); // Sign as attester const attestationHash = getHashedSignaturePayloadEthSignedMessage( @@ -631,7 +641,7 @@ export const makeCheckpointAttestation = (options: MakeCheckpointAttestationOpti // Sign as proposer - use CheckpointProposal's payload format (serializeToBuffer) // This is different from ConsensusPayload's format (ABI encoding) const proposalSignerToUse = proposerSigner ?? Secp256k1Signer.random(); - const tempProposal = new CheckpointProposal(header, archive, Signature.empty()); + const tempProposal = new CheckpointProposal(header, archive, feeAssetPriceModifier, Signature.empty()); const proposalHash = getHashedSignaturePayloadEthSignedMessage( tempProposal, SignatureDomainSeparator.checkpointProposal, @@ -648,7 +658,7 @@ export const makeCheckpointAttestationFromProposal = ( proposal: CheckpointProposal, attesterSigner?: Secp256k1Signer, ): CheckpointAttestation => { - const payload = new ConsensusPayload(proposal.checkpointHeader, proposal.archive); + const payload = new ConsensusPayload(proposal.checkpointHeader, proposal.archive, proposal.feeAssetPriceModifier); // Sign as attester const attestationHash = getHashedSignaturePayloadEthSignedMessage( @@ -672,8 +682,9 @@ export const makeCheckpointAttestationFromCheckpoint = ( ): CheckpointAttestation => { const header = checkpoint.header; const archive = checkpoint.archive.root; + const feeAssetPriceModifier = checkpoint.feeAssetPriceModifier; - return makeCheckpointAttestation({ header, archive, attesterSigner, proposerSigner }); + return makeCheckpointAttestation({ header, archive, feeAssetPriceModifier, attesterSigner, proposerSigner }); }; /** diff --git a/yarn-project/validator-client/src/block_proposal_handler.ts b/yarn-project/validator-client/src/block_proposal_handler.ts index 4ac149baf070..fba4fcb25f3f 100644 --- a/yarn-project/validator-client/src/block_proposal_handler.ts +++ b/yarn-project/validator-client/src/block_proposal_handler.ts @@ -491,6 +491,7 @@ export class BlockProposalHandler { const checkpointBuilder = await this.checkpointsBuilder.openCheckpoint( checkpointNumber, constants, + 0n, // only takes effect in the following checkpoint. l1ToL2Messages, previousCheckpointOutHashes, fork, diff --git a/yarn-project/validator-client/src/checkpoint_builder.ts b/yarn-project/validator-client/src/checkpoint_builder.ts index dd7acf2450c6..7805f431610b 100644 --- a/yarn-project/validator-client/src/checkpoint_builder.ts +++ b/yarn-project/validator-client/src/checkpoint_builder.ts @@ -215,6 +215,7 @@ export class FullNodeCheckpointsBuilder implements ICheckpointsBuilder { async startCheckpoint( checkpointNumber: CheckpointNumber, constants: CheckpointGlobalVariables, + feeAssetPriceModifier: bigint, l1ToL2Messages: Fr[], previousCheckpointOutHashes: Fr[], fork: MerkleTreeWriteOperations, @@ -229,6 +230,7 @@ export class FullNodeCheckpointsBuilder implements ICheckpointsBuilder { initialStateReference: stateReference.toInspect(), initialArchiveRoot: bufferToHex(archiveTree.root), constants, + feeAssetPriceModifier, }); const lightweightBuilder = await LightweightCheckpointBuilder.startNewCheckpoint( @@ -238,6 +240,7 @@ export class FullNodeCheckpointsBuilder implements ICheckpointsBuilder { previousCheckpointOutHashes, fork, bindings, + feeAssetPriceModifier, ); return new CheckpointBuilder( @@ -257,6 +260,7 @@ export class FullNodeCheckpointsBuilder implements ICheckpointsBuilder { async openCheckpoint( checkpointNumber: CheckpointNumber, constants: CheckpointGlobalVariables, + feeAssetPriceModifier: bigint, l1ToL2Messages: Fr[], previousCheckpointOutHashes: Fr[], fork: MerkleTreeWriteOperations, @@ -270,6 +274,7 @@ export class FullNodeCheckpointsBuilder implements ICheckpointsBuilder { return this.startCheckpoint( checkpointNumber, constants, + feeAssetPriceModifier, l1ToL2Messages, previousCheckpointOutHashes, fork, @@ -284,11 +289,13 @@ export class FullNodeCheckpointsBuilder implements ICheckpointsBuilder { initialStateReference: stateReference.toInspect(), initialArchiveRoot: bufferToHex(archiveTree.root), constants, + feeAssetPriceModifier, }); const lightweightBuilder = await LightweightCheckpointBuilder.resumeCheckpoint( checkpointNumber, constants, + feeAssetPriceModifier, l1ToL2Messages, previousCheckpointOutHashes, fork, diff --git a/yarn-project/validator-client/src/duties/validation_service.test.ts b/yarn-project/validator-client/src/duties/validation_service.test.ts index f8238ddcb460..44a08e4893ff 100644 --- a/yarn-project/validator-client/src/duties/validation_service.test.ts +++ b/yarn-project/validator-client/src/duties/validation_service.test.ts @@ -108,6 +108,7 @@ describe('ValidationService', () => { const proposal = await spyService.createCheckpointProposal( checkpointHeader, archive, + 0n, // feeAssetPriceModifier { blockHeader, indexWithinCheckpoint, diff --git a/yarn-project/validator-client/src/duties/validation_service.ts b/yarn-project/validator-client/src/duties/validation_service.ts index 8c9bb3eccab9..99d4740b78c7 100644 --- a/yarn-project/validator-client/src/duties/validation_service.ts +++ b/yarn-project/validator-client/src/duties/validation_service.ts @@ -95,6 +95,7 @@ export class ValidationService { public createCheckpointProposal( checkpointHeader: CheckpointHeader, archive: Fr, + feeAssetPriceModifier: bigint, lastBlockInfo: CreateCheckpointProposalLastBlockData | undefined, proposerAttesterAddress: EthAddress | undefined, options: CheckpointProposalOptions, @@ -119,7 +120,13 @@ export class ValidationService { txs: options.publishFullTxs ? lastBlockInfo.txs : undefined, }; - return CheckpointProposal.createProposalFromSigner(checkpointHeader, archive, lastBlock, payloadSigner); + return CheckpointProposal.createProposalFromSigner( + checkpointHeader, + archive, + feeAssetPriceModifier, + lastBlock, + payloadSigner, + ); } /** @@ -137,7 +144,7 @@ export class ValidationService { attestors: EthAddress[], ): Promise { // Create the attestation payload from the checkpoint proposal - const payload = new ConsensusPayload(proposal.checkpointHeader, proposal.archive); + const payload = new ConsensusPayload(proposal.checkpointHeader, proposal.archive, proposal.feeAssetPriceModifier); const buf = Buffer32.fromBuffer( keccak256(payload.getPayloadToSign(SignatureDomainSeparator.checkpointAttestation)), ); diff --git a/yarn-project/validator-client/src/validator.integration.test.ts b/yarn-project/validator-client/src/validator.integration.test.ts index 2e0551135539..109996b6ca23 100644 --- a/yarn-project/validator-client/src/validator.integration.test.ts +++ b/yarn-project/validator-client/src/validator.integration.test.ts @@ -285,6 +285,7 @@ describe('ValidatorClient Integration', () => { const builder = await proposer.checkpointsBuilder.startCheckpoint( checkpointNumber, globalVariables, + 0n, l1ToL2Messages, previousCheckpointOutHashes, fork, @@ -303,6 +304,7 @@ describe('ValidatorClient Integration', () => { const proposal = await proposer.validator.createCheckpointProposal( checkpoint.header, checkpoint.archive.root, + 0n, undefined, proposerSigner.address, ); @@ -528,6 +530,7 @@ describe('ValidatorClient Integration', () => { const badProposal = await CheckpointProposal.createProposalFromSigner( checkpoint.header, Fr.random(), // Wrong archive root + 0n, undefined, payload => Promise.resolve(proposerSigner.sign(payload)), ); diff --git a/yarn-project/validator-client/src/validator.test.ts b/yarn-project/validator-client/src/validator.test.ts index ac288b1a58a1..620e3b2b0b79 100644 --- a/yarn-project/validator-client/src/validator.test.ts +++ b/yarn-project/validator-client/src/validator.test.ts @@ -1,6 +1,7 @@ import type { BlobClientInterface } from '@aztec/blob-client/client'; import { GENESIS_ARCHIVE_ROOT } from '@aztec/constants'; import type { EpochCache } from '@aztec/epoch-cache'; +import { MAX_FEE_ASSET_PRICE_MODIFIER_BPS } from '@aztec/ethereum/contracts'; import { BlockNumber, CheckpointNumber, IndexWithinCheckpoint, SlotNumber } from '@aztec/foundation/branded-types'; import { Buffer32 } from '@aztec/foundation/buffer'; import { times } from '@aztec/foundation/collection'; @@ -409,6 +410,47 @@ describe('ValidatorClient', () => { expect(addCheckpointAttestationsSpy).not.toHaveBeenCalled(); }); + it('should not attest to a checkpoint proposal after validating a block for that slot if the fee asset price modifier is invalid', async () => { + const addCheckpointAttestationsSpy = jest.spyOn(p2pClient, 'addOwnCheckpointAttestations'); + + const didValidate = await validatorClient.validateBlockProposal(proposal, sender); + expect(didValidate).toBe(true); + + const attestationsNegative = await validatorClient.attestToCheckpointProposal( + await makeCheckpointProposal({ + archiveRoot: proposal.archive, + checkpointHeader: makeCheckpointHeader(0, { slotNumber: proposal.slotNumber }), + lastBlock: { + blockHeader: makeBlockHeader(1, { blockNumber: BlockNumber(123), slotNumber: proposal.slotNumber }), + indexWithinCheckpoint: IndexWithinCheckpoint(0), + txHashes: proposal.txHashes, + }, + feeAssetPriceModifier: -MAX_FEE_ASSET_PRICE_MODIFIER_BPS - 1n, + }), + sender, + ); + + expect(attestationsNegative).toBeUndefined(); + expect(addCheckpointAttestationsSpy).not.toHaveBeenCalled(); + + const attestationsPositive = await validatorClient.attestToCheckpointProposal( + await makeCheckpointProposal({ + archiveRoot: proposal.archive, + checkpointHeader: makeCheckpointHeader(0, { slotNumber: proposal.slotNumber }), + lastBlock: { + blockHeader: makeBlockHeader(1, { blockNumber: BlockNumber(123), slotNumber: proposal.slotNumber }), + indexWithinCheckpoint: IndexWithinCheckpoint(0), + txHashes: proposal.txHashes, + }, + feeAssetPriceModifier: MAX_FEE_ASSET_PRICE_MODIFIER_BPS + 1n, + }), + sender, + ); + + expect(attestationsPositive).toBeUndefined(); + expect(addCheckpointAttestationsSpy).not.toHaveBeenCalled(); + }); + it('should attest to a checkpoint proposal after validating a block for that slot', async () => { const addCheckpointAttestationsSpy = jest.spyOn(p2pClient, 'addOwnCheckpointAttestations'); diff --git a/yarn-project/validator-client/src/validator.ts b/yarn-project/validator-client/src/validator.ts index 32a725fdc8f0..a383f2dfe029 100644 --- a/yarn-project/validator-client/src/validator.ts +++ b/yarn-project/validator-client/src/validator.ts @@ -1,6 +1,7 @@ import type { BlobClientInterface } from '@aztec/blob-client/client'; import { type Blob, getBlobsPerL1Block } from '@aztec/blob-lib'; import type { EpochCache } from '@aztec/epoch-cache'; +import { validateFeeAssetPriceModifier } from '@aztec/ethereum/contracts'; import { BlockNumber, CheckpointNumber, @@ -471,6 +472,14 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter) return undefined; } + // Validate fee asset price modifier is within allowed range + if (!validateFeeAssetPriceModifier(proposal.feeAssetPriceModifier)) { + this.log.warn( + `Received checkpoint proposal with invalid feeAssetPriceModifier ${proposal.feeAssetPriceModifier} for slot ${slotNumber}`, + ); + return undefined; + } + // Check that I have any address in current committee before attesting const inCommittee = await this.epochCache.filterInCommittee(slotNumber, this.getValidatorAddresses()); const partOfCommittee = inCommittee.length > 0; @@ -665,6 +674,7 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter) const checkpointBuilder = await this.checkpointsBuilder.openCheckpoint( checkpointNumber, constants, + proposal.feeAssetPriceModifier, l1ToL2Messages, previousCheckpointOutHashes, fork, @@ -880,6 +890,7 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter) async createCheckpointProposal( checkpointHeader: CheckpointHeader, archive: Fr, + feeAssetPriceModifier: bigint, lastBlockInfo: CreateCheckpointProposalLastBlockData | undefined, proposerAddress: EthAddress | undefined, options: CheckpointProposalOptions = {}, @@ -901,6 +912,7 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter) const newProposal = await this.validationService.createCheckpointProposal( checkpointHeader, archive, + feeAssetPriceModifier, lastBlockInfo, proposerAddress, options,