diff --git a/yarn-project/end-to-end/src/composed/integration_l1_publisher.test.ts b/yarn-project/end-to-end/src/composed/integration_l1_publisher.test.ts index 2b935d32183..99d390b888c 100644 --- a/yarn-project/end-to-end/src/composed/integration_l1_publisher.test.ts +++ b/yarn-project/end-to-end/src/composed/integration_l1_publisher.test.ts @@ -43,7 +43,7 @@ import { L1Publisher } from '@aztec/sequencer-client'; import { NoopTelemetryClient } from '@aztec/telemetry-client/noop'; import { MerkleTrees, ServerWorldStateSynchronizer, type WorldStateConfig } from '@aztec/world-state'; -import { beforeEach, describe, expect, it } from '@jest/globals'; +import { beforeEach, describe, expect, it, jest } from '@jest/globals'; import * as fs from 'fs'; import { type MockProxy, mock } from 'jest-mock-extended'; import { @@ -321,149 +321,220 @@ describe('L1Publisher integration', () => { return builder.setBlockCompleted(); }; - it(`Build ${numberOfConsecutiveBlocks} blocks of 4 bloated txs building on each other`, async () => { - const archiveInRollup_ = await rollup.read.archive(); - expect(hexStringToBuffer(archiveInRollup_.toString())).toEqual(new Fr(GENESIS_ARCHIVE_ROOT).toBuffer()); + describe('block building', () => { + it(`builds ${numberOfConsecutiveBlocks} blocks of 4 bloated txs building on each other`, async () => { + const archiveInRollup_ = await rollup.read.archive(); + expect(hexStringToBuffer(archiveInRollup_.toString())).toEqual(new Fr(GENESIS_ARCHIVE_ROOT).toBuffer()); - const blockNumber = await publicClient.getBlockNumber(); - // random recipient address, just kept consistent for easy testing ts/sol. - const recipientAddress = AztecAddress.fromString( - '0x1647b194c649f5dd01d7c832f89b0f496043c9150797923ea89e93d5ac619a93', - ); - - let currentL1ToL2Messages: Fr[] = []; - let nextL1ToL2Messages: Fr[] = []; - - for (let i = 0; i < numberOfConsecutiveBlocks; i++) { - // @note Make sure that the state is up to date before we start building. - await worldStateSynchronizer.syncImmediate(); - - const l1ToL2Content = range(NUMBER_OF_L1_L2_MESSAGES_PER_ROLLUP, 128 * i + 1 + 0x400).map(fr); - - for (let j = 0; j < l1ToL2Content.length; j++) { - nextL1ToL2Messages.push(await sendToL2(l1ToL2Content[j], recipientAddress)); - } - - // Ensure that each transaction has unique (non-intersecting nullifier values) - const totalNullifiersPerBlock = 4 * MAX_NULLIFIERS_PER_TX; - const txs = [ - makeBloatedProcessedTx(totalNullifiersPerBlock * i + 1 * MAX_NULLIFIERS_PER_TX), - makeBloatedProcessedTx(totalNullifiersPerBlock * i + 2 * MAX_NULLIFIERS_PER_TX), - makeBloatedProcessedTx(totalNullifiersPerBlock * i + 3 * MAX_NULLIFIERS_PER_TX), - makeBloatedProcessedTx(totalNullifiersPerBlock * i + 4 * MAX_NULLIFIERS_PER_TX), - ]; - - const ts = (await publicClient.getBlock()).timestamp; - const slot = await rollup.read.getSlotAt([ts + BigInt(ETHEREUM_SLOT_DURATION)]); - const globalVariables = new GlobalVariables( - new Fr(chainId), - new Fr(config.version), - new Fr(1 + i), - new Fr(slot), - new Fr(await rollup.read.getTimestampForSlot([slot])), - coinbase, - feeRecipient, - GasFees.empty(), + const blockNumber = await publicClient.getBlockNumber(); + // random recipient address, just kept consistent for easy testing ts/sol. + const recipientAddress = AztecAddress.fromString( + '0x1647b194c649f5dd01d7c832f89b0f496043c9150797923ea89e93d5ac619a93', ); - const block = await buildBlock(globalVariables, txs, currentL1ToL2Messages); - prevHeader = block.header; - blockSource.getL1ToL2Messages.mockResolvedValueOnce(currentL1ToL2Messages); - blockSource.getBlocks.mockResolvedValueOnce([block]); - - const l2ToL1MsgsArray = block.body.txEffects.flatMap(txEffect => txEffect.l2ToL1Msgs); - - const [emptyRoot] = await outbox.read.getRootData([block.header.globalVariables.blockNumber.toBigInt()]); - - // Check that we have not yet written a root to this blocknumber - expect(BigInt(emptyRoot)).toStrictEqual(0n); - - writeJson(`mixed_block_${block.number}`, block, l1ToL2Content, recipientAddress, deployerAccount.address); - - await publisher.proposeL2Block(block); - - const logs = await publicClient.getLogs({ - address: rollupAddress, - event: getAbiItem({ + let currentL1ToL2Messages: Fr[] = []; + let nextL1ToL2Messages: Fr[] = []; + + for (let i = 0; i < numberOfConsecutiveBlocks; i++) { + // @note Make sure that the state is up to date before we start building. + await worldStateSynchronizer.syncImmediate(); + + const l1ToL2Content = range(NUMBER_OF_L1_L2_MESSAGES_PER_ROLLUP, 128 * i + 1 + 0x400).map(fr); + + for (let j = 0; j < l1ToL2Content.length; j++) { + nextL1ToL2Messages.push(await sendToL2(l1ToL2Content[j], recipientAddress)); + } + + // Ensure that each transaction has unique (non-intersecting nullifier values) + const totalNullifiersPerBlock = 4 * MAX_NULLIFIERS_PER_TX; + const txs = [ + makeBloatedProcessedTx(totalNullifiersPerBlock * i + 1 * MAX_NULLIFIERS_PER_TX), + makeBloatedProcessedTx(totalNullifiersPerBlock * i + 2 * MAX_NULLIFIERS_PER_TX), + makeBloatedProcessedTx(totalNullifiersPerBlock * i + 3 * MAX_NULLIFIERS_PER_TX), + makeBloatedProcessedTx(totalNullifiersPerBlock * i + 4 * MAX_NULLIFIERS_PER_TX), + ]; + + const ts = (await publicClient.getBlock()).timestamp; + const slot = await rollup.read.getSlotAt([ts + BigInt(ETHEREUM_SLOT_DURATION)]); + const globalVariables = new GlobalVariables( + new Fr(chainId), + new Fr(config.version), + new Fr(1 + i), + new Fr(slot), + new Fr(await rollup.read.getTimestampForSlot([slot])), + coinbase, + feeRecipient, + GasFees.empty(), + ); + + const block = await buildBlock(globalVariables, txs, currentL1ToL2Messages); + prevHeader = block.header; + blockSource.getL1ToL2Messages.mockResolvedValueOnce(currentL1ToL2Messages); + blockSource.getBlocks.mockResolvedValueOnce([block]); + + const l2ToL1MsgsArray = block.body.txEffects.flatMap(txEffect => txEffect.l2ToL1Msgs); + + const [emptyRoot] = await outbox.read.getRootData([block.header.globalVariables.blockNumber.toBigInt()]); + + // Check that we have not yet written a root to this blocknumber + expect(BigInt(emptyRoot)).toStrictEqual(0n); + + writeJson(`mixed_block_${block.number}`, block, l1ToL2Content, recipientAddress, deployerAccount.address); + + await publisher.proposeL2Block(block); + + const logs = await publicClient.getLogs({ + address: rollupAddress, + event: getAbiItem({ + abi: RollupAbi, + name: 'L2BlockProposed', + }), + fromBlock: blockNumber + 1n, + }); + expect(logs).toHaveLength(i + 1); + expect(logs[i].args.blockNumber).toEqual(BigInt(i + 1)); + + const ethTx = await publicClient.getTransaction({ + hash: logs[i].transactionHash!, + }); + + const expectedData = encodeFunctionData({ abi: RollupAbi, - name: 'L2BlockProposed', - }), - fromBlock: blockNumber + 1n, - }); - expect(logs).toHaveLength(i + 1); - expect(logs[i].args.blockNumber).toEqual(BigInt(i + 1)); - - const ethTx = await publicClient.getTransaction({ - hash: logs[i].transactionHash!, - }); - - const expectedData = encodeFunctionData({ - abi: RollupAbi, - functionName: 'propose', - args: [ - `0x${block.header.toBuffer().toString('hex')}`, - `0x${block.archive.root.toBuffer().toString('hex')}`, - `0x${block.header.hash().toBuffer().toString('hex')}`, - [], - [], - `0x${block.body.toBuffer().toString('hex')}`, - ], - }); - expect(ethTx.input).toEqual(expectedData); - - const treeHeight = Math.ceil(Math.log2(l2ToL1MsgsArray.length)); - - const tree = new StandardTree( - openTmpStore(true), - new SHA256Trunc(), - 'temp_outhash_sibling_path', - treeHeight, - 0n, - Fr, - ); - await tree.appendLeaves(l2ToL1MsgsArray); - - const expectedRoot = tree.getRoot(true); - const [returnedRoot] = await outbox.read.getRootData([block.header.globalVariables.blockNumber.toBigInt()]); - - // check that values are inserted into the outbox - expect(Fr.ZERO.toString()).toEqual(returnedRoot); - - const actualRoot = await ethCheatCodes.load( - EthAddress.fromString(outbox.address), - ethCheatCodes.keccak256(0n, 1n + BigInt(i)), - ); - expect(`0x${expectedRoot.toString('hex')}`).toEqual(new Fr(actualRoot).toString()); - - // There is a 1 block lag between before messages get consumed from the inbox - currentL1ToL2Messages = nextL1ToL2Messages; - // We wipe the messages from previous iteration - nextL1ToL2Messages = []; + functionName: 'propose', + args: [ + `0x${block.header.toBuffer().toString('hex')}`, + `0x${block.archive.root.toBuffer().toString('hex')}`, + `0x${block.header.hash().toBuffer().toString('hex')}`, + [], + [], + `0x${block.body.toBuffer().toString('hex')}`, + ], + }); + expect(ethTx.input).toEqual(expectedData); + + const treeHeight = Math.ceil(Math.log2(l2ToL1MsgsArray.length)); + + const tree = new StandardTree( + openTmpStore(true), + new SHA256Trunc(), + 'temp_outhash_sibling_path', + treeHeight, + 0n, + Fr, + ); + await tree.appendLeaves(l2ToL1MsgsArray); + + const expectedRoot = tree.getRoot(true); + const [returnedRoot] = await outbox.read.getRootData([block.header.globalVariables.blockNumber.toBigInt()]); + + // check that values are inserted into the outbox + expect(Fr.ZERO.toString()).toEqual(returnedRoot); + + const actualRoot = await ethCheatCodes.load( + EthAddress.fromString(outbox.address), + ethCheatCodes.keccak256(0n, 1n + BigInt(i)), + ); + expect(`0x${expectedRoot.toString('hex')}`).toEqual(new Fr(actualRoot).toString()); + + // There is a 1 block lag between before messages get consumed from the inbox + currentL1ToL2Messages = nextL1ToL2Messages; + // We wipe the messages from previous iteration + nextL1ToL2Messages = []; + + // Make sure that time have progressed to the next slot! + await progressTimeBySlot(); + } + }); - // Make sure that time have progressed to the next slot! - await progressTimeBySlot(); - } + it(`builds ${numberOfConsecutiveBlocks} blocks of 2 empty txs building on each other`, async () => { + const archiveInRollup_ = await rollup.read.archive(); + expect(hexStringToBuffer(archiveInRollup_.toString())).toEqual(new Fr(GENESIS_ARCHIVE_ROOT).toBuffer()); + + const blockNumber = await publicClient.getBlockNumber(); + + for (let i = 0; i < numberOfConsecutiveBlocks; i++) { + // @note Make sure that the state is up to date before we start building. + await worldStateSynchronizer.syncImmediate(); + + const l1ToL2Messages = new Array(NUMBER_OF_L1_L2_MESSAGES_PER_ROLLUP).fill(new Fr(0n)); + const txs = [makeEmptyProcessedTx(), makeEmptyProcessedTx()]; + + const ts = (await publicClient.getBlock()).timestamp; + const slot = await rollup.read.getSlotAt([ts + BigInt(ETHEREUM_SLOT_DURATION)]); + const globalVariables = new GlobalVariables( + new Fr(chainId), + new Fr(config.version), + new Fr(1 + i), + new Fr(slot), + new Fr(await rollup.read.getTimestampForSlot([slot])), + coinbase, + feeRecipient, + GasFees.empty(), + ); + const block = await buildBlock(globalVariables, txs, l1ToL2Messages); + prevHeader = block.header; + blockSource.getL1ToL2Messages.mockResolvedValueOnce(l1ToL2Messages); + blockSource.getBlocks.mockResolvedValueOnce([block]); + + writeJson(`empty_block_${block.number}`, block, [], AztecAddress.ZERO, deployerAccount.address); + + await publisher.proposeL2Block(block); + + const logs = await publicClient.getLogs({ + address: rollupAddress, + event: getAbiItem({ + abi: RollupAbi, + name: 'L2BlockProposed', + }), + fromBlock: blockNumber + 1n, + }); + expect(logs).toHaveLength(i + 1); + expect(logs[i].args.blockNumber).toEqual(BigInt(i + 1)); + + const ethTx = await publicClient.getTransaction({ + hash: logs[i].transactionHash!, + }); + + const expectedData = encodeFunctionData({ + abi: RollupAbi, + functionName: 'propose', + args: [ + `0x${block.header.toBuffer().toString('hex')}`, + `0x${block.archive.root.toBuffer().toString('hex')}`, + `0x${block.header.hash().toBuffer().toString('hex')}`, + [], + [], + `0x${block.body.toBuffer().toString('hex')}`, + ], + }); + expect(ethTx.input).toEqual(expectedData); + + await progressTimeBySlot(); + } + }); }); - it(`Build ${numberOfConsecutiveBlocks} blocks of 2 empty txs building on each other`, async () => { - const archiveInRollup_ = await rollup.read.archive(); - expect(hexStringToBuffer(archiveInRollup_.toString())).toEqual(new Fr(GENESIS_ARCHIVE_ROOT).toBuffer()); + describe('error handling', () => { + let loggerErrorSpy: ReturnType<(typeof jest)['spyOn']>; - const blockNumber = await publicClient.getBlockNumber(); - - for (let i = 0; i < numberOfConsecutiveBlocks; i++) { - // @note Make sure that the state is up to date before we start building. + it(`shows propose custom errors if tx reverts`, async () => { + // REFACTOR: code below is duplicated from "builds blocks of 2 empty txs building on each other" + const archiveInRollup_ = await rollup.read.archive(); + expect(hexStringToBuffer(archiveInRollup_.toString())).toEqual(new Fr(GENESIS_ARCHIVE_ROOT).toBuffer()); await worldStateSynchronizer.syncImmediate(); - const l1ToL2Messages = new Array(NUMBER_OF_L1_L2_MESSAGES_PER_ROLLUP).fill(new Fr(0n)); - const txs = [makeEmptyProcessedTx(), makeEmptyProcessedTx()]; + // Set up different l1-to-l2 messages than the ones on the inbox, so this submission reverts + // because the INBOX.consume does not match the header.contentCommitment.inHash and we get + // a Rollup__InvalidInHash that is not caught by validateHeader before. + const l1ToL2Messages = new Array(NUMBER_OF_L1_L2_MESSAGES_PER_ROLLUP).fill(new Fr(1n)); + const txs = [makeEmptyProcessedTx(), makeEmptyProcessedTx()]; const ts = (await publicClient.getBlock()).timestamp; const slot = await rollup.read.getSlotAt([ts + BigInt(ETHEREUM_SLOT_DURATION)]); const globalVariables = new GlobalVariables( new Fr(chainId), new Fr(config.version), - new Fr(1 + i), + new Fr(1), new Fr(slot), new Fr(await rollup.read.getTimestampForSlot([slot])), coinbase, @@ -475,41 +546,24 @@ describe('L1Publisher integration', () => { blockSource.getL1ToL2Messages.mockResolvedValueOnce(l1ToL2Messages); blockSource.getBlocks.mockResolvedValueOnce([block]); - writeJson(`empty_block_${block.number}`, block, [], AztecAddress.ZERO, deployerAccount.address); - - await publisher.proposeL2Block(block); - - const logs = await publicClient.getLogs({ - address: rollupAddress, - event: getAbiItem({ - abi: RollupAbi, - name: 'L2BlockProposed', + // Inspect logger + loggerErrorSpy = jest.spyOn((publisher as any).log, 'error'); + + // Expect the tx to revert + await expect(publisher.proposeL2Block(block)).resolves.toEqual(false); + + // Expect a proper error to be logged. Full message looks like: + // aztec:sequencer:publisher [ERROR] Rollup process tx reverted. The contract function "propose" reverted. Error: Rollup__InvalidInHash(bytes32 expected, bytes32 actual) (0x00089a9d421a82c4a25f7acbebe69e638d5b064fa8a60e018793dcb0be53752c, 0x00a5a12af159e0608de45d825718827a36d8a7cdfa9ecc7955bc62180ae78e51) blockNumber=1 slotNumber=49 blockHash=0x131c59ebc2ce21224de6473fe954b0d4eb918043432a3a95406bb7e7a4297fbd txHash=0xc01c3c26b6b67003a8cce352afe475faf7e0196a5a3bba963cfda3792750ed28 + expect(loggerErrorSpy).toHaveBeenCalledWith( + expect.stringMatching(/Rollup__InvalidInHash/), + undefined, + expect.objectContaining({ + blockHash: expect.any(String), + blockNumber: expect.any(Number), + slotNumber: expect.any(BigInt), }), - fromBlock: blockNumber + 1n, - }); - expect(logs).toHaveLength(i + 1); - expect(logs[i].args.blockNumber).toEqual(BigInt(i + 1)); - - const ethTx = await publicClient.getTransaction({ - hash: logs[i].transactionHash!, - }); - - const expectedData = encodeFunctionData({ - abi: RollupAbi, - functionName: 'propose', - args: [ - `0x${block.header.toBuffer().toString('hex')}`, - `0x${block.archive.root.toBuffer().toString('hex')}`, - `0x${block.header.hash().toBuffer().toString('hex')}`, - [], - [], - `0x${block.body.toBuffer().toString('hex')}`, - ], - }); - expect(ethTx.input).toEqual(expectedData); - - await progressTimeBySlot(); - } + ); + }); }); }); diff --git a/yarn-project/sequencer-client/src/publisher/l1-publisher.ts b/yarn-project/sequencer-client/src/publisher/l1-publisher.ts index c6275bc877b..0797c59e3e1 100644 --- a/yarn-project/sequencer-client/src/publisher/l1-publisher.ts +++ b/yarn-project/sequencer-client/src/publisher/l1-publisher.ts @@ -18,7 +18,7 @@ import { } from '@aztec/circuits.js'; import { createEthereumChain } from '@aztec/ethereum'; import { makeTuple } from '@aztec/foundation/array'; -import { areArraysEqual, times } from '@aztec/foundation/collection'; +import { areArraysEqual, compactArray, times } from '@aztec/foundation/collection'; import { type Signature } from '@aztec/foundation/eth-signature'; import { Fr } from '@aztec/foundation/fields'; import { createDebugLogger } from '@aztec/foundation/log'; @@ -34,6 +34,7 @@ import { type BaseError, type Chain, type Client, + type ContractFunctionExecutionError, ContractFunctionRevertedError, type GetContractReturnType, type Hex, @@ -81,13 +82,15 @@ export type MinimalTransactionReceipt = { /** True if the tx was successful, false if reverted. */ status: boolean; /** Hash of the transaction. */ - transactionHash: string; + transactionHash: `0x${string}`; /** Effective gas used by the tx. */ gasUsed: bigint; /** Effective gas price paid by the tx. */ gasPrice: bigint; /** Logs emitted in this tx. */ logs: any[]; + /** Block number in which this tx was mined. */ + blockNumber: bigint; }; /** Arguments to the process method of the rollup contract */ @@ -133,7 +136,8 @@ export class L1Publisher { private sleepTimeMs: number; private interrupted = false; private metrics: L1PublisherMetrics; - private log = createDebugLogger('aztec:sequencer:publisher'); + + protected log = createDebugLogger('aztec:sequencer:publisher'); private rollupContract: GetContractReturnType< typeof RollupAbi, @@ -375,15 +379,17 @@ export class L1Publisher { this.log.verbose(`Submitting propose transaction`); - const txHash = proofQuote + const tx = proofQuote ? await this.sendProposeAndClaimTx(proposeTxArgs, proofQuote) : await this.sendProposeTx(proposeTxArgs); - if (!txHash) { + if (!tx) { this.log.info(`Failed to publish block ${block.number} to L1`, ctx); return false; } + const { hash: txHash, args, functionName, gasLimit } = tx; + const receipt = await this.getTransactionReceipt(txHash); if (!receipt) { this.log.info(`Failed to get receipt for tx ${txHash}`, ctx); @@ -407,11 +413,45 @@ export class L1Publisher { this.metrics.recordFailedTx('process'); - this.log.error(`Rollup.process tx status failed: ${receipt.transactionHash}`, ctx); + const errorMsg = await this.tryGetErrorFromRevertedTx({ + args, + functionName, + gasLimit, + abi: RollupAbi, + address: this.rollupContract.address, + blockNumber: receipt.blockNumber, + }); + this.log.error(`Rollup process tx reverted. ${errorMsg}`, undefined, { + ...ctx, + txHash: receipt.transactionHash, + }); await this.sleepOrInterrupted(); return false; } + private async tryGetErrorFromRevertedTx(args: { + args: any[]; + functionName: string; + gasLimit: bigint; + abi: any; + address: Hex; + blockNumber: bigint | undefined; + }) { + try { + await this.publicClient.simulateContract({ ...args, account: this.walletClient.account }); + return undefined; + } catch (err: any) { + if (err.name === 'ContractFunctionExecutionError') { + const execErr = err as ContractFunctionExecutionError; + return compactArray([ + execErr.shortMessage, + ...(execErr.metaMessages ?? []).slice(0, 2).map(s => s.trim()), + ]).join(' '); + } + this.log.error(`Error getting error from simulation`, err); + } + } + public async submitEpochProof(args: { epochNumber: number; fromBlock: number; @@ -610,17 +650,24 @@ export class L1Publisher { ] as const; } - private async sendProposeTx(encodedData: L1ProcessArgs): Promise { + private async sendProposeTx( + encodedData: L1ProcessArgs, + ): Promise<{ hash: string; args: any; functionName: string; gasLimit: bigint } | undefined> { if (this.interrupted) { - return; + return undefined; } try { const { args, gasGuesstimate } = await this.prepareProposeTx(encodedData, L1Publisher.PROPOSE_GAS_GUESS); - return await this.rollupContract.write.propose(args, { - account: this.account, - gas: gasGuesstimate, - }); + return { + hash: await this.rollupContract.write.propose(args, { + account: this.account, + gas: gasGuesstimate, + }), + args, + functionName: 'propose', + gasLimit: gasGuesstimate, + }; } catch (err) { prettyLogViemError(err, this.log); this.log.error(`Rollup publish failed`, err); @@ -628,9 +675,12 @@ export class L1Publisher { } } - private async sendProposeAndClaimTx(encodedData: L1ProcessArgs, quote: EpochProofQuote): Promise { + private async sendProposeAndClaimTx( + encodedData: L1ProcessArgs, + quote: EpochProofQuote, + ): Promise<{ hash: string; args: any; functionName: string; gasLimit: bigint } | undefined> { if (this.interrupted) { - return; + return undefined; } try { const { args, gasGuesstimate } = await this.prepareProposeTx( @@ -640,10 +690,15 @@ export class L1Publisher { this.log.info(`ProposeAndClaim`); this.log.info(inspect(quote.payload)); - return await this.rollupContract.write.proposeAndClaim([...args, quote.toViemArgs()], { - account: this.account, - gas: gasGuesstimate, - }); + return { + hash: await this.rollupContract.write.proposeAndClaim([...args, quote.toViemArgs()], { + account: this.account, + gas: gasGuesstimate, + }), + functionName: 'proposeAndClaim', + args, + gasLimit: gasGuesstimate, + }; } catch (err) { prettyLogViemError(err, this.log); this.log.error(`Rollup publish failed`, err); @@ -674,6 +729,7 @@ export class L1Publisher { gasUsed: receipt.gasUsed, gasPrice: receipt.effectiveGasPrice, logs: receipt.logs, + blockNumber: receipt.blockNumber, }; }