diff --git a/l1-contracts/src/core/Rollup.sol b/l1-contracts/src/core/Rollup.sol index 0e34b3edb564..01c1c6efff39 100644 --- a/l1-contracts/src/core/Rollup.sol +++ b/l1-contracts/src/core/Rollup.sol @@ -127,7 +127,7 @@ contract Rollup is EIP712("Aztec Rollup", "1"), Leonidas, IRollup, ITestRollup { * @dev Will revert if there is nothing to prune or if the chain is not ready to be pruned */ function prune() external override(IRollup) { - require(_canPrune(), Errors.Rollup__NothingToPrune()); + require(canPrune(), Errors.Rollup__NothingToPrune()); _prune(); } @@ -315,12 +315,15 @@ contract Rollup is EIP712("Aztec Rollup", "1"), Leonidas, IRollup, ITestRollup { { Slot slot = getSlotAt(_ts); - Slot lastSlot = blocks[tips.pendingBlockNumber].slotNumber; + // Consider if a prune will hit in this slot + uint256 pendingBlockNumber = _canPruneAt(_ts) ? tips.provenBlockNumber : tips.pendingBlockNumber; + + Slot lastSlot = blocks[pendingBlockNumber].slotNumber; require(slot > lastSlot, Errors.Rollup__SlotAlreadyInChain(lastSlot, slot)); - // Make sure that the proposer is up to date - bytes32 tipArchive = archive(); + // Make sure that the proposer is up to date and on the right chain (ie no reorgs) + bytes32 tipArchive = blocks[pendingBlockNumber].archive; require(tipArchive == _archive, Errors.Rollup__InvalidArchive(tipArchive, _archive)); SignatureLib.Signature[] memory sigs = new SignatureLib.Signature[](0); @@ -328,7 +331,7 @@ contract Rollup is EIP712("Aztec Rollup", "1"), Leonidas, IRollup, ITestRollup { DataStructures.ExecutionFlags({ignoreDA: true, ignoreSignatures: true}); _validateLeonidas(slot, sigs, _archive, flags); - return (slot, tips.pendingBlockNumber + 1); + return (slot, pendingBlockNumber + 1); } /** @@ -417,7 +420,7 @@ contract Rollup is EIP712("Aztec Rollup", "1"), Leonidas, IRollup, ITestRollup { SignatureLib.Signature[] memory _signatures, bytes calldata _body ) public override(IRollup) { - if (_canPrune()) { + if (canPrune()) { _prune(); } bytes32 txsEffectsHash = TxsDecoder.decode(_body); @@ -733,7 +736,11 @@ contract Rollup is EIP712("Aztec Rollup", "1"), Leonidas, IRollup, ITestRollup { emit PrunedPending(tips.provenBlockNumber, pending); } - function _canPrune() internal view returns (bool) { + function canPrune() public view returns (bool) { + return _canPruneAt(Timestamp.wrap(block.timestamp)); + } + + function _canPruneAt(Timestamp _ts) internal view returns (bool) { if ( tips.pendingBlockNumber == tips.provenBlockNumber || tips.pendingBlockNumber <= assumeProvenThroughBlockNumber @@ -741,7 +748,7 @@ contract Rollup is EIP712("Aztec Rollup", "1"), Leonidas, IRollup, ITestRollup { return false; } - Slot currentSlot = getCurrentSlot(); + Slot currentSlot = getSlotAt(_ts); Epoch oldestPendingEpoch = getEpochForBlock(tips.provenBlockNumber + 1); Slot startSlotOfPendingEpoch = oldestPendingEpoch.toSlots(); @@ -780,7 +787,11 @@ contract Rollup is EIP712("Aztec Rollup", "1"), Leonidas, IRollup, ITestRollup { bytes32 _txEffectsHash, DataStructures.ExecutionFlags memory _flags ) internal view { - _validateHeaderForSubmissionBase(_header, _currentTime, _txEffectsHash, _flags); + uint256 pendingBlockNumber = + _canPruneAt(_currentTime) ? tips.provenBlockNumber : tips.pendingBlockNumber; + _validateHeaderForSubmissionBase( + _header, _currentTime, _txEffectsHash, pendingBlockNumber, _flags + ); _validateHeaderForSubmissionSequencerSelection( Slot.wrap(_header.globalVariables.slotNumber), _signatures, _digest, _currentTime, _flags ); @@ -846,6 +857,7 @@ contract Rollup is EIP712("Aztec Rollup", "1"), Leonidas, IRollup, ITestRollup { HeaderLib.Header memory _header, Timestamp _currentTime, bytes32 _txsEffectsHash, + uint256 _pendingBlockNumber, DataStructures.ExecutionFlags memory _flags ) internal view { require( @@ -859,20 +871,20 @@ contract Rollup is EIP712("Aztec Rollup", "1"), Leonidas, IRollup, ITestRollup { ); require( - _header.globalVariables.blockNumber == tips.pendingBlockNumber + 1, + _header.globalVariables.blockNumber == _pendingBlockNumber + 1, Errors.Rollup__InvalidBlockNumber( - tips.pendingBlockNumber + 1, _header.globalVariables.blockNumber + _pendingBlockNumber + 1, _header.globalVariables.blockNumber ) ); - bytes32 tipArchive = archive(); + bytes32 tipArchive = blocks[_pendingBlockNumber].archive; require( tipArchive == _header.lastArchive.root, Errors.Rollup__InvalidArchive(tipArchive, _header.lastArchive.root) ); Slot slot = Slot.wrap(_header.globalVariables.slotNumber); - Slot lastSlot = blocks[tips.pendingBlockNumber].slotNumber; + Slot lastSlot = blocks[_pendingBlockNumber].slotNumber; require(slot > lastSlot, Errors.Rollup__SlotAlreadyInChain(lastSlot, slot)); Timestamp timestamp = getTimestampForSlot(slot); diff --git a/l1-contracts/src/core/interfaces/IRollup.sol b/l1-contracts/src/core/interfaces/IRollup.sol index ed3e9b41109a..21e6b7c2e9f8 100644 --- a/l1-contracts/src/core/interfaces/IRollup.sol +++ b/l1-contracts/src/core/interfaces/IRollup.sol @@ -32,6 +32,8 @@ interface IRollup { function prune() external; + function canPrune() external view returns (bool); + function claimEpochProofRight(EpochProofQuoteLib.SignedEpochProofQuote calldata _quote) external; function propose( diff --git a/yarn-project/archiver/src/archiver/archiver.test.ts b/yarn-project/archiver/src/archiver/archiver.test.ts index 0bd499ab467b..dc6fc63e7567 100644 --- a/yarn-project/archiver/src/archiver/archiver.test.ts +++ b/yarn-project/archiver/src/archiver/archiver.test.ts @@ -298,7 +298,7 @@ describe('Archiver', () => { expect(loggerSpy).toHaveBeenNthCalledWith(2, `No blocks to retrieve from ${1n} to ${50n}`); }, 10_000); - it('Handle L2 reorg', async () => { + it('handles L2 reorg', async () => { const loggerSpy = jest.spyOn((archiver as any).log, 'verbose'); let latestBlockNum = await archiver.getBlockNumber(); @@ -378,6 +378,9 @@ describe('Archiver', () => { // The random blocks don't include contract instances nor classes we we cannot look for those here. }, 10_000); + // TODO(palla/reorg): Add a unit test for the archiver handleEpochPrune + xit('handles an upcoming L2 prune', () => {}); + // logs should be created in order of how archiver syncs. const mockGetLogs = (logs: { messageSent?: ReturnType[]; diff --git a/yarn-project/archiver/src/archiver/archiver.ts b/yarn-project/archiver/src/archiver/archiver.ts index d40be435fc16..01836e467e2c 100644 --- a/yarn-project/archiver/src/archiver/archiver.ts +++ b/yarn-project/archiver/src/archiver/archiver.ts @@ -246,7 +246,16 @@ export class Archiver implements ArchiveSource { await this.handleL1ToL2Messages(blockUntilSynced, messagesSynchedTo, currentL1BlockNumber); // ********** Events that are processed per L2 block ********** - await this.handleL2blocks(blockUntilSynced, blocksSynchedTo, currentL1BlockNumber); + if (currentL1BlockNumber > blocksSynchedTo) { + // First we retrieve new L2 blocks + const { provenBlockNumber } = await this.handleL2blocks(blockUntilSynced, blocksSynchedTo, currentL1BlockNumber); + // And then we prune the current epoch if it'd reorg on next submission. + // Note that we don't do this before retrieving L2 blocks because we may need to retrieve + // blocks from more than 2 epochs ago, so we want to make sure we have the latest view of + // the chain locally before we start unwinding stuff. This can be optimized by figuring out + // up to which point we're pruning, and then requesting L2 blocks up to that point only. + await this.handleEpochPrune(provenBlockNumber, currentL1BlockNumber); + } // Store latest l1 block number and timestamp seen. Used for epoch and slots calculations. if (!this.l1BlockNumber || this.l1BlockNumber < currentL1BlockNumber) { @@ -255,6 +264,27 @@ export class Archiver implements ArchiveSource { } } + /** Checks if there'd be a reorg for the next block submission and start pruning now. */ + private async handleEpochPrune(provenBlockNumber: bigint, currentL1BlockNumber: bigint) { + const localPendingBlockNumber = BigInt(await this.getBlockNumber()); + + const canPrune = + localPendingBlockNumber > provenBlockNumber && + (await this.rollup.read.canPrune({ blockNumber: currentL1BlockNumber })); + + if (canPrune) { + this.log.verbose(`L2 prune will occur on next submission. Rolling back to last proven block.`); + const blocksToUnwind = localPendingBlockNumber - provenBlockNumber; + this.log.verbose( + `Unwinding ${blocksToUnwind} block${blocksToUnwind > 1n ? 's' : ''} from block ${localPendingBlockNumber}`, + ); + await this.store.unwindBlocks(Number(localPendingBlockNumber), Number(blocksToUnwind)); + // TODO(palla/reorg): Do we need to set the block synched L1 block number here? + // Seems like the next iteration should handle this. + // await this.store.setBlockSynchedL1BlockNumber(currentL1BlockNumber); + } + } + private async handleL1ToL2Messages( blockUntilSynced: boolean, messagesSynchedTo: bigint, @@ -291,11 +321,11 @@ export class Archiver implements ArchiveSource { ); } - private async handleL2blocks(blockUntilSynced: boolean, blocksSynchedTo: bigint, currentL1BlockNumber: bigint) { - if (currentL1BlockNumber <= blocksSynchedTo) { - return; - } - + private async handleL2blocks( + blockUntilSynced: boolean, + blocksSynchedTo: bigint, + currentL1BlockNumber: bigint, + ): Promise<{ provenBlockNumber: bigint }> { const localPendingBlockNumber = BigInt(await this.getBlockNumber()); const [ provenBlockNumber, @@ -304,7 +334,7 @@ export class Archiver implements ArchiveSource { pendingArchive, archiveForLocalPendingBlockNumber, provenEpochNumber, - ] = await this.rollup.read.status([localPendingBlockNumber]); + ] = await this.rollup.read.status([localPendingBlockNumber], { blockNumber: currentL1BlockNumber }); const updateProvenBlock = async () => { const localBlockForDestinationProvenBlockNumber = await this.getBlock(Number(provenBlockNumber)); @@ -326,7 +356,7 @@ export class Archiver implements ArchiveSource { if (noBlocks) { await this.store.setBlockSynchedL1BlockNumber(currentL1BlockNumber); this.log.verbose(`No blocks to retrieve from ${blocksSynchedTo + 1n} to ${currentL1BlockNumber}`); - return; + return { provenBlockNumber }; } await updateProvenBlock(); @@ -343,7 +373,7 @@ export class Archiver implements ArchiveSource { if (noBlockSinceLast) { await this.store.setBlockSynchedL1BlockNumber(currentL1BlockNumber); this.log.verbose(`No blocks to retrieve from ${blocksSynchedTo + 1n} to ${currentL1BlockNumber}`); - return; + return { provenBlockNumber }; } const localPendingBlockInChain = archiveForLocalPendingBlockNumber === localPendingBlock.archive.root.toString(); @@ -383,7 +413,7 @@ export class Archiver implements ArchiveSource { this.rollup, this.publicClient, blockUntilSynced, - blocksSynchedTo + 1n, + blocksSynchedTo + 1n, // TODO(palla/reorg): If the L2 reorg was due to an L1 reorg, we need to start search earlier currentL1BlockNumber, this.log, ); @@ -391,8 +421,8 @@ export class Archiver implements ArchiveSource { if (retrievedBlocks.length === 0) { // We are not calling `setBlockSynchedL1BlockNumber` because it may cause sync issues if based off infura. // See further details in earlier comments. - this.log.verbose(`Retrieved no new blocks from ${blocksSynchedTo + 1n} to ${currentL1BlockNumber}`); - return; + this.log.verbose(`Retrieved no new L2 blocks from ${blocksSynchedTo + 1n} to ${currentL1BlockNumber}`); + return { provenBlockNumber }; } this.log.debug( @@ -410,6 +440,7 @@ export class Archiver implements ArchiveSource { const timer = new Timer(); await this.store.addBlocks(retrievedBlocks); + // Important that we update AFTER inserting the blocks. await updateProvenBlock(); this.instrumentation.processNewBlocks( @@ -418,6 +449,8 @@ export class Archiver implements ArchiveSource { ); const lastL2BlockNumber = retrievedBlocks[retrievedBlocks.length - 1].data.number; this.log.verbose(`Processed ${retrievedBlocks.length} new L2 blocks up to ${lastL2BlockNumber}`); + + return { provenBlockNumber }; } /** @@ -497,7 +530,10 @@ export class Archiver implements ArchiveSource { 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 + // since we know the next L1 block won't be mined within this range. Remember that + // l1timestamp is the timestamp of the last l1 block we've seen, so this 3s rely on + // the fact that L1 won't mine two blocks within 3s of each other. + // TODO(palla/reorg): Is the above a safe assumption? const leeway = 3n; return l1Timestamp + leeway >= endTimestamp; } diff --git a/yarn-project/aztec.js/src/utils/cheat_codes.ts b/yarn-project/aztec.js/src/utils/cheat_codes.ts index c7307dcb476a..82988c2bc2c8 100644 --- a/yarn-project/aztec.js/src/utils/cheat_codes.ts +++ b/yarn-project/aztec.js/src/utils/cheat_codes.ts @@ -125,7 +125,7 @@ export class EthCheatCodes { if (res.error) { throw new Error(`Error mining: ${res.error.message}`); } - this.logger.verbose(`Mined ${numberOfBlocks} blocks`); + this.logger.verbose(`Mined ${numberOfBlocks} L1 blocks`); } /** @@ -150,7 +150,7 @@ export class EthCheatCodes { if (res.error) { throw new Error(`Error setting block interval: ${res.error.message}`); } - this.logger.verbose(`Set block interval to ${interval}`); + this.logger.verbose(`Set L1 block interval to ${interval}`); } /** @@ -162,7 +162,7 @@ export class EthCheatCodes { if (res.error) { throw new Error(`Error setting next block timestamp: ${res.error.message}`); } - this.logger.verbose(`Set next block timestamp to ${timestamp}`); + this.logger.verbose(`Set L1 next block timestamp to ${timestamp}`); } /** @@ -175,7 +175,7 @@ export class EthCheatCodes { throw new Error(`Error warping: ${res.error.message}`); } await this.mine(); - this.logger.verbose(`Warped to ${timestamp}`); + this.logger.verbose(`Warped L1 timestamp to ${timestamp}`); } /** @@ -228,7 +228,7 @@ export class EthCheatCodes { if (res.error) { throw new Error(`Error setting storage for contract ${contract} at ${slot}: ${res.error.message}`); } - this.logger.verbose(`Set storage for contract ${contract} at ${slot} to ${value}`); + this.logger.verbose(`Set L1 storage for contract ${contract} at ${slot} to ${value}`); } /** @@ -329,6 +329,18 @@ export class RollupCheatCodes { this.logger.verbose(`Advanced to next epoch`); } + /** + * Warps time in L1 equivalent to however many slots. + * @param howMany - The number of slots to advance. + */ + public async advanceSlots(howMany: number) { + const l1Timestamp = Number((await this.client.getBlock()).timestamp); + const timeToWarp = howMany * AZTEC_SLOT_DURATION; + await this.ethCheatCodes.warp(l1Timestamp + timeToWarp); + const [slot, epoch] = await Promise.all([this.getSlot(), this.getEpoch()]); + this.logger.verbose(`Advanced ${howMany} slots up to slot ${slot} in epoch ${epoch}`); + } + /** Returns the current proof claim (if any) */ public async getProofClaim(): Promise { // REFACTOR: This code is duplicated from l1-publisher diff --git a/yarn-project/circuit-types/src/interfaces/world_state.ts b/yarn-project/circuit-types/src/interfaces/world_state.ts index d3d2697fa348..e2d4234da17e 100644 --- a/yarn-project/circuit-types/src/interfaces/world_state.ts +++ b/yarn-project/circuit-types/src/interfaces/world_state.ts @@ -1,3 +1,4 @@ +import { type L2BlockId } from '../l2_block_source.js'; import type { MerkleTreeReadOperations, MerkleTreeWriteOperations } from './merkle_tree_operations.js'; /** @@ -21,7 +22,7 @@ export interface WorldStateSynchronizerStatus { /** * The block number that the world state synchronizer is synced to. */ - syncedToL2Block: number; + syncedToL2Block: L2BlockId; } /** diff --git a/yarn-project/circuit-types/src/l2_block_downloader/l2_block_stream.test.ts b/yarn-project/circuit-types/src/l2_block_downloader/l2_block_stream.test.ts index b49f0c60734b..756c598eb1eb 100644 --- a/yarn-project/circuit-types/src/l2_block_downloader/l2_block_stream.test.ts +++ b/yarn-project/circuit-types/src/l2_block_downloader/l2_block_stream.test.ts @@ -5,7 +5,7 @@ import { type MockProxy, mock } from 'jest-mock-extended'; import times from 'lodash.times'; import { type L2Block } from '../l2_block.js'; -import { type L2BlockSource, type L2BlockTag } from '../l2_block_source.js'; +import { type L2BlockSource, type L2Tips } from '../l2_block_source.js'; import { L2BlockStream, type L2BlockStreamEvent, @@ -66,7 +66,7 @@ describe('L2BlockStream', () => { it('pulls new blocks from offset', async () => { setRemoteTips(15); - localData.latest = 10; + localData.latest.number = 10; await blockStream.work(); expect(blockSource.getBlocks).toHaveBeenCalledWith(11, 5, undefined); @@ -98,7 +98,7 @@ describe('L2BlockStream', () => { it('handles a reorg and requests blocks from new tip', async () => { setRemoteTips(45); - localData.latest = 40; + localData.latest.number = 40; for (const i of [37, 38, 39, 40]) { // Mess up the block hashes for a bunch of blocks @@ -114,9 +114,9 @@ describe('L2BlockStream', () => { it('emits events for chain proven and finalized', async () => { setRemoteTips(45, 40, 35); - localData.latest = 40; - localData.proven = 10; - localData.finalized = 10; + localData.latest.number = 40; + localData.proven.number = 10; + localData.finalized.number = 10; await blockStream.work(); expect(handler.events).toEqual([ @@ -125,14 +125,6 @@ describe('L2BlockStream', () => { { type: 'chain-finalized', blockNumber: 35 }, ]); }); - - it('does not emit events for chain proven or finalized if local data ignores them', async () => { - setRemoteTips(45, 40, 35); - localData.latest = 40; - - await blockStream.work(); - expect(handler.events).toEqual([{ type: 'blocks-added', blocks: times(5, i => makeBlock(i + 41)) }]); - }); }); }); @@ -148,15 +140,17 @@ class TestL2BlockStreamEventHandler implements L2BlockStreamEventHandler { class TestL2BlockStreamLocalDataProvider implements L2BlockStreamLocalDataProvider { public readonly blockHashes: Record = {}; - public latest = 0; - public proven: number | undefined = undefined; - public finalized: number | undefined = undefined; + public latest = { number: 0, hash: '' }; + public proven = { number: 0, hash: '' }; + public finalized = { number: 0, hash: '' }; public getL2BlockHash(number: number): Promise { - return Promise.resolve(number > this.latest ? undefined : this.blockHashes[number] ?? new Fr(number).toString()); + return Promise.resolve( + number > this.latest.number ? undefined : this.blockHashes[number] ?? new Fr(number).toString(), + ); } - public getL2Tips(): Promise<{ latest: number } & Partial>> { + public getL2Tips(): Promise { return Promise.resolve(this); } } diff --git a/yarn-project/circuit-types/src/l2_block_downloader/l2_block_stream.ts b/yarn-project/circuit-types/src/l2_block_downloader/l2_block_stream.ts index 0039c860127c..02f088a9529e 100644 --- a/yarn-project/circuit-types/src/l2_block_downloader/l2_block_stream.ts +++ b/yarn-project/circuit-types/src/l2_block_downloader/l2_block_stream.ts @@ -3,7 +3,7 @@ import { createDebugLogger } from '@aztec/foundation/log'; import { RunningPromise } from '@aztec/foundation/running-promise'; import { type L2Block } from '../l2_block.js'; -import { type L2BlockId, type L2BlockSource, type L2BlockTag } from '../l2_block_source.js'; +import { type L2BlockId, type L2BlockSource, type L2Tips } from '../l2_block_source.js'; /** Creates a stream of events for new blocks, chain tips updates, and reorgs, out of polling an archiver. */ export class L2BlockStream { @@ -58,12 +58,12 @@ export class L2BlockStream { }); // Check if there was a reorg and emit a chain-pruned event if so. - let latestBlockNumber = localTips.latest; + let latestBlockNumber = localTips.latest.number; while (!(await this.areBlockHashesEqual(latestBlockNumber, sourceTips.latest))) { latestBlockNumber--; } - if (latestBlockNumber < localTips.latest) { - this.log.verbose(`Reorg detected. Pruning blocks from ${latestBlockNumber + 1} to ${localTips.latest}.`); + if (latestBlockNumber < localTips.latest.number) { + this.log.verbose(`Reorg detected. Pruning blocks from ${latestBlockNumber + 1} to ${localTips.latest.number}.`); await this.emitEvent({ type: 'chain-pruned', blockNumber: latestBlockNumber }); } @@ -83,10 +83,10 @@ export class L2BlockStream { // Update the proven and finalized tips. // TODO(palla/reorg): Should we emit this before passing the new blocks? This would allow world-state to skip // building the data structures for the pending chain if it's unneeded. - if (localTips.proven !== undefined && sourceTips.proven.number !== localTips.proven) { + if (localTips.proven !== undefined && sourceTips.proven.number !== localTips.proven.number) { await this.emitEvent({ type: 'chain-proven', blockNumber: sourceTips.proven.number }); } - if (localTips.finalized !== undefined && sourceTips.finalized.number !== localTips.finalized) { + if (localTips.finalized !== undefined && sourceTips.finalized.number !== localTips.finalized.number) { await this.emitEvent({ type: 'chain-finalized', blockNumber: sourceTips.finalized.number }); } } catch (err: any) { @@ -124,7 +124,7 @@ export class L2BlockStream { /** Interface to the local view of the chain. Implemented by world-state. */ export interface L2BlockStreamLocalDataProvider { getL2BlockHash(number: number): Promise; - getL2Tips(): Promise<{ latest: number } & Partial>>; + getL2Tips(): Promise; } /** Interface to a handler of events emitted. */ diff --git a/yarn-project/end-to-end/src/e2e_block_building.test.ts b/yarn-project/end-to-end/src/e2e_block_building.test.ts index 17e19850e809..2b0fd6bb0e2a 100644 --- a/yarn-project/end-to-end/src/e2e_block_building.test.ts +++ b/yarn-project/end-to-end/src/e2e_block_building.test.ts @@ -1,7 +1,9 @@ import { getSchnorrAccount } from '@aztec/accounts/schnorr'; +import { createAccount } from '@aztec/accounts/testing'; import { type AztecAddress, type AztecNode, + type CheatCodes, ContractDeployer, ContractFunctionInteraction, type DebugLogger, @@ -14,11 +16,13 @@ import { retryUntil, sleep, } from '@aztec/aztec.js'; +import { AZTEC_EPOCH_PROOF_CLAIM_WINDOW_IN_L2_SLOTS } from '@aztec/circuits.js'; import { times } from '@aztec/foundation/collection'; import { poseidon2HashWithSeparator } from '@aztec/foundation/crypto'; import { StatefulTestContract, StatefulTestContractArtifact } from '@aztec/noir-contracts.js'; import { TestContract } from '@aztec/noir-contracts.js/Test'; import { TokenContract } from '@aztec/noir-contracts.js/Token'; +import { createPXEService, getPXEServiceConfig } from '@aztec/pxe'; import 'jest-extended'; @@ -401,6 +405,70 @@ describe('e2e_block_building', () => { await Promise.all(txs.map(tx => tx.wait({ proven: false, timeout: 600 }))); }); }); + + describe('reorgs', () => { + let contract: StatefulTestContract; + let cheatCodes: CheatCodes; + let ownerAddress: AztecAddress; + let initialBlockNumber: number; + let teardown: () => Promise; + + beforeEach(async () => { + ({ + teardown, + aztecNode, + pxe, + logger, + wallet: owner, + cheatCodes, + } = await setup(1, { assumeProvenThrough: undefined })); + + ownerAddress = owner.getCompleteAddress().address; + contract = await StatefulTestContract.deploy(owner, ownerAddress, ownerAddress, 1).send().deployed(); + initialBlockNumber = await pxe.getBlockNumber(); + logger.info(`Stateful test contract deployed at ${contract.address}`); + }); + + afterEach(() => teardown()); + + it('detects an upcoming reorg and builds a block for the correct slot', async () => { + // Advance to a fresh epoch and mark the current one as proven + await cheatCodes.rollup.advanceToNextEpoch(); + await cheatCodes.rollup.markAsProven(); + + // Send a tx to the contract that updates the public data tree, this should take the first slot + logger.info('Sending initial tx'); + const tx1 = await contract.methods.increment_public_value(ownerAddress, 20).send().wait(); + expect(tx1.blockNumber).toEqual(initialBlockNumber + 1); + expect(await contract.methods.get_public_value(ownerAddress).simulate()).toEqual(20n); + + // Now move to a new epoch and past the proof claim window + logger.info('Advancing past the proof claim window'); + await cheatCodes.rollup.advanceToNextEpoch(); + await cheatCodes.rollup.advanceSlots(AZTEC_EPOCH_PROOF_CLAIM_WINDOW_IN_L2_SLOTS + 1); // off-by-one? + + // Wait a bit before spawning a new pxe + await sleep(2000); + + // Send another tx which should be mined a block that is built on the reorg'd chain + // We need to send it from a new pxe since pxe doesn't detect reorgs (yet) + logger.info(`Creating new PXE service`); + const pxeServiceConfig = { ...getPXEServiceConfig() }; + const newPxe = await createPXEService(aztecNode, pxeServiceConfig); + const newWallet = await createAccount(newPxe); + expect(await pxe.getBlockNumber()).toEqual(initialBlockNumber + 1); + + // TODO: Contract.at should automatically register the instance in the pxe + logger.info(`Registering contract at ${contract.address} in new pxe`); + await newPxe.registerContract({ instance: contract.instance, artifact: StatefulTestContractArtifact }); + const contractFromNewPxe = await StatefulTestContract.at(contract.address, newWallet); + + logger.info('Sending new tx on reorgd chain'); + const tx2 = await contractFromNewPxe.methods.increment_public_value(ownerAddress, 10).send().wait(); + expect(await contractFromNewPxe.methods.get_public_value(ownerAddress).simulate()).toEqual(10n); + expect(tx2.blockNumber).toEqual(initialBlockNumber + 2); + }); + }); }); async function sendAndWait(calls: ContractFunctionInteraction[]) { diff --git a/yarn-project/p2p/src/client/p2p_client.ts b/yarn-project/p2p/src/client/p2p_client.ts index f4fee0b8abc3..85fca19d464d 100644 --- a/yarn-project/p2p/src/client/p2p_client.ts +++ b/yarn-project/p2p/src/client/p2p_client.ts @@ -4,6 +4,7 @@ import { type EpochProofQuote, type L2Block, L2BlockDownloader, + type L2BlockId, type L2BlockSource, type Tx, type TxHash, @@ -44,7 +45,7 @@ export interface P2PSyncState { /** * The block number that the p2p client is synced to. */ - syncedToL2Block: number; + syncedToL2Block: L2BlockId; } /** @@ -472,10 +473,15 @@ export class P2PClient extends WithTracer implements P2P { * Method to check the status the p2p client. * @returns Information about p2p client status: state & syncedToBlockNum. */ - public getStatus(): Promise { + public async getStatus(): Promise { + const blockNumber = this.getSyncedLatestBlockNum(); + const blockHash = + blockNumber == 0 + ? '' + : await this.l2BlockSource.getBlockHeader(blockNumber).then(header => header?.hash().toString()); return Promise.resolve({ state: this.currentState, - syncedToL2Block: this.getSyncedLatestBlockNum(), + syncedToL2Block: { number: blockNumber, hash: blockHash }, } as P2PSyncState); } diff --git a/yarn-project/prover-node/src/prover-node.test.ts b/yarn-project/prover-node/src/prover-node.test.ts index be800b17766f..cafedf3d0771 100644 --- a/yarn-project/prover-node/src/prover-node.test.ts +++ b/yarn-project/prover-node/src/prover-node.test.ts @@ -115,7 +115,10 @@ describe('prover-node', () => { // World state returns a new mock db every time it is asked to fork worldState.fork.mockImplementation(() => Promise.resolve(mock())); - worldState.status.mockResolvedValue({ syncedToL2Block: 1, state: WorldStateRunningState.RUNNING }); + worldState.status.mockResolvedValue({ + syncedToL2Block: { number: 1, hash: '' }, + state: WorldStateRunningState.RUNNING, + }); // Publisher returns its sender address address = EthAddress.random(); diff --git a/yarn-project/sequencer-client/src/publisher/l1-publisher.ts b/yarn-project/sequencer-client/src/publisher/l1-publisher.ts index a7518a1798b3..a9a54e6eed76 100644 --- a/yarn-project/sequencer-client/src/publisher/l1-publisher.ts +++ b/yarn-project/sequencer-client/src/publisher/l1-publisher.ts @@ -213,6 +213,9 @@ export class L1Publisher { * @return blockNumber - The L2 block number of the next L2 block */ public async canProposeAtNextEthBlock(archive: Buffer): Promise<[bigint, bigint]> { + // FIXME: This should not throw if unable to propose but return a falsey value, so + // we can differentiate between errors when hitting the L1 rollup contract (eg RPC error) + // which may require a retry, vs actually not being the turn for proposing. const ts = BigInt((await this.publicClient.getBlock()).timestamp + BigInt(ETHEREUM_SLOT_DURATION)); const [slot, blockNumber] = await this.rollupContract.read.canProposeAtTime([ts, `0x${archive.toString('hex')}`]); return [slot, blockNumber]; diff --git a/yarn-project/sequencer-client/src/sequencer/sequencer.test.ts b/yarn-project/sequencer-client/src/sequencer/sequencer.test.ts index 7b711b257232..7a2212fde005 100644 --- a/yarn-project/sequencer-client/src/sequencer/sequencer.test.ts +++ b/yarn-project/sequencer-client/src/sequencer/sequencer.test.ts @@ -9,6 +9,7 @@ import { type L2BlockSource, MerkleTreeId, type MerkleTreeReadOperations, + type MerkleTreeWriteOperations, type Tx, TxHash, type UnencryptedL2Log, @@ -53,6 +54,7 @@ describe('sequencer', () => { let globalVariableBuilder: MockProxy; let p2p: MockProxy; let worldState: MockProxy; + let fork: MockProxy; let blockBuilder: MockProxy; let merkleTreeOps: MockProxy; let publicProcessor: MockProxy; @@ -61,6 +63,7 @@ describe('sequencer', () => { let publicProcessorFactory: MockProxy; let lastBlockNumber: number; + let hash: string; let sequencer: TestSubject; @@ -92,6 +95,7 @@ describe('sequencer', () => { beforeEach(() => { lastBlockNumber = 0; + hash = Fr.ZERO.toString(); block = L2Block.random(lastBlockNumber + 1); @@ -120,12 +124,20 @@ describe('sequencer', () => { blockBuilder = mock(); p2p = mock({ - getStatus: mockFn().mockResolvedValue({ state: P2PClientState.IDLE, syncedToL2Block: lastBlockNumber }), + getStatus: mockFn().mockResolvedValue({ + state: P2PClientState.IDLE, + syncedToL2Block: { number: lastBlockNumber, hash }, + }), }); + fork = mock(); worldState = mock({ + fork: () => Promise.resolve(fork), getCommitted: () => merkleTreeOps, - status: mockFn().mockResolvedValue({ state: WorldStateRunningState.IDLE, syncedToL2Block: lastBlockNumber }), + status: mockFn().mockResolvedValue({ + state: WorldStateRunningState.IDLE, + syncedToL2Block: { number: lastBlockNumber, hash }, + }), }); publicProcessor = mock({ @@ -142,6 +154,7 @@ describe('sequencer', () => { l2BlockSource = mock({ getBlockNumber: mockFn().mockResolvedValue(lastBlockNumber), + getL2Tips: mockFn().mockResolvedValue({ latest: { number: lastBlockNumber, hash } }), }); l1ToL2MessageSource = mock({ @@ -191,7 +204,6 @@ describe('sequencer', () => { globalVariableBuilder.buildGlobalVariables.mockResolvedValueOnce(mockedGlobalVariables); - await sequencer.initialSync(); await sequencer.work(); expect(blockBuilder.startNewBlock).toHaveBeenCalledWith( @@ -219,7 +231,6 @@ describe('sequencer', () => { publisher.canProposeAtNextEthBlock.mockRejectedValue(new Error()); publisher.validateBlockForSubmission.mockRejectedValue(new Error()); - await sequencer.initialSync(); await sequencer.work(); expect(blockBuilder.startNewBlock).not.toHaveBeenCalled(); @@ -269,7 +280,6 @@ describe('sequencer', () => { ); }); - await sequencer.initialSync(); await sequencer.work(); expect(blockBuilder.startNewBlock).toHaveBeenCalledWith( @@ -299,7 +309,6 @@ describe('sequencer', () => { // We make the chain id on the invalid tx not equal to the configured chain id invalidChainTx.data.constants.txContext.chainId = new Fr(1n + chainId.value); - await sequencer.initialSync(); await sequencer.work(); expect(blockBuilder.startNewBlock).toHaveBeenCalledWith( @@ -331,7 +340,6 @@ describe('sequencer', () => { (txs[invalidTransactionIndex].unencryptedLogs.functionLogs[0].logs[0] as Writeable).data = randomBytes(1024 * 1022); - await sequencer.initialSync(); await sequencer.work(); expect(blockBuilder.startNewBlock).toHaveBeenCalledWith( @@ -355,8 +363,6 @@ describe('sequencer', () => { globalVariableBuilder.buildGlobalVariables.mockResolvedValueOnce(mockedGlobalVariables); - await sequencer.initialSync(); - sequencer.updateConfig({ minTxsPerBlock: 4 }); // block is not built with 0 txs @@ -398,8 +404,6 @@ describe('sequencer', () => { globalVariableBuilder.buildGlobalVariables.mockResolvedValueOnce(mockedGlobalVariables); - await sequencer.initialSync(); - sequencer.updateConfig({ minTxsPerBlock: 4 }); // block is not built with 0 txs @@ -441,8 +445,6 @@ describe('sequencer', () => { globalVariableBuilder.buildGlobalVariables.mockResolvedValueOnce(mockedGlobalVariables); - await sequencer.initialSync(); - sequencer.updateConfig({ minTxsPerBlock: 4 }); // block is not built with 0 txs @@ -495,8 +497,6 @@ describe('sequencer', () => { globalVariableBuilder.buildGlobalVariables.mockResolvedValueOnce(mockedGlobalVariables); - await sequencer.initialSync(); - // This could practically be for any reason, e.g., could also be that we have entered a new slot. publisher.validateBlockForSubmission .mockResolvedValueOnce() @@ -529,11 +529,11 @@ describe('sequencer', () => { worldState.status.mockResolvedValue({ state: WorldStateRunningState.IDLE, - syncedToL2Block: block.header.globalVariables.blockNumber.toNumber() - 1, + syncedToL2Block: { number: block.header.globalVariables.blockNumber.toNumber() - 1, hash }, }); p2p.getStatus.mockResolvedValue({ - syncedToL2Block: block.header.globalVariables.blockNumber.toNumber() - 1, + syncedToL2Block: { number: block.header.globalVariables.blockNumber.toNumber() - 1, hash }, state: P2PClientState.IDLE, }); @@ -579,7 +579,6 @@ describe('sequencer', () => { // The previous epoch can be claimed publisher.nextEpochToClaim.mockImplementation(() => Promise.resolve(currentEpoch - 1n)); - await sequencer.initialSync(); await sequencer.work(); expect(publisher.proposeL2Block).toHaveBeenCalledWith(block, getSignatures(), [txHash], proofQuote); }); @@ -603,7 +602,6 @@ describe('sequencer', () => { // The previous epoch can be claimed publisher.nextEpochToClaim.mockImplementation(() => Promise.resolve(0n)); - await sequencer.initialSync(); await sequencer.work(); expect(publisher.proposeL2Block).toHaveBeenCalledWith(block, getSignatures(), [txHash], undefined); }); @@ -628,7 +626,6 @@ describe('sequencer', () => { // The previous epoch can be claimed publisher.nextEpochToClaim.mockImplementation(() => Promise.resolve(currentEpoch - 1n)); - await sequencer.initialSync(); await sequencer.work(); expect(publisher.proposeL2Block).toHaveBeenCalledWith(block, getSignatures(), [txHash], undefined); }); @@ -652,7 +649,6 @@ describe('sequencer', () => { // The previous epoch can be claimed publisher.nextEpochToClaim.mockResolvedValue(currentEpoch); - await sequencer.initialSync(); await sequencer.work(); expect(publisher.proposeL2Block).toHaveBeenCalledWith(block, getSignatures(), [txHash], undefined); }); @@ -678,7 +674,6 @@ describe('sequencer', () => { // The previous epoch can be claimed publisher.nextEpochToClaim.mockImplementation(() => Promise.resolve(currentEpoch - 1n)); - await sequencer.initialSync(); await sequencer.work(); expect(publisher.proposeL2Block).toHaveBeenCalledWith(block, getSignatures(), [txHash], undefined); }); @@ -734,7 +729,6 @@ describe('sequencer', () => { // The previous epoch can be claimed publisher.nextEpochToClaim.mockImplementation(() => Promise.resolve(currentEpoch - 1n)); - await sequencer.initialSync(); await sequencer.work(); expect(publisher.proposeL2Block).toHaveBeenCalledWith(block, getSignatures(), [txHash], validProofQuote); }); @@ -794,7 +788,6 @@ describe('sequencer', () => { // The previous epoch can be claimed publisher.nextEpochToClaim.mockImplementation(() => Promise.resolve(currentEpoch - 1n)); - await sequencer.initialSync(); await sequencer.work(); expect(publisher.proposeL2Block).toHaveBeenCalledWith(block, getSignatures(), [txHash], validQuotes[0]); }); @@ -805,8 +798,4 @@ class TestSubject extends Sequencer { public override work() { return super.work(); } - - public override initialSync(): Promise { - return super.initialSync(); - } } diff --git a/yarn-project/sequencer-client/src/sequencer/sequencer.ts b/yarn-project/sequencer-client/src/sequencer/sequencer.ts index 4173a03aa291..043463aac54a 100644 --- a/yarn-project/sequencer-client/src/sequencer/sequencer.ts +++ b/yarn-project/sequencer-client/src/sequencer/sequencer.ts @@ -70,7 +70,6 @@ export class Sequencer { // TODO: zero values should not be allowed for the following 2 values in PROD private _coinbase = EthAddress.ZERO; private _feeRecipient = AztecAddress.ZERO; - private lastPublishedBlock = 0; private state = SequencerState.STOPPED; private allowedInSetup: AllowedElement[] = []; private allowedInTeardown: AllowedElement[] = []; @@ -145,13 +144,12 @@ export class Sequencer { /** * Starts the sequencer and moves to IDLE state. Blocks until the initial sync is complete. */ - public async start() { - await this.initialSync(); - + public start() { this.runningPromise = new RunningPromise(this.work.bind(this), this.pollingIntervalMs); this.runningPromise.start(); this.state = SequencerState.IDLE; this.log.info('Sequencer started'); + return Promise.resolve(); } /** @@ -183,13 +181,6 @@ export class Sequencer { return { state: this.state }; } - protected async initialSync() { - // TODO: Should we wait for world state to be ready, or is the caller expected to run await start? - this.lastPublishedBlock = await this.worldState - .status() - .then((s: WorldStateSynchronizerStatus) => s.syncedToL2Block); - } - /** * @notice Performs most of the sequencer duties: * - Checks if we are up to date @@ -318,6 +309,7 @@ export class Sequencer { this.log.debug(`Can propose block ${proposalBlockNumber} at slot ${slot}`); return slot; } catch (err) { + this.log.verbose(`Rejected from being able to propose at next block with ${tipArchive}`); prettyLogViemError(err, this.log); throw err; } @@ -614,9 +606,7 @@ export class Sequencer { this.state = SequencerState.PUBLISHING_BLOCK; const publishedL2Block = await this.publisher.proposeL2Block(block, attestations, txHashes, proofQuote); - if (publishedL2Block) { - this.lastPublishedBlock = block.number; - } else { + if (!publishedL2Block) { throw new Error(`Failed to publish block ${block.number}`); } } @@ -652,24 +642,37 @@ export class Sequencer { } /** - * Returns whether the previous block sent has been mined, and all dependencies have caught up with it. + * Returns whether all dependencies have caught up. + * We don't check against the previous block submitted since it may have been reorg'd out. * @returns Boolean indicating if our dependencies are synced to the latest block. */ protected async isBlockSynced() { const syncedBlocks = await Promise.all([ this.worldState.status().then((s: WorldStateSynchronizerStatus) => s.syncedToL2Block), - this.p2pClient.getStatus().then(s => s.syncedToL2Block), - this.l2BlockSource.getBlockNumber(), + this.l2BlockSource.getL2Tips().then(t => t.latest), + this.p2pClient.getStatus().then(s => s.syncedToL2Block.number), this.l1ToL2MessageSource.getBlockNumber(), - ]); - const min = Math.min(...syncedBlocks); - const [worldState, p2p, l2BlockSource, l1ToL2MessageSource] = syncedBlocks; - const result = min >= this.lastPublishedBlock; - this.log.debug(`Sync check to last published block ${this.lastPublishedBlock} ${result ? 'succeeded' : 'failed'}`, { - worldState, - p2p, - l2BlockSource, - l1ToL2MessageSource, + ] as const); + const [worldState, l2BlockSource, p2p, l1ToL2MessageSource] = syncedBlocks; + const result = + // check that world state has caught up with archiver + // note that the archiver reports undefined hash for the genesis block + // because it doesn't have access to world state to compute it (facepalm) + (l2BlockSource.hash === undefined || worldState.hash === l2BlockSource.hash) && + // and p2p client and message source are at least at the same block + // this should change to hashes once p2p client handles reorgs + // and once we stop pretending that the l1tol2message source is not + // just the archiver under a different name + p2p >= l2BlockSource.number && + l1ToL2MessageSource >= l2BlockSource.number; + + this.log.verbose(`Sequencer sync check ${result ? 'succeeded' : 'failed'}`, { + worldStateNumber: worldState.number, + worldStateHash: worldState.hash, + l2BlockSourceNumber: l2BlockSource.number, + l2BlockSourceHash: l2BlockSource.hash, + p2pNumber: p2p, + l1ToL2MessageSourceNumber: l1ToL2MessageSource, }); return result; } diff --git a/yarn-project/world-state/src/synchronizer/server_world_state_synchronizer.test.ts b/yarn-project/world-state/src/synchronizer/server_world_state_synchronizer.test.ts index 53875ca6e8f3..e4e0ec7384ad 100644 --- a/yarn-project/world-state/src/synchronizer/server_world_state_synchronizer.test.ts +++ b/yarn-project/world-state/src/synchronizer/server_world_state_synchronizer.test.ts @@ -87,7 +87,7 @@ describe('ServerWorldStateSynchronizer', () => { type: 'blocks-added', blocks: times(to - from + 1, i => L2Block.random(i + from, 4, 2, 3, 2, 1, inHash)), }); - server.latest = to; + server.latest.number = to; }; const expectServerStatus = async (state: WorldStateRunningState, blockNumber: number) => { @@ -199,7 +199,9 @@ describe('ServerWorldStateSynchronizer', () => { }); class TestWorldStateSynchronizer extends ServerWorldStateSynchronizer { - public latest = 0; + public latest = { number: 0, hash: '' }; + public finalized = { number: 0, hash: '' }; + public proven = { number: 0, hash: '' }; constructor( merkleTrees: MerkleTreeAdminDatabase, @@ -215,6 +217,6 @@ class TestWorldStateSynchronizer extends ServerWorldStateSynchronizer { } public override getL2Tips() { - return Promise.resolve({ latest: this.latest, proven: undefined, finalized: undefined }); + return Promise.resolve({ latest: this.latest, proven: this.proven, finalized: this.finalized }); } } diff --git a/yarn-project/world-state/src/synchronizer/server_world_state_synchronizer.ts b/yarn-project/world-state/src/synchronizer/server_world_state_synchronizer.ts index 44956ba4da65..9ae8380797ad 100644 --- a/yarn-project/world-state/src/synchronizer/server_world_state_synchronizer.ts +++ b/yarn-project/world-state/src/synchronizer/server_world_state_synchronizer.ts @@ -1,12 +1,13 @@ import { type L1ToL2MessageSource, type L2Block, + type L2BlockId, type L2BlockSource, L2BlockStream, type L2BlockStreamEvent, type L2BlockStreamEventHandler, type L2BlockStreamLocalDataProvider, - type L2BlockTag, + type L2Tips, MerkleTreeId, type MerkleTreeReadOperations, type MerkleTreeWriteOperations, @@ -121,7 +122,7 @@ export class ServerWorldStateSynchronizer } public async getLatestBlockNumber() { - return (await this.getL2Tips()).latest; + return (await this.getL2Tips()).latest.number; } /** @@ -161,12 +162,15 @@ export class ServerWorldStateSynchronizer } /** Returns the latest L2 block number for each tip of the chain (latest, proven, finalized). */ - public async getL2Tips(): Promise<{ latest: number } & Partial>> { + public async getL2Tips(): Promise { const status = await this.merkleTreeDb.getStatus(); + const unfinalisedBlockHash = await this.getL2BlockHash(Number(status.unfinalisedBlockNumber)); + const latestBlockId: L2BlockId = { number: Number(status.unfinalisedBlockNumber), hash: unfinalisedBlockHash! }; + return { - latest: Number(status.unfinalisedBlockNumber), - finalized: Number(status.finalisedBlockNumber), - proven: Number(status.finalisedBlockNumber), // TODO(palla/reorg): Using finalised as proven for now + latest: latestBlockId, + finalized: { number: Number(status.finalisedBlockNumber), hash: '' }, + proven: { number: Number(status.finalisedBlockNumber), hash: '' }, // TODO(palla/reorg): Using finalised as proven for now }; } diff --git a/yarn-project/world-state/src/test/integration.test.ts b/yarn-project/world-state/src/test/integration.test.ts index 86f4c3d8603b..259a5de98392 100644 --- a/yarn-project/world-state/src/test/integration.test.ts +++ b/yarn-project/world-state/src/test/integration.test.ts @@ -68,13 +68,13 @@ describe('world-state integration', () => { return finalized > tipFinalised; }; - while (tips.latest < blockToSyncTo && sleepTime < maxTimeoutMS) { + while (tips.latest.number < blockToSyncTo && sleepTime < maxTimeoutMS) { await sleep(100); sleepTime = Date.now() - startTime; tips = await synchronizer.getL2Tips(); } - while (waitForFinalised(tips.finalized) && sleepTime < maxTimeoutMS) { + while (waitForFinalised(tips.finalized.number) && sleepTime < maxTimeoutMS) { await sleep(100); sleepTime = Date.now() - startTime; tips = await synchronizer.getL2Tips(); @@ -89,11 +89,11 @@ describe('world-state integration', () => { const expectSynchedToBlock = async (latest: number, finalized?: number) => { const tips = await synchronizer.getL2Tips(); - expect(tips.latest).toEqual(latest); + expect(tips.latest.number).toEqual(latest); await expectSynchedBlockHashMatches(latest); if (finalized !== undefined) { - expect(tips.finalized).toEqual(finalized); + expect(tips.finalized.number).toEqual(finalized); await expectSynchedBlockHashMatches(finalized); } };