diff --git a/yarn-project/archiver/package.json b/yarn-project/archiver/package.json index c1571c1429c4..4afb656f15c5 100644 --- a/yarn-project/archiver/package.json +++ b/yarn-project/archiver/package.json @@ -4,7 +4,8 @@ "type": "module", "exports": { ".": "./dest/index.js", - "./data-retrieval": "./dest/archiver/data_retrieval.js" + "./data-retrieval": "./dest/archiver/data_retrieval.js", + "./test": "./dest/test/index.js" }, "typedocOptions": { "entryPoints": [ diff --git a/yarn-project/archiver/src/archiver/archiver.ts b/yarn-project/archiver/src/archiver/archiver.ts index 23f5f668254c..cccef6420c90 100644 --- a/yarn-project/archiver/src/archiver/archiver.ts +++ b/yarn-project/archiver/src/archiver/archiver.ts @@ -18,6 +18,7 @@ import { ContractClassRegisteredEvent, ContractInstanceDeployedEvent, type FunctionSelector, + type Header, PrivateFunctionBroadcastedEvent, UnconstrainedFunctionBroadcastedEvent, isValidPrivateFunctionMembershipProof, @@ -57,6 +58,12 @@ import { import { type ArchiverDataStore, type ArchiverL1SynchPoint } from './archiver_store.js'; import { type ArchiverConfig } from './config.js'; import { retrieveBlockFromRollup, retrieveL1ToL2Messages } from './data_retrieval.js'; +import { + getEpochNumberAtTimestamp, + getSlotAtTimestamp, + getSlotRangeForEpoch, + getTimestampRangeForEpoch, +} from './epoch_helpers.js'; import { ArchiverInstrumentation } from './instrumentation.js'; import { type DataRetrieval } from './structs/data_retrieval.js'; import { type L1Published } from './structs/published.js'; @@ -82,6 +89,9 @@ export class Archiver implements ArchiveSource { private store: ArchiverStoreHelper; + public l1BlockNumber: bigint | undefined; + public l1Timestamp: bigint | undefined; + /** * Creates a new instance of the Archiver. * @param publicClient - A client for interacting with the Ethereum node. @@ -98,9 +108,9 @@ export class Archiver implements ArchiveSource { readonly inboxAddress: EthAddress, private readonly registryAddress: EthAddress, readonly dataStore: ArchiverDataStore, - private readonly pollingIntervalMs = 10_000, + private readonly pollingIntervalMs: number, private readonly instrumentation: ArchiverInstrumentation, - private readonly l1StartBlock: bigint = 0n, + private readonly l1constants: L1RollupConstants = EmptyL1RollupConstants, private readonly log: DebugLogger = createDebugLogger('aztec:archiver'), ) { this.store = new ArchiverStoreHelper(dataStore); @@ -144,7 +154,10 @@ export class Archiver implements ArchiveSource { client: publicClient, }); - const l1StartBlock = await rollup.read.L1_BLOCK_AT_GENESIS(); + const [l1StartBlock, l1GenesisTime] = await Promise.all([ + rollup.read.L1_BLOCK_AT_GENESIS(), + rollup.read.GENESIS_TIME(), + ] as const); const archiver = new Archiver( publicClient, @@ -152,9 +165,9 @@ export class Archiver implements ArchiveSource { config.l1Contracts.inboxAddress, config.l1Contracts.registryAddress, archiverStore, - config.archiverPollingIntervalMS, + config.archiverPollingIntervalMS ?? 10_000, new ArchiverInstrumentation(telemetry), - BigInt(l1StartBlock), + { l1StartBlock, l1GenesisTime }, ); await archiver.start(blockUntilSynced); return archiver; @@ -206,8 +219,8 @@ export class Archiver implements ArchiveSource { * * This code does not handle reorgs. */ - const { blocksSynchedTo = this.l1StartBlock, messagesSynchedTo = this.l1StartBlock } = - await this.store.getSynchPoint(); + const { l1StartBlock } = this.l1constants; + const { blocksSynchedTo = l1StartBlock, messagesSynchedTo = l1StartBlock } = await this.store.getSynchPoint(); const currentL1BlockNumber = await this.publicClient.getBlockNumber(); // ********** Ensuring Consistency of data pulled from L1 ********** @@ -234,6 +247,12 @@ export class Archiver implements ArchiveSource { // ********** Events that are processed per L2 block ********** await this.handleL2blocks(blockUntilSynced, blocksSynchedTo, currentL1BlockNumber); + + // Store latest l1 block number and timestamp seen. Used for epoch and slots calculations. + if (!this.l1BlockNumber || this.l1BlockNumber < currentL1BlockNumber) { + this.l1Timestamp = await this.publicClient.getBlock({ blockNumber: currentL1BlockNumber }).then(b => b.timestamp); + this.l1BlockNumber = currentL1BlockNumber; + } } private async handleL1ToL2Messages( @@ -421,6 +440,68 @@ export class Archiver implements ArchiveSource { return Promise.resolve(this.registryAddress); } + public getL1BlockNumber(): bigint { + const l1BlockNumber = this.l1BlockNumber; + if (!l1BlockNumber) { + throw new Error('L1 block number not yet available. Complete an initial sync first.'); + } + return l1BlockNumber; + } + + public getL1Timestamp(): bigint { + const l1Timestamp = this.l1Timestamp; + if (!l1Timestamp) { + throw new Error('L1 timestamp not yet available. Complete an initial sync first.'); + } + return l1Timestamp; + } + + public getL2SlotNumber(): Promise { + return Promise.resolve(getSlotAtTimestamp(this.getL1Timestamp(), this.l1constants)); + } + + public getL2EpochNumber(): Promise { + return Promise.resolve(getEpochNumberAtTimestamp(this.getL1Timestamp(), this.l1constants)); + } + + public async getBlocksForEpoch(epochNumber: bigint): Promise { + const [start, end] = getSlotRangeForEpoch(epochNumber); + const blocks: L2Block[] = []; + + // Walk the list of blocks backwards and filter by slots matching the requested epoch. + // We'll typically ask for blocks for a very recent epoch, so we shouldn't need an index here. + let block = await this.getBlock(await this.store.getSynchedL2BlockNumber()); + const slot = (b: L2Block) => b.header.globalVariables.slotNumber.toBigInt(); + while (block && slot(block) >= start) { + if (slot(block) <= end) { + blocks.push(block); + } + block = await this.getBlock(block.number - 1); + } + + return blocks; + } + + public async isEpochComplete(epochNumber: bigint): Promise { + // The epoch is complete if the current L2 block is the last one in the epoch (or later) + const header = await this.getBlockHeader('latest'); + const slot = header?.globalVariables.slotNumber.toBigInt(); + const [_startSlot, endSlot] = getSlotRangeForEpoch(epochNumber); + if (slot && slot >= endSlot) { + return true; + } + + // If not, the epoch may also be complete if the L2 slot has passed without a block + // We compute this based on the timestamp for the given epoch and the timestamp of the last L1 block + const l1Timestamp = this.getL1Timestamp(); + const [_startTimestamp, endTimestamp] = getTimestampRangeForEpoch(epochNumber, this.l1constants); + + // For this computation, we throw in a few extra seconds just for good measure, + // since we know the next L1 block won't be mined within this range + const leeway = 3n; + return l1Timestamp + leeway >= endTimestamp; + } + /** * Gets up to `limit` amount of L2 blocks starting from `from`. * @param from - Number of the first block to return (inclusive). @@ -452,6 +533,14 @@ export class Archiver implements ArchiveSource { return blocks.length === 0 ? undefined : blocks[0].data; } + public async getBlockHeader(number: number | 'latest'): Promise
{ + if (number === 'latest') { + number = await this.store.getSynchedL2BlockNumber(); + } + const headers = await this.store.getBlockHeaders(number, 1); + return headers.length === 0 ? undefined : headers[0]; + } + public getTxEffect(txHash: TxHash): Promise { return this.store.getTxEffect(txHash); } @@ -735,11 +824,12 @@ class ArchiverStoreHelper getBlocks(from: number, limit: number): Promise[]> { return this.store.getBlocks(from, limit); } - + getBlockHeaders(from: number, limit: number): Promise { + return this.store.getBlockHeaders(from, limit); + } getTxEffect(txHash: TxHash): Promise { return this.store.getTxEffect(txHash); } - getSettledTxReceipt(txHash: TxHash): Promise { return this.store.getSettledTxReceipt(txHash); } @@ -805,3 +895,13 @@ class ArchiverStoreHelper return this.store.getTotalL1ToL2MessageCount(); } } + +type L1RollupConstants = { + l1StartBlock: bigint; + l1GenesisTime: bigint; +}; + +const EmptyL1RollupConstants: L1RollupConstants = { + l1StartBlock: 0n, + l1GenesisTime: 0n, +}; diff --git a/yarn-project/archiver/src/archiver/archiver_store.ts b/yarn-project/archiver/src/archiver/archiver_store.ts index b181c44db39c..af04befb3cc0 100644 --- a/yarn-project/archiver/src/archiver/archiver_store.ts +++ b/yarn-project/archiver/src/archiver/archiver_store.ts @@ -10,7 +10,7 @@ import { type TxHash, type TxReceipt, } from '@aztec/circuit-types'; -import { type Fr } from '@aztec/circuits.js'; +import { type Fr, type Header } from '@aztec/circuits.js'; import { type ContractArtifact } from '@aztec/foundation/abi'; import { type AztecAddress } from '@aztec/foundation/aztec-address'; import { @@ -64,6 +64,14 @@ export interface ArchiverDataStore { */ getBlocks(from: number, limit: number): Promise[]>; + /** + * Gets up to `limit` amount of L2 block headers starting from `from`. + * @param from - Number of the first block to return (inclusive). + * @param limit - The number of blocks to return. + * @returns The requested L2 block headers. + */ + getBlockHeaders(from: number, limit: number): Promise; + /** * Gets a tx effect. * @param txHash - The txHash of the tx corresponding to the tx effect. diff --git a/yarn-project/archiver/src/archiver/epoch_helpers.ts b/yarn-project/archiver/src/archiver/epoch_helpers.ts new file mode 100644 index 000000000000..2612329dd067 --- /dev/null +++ b/yarn-project/archiver/src/archiver/epoch_helpers.ts @@ -0,0 +1,26 @@ +import { AZTEC_EPOCH_DURATION, AZTEC_SLOT_DURATION } from '@aztec/circuits.js'; + +/** Returns the slot number for a given timestamp. */ +export function getSlotAtTimestamp(ts: bigint, constants: { l1GenesisTime: bigint }) { + return ts < constants.l1GenesisTime ? 0n : (ts - constants.l1GenesisTime) / BigInt(AZTEC_SLOT_DURATION); +} + +/** Returns the epoch number for a given timestamp. */ +export function getEpochNumberAtTimestamp(ts: bigint, constants: { l1GenesisTime: bigint }) { + return getSlotAtTimestamp(ts, constants) / BigInt(AZTEC_EPOCH_DURATION); +} + +/** Returns the range of slots (inclusive) for a given epoch number. */ +export function getSlotRangeForEpoch(epochNumber: bigint) { + const startSlot = epochNumber * BigInt(AZTEC_EPOCH_DURATION); + return [startSlot, startSlot + BigInt(AZTEC_EPOCH_DURATION) - 1n]; +} + +/** Returns the range of L1 timestamps (inclusive) for a given epoch number. */ +export function getTimestampRangeForEpoch(epochNumber: bigint, constants: { l1GenesisTime: bigint }) { + const [startSlot, endSlot] = getSlotRangeForEpoch(epochNumber); + return [ + constants.l1GenesisTime + startSlot * BigInt(AZTEC_SLOT_DURATION), + constants.l1GenesisTime + endSlot * BigInt(AZTEC_SLOT_DURATION), + ]; +} diff --git a/yarn-project/archiver/src/archiver/kv_archiver_store/block_store.ts b/yarn-project/archiver/src/archiver/kv_archiver_store/block_store.ts index 7c650c0e1d20..a4a3ba3281c3 100644 --- a/yarn-project/archiver/src/archiver/kv_archiver_store/block_store.ts +++ b/yarn-project/archiver/src/archiver/kv_archiver_store/block_store.ts @@ -139,6 +139,18 @@ export class BlockStore { return this.getBlockFromBlockStorage(blockStorage); } + /** + * Gets the headers for a sequence of L2 blocks. + * @param start - Number of the first block to return (inclusive). + * @param limit - The number of blocks to return. + * @returns The requested L2 block headers + */ + *getBlockHeaders(start: number, limit: number): IterableIterator
{ + for (const blockStorage of this.#blocks.values(this.#computeBlockRange(start, limit))) { + yield Header.fromBuffer(blockStorage.header); + } + } + private getBlockFromBlockStorage(blockStorage: BlockStorage) { const header = Header.fromBuffer(blockStorage.header); const archive = AppendOnlyTreeSnapshot.fromBuffer(blockStorage.archive); diff --git a/yarn-project/archiver/src/archiver/kv_archiver_store/kv_archiver_store.ts b/yarn-project/archiver/src/archiver/kv_archiver_store/kv_archiver_store.ts index baebf6efc641..37a2d7d11f4a 100644 --- a/yarn-project/archiver/src/archiver/kv_archiver_store/kv_archiver_store.ts +++ b/yarn-project/archiver/src/archiver/kv_archiver_store/kv_archiver_store.ts @@ -10,7 +10,7 @@ import { type TxHash, type TxReceipt, } from '@aztec/circuit-types'; -import { type Fr } from '@aztec/circuits.js'; +import { type Fr, type Header } from '@aztec/circuits.js'; import { type ContractArtifact } from '@aztec/foundation/abi'; import { type AztecAddress } from '@aztec/foundation/aztec-address'; import { createDebugLogger } from '@aztec/foundation/log'; @@ -136,6 +136,22 @@ export class KVArchiverDataStore implements ArchiverDataStore { } } + /** + * Gets up to `limit` amount of L2 blocks headers starting from `from`. + * + * @param start - Number of the first block to return (inclusive). + * @param limit - The number of blocks to return. + * @returns The requested L2 blocks + */ + getBlockHeaders(start: number, limit: number): Promise { + try { + return Promise.resolve(Array.from(this.#blockStore.getBlockHeaders(start, limit))); + } catch (err) { + // this function is sync so if any errors are thrown we need to make sure they're passed on as rejected Promises + return Promise.reject(err); + } + } + /** * Gets a tx effect. * @param txHash - The txHash of the tx corresponding to the tx effect. diff --git a/yarn-project/archiver/src/archiver/memory_archiver_store/memory_archiver_store.ts b/yarn-project/archiver/src/archiver/memory_archiver_store/memory_archiver_store.ts index df06ee022def..6bb14927baa7 100644 --- a/yarn-project/archiver/src/archiver/memory_archiver_store/memory_archiver_store.ts +++ b/yarn-project/archiver/src/archiver/memory_archiver_store/memory_archiver_store.ts @@ -15,7 +15,7 @@ import { TxReceipt, type UnencryptedL2BlockL2Logs, } from '@aztec/circuit-types'; -import { Fr, INITIAL_L2_BLOCK_NUM } from '@aztec/circuits.js'; +import { Fr, type Header, INITIAL_L2_BLOCK_NUM } from '@aztec/circuits.js'; import { type ContractArtifact } from '@aztec/foundation/abi'; import { type AztecAddress } from '@aztec/foundation/aztec-address'; import { @@ -262,7 +262,6 @@ export class MemoryArchiverStore implements ArchiverDataStore { * @remarks When "from" is smaller than genesis block number, blocks from the beginning are returned. */ public getBlocks(from: number, limit: number): Promise[]> { - // Return an empty array if we are outside of range if (limit < 1) { return Promise.reject(new Error(`Invalid limit: ${limit}`)); } @@ -280,6 +279,11 @@ export class MemoryArchiverStore implements ArchiverDataStore { return Promise.resolve(this.l2Blocks.slice(fromIndex, toIndex)); } + public async getBlockHeaders(from: number, limit: number): Promise { + const blocks = await this.getBlocks(from, limit); + return blocks.map(block => block.data.header); + } + /** * Gets a tx effect. * @param txHash - The txHash of the tx effect. diff --git a/yarn-project/archiver/src/test/index.ts b/yarn-project/archiver/src/test/index.ts new file mode 100644 index 000000000000..5ff05e2acb40 --- /dev/null +++ b/yarn-project/archiver/src/test/index.ts @@ -0,0 +1 @@ +export * from './mock_l2_block_source.js'; diff --git a/yarn-project/p2p/src/client/mocks.ts b/yarn-project/archiver/src/test/mock_l2_block_source.ts similarity index 81% rename from yarn-project/p2p/src/client/mocks.ts rename to yarn-project/archiver/src/test/mock_l2_block_source.ts index 1783c83a8bda..4206f29c4fec 100644 --- a/yarn-project/p2p/src/client/mocks.ts +++ b/yarn-project/archiver/src/test/mock_l2_block_source.ts @@ -1,5 +1,7 @@ import { L2Block, type L2BlockSource, type TxEffect, type TxHash, TxReceipt, TxStatus } from '@aztec/circuit-types'; -import { EthAddress } from '@aztec/circuits.js'; +import { EthAddress, type Header } from '@aztec/circuits.js'; + +import { getSlotRangeForEpoch } from '../archiver/epoch_helpers.js'; /** * A mocked implementation of L2BlockSource to be used in p2p tests. @@ -85,6 +87,19 @@ export class MockBlockSource implements L2BlockSource { ); } + getBlockHeader(number: number | 'latest'): Promise
{ + return Promise.resolve(this.l2Blocks.at(typeof number === 'number' ? number : -1)?.header); + } + + getBlocksForEpoch(epochNumber: bigint): Promise { + const [start, end] = getSlotRangeForEpoch(epochNumber); + const blocks = this.l2Blocks.filter(b => { + const slot = b.header.globalVariables.slotNumber.toBigInt(); + return slot >= start && slot <= end; + }); + return Promise.resolve(blocks); + } + /** * Gets a tx effect. * @param txHash - The hash of a transaction which resulted in the returned tx effect. @@ -120,6 +135,18 @@ export class MockBlockSource implements L2BlockSource { return Promise.resolve(undefined); } + getL2EpochNumber(): Promise { + throw new Error('Method not implemented.'); + } + + getL2SlotNumber(): Promise { + throw new Error('Method not implemented.'); + } + + isEpochComplete(_epochNumber: bigint): Promise { + throw new Error('Method not implemented.'); + } + /** * Starts the block source. In this mock implementation, this is a noop. * @returns A promise that signals the initialization of the l2 block source on completion. diff --git a/yarn-project/aztec.js/src/utils/cheat_codes.ts b/yarn-project/aztec.js/src/utils/cheat_codes.ts index f37e256ff7b7..055248ec346a 100644 --- a/yarn-project/aztec.js/src/utils/cheat_codes.ts +++ b/yarn-project/aztec.js/src/utils/cheat_codes.ts @@ -1,11 +1,15 @@ import { type Note, type PXE } from '@aztec/circuit-types'; -import { type AztecAddress, type EthAddress, Fr } from '@aztec/circuits.js'; +import { AZTEC_EPOCH_DURATION, AZTEC_SLOT_DURATION, type AztecAddress, type EthAddress, Fr } from '@aztec/circuits.js'; import { deriveStorageSlotInMap } from '@aztec/circuits.js/hash'; +import { type L1ContractAddresses } from '@aztec/ethereum'; import { toBigIntBE, toHex } from '@aztec/foundation/bigint-buffer'; import { keccak256 } from '@aztec/foundation/crypto'; import { createDebugLogger } from '@aztec/foundation/log'; +import { RollupAbi } from '@aztec/l1-artifacts'; import fs from 'fs'; +import { type GetContractReturnType, type PublicClient, createPublicClient, getContract, http } from 'viem'; +import { foundry } from 'viem/chains'; /** * A class that provides utility functions for interacting with the chain. @@ -271,6 +275,45 @@ export class EthCheatCodes { } } +/** Cheat codes for the L1 rollup contract. */ +export class RollupCheatCodes { + private client: PublicClient; + private rollup: GetContractReturnType; + + private logger = createDebugLogger('aztec:js:cheat_codes'); + + constructor(private ethCheatCodes: EthCheatCodes, private addresses: Pick) { + this.client = createPublicClient({ chain: foundry, transport: http(ethCheatCodes.rpcUrl) }); + this.rollup = getContract({ + abi: RollupAbi, + address: addresses.rollupAddress.toString(), + client: this.client, + }); + } + + /** Returns the current slot */ + public async getSlot() { + const ts = BigInt((await this.client.getBlock()).timestamp); + return await this.rollup.read.getSlotAt([ts]); + } + + /** Returns the current epoch */ + public async getEpoch() { + const slotNumber = await this.getSlot(); + return await this.rollup.read.getEpochAtSlot([slotNumber]); + } + + /** Warps time in L1 until the next epoch */ + public async advanceToNextEpoch() { + const slot = await this.getSlot(); + const slotsUntilNextEpoch = BigInt(AZTEC_EPOCH_DURATION) - (slot % BigInt(AZTEC_EPOCH_DURATION)) + 1n; + const timeToNextEpoch = slotsUntilNextEpoch * BigInt(AZTEC_SLOT_DURATION); + const l1Timestamp = BigInt((await this.client.getBlock()).timestamp); + await this.ethCheatCodes.warp(Number(l1Timestamp + timeToNextEpoch)); + this.logger.verbose(`Advanced to next epoch`); + } +} + /** * A class that provides utility functions for interacting with the aztec chain. */ diff --git a/yarn-project/circuit-types/src/interfaces/aztec-node.ts b/yarn-project/circuit-types/src/interfaces/aztec-node.ts index 733982bb432c..822c2dad05d8 100644 --- a/yarn-project/circuit-types/src/interfaces/aztec-node.ts +++ b/yarn-project/circuit-types/src/interfaces/aztec-node.ts @@ -28,12 +28,13 @@ import type { SequencerConfig } from './configs.js'; import type { L2BlockNumber } from './l2_block_number.js'; import type { NullifierMembershipWitness } from './nullifier_tree.js'; import type { ProverConfig } from './prover-client.js'; +import { type ProverCoordination } from './prover-coordination.js'; /** * The aztec node. * We will probably implement the additional interfaces by means other than Aztec Node as it's currently a privacy leak */ -export interface AztecNode { +export interface AztecNode extends ProverCoordination { /** * Find the index of the given leaf in the given tree. * @param blockNumber - The block number at which to get the data or 'latest' for latest data diff --git a/yarn-project/circuit-types/src/l2_block_source.ts b/yarn-project/circuit-types/src/l2_block_source.ts index 65bcf58d7de3..0d9fc5978393 100644 --- a/yarn-project/circuit-types/src/l2_block_source.ts +++ b/yarn-project/circuit-types/src/l2_block_source.ts @@ -1,4 +1,4 @@ -import { type EthAddress } from '@aztec/circuits.js'; +import { type EthAddress, type Header } from '@aztec/circuits.js'; import { type L2Block } from './l2_block.js'; import { type TxHash } from './tx/tx_hash.js'; @@ -46,6 +46,13 @@ export interface L2BlockSource { */ getBlock(number: number): Promise; + /** + * Gets an l2 block header. + * @param number - The block number to return or 'latest' for the most recent one. + * @returns The requested L2 block header. + */ + getBlockHeader(number: number | 'latest'): Promise
; + /** * Gets up to `limit` amount of L2 blocks starting from `from`. * @param from - Number of the first block to return (inclusive). @@ -69,6 +76,29 @@ export interface L2BlockSource { */ getSettledTxReceipt(txHash: TxHash): Promise; + /** + * Returns the current L2 slot number based on the current L1 timestamp. + */ + getL2SlotNumber(): Promise; + + /** + * Returns the current L2 epoch number based on the current L1 timestamp. + */ + getL2EpochNumber(): Promise; + + /** + * Returns all blocks for a given epoch. + * @dev Use this method only with recent epochs, since it walks the block list backwards. + * @param epochNumber - The epoch number to return blocks for. + */ + getBlocksForEpoch(epochNumber: bigint): Promise; + + /** + * Returns whether the given epoch is completed on L1, based on the current L1 and L2 block numbers. + * @param epochNumber - The epoch number to check. + */ + isEpochComplete(epochNumber: bigint): Promise; + /** * Starts the L2 block source. * @param blockUntilSynced - If true, blocks until the data source has fully synced. diff --git a/yarn-project/circuit-types/src/prover_coordination/epoch_proof_claim.ts b/yarn-project/circuit-types/src/prover_coordination/epoch_proof_claim.ts new file mode 100644 index 000000000000..3c41f0b00dfc --- /dev/null +++ b/yarn-project/circuit-types/src/prover_coordination/epoch_proof_claim.ts @@ -0,0 +1,9 @@ +import { type EthAddress } from '@aztec/circuits.js'; + +export type EpochProofClaim = { + epochToProve: bigint; + basisPointFee: bigint; + bondAmount: bigint; + bondProvider: EthAddress; + proposerClaimant: EthAddress; +}; diff --git a/yarn-project/circuit-types/src/prover_coordination/epoch_proof_quote.test.ts b/yarn-project/circuit-types/src/prover_coordination/epoch_proof_quote.test.ts index 9ab6d7172c4c..104188a23506 100644 --- a/yarn-project/circuit-types/src/prover_coordination/epoch_proof_quote.test.ts +++ b/yarn-project/circuit-types/src/prover_coordination/epoch_proof_quote.test.ts @@ -4,7 +4,7 @@ import { EpochProofQuotePayload } from './epoch_proof_quote_payload.js'; describe('epoch proof quote', () => { it('should serialize / deserialize', () => { - const payload = EpochProofQuotePayload.fromFields({ + const payload = EpochProofQuotePayload.from({ basisPointFee: 5000, bondAmount: 1000000000000000000n, epochToProve: 42n, diff --git a/yarn-project/circuit-types/src/prover_coordination/epoch_proof_quote_payload.ts b/yarn-project/circuit-types/src/prover_coordination/epoch_proof_quote_payload.ts index e7b62c707f93..9555df73920a 100644 --- a/yarn-project/circuit-types/src/prover_coordination/epoch_proof_quote_payload.ts +++ b/yarn-project/circuit-types/src/prover_coordination/epoch_proof_quote_payload.ts @@ -38,7 +38,7 @@ export class EpochProofQuotePayload { ); } - static fromFields(fields: FieldsOf): EpochProofQuotePayload { + static from(fields: FieldsOf): EpochProofQuotePayload { return new EpochProofQuotePayload( fields.epochToProve, fields.validUntilSlot, diff --git a/yarn-project/circuit-types/src/prover_coordination/index.ts b/yarn-project/circuit-types/src/prover_coordination/index.ts index 331978ec5561..33b8a68050b7 100644 --- a/yarn-project/circuit-types/src/prover_coordination/index.ts +++ b/yarn-project/circuit-types/src/prover_coordination/index.ts @@ -1,2 +1,3 @@ export * from './epoch_proof_quote.js'; export * from './epoch_proof_quote_payload.js'; +export * from './epoch_proof_claim.js'; diff --git a/yarn-project/end-to-end/src/e2e_prover/e2e_prover_test.ts b/yarn-project/end-to-end/src/e2e_prover/e2e_prover_test.ts index 9431cf0da326..fd26b9b78b2a 100644 --- a/yarn-project/end-to-end/src/e2e_prover/e2e_prover_test.ts +++ b/yarn-project/end-to-end/src/e2e_prover/e2e_prover_test.ts @@ -264,12 +264,15 @@ export class FullProverTest { proverAgentConcurrency: 2, publisherPrivateKey: `0x${proverNodePrivateKey!.toString('hex')}`, proverNodeMaxPendingJobs: 100, + proverNodePollingIntervalMs: 100, + quoteProviderBasisPointFee: 100, + quoteProviderBondAmount: 0n, }; this.proverNode = await createProverNode(proverConfig, { aztecNodeTxProvider: this.aztecNode, archiver: archiver as Archiver, }); - this.proverNode.start(); + await this.proverNode.start(); this.logger.info('Prover node started'); diff --git a/yarn-project/end-to-end/src/e2e_prover_node.test.ts b/yarn-project/end-to-end/src/e2e_prover_node.test.ts index 5a0a5e3f7054..4a511736132f 100644 --- a/yarn-project/end-to-end/src/e2e_prover_node.test.ts +++ b/yarn-project/end-to-end/src/e2e_prover_node.test.ts @@ -132,19 +132,21 @@ describe('e2e_prover_node', () => { dataDirectory: undefined, proverId, proverNodeMaxPendingJobs: 100, - proverNodeEpochSize: 2, + proverNodePollingIntervalMs: 200, + quoteProviderBasisPointFee: 100, + quoteProviderBondAmount: 0n, }; const archiver = ctx.aztecNode.getBlockSource() as Archiver; const proverNode = await createProverNode(proverConfig, { aztecNodeTxProvider: ctx.aztecNode, archiver }); // Prove the first two epochs simultaneously logger.info(`Starting proof for first epoch ${startBlock}-${startBlock + 1}`); - await proverNode.startProof(startBlock, startBlock + 1); + await proverNode.startProof(startBlock); logger.info(`Starting proof for second epoch ${startBlock + 2}-${startBlock + 3}`); - await proverNode.startProof(startBlock + 2, startBlock + 3); + await proverNode.startProof(startBlock + 2); // Confirm that we cannot go back to prove an old one - await expect(proverNode.startProof(startBlock, startBlock + 1)).rejects.toThrow(/behind the current world state/i); + await expect(proverNode.startProof(startBlock)).rejects.toThrow(/behind the current world state/i); // Await until proofs get submitted await waitForProvenChain(ctx.aztecNode, startBlock + 3); diff --git a/yarn-project/end-to-end/src/fixtures/snapshot_manager.ts b/yarn-project/end-to-end/src/fixtures/snapshot_manager.ts index 2155c660e7ab..b1788aecd1c5 100644 --- a/yarn-project/end-to-end/src/fixtures/snapshot_manager.ts +++ b/yarn-project/end-to-end/src/fixtures/snapshot_manager.ts @@ -267,13 +267,16 @@ export async function createAndSyncProverNode( realProofs: false, proverAgentConcurrency: 2, publisherPrivateKey: proverNodePrivateKey, - proverNodeMaxPendingJobs: 100, + proverNodeMaxPendingJobs: 10, + proverNodePollingIntervalMs: 200, + quoteProviderBasisPointFee: 100, + quoteProviderBondAmount: 0n, }; const proverNode = await createProverNode(proverConfig, { aztecNodeTxProvider: aztecNode, archiver: archiver as Archiver, }); - proverNode.start(); + await proverNode.start(); return proverNode; } diff --git a/yarn-project/end-to-end/src/prover-coordination/e2e_json_coordination.test.ts b/yarn-project/end-to-end/src/prover-coordination/e2e_prover_coordination.test.ts similarity index 100% rename from yarn-project/end-to-end/src/prover-coordination/e2e_json_coordination.test.ts rename to yarn-project/end-to-end/src/prover-coordination/e2e_prover_coordination.test.ts diff --git a/yarn-project/foundation/src/config/env_var.ts b/yarn-project/foundation/src/config/env_var.ts index 7a0bc86efe22..146a4724b007 100644 --- a/yarn-project/foundation/src/config/env_var.ts +++ b/yarn-project/foundation/src/config/env_var.ts @@ -91,8 +91,7 @@ export type EnvVar = | 'PROVER_ID' | 'PROVER_JOB_POLL_INTERVAL_MS' | 'PROVER_JOB_TIMEOUT_MS' - | 'PROVER_NODE_DISABLE_AUTOMATIC_PROVING' - | 'PROVER_NODE_EPOCH_SIZE' + | 'PROVER_NODE_POLLING_INTERVAL_MS' | 'PROVER_NODE_MAX_PENDING_JOBS' | 'PROVER_PUBLISH_RETRY_INTERVAL_MS' | 'PROVER_PUBLISHER_PRIVATE_KEY' @@ -103,6 +102,8 @@ export type EnvVar = | 'PXE_DATA_DIRECTORY' | 'PXE_L2_STARTING_BLOCK' | 'PXE_PROVER_ENABLED' + | 'QUOTE_PROVIDER_BASIS_POINT_FEE' + | 'QUOTE_PROVIDER_BOND_AMOUNT' | 'REGISTRY_CONTRACT_ADDRESS' | 'ROLLUP_CONTRACT_ADDRESS' | 'SEQ_ALLOWED_SETUP_FN' diff --git a/yarn-project/foundation/src/config/index.ts b/yarn-project/foundation/src/config/index.ts index 68b14921785f..a075be3fccf7 100644 --- a/yarn-project/foundation/src/config/index.ts +++ b/yarn-project/foundation/src/config/index.ts @@ -67,6 +67,18 @@ export function numberConfigHelper(defaultVal: number): Pick { + return { + parseEnv: (val: string) => BigInt(val), + defaultValue: defaultVal, + }; +} + /** * Generates parseEnv for an optional numerical config value. */ diff --git a/yarn-project/p2p/package.json b/yarn-project/p2p/package.json index 5d754eea5522..5a4260dc2579 100644 --- a/yarn-project/p2p/package.json +++ b/yarn-project/p2p/package.json @@ -86,6 +86,7 @@ "tslib": "^2.4.0" }, "devDependencies": { + "@aztec/archiver": "workspace:^", "@jest/globals": "^29.5.0", "@types/jest": "^29.5.0", "@types/node": "^18.14.6", diff --git a/yarn-project/p2p/src/client/p2p_client.test.ts b/yarn-project/p2p/src/client/p2p_client.test.ts index 8e015b5b74cf..3d16237dfb17 100644 --- a/yarn-project/p2p/src/client/p2p_client.test.ts +++ b/yarn-project/p2p/src/client/p2p_client.test.ts @@ -1,3 +1,4 @@ +import { MockBlockSource } from '@aztec/archiver/test'; import { mockEpochProofQuote, mockTx } from '@aztec/circuit-types'; import { retryUntil } from '@aztec/foundation/retry'; import { type AztecKVStore } from '@aztec/kv-store'; @@ -8,7 +9,6 @@ import { expect, jest } from '@jest/globals'; import { type AttestationPool } from '../attestation_pool/attestation_pool.js'; import { type EpochProofQuotePool, type P2PService } from '../index.js'; import { type TxPool } from '../tx_pool/index.js'; -import { MockBlockSource } from './mocks.js'; import { P2PClient } from './p2p_client.js'; /** diff --git a/yarn-project/p2p/src/epoch_proof_quote_pool/test_utils.ts b/yarn-project/p2p/src/epoch_proof_quote_pool/test_utils.ts index baa5f9e2425a..a59de8daa3c0 100644 --- a/yarn-project/p2p/src/epoch_proof_quote_pool/test_utils.ts +++ b/yarn-project/p2p/src/epoch_proof_quote_pool/test_utils.ts @@ -4,7 +4,7 @@ import { Buffer32 } from '@aztec/foundation/buffer'; import { Secp256k1Signer, randomBigInt, randomInt } from '@aztec/foundation/crypto'; export function makeRandomEpochProofQuotePayload(): EpochProofQuotePayload { - return EpochProofQuotePayload.fromFields({ + return EpochProofQuotePayload.from({ basisPointFee: randomInt(10000), bondAmount: 1000000000000000000n, epochToProve: randomBigInt(1000000n), diff --git a/yarn-project/p2p/src/service/reqresp/p2p_client.integration.test.ts b/yarn-project/p2p/src/service/reqresp/p2p_client.integration.test.ts index 0612f7c81f18..b813c799a693 100644 --- a/yarn-project/p2p/src/service/reqresp/p2p_client.integration.test.ts +++ b/yarn-project/p2p/src/service/reqresp/p2p_client.integration.test.ts @@ -1,4 +1,5 @@ // An integration test for the p2p client to test req resp protocols +import { MockBlockSource } from '@aztec/archiver/test'; import { type ClientProtocolCircuitVerifier, type WorldStateSynchronizer, mockTx } from '@aztec/circuit-types'; import { EthAddress } from '@aztec/circuits.js'; import { createDebugLogger } from '@aztec/foundation/log'; @@ -14,7 +15,6 @@ import { generatePrivateKey } from 'viem/accounts'; import { type AttestationPool } from '../../attestation_pool/attestation_pool.js'; import { createP2PClient } from '../../client/index.js'; -import { MockBlockSource } from '../../client/mocks.js'; import { type P2PClient } from '../../client/p2p_client.js'; import { type P2PConfig, getP2PDefaultConfig } from '../../config.js'; import { type EpochProofQuotePool } from '../../epoch_proof_quote_pool/epoch_proof_quote_pool.js'; diff --git a/yarn-project/p2p/tsconfig.json b/yarn-project/p2p/tsconfig.json index fcbafbb11d0d..dbffd3db0465 100644 --- a/yarn-project/p2p/tsconfig.json +++ b/yarn-project/p2p/tsconfig.json @@ -20,6 +20,9 @@ }, { "path": "../telemetry-client" + }, + { + "path": "../archiver" } ], "include": ["src"] diff --git a/yarn-project/prover-node/src/config.ts b/yarn-project/prover-node/src/config.ts index 1758e3913629..acc3ec827d49 100644 --- a/yarn-project/prover-node/src/config.ts +++ b/yarn-project/prover-node/src/config.ts @@ -1,7 +1,7 @@ import { type ArchiverConfig, archiverConfigMappings, getArchiverConfigFromEnv } from '@aztec/archiver'; import { type ConfigMappingsType, - booleanConfigHelper, + bigintConfigHelper, getConfigFromMappings, numberConfigHelper, } from '@aztec/foundation/config'; @@ -27,29 +27,42 @@ export type ProverNodeConfig = ArchiverConfig & WorldStateConfig & PublisherConfig & TxSenderConfig & - ProverCoordinationConfig & { - proverNodeDisableAutomaticProving?: boolean; - proverNodeMaxPendingJobs?: number; - proverNodeEpochSize?: number; + ProverCoordinationConfig & + QuoteProviderConfig & { + proverNodeMaxPendingJobs: number; + proverNodePollingIntervalMs: number; }; +export type QuoteProviderConfig = { + quoteProviderBasisPointFee: number; + quoteProviderBondAmount: bigint; +}; + const specificProverNodeConfigMappings: ConfigMappingsType< - Pick + Pick > = { - proverNodeDisableAutomaticProving: { - env: 'PROVER_NODE_DISABLE_AUTOMATIC_PROVING', - description: 'Whether to disable automatic proving of pending blocks seen on L1', - ...booleanConfigHelper(false), - }, proverNodeMaxPendingJobs: { env: 'PROVER_NODE_MAX_PENDING_JOBS', description: 'The maximum number of pending jobs for the prover node', + ...numberConfigHelper(10), + }, + proverNodePollingIntervalMs: { + env: 'PROVER_NODE_POLLING_INTERVAL_MS', + description: 'The interval in milliseconds to poll for new jobs', + ...numberConfigHelper(1000), + }, +}; + +const quoteProviderConfigMappings: ConfigMappingsType = { + quoteProviderBasisPointFee: { + env: 'QUOTE_PROVIDER_BASIS_POINT_FEE', + description: 'The basis point fee to charge for providing quotes', ...numberConfigHelper(100), }, - proverNodeEpochSize: { - env: 'PROVER_NODE_EPOCH_SIZE', - description: 'The number of blocks to prove in a single epoch', - ...numberConfigHelper(2), + quoteProviderBondAmount: { + env: 'QUOTE_PROVIDER_BOND_AMOUNT', + description: 'The bond amount to charge for providing quotes', + ...bigintConfigHelper(0n), }, }; @@ -60,6 +73,7 @@ export const proverNodeConfigMappings: ConfigMappingsType = { ...getPublisherConfigMappings('PROVER'), ...getTxSenderConfigMappings('PROVER'), ...proverCoordinationConfigMappings, + ...quoteProviderConfigMappings, ...specificProverNodeConfigMappings, }; @@ -71,6 +85,7 @@ export function getProverNodeConfigFromEnv(): ProverNodeConfig { ...getPublisherConfigFromEnv('PROVER'), ...getTxSenderConfigFromEnv('PROVER'), ...getTxProviderConfigFromEnv(), + ...getConfigFromMappings(quoteProviderConfigMappings), ...getConfigFromMappings(specificProverNodeConfigMappings), }; } diff --git a/yarn-project/prover-node/src/factory.ts b/yarn-project/prover-node/src/factory.ts index a909184bd961..405c33803c5e 100644 --- a/yarn-project/prover-node/src/factory.ts +++ b/yarn-project/prover-node/src/factory.ts @@ -1,5 +1,6 @@ import { type Archiver, createArchiver } from '@aztec/archiver'; import { type AztecNode } from '@aztec/circuit-types'; +import { Buffer32 } from '@aztec/foundation/buffer'; import { type DebugLogger, createDebugLogger } from '@aztec/foundation/log'; import { createProverClient } from '@aztec/prover-client'; import { L1Publisher } from '@aztec/sequencer-client'; @@ -8,10 +9,13 @@ import { type TelemetryClient } from '@aztec/telemetry-client'; import { NoopTelemetryClient } from '@aztec/telemetry-client/noop'; import { createWorldStateSynchronizer } from '@aztec/world-state'; -import { type ProverNodeConfig } from './config.js'; -import { AztecNodeProverCoordination } from './prover-coordination/aztec-node-prover-coordination.js'; +import { type ProverNodeConfig, type QuoteProviderConfig } from './config.js'; +import { ClaimsMonitor } from './monitors/claims-monitor.js'; +import { EpochMonitor } from './monitors/epoch-monitor.js'; import { createProverCoordination } from './prover-coordination/factory.js'; import { ProverNode } from './prover-node.js'; +import { SimpleQuoteProvider } from './quote-provider/simple.js'; +import { QuoteSigner } from './quote-signer.js'; /** Creates a new prover node given a config. */ export async function createProverNode( @@ -39,9 +43,17 @@ export async function createProverNode( // REFACTOR: Move publisher out of sequencer package and into an L1-related package const publisher = new L1Publisher(config, telemetry); - const txProvider = deps.aztecNodeTxProvider - ? new AztecNodeProverCoordination(deps.aztecNodeTxProvider) - : createProverCoordination(config); + const txProvider = deps.aztecNodeTxProvider ? deps.aztecNodeTxProvider : createProverCoordination(config); + const quoteProvider = createQuoteProvider(config); + const quoteSigner = createQuoteSigner(config); + + const proverNodeConfig = { + maxPendingJobs: config.proverNodeMaxPendingJobs, + pollingIntervalMs: config.proverNodePollingIntervalMs, + }; + + const claimsMonitor = new ClaimsMonitor(publisher, proverNodeConfig); + const epochMonitor = new EpochMonitor(archiver, proverNodeConfig); return new ProverNode( prover!, @@ -52,11 +64,20 @@ export async function createProverNode( worldStateSynchronizer, txProvider, simulationProvider, + quoteProvider, + quoteSigner, + claimsMonitor, + epochMonitor, telemetry, - { - disableAutomaticProving: config.proverNodeDisableAutomaticProving, - maxPendingJobs: config.proverNodeMaxPendingJobs, - epochSize: config.proverNodeEpochSize, - }, + proverNodeConfig, ); } + +function createQuoteProvider(config: QuoteProviderConfig) { + return new SimpleQuoteProvider(config.quoteProviderBasisPointFee, config.quoteProviderBondAmount); +} + +function createQuoteSigner(config: ProverNodeConfig) { + const privateKey = config.publisherPrivateKey; + return QuoteSigner.new(Buffer32.fromString(privateKey)); +} diff --git a/yarn-project/prover-node/src/job/epoch-proving-job.ts b/yarn-project/prover-node/src/job/epoch-proving-job.ts index f06757dac0af..22c2f640d548 100644 --- a/yarn-project/prover-node/src/job/epoch-proving-job.ts +++ b/yarn-project/prover-node/src/job/epoch-proving-job.ts @@ -29,6 +29,8 @@ export class EpochProvingJob { private uuid: string; constructor( + private epochNumber: bigint, + private blocks: L2Block[], private prover: EpochProver, private publicProcessorFactory: PublicProcessorFactory, private publisher: L1Publisher, @@ -50,28 +52,21 @@ export class EpochProvingJob { } /** - * Proves the given block range and submits the proof to L1. - * @param fromBlock - Start block. - * @param toBlock - Last block (inclusive). + * Proves the given epoch and submits the proof to L1. */ - public async run(fromBlock: number, toBlock: number) { - if (fromBlock > toBlock) { - throw new Error(`Invalid block range: ${fromBlock} to ${toBlock}`); - } - - const epochNumber = fromBlock; // Use starting block number as epoch number - const epochSize = toBlock - fromBlock + 1; - this.log.info(`Starting epoch proving job`, { fromBlock, toBlock, epochNumber, uuid: this.uuid }); + public async run() { + const epochNumber = Number(this.epochNumber); + const epochSize = this.blocks.length; + this.log.info(`Starting epoch proving job`, { epochSize, epochNumber, uuid: this.uuid }); this.state = 'processing'; const timer = new Timer(); try { this.prover.startNewEpoch(epochNumber, epochSize); - let previousHeader = (await this.l2BlockSource.getBlock(fromBlock - 1))?.header; + let previousHeader = await this.l2BlockSource.getBlockHeader(this.blocks[0].number - 1); - for (let blockNumber = fromBlock; blockNumber <= toBlock; blockNumber++) { + for (const block of this.blocks) { // Gather all data to prove this block - const block = await this.getBlock(blockNumber); const globalVariables = block.header.globalVariables; const txHashes = block.body.txEffects.map(tx => tx.txHash); const txCount = block.body.numberOfTxsIncludingPadded; @@ -109,11 +104,12 @@ export class EpochProvingJob { this.state = 'awaiting-prover'; const { publicInputs, proof } = await this.prover.finaliseEpoch(); - this.log.info(`Finalised proof for epoch`, { epochNumber, fromBlock, toBlock, uuid: this.uuid }); + this.log.info(`Finalised proof for epoch`, { epochNumber, uuid: this.uuid }); this.state = 'publishing-proof'; - await this.publisher.submitEpochProof({ epochNumber, fromBlock, toBlock, publicInputs, proof }); - this.log.info(`Submitted proof for epoch`, { epochNumber, fromBlock, toBlock, uuid: this.uuid }); + const [fromBlock, toBlock] = [this.blocks[0].number, this.blocks.at(-1)!.number]; + await this.publisher.submitEpochProof({ fromBlock, toBlock, epochNumber, publicInputs, proof }); + this.log.info(`Submitted proof for epoch`, { epochNumber, uuid: this.uuid }); this.state = 'completed'; this.metrics.recordProvingJob(timer); @@ -129,14 +125,6 @@ export class EpochProvingJob { this.prover.cancel(); } - private async getBlock(blockNumber: number): Promise { - const block = await this.l2BlockSource.getBlock(blockNumber); - if (!block) { - throw new Error(`Block ${blockNumber} not found in L2 block source`); - } - return block; - } - private async getTxs(txHashes: TxHash[]): Promise { const txs = await Promise.all( txHashes.map(txHash => this.coordination.getTxByHash(txHash).then(tx => [txHash, tx] as const)), diff --git a/yarn-project/prover-node/src/monitors/claims-monitor.test.ts b/yarn-project/prover-node/src/monitors/claims-monitor.test.ts new file mode 100644 index 000000000000..610284e3caa1 --- /dev/null +++ b/yarn-project/prover-node/src/monitors/claims-monitor.test.ts @@ -0,0 +1,79 @@ +import { type EpochProofClaim } from '@aztec/circuit-types'; +import { EthAddress } from '@aztec/circuits.js'; +import { sleep } from '@aztec/foundation/sleep'; +import { type L1Publisher } from '@aztec/sequencer-client'; + +import { type MockProxy, mock } from 'jest-mock-extended'; + +import { ClaimsMonitor, type ClaimsMonitorHandler } from './claims-monitor.js'; + +describe('ClaimsMonitor', () => { + let l1Publisher: MockProxy; + let handler: MockProxy; + let claimsMonitor: ClaimsMonitor; + + let publisherAddress: EthAddress; + + beforeEach(() => { + l1Publisher = mock(); + handler = mock(); + + publisherAddress = EthAddress.random(); + l1Publisher.getSenderAddress.mockReturnValue(publisherAddress); + + claimsMonitor = new ClaimsMonitor(l1Publisher, { pollingIntervalMs: 10 }); + }); + + afterEach(async () => { + await claimsMonitor.stop(); + }); + + const makeEpochProofClaim = (epoch: number, bondProvider: EthAddress): MockProxy => { + return { + basisPointFee: 100n, + bondAmount: 10n, + bondProvider, + epochToProve: BigInt(epoch), + proposerClaimant: EthAddress.random(), + }; + }; + + it('should handle a new claim if it belongs to the prover', async () => { + const proofClaim = makeEpochProofClaim(1, publisherAddress); + l1Publisher.getProofClaim.mockResolvedValue(proofClaim); + + claimsMonitor.start(handler); + await sleep(100); + + expect(handler.handleClaim).toHaveBeenCalledWith(proofClaim); + expect(handler.handleClaim).toHaveBeenCalledTimes(1); + }); + + it('should not handle a new claim if it does not belong to the prover', async () => { + const proofClaim = makeEpochProofClaim(1, EthAddress.random()); + l1Publisher.getProofClaim.mockResolvedValue(proofClaim); + + claimsMonitor.start(handler); + await sleep(100); + + expect(handler.handleClaim).not.toHaveBeenCalled(); + }); + + it('should only trigger when a new claim is seen', async () => { + const proofClaim = makeEpochProofClaim(1, publisherAddress); + l1Publisher.getProofClaim.mockResolvedValue(proofClaim); + + claimsMonitor.start(handler); + await sleep(100); + + expect(handler.handleClaim).toHaveBeenCalledWith(proofClaim); + expect(handler.handleClaim).toHaveBeenCalledTimes(1); + + const proofClaim2 = makeEpochProofClaim(2, publisherAddress); + l1Publisher.getProofClaim.mockResolvedValue(proofClaim2); + await sleep(100); + + expect(handler.handleClaim).toHaveBeenCalledWith(proofClaim2); + expect(handler.handleClaim).toHaveBeenCalledTimes(2); + }); +}); diff --git a/yarn-project/prover-node/src/monitors/claims-monitor.ts b/yarn-project/prover-node/src/monitors/claims-monitor.ts new file mode 100644 index 000000000000..1a8165d5800f --- /dev/null +++ b/yarn-project/prover-node/src/monitors/claims-monitor.ts @@ -0,0 +1,52 @@ +import { type EpochProofClaim } from '@aztec/circuit-types'; +import { type EthAddress } from '@aztec/circuits.js'; +import { createDebugLogger } from '@aztec/foundation/log'; +import { RunningPromise } from '@aztec/foundation/running-promise'; +import { type L1Publisher } from '@aztec/sequencer-client'; + +export interface ClaimsMonitorHandler { + handleClaim(proofClaim: EpochProofClaim): Promise; +} + +export class ClaimsMonitor { + private runningPromise: RunningPromise; + private log = createDebugLogger('aztec:prover-node:claims-monitor'); + + private handler: ClaimsMonitorHandler | undefined; + private lastClaimEpochNumber: bigint | undefined; + + constructor(private readonly l1Publisher: L1Publisher, private options: { pollingIntervalMs: number }) { + this.runningPromise = new RunningPromise(this.work.bind(this), this.options.pollingIntervalMs); + } + + public start(handler: ClaimsMonitorHandler) { + this.handler = handler; + this.runningPromise.start(); + this.log.info(`Started ClaimsMonitor with prover address ${this.getProverAddress().toString()}`, this.options); + } + + public async stop() { + this.log.verbose('Stopping ClaimsMonitor'); + await this.runningPromise.stop(); + this.log.info('Stopped ClaimsMonitor'); + } + + public async work() { + const proofClaim = await this.l1Publisher.getProofClaim(); + if (!proofClaim) { + return; + } + + if (!this.lastClaimEpochNumber || proofClaim.epochToProve > this.lastClaimEpochNumber) { + this.log.verbose(`Found new claim for epoch ${proofClaim.epochToProve} by ${proofClaim.bondProvider.toString()}`); + if (proofClaim.bondProvider.equals(this.getProverAddress())) { + await this.handler?.handleClaim(proofClaim); + } + this.lastClaimEpochNumber = proofClaim.epochToProve; + } + } + + protected getProverAddress(): EthAddress { + return this.l1Publisher.getSenderAddress(); + } +} diff --git a/yarn-project/prover-node/src/monitors/epoch-monitor.test.ts b/yarn-project/prover-node/src/monitors/epoch-monitor.test.ts new file mode 100644 index 000000000000..f7a0d7dc4067 --- /dev/null +++ b/yarn-project/prover-node/src/monitors/epoch-monitor.test.ts @@ -0,0 +1,76 @@ +import { type L2BlockSource } from '@aztec/circuit-types'; +import { sleep } from '@aztec/foundation/sleep'; + +import { type MockProxy, mock } from 'jest-mock-extended'; + +import { EpochMonitor, type EpochMonitorHandler } from './epoch-monitor.js'; + +describe('EpochMonitor', () => { + let l2BlockSource: MockProxy; + let handler: MockProxy; + let epochMonitor: EpochMonitor; + + let lastEpochComplete: bigint = 0n; + + beforeEach(() => { + handler = mock(); + l2BlockSource = mock({ + isEpochComplete(epochNumber) { + return Promise.resolve(epochNumber <= lastEpochComplete); + }, + }); + + epochMonitor = new EpochMonitor(l2BlockSource, { pollingIntervalMs: 10 }); + }); + + afterEach(async () => { + await epochMonitor.stop(); + }); + + it('triggers initial epoch sync', async () => { + l2BlockSource.getL2EpochNumber.mockResolvedValue(10n); + epochMonitor.start(handler); + await sleep(100); + + expect(handler.handleInitialEpochSync).toHaveBeenCalledWith(9n); + }); + + it('does not trigger initial epoch sync on epoch zero', async () => { + l2BlockSource.getL2EpochNumber.mockResolvedValue(0n); + epochMonitor.start(handler); + await sleep(100); + + expect(handler.handleInitialEpochSync).not.toHaveBeenCalled(); + }); + + it('triggers epoch completion', async () => { + lastEpochComplete = 9n; + l2BlockSource.getL2EpochNumber.mockResolvedValue(10n); + epochMonitor.start(handler); + + await sleep(100); + expect(handler.handleEpochCompleted).not.toHaveBeenCalled(); + + lastEpochComplete = 10n; + await sleep(100); + expect(handler.handleEpochCompleted).toHaveBeenCalledWith(10n); + expect(handler.handleEpochCompleted).toHaveBeenCalledTimes(1); + + lastEpochComplete = 11n; + await sleep(100); + expect(handler.handleEpochCompleted).toHaveBeenCalledWith(11n); + expect(handler.handleEpochCompleted).toHaveBeenCalledTimes(2); + }); + + it('triggers epoch completion if initial epoch was already complete', async () => { + // this happens if we start the monitor on the very last slot of an epoch + lastEpochComplete = 10n; + l2BlockSource.getL2EpochNumber.mockResolvedValue(10n); + epochMonitor.start(handler); + + await sleep(100); + expect(handler.handleInitialEpochSync).toHaveBeenCalledWith(9n); + expect(handler.handleEpochCompleted).toHaveBeenCalledWith(10n); + expect(handler.handleEpochCompleted).toHaveBeenCalledTimes(1); + }); +}); diff --git a/yarn-project/prover-node/src/monitors/epoch-monitor.ts b/yarn-project/prover-node/src/monitors/epoch-monitor.ts new file mode 100644 index 000000000000..0f106e385220 --- /dev/null +++ b/yarn-project/prover-node/src/monitors/epoch-monitor.ts @@ -0,0 +1,48 @@ +import { type L2BlockSource } from '@aztec/circuit-types'; +import { createDebugLogger } from '@aztec/foundation/log'; +import { RunningPromise } from '@aztec/foundation/running-promise'; + +export interface EpochMonitorHandler { + handleInitialEpochSync(epochNumber: bigint): Promise; + handleEpochCompleted(epochNumber: bigint): Promise; +} + +export class EpochMonitor { + private runningPromise: RunningPromise; + private log = createDebugLogger('aztec:prover-node:epoch-monitor'); + + private handler: EpochMonitorHandler | undefined; + + private latestEpochNumber: bigint | undefined; + + constructor(private readonly l2BlockSource: L2BlockSource, private options: { pollingIntervalMs: number }) { + this.runningPromise = new RunningPromise(this.work.bind(this), this.options.pollingIntervalMs); + } + + public start(handler: EpochMonitorHandler) { + this.handler = handler; + this.runningPromise.start(); + this.log.info('Started EpochMonitor', this.options); + } + + public async stop() { + await this.runningPromise.stop(); + this.log.info('Stopped EpochMonitor'); + } + + public async work() { + if (!this.latestEpochNumber) { + const epochNumber = await this.l2BlockSource.getL2EpochNumber(); + if (epochNumber > 0n) { + await this.handler?.handleInitialEpochSync(epochNumber - 1n); + } + this.latestEpochNumber = epochNumber; + return; + } + + if (await this.l2BlockSource.isEpochComplete(this.latestEpochNumber)) { + await this.handler?.handleEpochCompleted(this.latestEpochNumber); + this.latestEpochNumber += 1n; + } + } +} diff --git a/yarn-project/prover-node/src/monitors/index.ts b/yarn-project/prover-node/src/monitors/index.ts new file mode 100644 index 000000000000..f3b5be62922c --- /dev/null +++ b/yarn-project/prover-node/src/monitors/index.ts @@ -0,0 +1,2 @@ +export * from './claims-monitor.js'; +export * from './epoch-monitor.js'; diff --git a/yarn-project/prover-node/src/prover-coordination/aztec-node-prover-coordination.ts b/yarn-project/prover-node/src/prover-coordination/aztec-node-prover-coordination.ts deleted file mode 100644 index 3152cd2ebf87..000000000000 --- a/yarn-project/prover-node/src/prover-coordination/aztec-node-prover-coordination.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { AztecNode, EpochProofQuote, ProverCoordination, Tx, TxHash } from '@aztec/circuit-types'; - -/** Implements ProverCoordinator by wrapping an Aztec node */ -export class AztecNodeProverCoordination implements ProverCoordination { - constructor(private node: AztecNode) {} - - getTxByHash(txHash: TxHash): Promise { - return this.node.getTxByHash(txHash); - } - - addEpochProofQuote(quote: EpochProofQuote): Promise { - return this.node.addEpochProofQuote(quote); - } -} diff --git a/yarn-project/prover-node/src/prover-coordination/factory.ts b/yarn-project/prover-node/src/prover-coordination/factory.ts index 71e5ba8a95e3..e48720a329c7 100644 --- a/yarn-project/prover-node/src/prover-coordination/factory.ts +++ b/yarn-project/prover-node/src/prover-coordination/factory.ts @@ -1,12 +1,10 @@ import { type ProverCoordination, createAztecNodeClient } from '@aztec/circuit-types'; -import { AztecNodeProverCoordination } from './aztec-node-prover-coordination.js'; import { type ProverCoordinationConfig } from './config.js'; export function createProverCoordination(config: ProverCoordinationConfig): ProverCoordination { if (config.proverCoordinationNodeUrl) { - const node = createAztecNodeClient(config.proverCoordinationNodeUrl); - return new AztecNodeProverCoordination(node); + return createAztecNodeClient(config.proverCoordinationNodeUrl); } else { throw new Error(`Aztec Node URL for Tx Provider is not set.`); } diff --git a/yarn-project/prover-node/src/prover-coordination/index.ts b/yarn-project/prover-node/src/prover-coordination/index.ts index 7394c367754d..0ded7f3c0c1e 100644 --- a/yarn-project/prover-node/src/prover-coordination/index.ts +++ b/yarn-project/prover-node/src/prover-coordination/index.ts @@ -1,3 +1,2 @@ -export * from './aztec-node-prover-coordination.js'; export * from './config.js'; export * from './factory.js'; diff --git a/yarn-project/prover-node/src/prover-node.test.ts b/yarn-project/prover-node/src/prover-node.test.ts index 669c56527a1a..6678fb5bcf8c 100644 --- a/yarn-project/prover-node/src/prover-node.test.ts +++ b/yarn-project/prover-node/src/prover-node.test.ts @@ -1,12 +1,20 @@ import { + type EpochProofClaim, + EpochProofQuote, + EpochProofQuotePayload, type EpochProverManager, type L1ToL2MessageSource, + type L2Block, type L2BlockSource, type MerkleTreeAdminOperations, type ProverCoordination, WorldStateRunningState, type WorldStateSynchronizer, } from '@aztec/circuit-types'; +import { EthAddress } from '@aztec/circuits.js'; +import { times } from '@aztec/foundation/collection'; +import { Signature } from '@aztec/foundation/eth-signature'; +import { sleep } from '@aztec/foundation/sleep'; import { type L1Publisher } from '@aztec/sequencer-client'; import { type PublicProcessorFactory, type SimulationProvider } from '@aztec/simulator'; import { NoopTelemetryClient } from '@aztec/telemetry-client/noop'; @@ -15,27 +23,60 @@ import { type ContractDataSource } from '@aztec/types/contracts'; import { type MockProxy, mock } from 'jest-mock-extended'; import { type EpochProvingJob } from './job/epoch-proving-job.js'; -import { ProverNode } from './prover-node.js'; +import { ClaimsMonitor } from './monitors/claims-monitor.js'; +import { EpochMonitor } from './monitors/epoch-monitor.js'; +import { ProverNode, type ProverNodeOptions } from './prover-node.js'; +import { type QuoteProvider } from './quote-provider/index.js'; +import { type QuoteSigner } from './quote-signer.js'; describe('prover-node', () => { + // Prover node dependencies let prover: MockProxy; let publisher: MockProxy; let l2BlockSource: MockProxy; let l1ToL2MessageSource: MockProxy; let contractDataSource: MockProxy; let worldState: MockProxy; - let txProvider: MockProxy; + let coordination: MockProxy; let simulator: MockProxy; + let quoteProvider: MockProxy; + let quoteSigner: MockProxy; + let telemetryClient: NoopTelemetryClient; + let config: ProverNodeOptions; + // Subject under test let proverNode: TestProverNode; + // Quote returned by the provider by default and its completed quote + let partialQuote: Pick; + + // Sample claim + let claim: MockProxy; + + // Blocks returned by the archiver + let blocks: MockProxy[]; + + // Address of the publisher + let address: EthAddress; + // List of all jobs ever created by the test prover node and their dependencies let jobs: { job: MockProxy; cleanUp: (job: EpochProvingJob) => Promise; db: MerkleTreeAdminOperations; + epochNumber: bigint; }[]; + const toQuotePayload = ( + epoch: bigint, + partialQuote: Pick, + ) => EpochProofQuotePayload.from({ ...partialQuote, prover: address, epochToProve: epoch }); + + const toExpectedQuote = ( + epoch: bigint, + quote: Pick = partialQuote, + ) => expect.objectContaining({ payload: toQuotePayload(epoch, quote) }); + beforeEach(() => { prover = mock(); publisher = mock(); @@ -43,132 +84,229 @@ describe('prover-node', () => { l1ToL2MessageSource = mock(); contractDataSource = mock(); worldState = mock(); - txProvider = mock(); + coordination = mock(); simulator = mock(); - const telemetryClient = new NoopTelemetryClient(); + quoteProvider = mock(); + quoteSigner = mock(); + + telemetryClient = new NoopTelemetryClient(); + config = { maxPendingJobs: 3, pollingIntervalMs: 10 }; // World state returns a new mock db every time it is asked to fork worldState.syncImmediateAndFork.mockImplementation(() => Promise.resolve(mock())); + worldState.status.mockResolvedValue({ syncedToL2Block: 1, state: WorldStateRunningState.RUNNING }); + + // Publisher returns its sender address + address = EthAddress.random(); + publisher.getSenderAddress.mockReturnValue(address); + + // Quote provider returns a mock + partialQuote = { basisPointFee: 100, bondAmount: 0n, validUntilSlot: 30n }; + quoteProvider.getQuote.mockResolvedValue(partialQuote); + + // Signer returns an empty signature + quoteSigner.sign.mockImplementation(payload => new EpochProofQuote(payload, Signature.empty())); + + // Archiver returns a bunch of fake blocks + blocks = times(3, i => mock({ number: i + 20 })); + l2BlockSource.getBlocksForEpoch.mockResolvedValue(blocks); + + // A sample claim + claim = { epochToProve: 10n, bondProvider: address } as EpochProofClaim; jobs = []; - proverNode = new TestProverNode( - prover, - publisher, - l2BlockSource, - l1ToL2MessageSource, - contractDataSource, - worldState, - txProvider, - simulator, - telemetryClient, - { maxPendingJobs: 3, pollingIntervalMs: 10, epochSize: 2 }, - ); }); afterEach(async () => { await proverNode.stop(); }); - const setBlockNumbers = (blockNumber: number, provenBlockNumber: number) => { - l2BlockSource.getBlockNumber.mockResolvedValue(blockNumber); - l2BlockSource.getProvenBlockNumber.mockResolvedValue(provenBlockNumber); - worldState.status.mockResolvedValue({ syncedToL2Block: provenBlockNumber, state: WorldStateRunningState.RUNNING }); - }; + describe('with mocked monitors', () => { + let claimsMonitor: MockProxy; + let epochMonitor: MockProxy; - it('proves pending blocks', async () => { - setBlockNumbers(5, 3); + beforeEach(() => { + claimsMonitor = mock(); + epochMonitor = mock(); - await proverNode.work(); - await proverNode.work(); - await proverNode.work(); + proverNode = new TestProverNode( + prover, + publisher, + l2BlockSource, + l1ToL2MessageSource, + contractDataSource, + worldState, + coordination, + simulator, + quoteProvider, + quoteSigner, + claimsMonitor, + epochMonitor, + telemetryClient, + config, + ); + }); - expect(jobs.length).toEqual(1); - expect(jobs[0].job.run).toHaveBeenCalledWith(4, 5); - }); + it('sends a quote on a finished epoch', async () => { + await proverNode.handleEpochCompleted(10n); - it('stops proving when maximum jobs are reached', async () => { - setBlockNumbers(20, 3); + expect(quoteProvider.getQuote).toHaveBeenCalledWith(blocks); + expect(quoteSigner.sign).toHaveBeenCalledWith(expect.objectContaining(partialQuote)); + expect(coordination.addEpochProofQuote).toHaveBeenCalledTimes(1); - await proverNode.work(); - await proverNode.work(); - await proverNode.work(); - await proverNode.work(); + expect(coordination.addEpochProofQuote).toHaveBeenCalledWith(toExpectedQuote(10n)); + }); - expect(jobs.length).toEqual(3); - expect(jobs[0].job.run).toHaveBeenCalledWith(4, 5); - expect(jobs[1].job.run).toHaveBeenCalledWith(6, 7); - expect(jobs[2].job.run).toHaveBeenCalledWith(8, 9); - }); + it('does not send a quote on a finished epoch if the provider does not return one', async () => { + quoteProvider.getQuote.mockResolvedValue(undefined); + await proverNode.handleEpochCompleted(10n); - it('reports on pending jobs', async () => { - setBlockNumbers(8, 3); + expect(quoteSigner.sign).not.toHaveBeenCalled(); + expect(coordination.addEpochProofQuote).not.toHaveBeenCalled(); + }); - await proverNode.work(); - await proverNode.work(); + it('starts proving on a new claim', async () => { + await proverNode.handleClaim(claim); - expect(jobs.length).toEqual(2); - expect(proverNode.getJobs().length).toEqual(2); - expect(proverNode.getJobs()).toEqual([ - { uuid: '0', status: 'processing' }, - { uuid: '1', status: 'processing' }, - ]); - }); + expect(jobs[0].epochNumber).toEqual(10n); + }); + + it('fails to start proving if world state is synced past the first block in the epoch', async () => { + // This test will probably be no longer necessary once we have the proper world state + worldState.status.mockResolvedValue({ syncedToL2Block: 21, state: WorldStateRunningState.RUNNING }); + await proverNode.handleClaim(claim); + + expect(jobs.length).toEqual(0); + }); - it('cleans up jobs when completed', async () => { - setBlockNumbers(20, 3); + it('does not prove the same epoch twice', async () => { + await proverNode.handleClaim(claim); + await proverNode.handleClaim(claim); - await proverNode.work(); - await proverNode.work(); - await proverNode.work(); - await proverNode.work(); + expect(jobs.length).toEqual(1); + }); - expect(jobs.length).toEqual(3); - expect(proverNode.getJobs().length).toEqual(3); + it('sends a quote on the initial sync if there is no claim', async () => { + await proverNode.handleInitialEpochSync(10n); - // Clean up the first job - await jobs[0].cleanUp(jobs[0].job); - expect(proverNode.getJobs().length).toEqual(2); - expect(jobs[0].db.delete).toHaveBeenCalled(); + expect(coordination.addEpochProofQuote).toHaveBeenCalledTimes(1); + }); - // Request another job to run and ensure it gets pushed - await proverNode.work(); - expect(jobs.length).toEqual(4); - expect(jobs[3].job.run).toHaveBeenCalledWith(10, 11); - expect(proverNode.getJobs().length).toEqual(3); - expect(proverNode.getJobs().map(({ uuid }) => uuid)).toEqual(['1', '2', '3']); + it('sends a quote on the initial sync if there is a claim for an older epoch', async () => { + const claim = { epochToProve: 9n, bondProvider: EthAddress.random() } as EpochProofClaim; + publisher.getProofClaim.mockResolvedValue(claim); + await proverNode.handleInitialEpochSync(10n); + + expect(coordination.addEpochProofQuote).toHaveBeenCalledTimes(1); + }); + + it('does not send a quote on the initial sync if there is already a claim', async () => { + const claim = { epochToProve: 10n, bondProvider: EthAddress.random() } as EpochProofClaim; + publisher.getProofClaim.mockResolvedValue(claim); + await proverNode.handleInitialEpochSync(10n); + + expect(coordination.addEpochProofQuote).not.toHaveBeenCalled(); + }); + + it('starts proving if there is a claim sent by us', async () => { + publisher.getProofClaim.mockResolvedValue(claim); + l2BlockSource.getProvenL2EpochNumber.mockResolvedValue(9); + await proverNode.handleInitialEpochSync(10n); + + expect(jobs[0].epochNumber).toEqual(10n); + }); + + it('does not start proving if there is a claim sent by us but proof has already landed', async () => { + publisher.getProofClaim.mockResolvedValue(claim); + l2BlockSource.getProvenL2EpochNumber.mockResolvedValue(10); + await proverNode.handleInitialEpochSync(10n); + + expect(jobs.length).toEqual(0); + }); }); - it('moves forward when proving fails', async () => { - setBlockNumbers(10, 3); + describe('with actual monitors', () => { + let claimsMonitor: ClaimsMonitor; + let epochMonitor: EpochMonitor; + + // Answers l2BlockSource.isEpochComplete, queried from the epoch monitor + let lastEpochComplete: bigint = 0n; + + beforeEach(() => { + claimsMonitor = new ClaimsMonitor(publisher, config); + epochMonitor = new EpochMonitor(l2BlockSource, config); + + l2BlockSource.isEpochComplete.mockImplementation(epochNumber => + Promise.resolve(epochNumber <= lastEpochComplete), + ); + + proverNode = new TestProverNode( + prover, + publisher, + l2BlockSource, + l1ToL2MessageSource, + contractDataSource, + worldState, + coordination, + simulator, + quoteProvider, + quoteSigner, + claimsMonitor, + epochMonitor, + telemetryClient, + config, + ); + }); + + it('sends a quote on initial sync', async () => { + l2BlockSource.getL2EpochNumber.mockResolvedValue(10n); + + await proverNode.start(); + await sleep(100); + expect(coordination.addEpochProofQuote).toHaveBeenCalledTimes(1); + }); + + it('sends another quote when a new epoch is completed', async () => { + lastEpochComplete = 10n; + l2BlockSource.getL2EpochNumber.mockResolvedValue(11n); + + await proverNode.start(); + await sleep(100); + + lastEpochComplete = 11n; + await sleep(100); + + expect(coordination.addEpochProofQuote).toHaveBeenCalledTimes(2); + expect(coordination.addEpochProofQuote).toHaveBeenCalledWith(toExpectedQuote(10n)); + expect(coordination.addEpochProofQuote).toHaveBeenCalledWith(toExpectedQuote(11n)); + }); - // We trigger an error by setting world state past the block that the prover node will try proving - worldState.status.mockResolvedValue({ syncedToL2Block: 7, state: WorldStateRunningState.RUNNING }); + it('starts proving when a claim is seen', async () => { + publisher.getProofClaim.mockResolvedValue(claim); - // These two calls should return in failures - await proverNode.work(); - await proverNode.work(); - expect(jobs.length).toEqual(0); + await proverNode.start(); + await sleep(100); - // But now the prover node should move forward - await proverNode.work(); - expect(jobs.length).toEqual(1); - expect(jobs[0].job.run).toHaveBeenCalledWith(8, 9); + expect(jobs[0].epochNumber).toEqual(10n); + }); }); class TestProverNode extends ProverNode { protected override doCreateEpochProvingJob( + epochNumber: bigint, + _blocks: L2Block[], db: MerkleTreeAdminOperations, _publicProcessorFactory: PublicProcessorFactory, cleanUp: (job: EpochProvingJob) => Promise, ): EpochProvingJob { - const job = mock({ getState: () => 'processing' }); + const job = mock({ getState: () => 'processing', run: () => Promise.resolve() }); job.getId.mockReturnValue(jobs.length.toString()); - jobs.push({ job, cleanUp, db }); + jobs.push({ epochNumber, job, cleanUp, db }); return job; } - public override work() { - return super.work(); + public override triggerMonitors() { + return super.triggerMonitors(); } } }); diff --git a/yarn-project/prover-node/src/prover-node.ts b/yarn-project/prover-node/src/prover-node.ts index 246171478142..0d20e6675bec 100644 --- a/yarn-project/prover-node/src/prover-node.ts +++ b/yarn-project/prover-node/src/prover-node.ts @@ -1,7 +1,10 @@ import { + type EpochProofClaim, type EpochProofQuote, + EpochProofQuotePayload, type EpochProverManager, type L1ToL2MessageSource, + type L2Block, type L2BlockSource, type MerkleTreeOperations, type ProverCoordination, @@ -9,7 +12,6 @@ import { } from '@aztec/circuit-types'; import { compact } from '@aztec/foundation/collection'; import { createDebugLogger } from '@aztec/foundation/log'; -import { RunningPromise } from '@aztec/foundation/running-promise'; import { type L1Publisher } from '@aztec/sequencer-client'; import { PublicProcessorFactory, type SimulationProvider } from '@aztec/simulator'; import { type TelemetryClient } from '@aztec/telemetry-client'; @@ -17,57 +19,130 @@ import { type ContractDataSource } from '@aztec/types/contracts'; import { EpochProvingJob, type EpochProvingJobState } from './job/epoch-proving-job.js'; import { ProverNodeMetrics } from './metrics.js'; +import { type ClaimsMonitor, type ClaimsMonitorHandler } from './monitors/claims-monitor.js'; +import { type EpochMonitor, type EpochMonitorHandler } from './monitors/epoch-monitor.js'; +import { type QuoteProvider } from './quote-provider/index.js'; +import { type QuoteSigner } from './quote-signer.js'; -type ProverNodeOptions = { +export type ProverNodeOptions = { pollingIntervalMs: number; - disableAutomaticProving: boolean; maxPendingJobs: number; - epochSize: number; }; /** * An Aztec Prover Node is a standalone process that monitors the unfinalised chain on L1 for unproven blocks, - * fetches their txs from a tx source in the p2p network or an external node, re-executes their public functions, - * creates a rollup proof, and submits it to L1. + * submits bids for proving them, and monitors if they are accepted. If so, the prover node fetches the txs + * from a tx source in the p2p network or an external node, re-executes their public functions, creates a rollup + * proof for the epoch, and submits it to L1. */ -export class ProverNode { +export class ProverNode implements ClaimsMonitorHandler, EpochMonitorHandler { private log = createDebugLogger('aztec:prover-node'); - private runningPromise: RunningPromise | undefined; - private latestBlockWeAreProving: number | undefined; + + private latestEpochWeAreProving: bigint | undefined; private jobs: Map = new Map(); private options: ProverNodeOptions; private metrics: ProverNodeMetrics; constructor( - private prover: EpochProverManager, - private publisher: L1Publisher, - private l2BlockSource: L2BlockSource, - private l1ToL2MessageSource: L1ToL2MessageSource, - private contractDataSource: ContractDataSource, - private worldState: WorldStateSynchronizer, - private coordination: ProverCoordination, - private simulator: SimulationProvider, - private telemetryClient: TelemetryClient, + private readonly prover: EpochProverManager, + private readonly publisher: L1Publisher, + private readonly l2BlockSource: L2BlockSource, + private readonly l1ToL2MessageSource: L1ToL2MessageSource, + private readonly contractDataSource: ContractDataSource, + private readonly worldState: WorldStateSynchronizer, + private readonly coordination: ProverCoordination, + private readonly simulator: SimulationProvider, + private readonly quoteProvider: QuoteProvider, + private readonly quoteSigner: QuoteSigner, + private readonly claimsMonitor: ClaimsMonitor, + private readonly epochsMonitor: EpochMonitor, + private readonly telemetryClient: TelemetryClient, options: Partial = {}, ) { this.options = { pollingIntervalMs: 1_000, - disableAutomaticProving: false, maxPendingJobs: 100, - epochSize: 2, ...compact(options), }; this.metrics = new ProverNodeMetrics(telemetryClient, 'ProverNode'); } + async ensureBond() { + // Ensure the prover has enough bond to submit proofs + // Can we just run this at the beginning and forget about it? + // Or do we need to check periodically? Or only when we get slashed? How do we know we got slashed? + } + + async handleClaim(proofClaim: EpochProofClaim): Promise { + if (proofClaim.epochToProve === this.latestEpochWeAreProving) { + this.log.verbose(`Already proving claim for epoch ${proofClaim.epochToProve}`); + return; + } + + try { + await this.startProof(proofClaim.epochToProve); + this.latestEpochWeAreProving = proofClaim.epochToProve; + } catch (err) { + this.log.error(`Error handling claim for epoch ${proofClaim.epochToProve}`, err); + } + } + + /** + * Handles the epoch number to prove when the prover node starts by checking if there + * is an existing claim for it. If not, it creates and sends a quote for it. + * @param epochNumber - The epoch immediately before the current one when the prover node starts. + */ + async handleInitialEpochSync(epochNumber: bigint): Promise { + try { + const claim = await this.publisher.getProofClaim(); + if (!claim || claim.epochToProve < epochNumber) { + await this.handleEpochCompleted(epochNumber); + } else if (claim && claim.bondProvider.equals(this.publisher.getSenderAddress())) { + const lastEpochProven = await this.l2BlockSource.getProvenL2EpochNumber(); + if (!lastEpochProven || lastEpochProven < claim.epochToProve) { + await this.handleClaim(claim); + } + } + } catch (err) { + this.log.error(`Error handling initial epoch sync`, err); + } + } + + /** + * Handles an epoch being completed by sending a quote for proving it. + * @param epochNumber - The epoch number that was just completed. + */ + async handleEpochCompleted(epochNumber: bigint): Promise { + try { + const blocks = await this.l2BlockSource.getBlocksForEpoch(epochNumber); + const partialQuote = await this.quoteProvider.getQuote(blocks); + if (!partialQuote) { + this.log.verbose(`No quote produced for epoch ${epochNumber}`); + return; + } + + const quote = EpochProofQuotePayload.from({ + ...partialQuote, + epochToProve: BigInt(epochNumber), + prover: this.publisher.getSenderAddress(), + validUntilSlot: partialQuote.validUntilSlot ?? BigInt(Number.MAX_SAFE_INTEGER), // Should we constrain this? + }); + const signed = this.quoteSigner.sign(quote); + await this.sendEpochProofQuote(signed); + } catch (err) { + this.log.error(`Error handling epoch completed`, err); + } + } + /** * Starts the prover node so it periodically checks for unproven blocks in the unfinalised chain from L1 and proves them. * This may change once we implement a prover coordination mechanism. */ - start() { - this.runningPromise = new RunningPromise(this.work.bind(this), this.options.pollingIntervalMs); - this.runningPromise.start(); + async start() { + await this.ensureBond(); + this.epochsMonitor.start(this); + this.claimsMonitor.start(this); this.log.info('Started ProverNode', this.options); } @@ -76,7 +151,8 @@ export class ProverNode { */ async stop() { this.log.info('Stopping ProverNode'); - await this.runningPromise?.stop(); + await this.epochsMonitor.stop(); + await this.claimsMonitor.stop(); await this.prover.stop(); await this.l2BlockSource.stop(); this.publisher.interrupt(); @@ -86,73 +162,27 @@ export class ProverNode { } /** - * Single iteration of recurring work. This method is called periodically by the running promise. - * Checks whether there are new blocks to prove, proves them, and submits them. + * Sends an epoch proof quote to the coordinator. */ - protected async work() { - try { - if (this.options.disableAutomaticProving) { - return; - } - - if (!this.checkMaximumPendingJobs()) { - this.log.debug(`Maximum pending proving jobs reached. Skipping work.`, { - maxPendingJobs: this.options.maxPendingJobs, - pendingJobs: this.jobs.size, - }); - return; - } - - const [latestBlockNumber, latestProvenBlockNumber] = await Promise.all([ - this.l2BlockSource.getBlockNumber(), - this.l2BlockSource.getProvenBlockNumber(), - ]); - - // Consider both the latest block we are proving and the last block proven on the chain - const latestBlockBeingProven = this.latestBlockWeAreProving ?? 0; - const latestProven = Math.max(latestBlockBeingProven, latestProvenBlockNumber); - if (latestBlockNumber - latestProven < this.options.epochSize) { - this.log.debug(`No epoch to prove`, { - latestBlockNumber, - latestProvenBlockNumber, - latestBlockBeingProven, - }); - return; - } - - const fromBlock = latestProven + 1; - const toBlock = fromBlock + this.options.epochSize - 1; - - try { - await this.startProof(fromBlock, toBlock); - } finally { - // If we fail to create a proving job for the given block, skip it instead of getting stuck on it. - this.log.verbose(`Setting ${toBlock} as latest block we are proving`); - this.latestBlockWeAreProving = toBlock; - } - } catch (err) { - this.log.error(`Error in prover node work`, err); - } - } - public sendEpochProofQuote(quote: EpochProofQuote): Promise { + this.log.info(`Sending quote for epoch`, quote.toViemArgs().quote); return this.coordination.addEpochProofQuote(quote); } /** * Creates a proof for a block range. Returns once the proof has been submitted to L1. */ - public async prove(fromBlock: number, toBlock: number) { - const job = await this.createProvingJob(fromBlock); - return job.run(fromBlock, toBlock); + public async prove(epochNumber: number | bigint) { + const job = await this.createProvingJob(BigInt(epochNumber)); + return job.run(); } /** * Starts a proving process and returns immediately. */ - public async startProof(fromBlock: number, toBlock: number) { - const job = await this.createProvingJob(fromBlock); - void job.run(fromBlock, toBlock); + public async startProof(epochNumber: number | bigint) { + const job = await this.createProvingJob(BigInt(epochNumber)); + void job.run().catch(err => this.log.error(`Error proving epoch ${epochNumber}`, err)); } /** @@ -174,17 +204,25 @@ export class ProverNode { return maxPendingJobs === 0 || this.jobs.size < maxPendingJobs; } - private async createProvingJob(fromBlock: number) { + private async createProvingJob(epochNumber: bigint) { if (!this.checkMaximumPendingJobs()) { throw new Error(`Maximum pending proving jobs ${this.options.maxPendingJobs} reached. Cannot create new job.`); } + // Gather blocks for this epoch + const blocks = await this.l2BlockSource.getBlocksForEpoch(epochNumber); + if (blocks.length === 0) { + throw new Error(`No blocks found for epoch ${epochNumber}`); + } + const fromBlock = blocks[0].number; + const toBlock = blocks.at(-1)!.number; + if ((await this.worldState.status()).syncedToL2Block >= fromBlock) { throw new Error(`Cannot create proving job for block ${fromBlock} as it is behind the current world state`); } // Fast forward world state to right before the target block and get a fork - this.log.verbose(`Creating proving job for block ${fromBlock}`); + this.log.verbose(`Creating proving job for epoch ${epochNumber} for block range ${fromBlock} to ${toBlock}`); const db = await this.worldState.syncImmediateAndFork(fromBlock - 1, true); // Create a processor using the forked world state @@ -200,18 +238,22 @@ export class ProverNode { this.jobs.delete(job.getId()); }; - const job = this.doCreateEpochProvingJob(db, publicProcessorFactory, cleanUp); + const job = this.doCreateEpochProvingJob(epochNumber, blocks, db, publicProcessorFactory, cleanUp); this.jobs.set(job.getId(), job); return job; } /** Extracted for testing purposes. */ protected doCreateEpochProvingJob( + epochNumber: bigint, + blocks: L2Block[], db: MerkleTreeOperations, publicProcessorFactory: PublicProcessorFactory, cleanUp: () => Promise, ) { return new EpochProvingJob( + epochNumber, + blocks, this.prover.createEpochProver(db), publicProcessorFactory, this.publisher, @@ -222,4 +264,10 @@ export class ProverNode { cleanUp, ); } + + /** Extracted for testing purposes. */ + protected async triggerMonitors() { + await this.epochsMonitor.work(); + await this.claimsMonitor.work(); + } } diff --git a/yarn-project/prover-node/src/quote-provider/http.ts b/yarn-project/prover-node/src/quote-provider/http.ts new file mode 100644 index 000000000000..b5ada13cbb9b --- /dev/null +++ b/yarn-project/prover-node/src/quote-provider/http.ts @@ -0,0 +1,2 @@ +// TODO: Implement me! This should send a request to a configurable URL to get the quote. +export class HttpQuoteProvider {} diff --git a/yarn-project/prover-node/src/quote-provider/index.ts b/yarn-project/prover-node/src/quote-provider/index.ts new file mode 100644 index 000000000000..770833cb280e --- /dev/null +++ b/yarn-project/prover-node/src/quote-provider/index.ts @@ -0,0 +1,8 @@ +import { type EpochProofQuotePayload, type L2Block } from '@aztec/circuit-types'; + +type QuoteProviderResult = Pick & + Partial>; + +export interface QuoteProvider { + getQuote(epoch: L2Block[]): Promise; +} diff --git a/yarn-project/prover-node/src/quote-provider/simple.ts b/yarn-project/prover-node/src/quote-provider/simple.ts new file mode 100644 index 000000000000..c2eff3fc5c07 --- /dev/null +++ b/yarn-project/prover-node/src/quote-provider/simple.ts @@ -0,0 +1,12 @@ +import { type EpochProofQuotePayload, type L2Block } from '@aztec/circuit-types'; + +import { type QuoteProvider } from './index.js'; + +export class SimpleQuoteProvider implements QuoteProvider { + constructor(public readonly basisPointFee: number, public readonly bondAmount: bigint) {} + + getQuote(_epoch: L2Block[]): Promise> { + const { basisPointFee, bondAmount } = this; + return Promise.resolve({ basisPointFee, bondAmount }); + } +} diff --git a/yarn-project/prover-node/src/quote-provider/utils.ts b/yarn-project/prover-node/src/quote-provider/utils.ts new file mode 100644 index 000000000000..253480476c19 --- /dev/null +++ b/yarn-project/prover-node/src/quote-provider/utils.ts @@ -0,0 +1,10 @@ +import { type L2Block } from '@aztec/circuit-types'; +import { Fr } from '@aztec/circuits.js'; + +export function getTotalFees(epoch: L2Block[]) { + return epoch.reduce((total, block) => total.add(block.header.totalFees), Fr.ZERO).toBigInt(); +} + +export function getTxCount(epoch: L2Block[]) { + return epoch.reduce((total, block) => total + block.body.txEffects.length, 0); +} diff --git a/yarn-project/prover-node/src/quote-signer.ts b/yarn-project/prover-node/src/quote-signer.ts new file mode 100644 index 000000000000..46c7a948d0e4 --- /dev/null +++ b/yarn-project/prover-node/src/quote-signer.ts @@ -0,0 +1,15 @@ +import { EpochProofQuote, type EpochProofQuotePayload } from '@aztec/circuit-types'; +import { type Buffer32 } from '@aztec/foundation/buffer'; +import { Secp256k1Signer } from '@aztec/foundation/crypto'; + +export class QuoteSigner { + constructor(private readonly signer: Secp256k1Signer) {} + + static new(privateKey: Buffer32) { + return new QuoteSigner(new Secp256k1Signer(privateKey)); + } + + public sign(payload: EpochProofQuotePayload) { + return EpochProofQuote.new(payload, this.signer); + } +} diff --git a/yarn-project/sequencer-client/src/publisher/l1-publisher.ts b/yarn-project/sequencer-client/src/publisher/l1-publisher.ts index 99df20363d12..af0e13258d8c 100644 --- a/yarn-project/sequencer-client/src/publisher/l1-publisher.ts +++ b/yarn-project/sequencer-client/src/publisher/l1-publisher.ts @@ -1,5 +1,6 @@ import { ConsensusPayload, + type EpochProofClaim, type EpochProofQuote, type L2Block, type TxHash, @@ -30,6 +31,7 @@ import { type TelemetryClient } from '@aztec/telemetry-client'; import pick from 'lodash.pick'; import { inspect } from 'util'; import { + type BaseError, ContractFunctionRevertedError, type GetContractReturnType, type Hex, @@ -40,6 +42,7 @@ import { createPublicClient, createWalletClient, encodeFunctionData, + getAbiItem, getAddress, getContract, hexToBytes, @@ -161,8 +164,8 @@ export class L1Publisher { }); } - public getSenderAddress(): Promise { - return Promise.resolve(EthAddress.fromString(this.account.address)); + public getSenderAddress(): EthAddress { + return EthAddress.fromString(this.account.address); } /** @@ -188,6 +191,46 @@ export class L1Publisher { return await this.rollupContract.read.getEpochAtSlot([slotNumber]); } + public async getEpochToProve(): Promise { + try { + return await this.rollupContract.read.getEpochToProve(); + } catch (err: any) { + // If this is a revert with Rollup__NoEpochToProve, it means there is no epoch to prove, so we return undefined + // See https://viem.sh/docs/contract/simulateContract#handling-custom-errors + if (err.name === 'ViemError') { + const baseError = err as BaseError; + const revertError = baseError.walk(err => (err as Error).name === 'ContractFunctionRevertedError'); + if (revertError) { + const errorName = (revertError as ContractFunctionRevertedError).data?.errorName; + if (errorName === getAbiItem({ abi: RollupAbi, name: 'Rollup__NoEpochToProve' }).name) { + return undefined; + } + } + } + throw err; + } + } + + public async getProofClaim(): Promise { + const [epochToProve, basisPointFee, bondAmount, bondProviderHex, proposerClaimantHex] = + await this.rollupContract.read.proofClaim(); + + const bondProvider = EthAddress.fromString(bondProviderHex); + const proposerClaimant = EthAddress.fromString(proposerClaimantHex); + + if (bondProvider.isZero() && proposerClaimant.isZero() && epochToProve === 0n) { + return undefined; + } + + return { + epochToProve, + basisPointFee, + bondAmount, + bondProvider, + proposerClaimant, + }; + } + public async validateProofQuote(quote: EpochProofQuote): Promise { const args = [quote.toViemArgs()] as const; try { diff --git a/yarn-project/sequencer-client/src/sequencer/sequencer.ts b/yarn-project/sequencer-client/src/sequencer/sequencer.ts index 3e83d8a40c0c..29de94931086 100644 --- a/yarn-project/sequencer-client/src/sequencer/sequencer.ts +++ b/yarn-project/sequencer-client/src/sequencer/sequencer.ts @@ -501,7 +501,7 @@ export class Sequencer { this.log.info( `Submitted rollup block ${block.number} with ${ processedTxs.length - } transactions duration=${workDuration}ms (Submitter: ${await this.publisher.getSenderAddress()})`, + } transactions duration=${workDuration}ms (Submitter: ${this.publisher.getSenderAddress()})`, ); } catch (err) { this.metrics.recordFailedBlock(); diff --git a/yarn-project/yarn.lock b/yarn-project/yarn.lock index 5275032f7981..4dbd62b8e9a4 100644 --- a/yarn-project/yarn.lock +++ b/yarn-project/yarn.lock @@ -844,6 +844,7 @@ __metadata: version: 0.0.0-use.local resolution: "@aztec/p2p@workspace:p2p" dependencies: + "@aztec/archiver": "workspace:^" "@aztec/circuit-types": "workspace:^" "@aztec/circuits.js": "workspace:^" "@aztec/foundation": "workspace:^"