From 7b8396d85d3f56773a0d6a472ad9107e2e3719eb Mon Sep 17 00:00:00 2001 From: Santiago Palladino Date: Mon, 15 Sep 2025 11:48:52 -0300 Subject: [PATCH] fix: Wait until rollup catches up to inbox for msg sync Do not acknowledge an L1 to L2 message as synced until the rollup pending block number has caught up with the message block. The inbox block number may drift way ahead of the rollup block number in the event of a reorg or if there are too many l1 to l2 messages being inserted. Note that the existing approach used throughout the codebase of waiting for two blocks if flawed, since if there was an earlier reorg on the chain, then the inbox will have drifted and the message will require more blocks to become available. This PR does NOT remove the existing isL1ToL2MessageSynced call, since it's used all over the place, but rather flags it as deprecated. Instead, the node and pxe now expose a function that returns the block in which the message is to be available, and aztecjs provides a helper to wait until the block is reached. The bot factory is updated to use this new approach. --- .../aztec-node/src/aztec-node/server.ts | 10 +- yarn-project/aztec.js/src/api/utils.ts | 1 + .../aztec.js/src/contract/batch_call.ts | 8 + .../aztec.js/src/utils/cross_chain.ts | 54 ++++ .../aztec/src/testing/anvil_test_watcher.ts | 1 + yarn-project/bot/src/config.ts | 2 +- yarn-project/bot/src/factory.ts | 26 +- yarn-project/end-to-end/src/e2e_bot.test.ts | 41 ++- .../cross_chain_messaging_test.ts | 22 +- .../l1_to_l2.test.ts | 236 +++++++++++++++--- .../ethereum/src/test/eth_cheat_codes.ts | 15 +- .../src/test/rollup_cheat_codes.test.ts | 98 ++++++++ .../ethereum/src/test/rollup_cheat_codes.ts | 41 +++ .../pxe/src/pxe_service/pxe_service.ts | 4 + .../src/watchers/epoch_prune_watcher.ts | 4 +- .../stdlib/src/interfaces/aztec-node.test.ts | 9 + .../stdlib/src/interfaces/aztec-node.ts | 10 +- .../stdlib/src/interfaces/pxe.test.ts | 8 + yarn-project/stdlib/src/interfaces/pxe.ts | 11 +- .../stdlib/src/messaging/inbox_leaf.ts | 5 + 20 files changed, 523 insertions(+), 83 deletions(-) create mode 100644 yarn-project/aztec.js/src/utils/cross_chain.ts create mode 100644 yarn-project/ethereum/src/test/rollup_cheat_codes.test.ts diff --git a/yarn-project/aztec-node/src/aztec-node/server.ts b/yarn-project/aztec-node/src/aztec-node/server.ts index 6ed4d0784f34..bf4ef52dedc7 100644 --- a/yarn-project/aztec-node/src/aztec-node/server.ts +++ b/yarn-project/aztec-node/src/aztec-node/server.ts @@ -82,7 +82,7 @@ import { tryStop, } from '@aztec/stdlib/interfaces/server'; import type { LogFilter, PrivateLog, TxScopedL2Log } from '@aztec/stdlib/logs'; -import type { L1ToL2MessageSource } from '@aztec/stdlib/messaging'; +import { InboxLeaf, type L1ToL2MessageSource } from '@aztec/stdlib/messaging'; import { P2PClientType } from '@aztec/stdlib/p2p'; import type { Offense, SlashPayloadRound } from '@aztec/stdlib/slashing'; import type { NullifierLeafPreimage, PublicDataTreeLeaf, PublicDataTreeLeafPreimage } from '@aztec/stdlib/trees'; @@ -860,13 +860,19 @@ export class AztecNodeService implements AztecNode, AztecNodeAdmin, Traceable { return [witness.index, witness.path]; } + public async getL1ToL2MessageBlock(l1ToL2Message: Fr): Promise { + const messageIndex = await this.l1ToL2MessageSource.getL1ToL2MessageIndex(l1ToL2Message); + return messageIndex ? InboxLeaf.l2BlockFromIndex(messageIndex) : undefined; + } + /** * Returns whether an L1 to L2 message is synced by archiver and if it's ready to be included in a block. * @param l1ToL2Message - The L1 to L2 message to check. * @returns Whether the message is synced and ready to be included in a block. */ public async isL1ToL2MessageSynced(l1ToL2Message: Fr): Promise { - return (await this.l1ToL2MessageSource.getL1ToL2MessageIndex(l1ToL2Message)) !== undefined; + const messageIndex = await this.l1ToL2MessageSource.getL1ToL2MessageIndex(l1ToL2Message); + return messageIndex !== undefined; } /** diff --git a/yarn-project/aztec.js/src/api/utils.ts b/yarn-project/aztec.js/src/api/utils.ts index 6fd68b85741a..3104484b9dfb 100644 --- a/yarn-project/aztec.js/src/api/utils.ts +++ b/yarn-project/aztec.js/src/api/utils.ts @@ -19,3 +19,4 @@ export { waitForPXE } from '../utils/pxe.js'; export { waitForNode, createAztecNodeClient, type AztecNode } from '../utils/node.js'; export { getFeeJuiceBalance } from '../utils/fee_juice.js'; export { readFieldCompressedString } from '../utils/field_compressed_string.js'; +export { isL1ToL2MessageReady, waitForL1ToL2MessageReady } from '../utils/cross_chain.js'; diff --git a/yarn-project/aztec.js/src/contract/batch_call.ts b/yarn-project/aztec.js/src/contract/batch_call.ts index 6dc7f85a4f52..93d6adceb2a9 100644 --- a/yarn-project/aztec.js/src/contract/batch_call.ts +++ b/yarn-project/aztec.js/src/contract/batch_call.ts @@ -14,6 +14,14 @@ export class BatchCall extends BaseContractInteraction { super(wallet); } + /** + * Creates a new instance with no actual calls. Useful for triggering a no-op. + * @param wallet - The wallet to use for sending the batch call. + */ + public static empty(wallet: Wallet) { + return new BatchCall(wallet, []); + } + /** * Returns an execution request that represents this operation. * @param options - An optional object containing additional configuration for the request generation. diff --git a/yarn-project/aztec.js/src/utils/cross_chain.ts b/yarn-project/aztec.js/src/utils/cross_chain.ts new file mode 100644 index 000000000000..0f56fbf4b0e0 --- /dev/null +++ b/yarn-project/aztec.js/src/utils/cross_chain.ts @@ -0,0 +1,54 @@ +import type { Fr } from '@aztec/foundation/fields'; +import { retryUntil } from '@aztec/foundation/retry'; + +import type { PXE } from '../api/interfaces.js'; + +/** + * Waits for the L1 to L2 message to be ready to be consumed. + * @param pxe - PXE instance + * @param l1ToL2MessageHash - Hash of the L1 to L2 message + * @param opts - Options + */ +export async function waitForL1ToL2MessageReady( + pxe: Pick, + l1ToL2MessageHash: Fr, + opts: { + /** Timeout for the operation in seconds */ timeoutSeconds: number; + /** True if the message is meant to be consumed from a public function */ forPublicConsumption: boolean; + }, +) { + const messageBlockNumber = await pxe.getL1ToL2MessageBlock(l1ToL2MessageHash); + return retryUntil( + () => isL1ToL2MessageReady(pxe, l1ToL2MessageHash, { ...opts, messageBlockNumber }), + `L1 to L2 message ${l1ToL2MessageHash.toString()} ready`, + opts.timeoutSeconds, + 1, + ); +} + +/** + * Returns whether the L1 to L2 message is ready to be consumed. + * @param pxe - PXE instance + * @param l1ToL2MessageHash - Hash of the L1 to L2 message + * @param opts - Options + * @returns True if the message is ready to be consumed, false otherwise + */ +export async function isL1ToL2MessageReady( + pxe: Pick, + l1ToL2MessageHash: Fr, + opts: { + /** True if the message is meant to be consumed from a public function */ forPublicConsumption: boolean; + /** Cached synced block number for the message (will be fetched from PXE otherwise) */ messageBlockNumber?: number; + }, +): Promise { + const blockNumber = await pxe.getBlockNumber(); + const messageBlockNumber = opts.messageBlockNumber ?? (await pxe.getL1ToL2MessageBlock(l1ToL2MessageHash)); + if (messageBlockNumber === undefined) { + return false; + } + + // Note that public messages can be consumed 1 block earlier, since the sequencer will include the messages + // in the L1 to L2 message tree before executing the txs for the block. In private, however, we need to wait + // until the message is included so we can make use of the membership witness. + return opts.forPublicConsumption ? blockNumber + 1 >= messageBlockNumber : blockNumber >= messageBlockNumber; +} diff --git a/yarn-project/aztec/src/testing/anvil_test_watcher.ts b/yarn-project/aztec/src/testing/anvil_test_watcher.ts index 791263c4fb51..168cb450a6e7 100644 --- a/yarn-project/aztec/src/testing/anvil_test_watcher.ts +++ b/yarn-project/aztec/src/testing/anvil_test_watcher.ts @@ -50,6 +50,7 @@ export class AnvilTestWatcher { } setIsMarkingAsProven(isMarkingAsProven: boolean) { + this.logger.warn(`Watcher is now ${isMarkingAsProven ? 'marking' : 'not marking'} blocks as proven`); this.isMarkingAsProven = isMarkingAsProven; } diff --git a/yarn-project/bot/src/config.ts b/yarn-project/bot/src/config.ts index ad8377ce6a34..e2744a361269 100644 --- a/yarn-project/bot/src/config.ts +++ b/yarn-project/bot/src/config.ts @@ -154,7 +154,7 @@ export const botConfigMappings: ConfigMappingsType = { l1ToL2MessageTimeoutSeconds: { env: 'BOT_L1_TO_L2_TIMEOUT_SECONDS', description: 'How long to wait for L1 to L2 messages to become available on L2', - ...numberConfigHelper(60), + ...numberConfigHelper(3600), }, senderPrivateKey: { env: 'BOT_PRIVATE_KEY', diff --git a/yarn-project/bot/src/factory.ts b/yarn-project/bot/src/factory.ts index bda35fb46529..fa5836990855 100644 --- a/yarn-project/bot/src/factory.ts +++ b/yarn-project/bot/src/factory.ts @@ -12,7 +12,7 @@ import { type PXE, createLogger, createPXEClient, - retryUntil, + waitForL1ToL2MessageReady, } from '@aztec/aztec.js'; import { createEthereumChain, createExtendedL1Client } from '@aztec/ethereum'; import { Fr } from '@aztec/foundation/fields'; @@ -112,8 +112,10 @@ export class BotFactory { private async setupAccount() { const privateKey = this.config.senderPrivateKey?.getValue(); if (privateKey) { + this.log.info(`Setting up account with provided private key`); return await this.setupAccountWithPrivateKey(privateKey); } else { + this.log.info(`Setting up test account`); return await this.setupTestAccount(); } } @@ -395,35 +397,31 @@ export class BotFactory { const mintAmount = await portal.getTokenManager().getMintAmount(); const claim = await portal.bridgeTokensPublic(recipient, mintAmount, true /* mint */); - const isSynced = async () => await this.pxe.isL1ToL2MessageSynced(Fr.fromHexString(claim.messageHash)); - await retryUntil(isSynced, `message ${claim.messageHash} sync`, this.config.l1ToL2MessageTimeoutSeconds, 1); + await this.withNoMinTxsPerBlock(() => + waitForL1ToL2MessageReady(this.pxe, Fr.fromHexString(claim.messageHash), { + timeoutSeconds: this.config.l1ToL2MessageTimeoutSeconds, + forPublicConsumption: false, + }), + ); this.log.info(`Created a claim for ${mintAmount} L1 fee juice to ${recipient}.`, claim); - // Progress by 2 L2 blocks so that the l1ToL2Message added above will be available to use on L2. - await this.advanceL2Block(); - await this.advanceL2Block(); - return claim; } private async withNoMinTxsPerBlock(fn: () => Promise): Promise { if (!this.nodeAdmin || !this.config.flushSetupTransactions) { + this.log.verbose(`No node admin client or flushing not requested (not setting minTxsPerBlock to 0)`); return fn(); } const { minTxsPerBlock } = await this.nodeAdmin.getConfig(); + this.log.warn(`Setting sequencer minTxsPerBlock to 0 from ${minTxsPerBlock} to flush setup transactions`); await this.nodeAdmin.setConfig({ minTxsPerBlock: 0 }); try { return await fn(); } finally { + this.log.warn(`Restoring sequencer minTxsPerBlock to ${minTxsPerBlock}`); await this.nodeAdmin.setConfig({ minTxsPerBlock }); } } - - private async advanceL2Block() { - await this.withNoMinTxsPerBlock(async () => { - const initialBlockNumber = await this.node!.getBlockNumber(); - await retryUntil(async () => (await this.node!.getBlockNumber()) >= initialBlockNumber + 1); - }); - } } diff --git a/yarn-project/end-to-end/src/e2e_bot.test.ts b/yarn-project/end-to-end/src/e2e_bot.test.ts index 685442af3771..42fc41d58a21 100644 --- a/yarn-project/end-to-end/src/e2e_bot.test.ts +++ b/yarn-project/end-to-end/src/e2e_bot.test.ts @@ -1,21 +1,23 @@ import { getInitialTestAccountsData } from '@aztec/accounts/testing'; -import type { PXE } from '@aztec/aztec.js'; +import { Fr, type PXE } from '@aztec/aztec.js'; import { AmmBot, Bot, type BotConfig, SupportedTokenContracts, getBotDefaultConfig } from '@aztec/bot'; import { AVM_MAX_PROCESSABLE_L2_GAS, MAX_PROCESSABLE_DA_GAS_PER_BLOCK } from '@aztec/constants'; +import { SecretValue } from '@aztec/foundation/config'; +import { bufferToHex } from '@aztec/foundation/string'; -import { setup } from './fixtures/utils.js'; +import { type EndToEndContext, getPrivateKeyFromIndex, setup } from './fixtures/utils.js'; describe('e2e_bot', () => { let pxe: PXE; let teardown: () => Promise; let config: BotConfig; + let ctx: EndToEndContext; beforeAll(async () => { const initialFundedAccounts = await getInitialTestAccountsData(); - ({ teardown, pxe } = await setup(1, { - initialFundedAccounts, - })); + ctx = await setup(1, { initialFundedAccounts }); + ({ teardown, pxe } = ctx); }); afterAll(() => teardown()); @@ -59,13 +61,7 @@ describe('e2e_bot', () => { }); it('sends token from the bot using PrivateToken', async () => { - const easyBot = await Bot.create( - { - ...config, - contract: SupportedTokenContracts.PrivateTokenContract, - }, - { pxe }, - ); + const easyBot = await Bot.create({ ...config, contract: SupportedTokenContracts.PrivateTokenContract }, { pxe }); const { recipient: recipientBefore } = await easyBot.getBalances(); await easyBot.run(); @@ -105,4 +101,25 @@ describe('e2e_bot', () => { ).toBeTrue(); }); }); + + describe('setup via bridging funds cross-chain', () => { + beforeAll(() => { + config = { + ...getBotDefaultConfig(), + followChain: 'PENDING', + ammTxs: false, + senderPrivateKey: new SecretValue(Fr.random()), + l1PrivateKey: new SecretValue(bufferToHex(getPrivateKeyFromIndex(8)!)), + l1RpcUrls: ctx.config.l1RpcUrls, + flushSetupTransactions: true, + }; + }); + + // See 'can consume L1 to L2 message in %s after inbox drifts away from the rollup' + // in end-to-end/src/e2e_cross_chain_messaging/l1_to_l2.test.ts for context on this test. + it('creates bot after inbox drift', async () => { + await ctx.cheatCodes.rollup.advanceInboxInProgress(10); + await Bot.create(config, { pxe, node: ctx.aztecNode, nodeAdmin: ctx.aztecNodeAdmin }); + }, 300_000); + }); }); diff --git a/yarn-project/end-to-end/src/e2e_cross_chain_messaging/cross_chain_messaging_test.ts b/yarn-project/end-to-end/src/e2e_cross_chain_messaging/cross_chain_messaging_test.ts index 72aa8bb7e514..91cacb70c292 100644 --- a/yarn-project/end-to-end/src/e2e_cross_chain_messaging/cross_chain_messaging_test.ts +++ b/yarn-project/end-to-end/src/e2e_cross_chain_messaging/cross_chain_messaging_test.ts @@ -2,6 +2,7 @@ import type { AztecNodeConfig } from '@aztec/aztec-node'; import { AztecAddress, type AztecNode, EthAddress, type Logger, type PXE, createLogger } from '@aztec/aztec.js'; import { CheatCodes } from '@aztec/aztec/testing'; import { + type DeployL1ContractsArgs, type DeployL1ContractsReturnType, type ExtendedViemWalletClient, createExtendedL1Client, @@ -23,6 +24,7 @@ import { deployAccounts, publicDeployAccounts, } from '../fixtures/snapshot_manager.js'; +import type { SetupOptions } from '../fixtures/utils.js'; import { CrossChainTestHarness } from '../shared/cross_chain_test_harness.js'; const { E2E_DATA_PATH: dataPath } = process.env; @@ -34,6 +36,7 @@ export class CrossChainMessagingTest { pxe!: PXE; aztecNodeConfig!: AztecNodeConfig; aztecNodeAdmin!: AztecNodeAdmin; + ctx!: SubsystemsContext; l1Client!: ExtendedViemWalletClient | undefined; @@ -52,9 +55,12 @@ export class CrossChainMessagingTest { deployL1ContractsValues!: DeployL1ContractsReturnType; - constructor(testName: string) { + constructor(testName: string, opts: SetupOptions = {}, deployL1ContractsArgs: Partial = {}) { this.logger = createLogger(`e2e:e2e_cross_chain_messaging:${testName}`); - this.snapshotManager = createSnapshotManager(`e2e_cross_chain_messaging/${testName}`, dataPath); + this.snapshotManager = createSnapshotManager(`e2e_cross_chain_messaging/${testName}`, dataPath, opts, { + initialValidators: [], + ...deployL1ContractsArgs, + }); } async assumeProven() { @@ -62,13 +68,13 @@ export class CrossChainMessagingTest { } async setup() { - const { aztecNode, pxe, aztecNodeConfig, deployL1ContractsValues } = await this.snapshotManager.setup(); - this.aztecNode = aztecNode; - this.pxe = pxe; - this.aztecNodeConfig = aztecNodeConfig; + this.ctx = await this.snapshotManager.setup(); + this.aztecNode = this.ctx.aztecNode; + this.pxe = this.ctx.pxe; + this.aztecNodeConfig = this.ctx.aztecNodeConfig; this.cheatCodes = await CheatCodes.create(this.aztecNodeConfig.l1RpcUrls, this.pxe); - this.deployL1ContractsValues = deployL1ContractsValues; - this.aztecNodeAdmin = aztecNode; + this.deployL1ContractsValues = this.ctx.deployL1ContractsValues; + this.aztecNodeAdmin = this.ctx.aztecNode; } snapshot = ( diff --git a/yarn-project/end-to-end/src/e2e_cross_chain_messaging/l1_to_l2.test.ts b/yarn-project/end-to-end/src/e2e_cross_chain_messaging/l1_to_l2.test.ts index 62cf7e61f8fa..d1e921f02507 100644 --- a/yarn-project/end-to-end/src/e2e_cross_chain_messaging/l1_to_l2.test.ts +++ b/yarn-project/end-to-end/src/e2e_cross_chain_messaging/l1_to_l2.test.ts @@ -1,74 +1,234 @@ -import { type AztecAddress, Fr, generateClaimSecret } from '@aztec/aztec.js'; +import { + AztecAddress, + type AztecNode, + BatchCall, + Fr, + type Logger, + TxStatus, + type Wallet, + generateClaimSecret, + retryUntil, +} from '@aztec/aztec.js'; +import { isL1ToL2MessageReady } from '@aztec/aztec.js'; +import { timesAsync } from '@aztec/foundation/collection'; import { TestContract } from '@aztec/noir-test-contracts.js/Test'; import { sendL1ToL2Message } from '../fixtures/l1_to_l2_messaging.js'; +import type { CrossChainTestHarness } from '../shared/cross_chain_test_harness.js'; import { CrossChainMessagingTest } from './cross_chain_messaging_test.js'; describe('e2e_cross_chain_messaging l1_to_l2', () => { - const t = new CrossChainMessagingTest('l1_to_l2'); - - let { crossChainTestHarness, aztecNode, wallet, user1Address } = t; - - beforeAll(async () => { + let t: CrossChainMessagingTest; + let log: Logger; + let crossChainTestHarness: CrossChainTestHarness; + let aztecNode: AztecNode; + let wallet: Wallet; + let user1Address: AztecAddress; + let testContract: TestContract; + + beforeEach(async () => { + t = new CrossChainMessagingTest( + 'l1_to_l2', + { minTxsPerBlock: 1 }, + { aztecProofSubmissionEpochs: 2, aztecEpochDuration: 4 }, + ); await t.applyBaseSnapshots(); await t.setup(); - // Have to destructure again to ensure we have latest refs. - ({ crossChainTestHarness, wallet, user1Address } = t); - aztecNode = crossChainTestHarness.aztecNode; + ({ logger: log, crossChainTestHarness, wallet, user1Address, aztecNode } = t); + testContract = await TestContract.deploy(wallet).send({ from: user1Address }).deployed(); }, 300_000); - afterAll(async () => { + afterEach(async () => { await t.teardown(); }); - // Note: We register one portal address when deploying contract but that address is no-longer the only address - // allowed to send messages to the given contract. In the following test we'll test that it's really the case. - it.each([true, false])( - 'can send an L1 -> L2 message from a non-registered portal address consumed from private or public and then sends and claims exactly the same message again', - async (isPrivate: boolean) => { - const testContract = await TestContract.deploy(wallet).send({ from: user1Address }).deployed(); - - const consumeMethod = isPrivate - ? testContract.methods.consume_message_from_arbitrary_sender_private - : testContract.methods.consume_message_from_arbitrary_sender_public; + const getConsumeMethod = (scope: 'private' | 'public') => + scope === 'private' + ? testContract.methods.consume_message_from_arbitrary_sender_private + : testContract.methods.consume_message_from_arbitrary_sender_public; + + // Sends a tx to L2 to advance the block number by 1 + const advanceBlock = async () => { + const block = await aztecNode.getBlockNumber(); + log.warn(`Sending noop tx at block ${block}`); + await BatchCall.empty(wallet).send({ from: user1Address }).wait(); + const newBlock = await aztecNode.getBlockNumber(); + log.warn(`Advanced to block ${newBlock}`); + if (newBlock !== block + 1) { + throw new Error(`Failed to advance block ${block}`); + } + return undefined; + }; - const [secret, secretHash] = await generateClaimSecret(); + // Same as above but ignores errors. Useful if we expect a prune. + const tryAdvanceBlock = async () => { + try { + await advanceBlock(); + } catch (err) { + log.warn(`Failed to advance block: ${(err as Error).message}`); + } + }; - const message = { recipient: testContract.address, content: Fr.random(), secretHash }; - const [message1Hash, actualMessage1Index] = await sendL2Message(message); + // Waits until the message is fetched by the archiver of the node and returns the msg target block + const waitForMessageFetched = async (msgHash: Fr) => { + log.warn(`Waiting until the message is fetched by the node`); + return await retryUntil( + async () => (await aztecNode.getL1ToL2MessageBlock(msgHash)) ?? (await advanceBlock()), + 'get msg block', + 60, + ); + }; - const [message1Index] = (await aztecNode.getL1ToL2MessageMembershipWitness('latest', message1Hash))!; - expect(actualMessage1Index.toBigInt()).toBe(message1Index); + // Waits until the message is ready to be consumed on L2 as it's been added to the world state + const waitForMessageReady = async ( + msgHash: Fr, + scope: 'private' | 'public', + onNotReady?: (blockNumber: number) => Promise, + ) => { + const msgBlock = await waitForMessageFetched(msgHash); + log.warn(`Waiting until L2 reaches msg block ${msgBlock} (current is ${await aztecNode.getBlockNumber()})`); + await retryUntil( + async () => { + const blockNumber = await aztecNode.getBlockNumber(); + const witness = await aztecNode.getL1ToL2MessageMembershipWitness('latest', msgHash); + const isReady = await isL1ToL2MessageReady(aztecNode, msgHash, { forPublicConsumption: scope === 'public' }); + log.info(`Block is ${blockNumber}. Message block is ${msgBlock}. Witness ${!!witness}. Ready ${isReady}.`); + if (!isReady) { + await (onNotReady ? onNotReady(blockNumber) : advanceBlock()); + } + return isReady; + }, + `wait for rollup to reach msg block ${msgBlock}`, + 120, + ); + }; - // Finally, we consume the L1 -> L2 message using the test contract either from private or public - await consumeMethod(message.content, secret, crossChainTestHarness.ethAccount, message1Index) + // We register one portal address when deploying contract but that address is no-longer the only address + // allowed to send messages to the given contract. In the following test we'll test that it's really the case. + // We'll also test that we can send the same message content across the bridge multiple times. + it.each(['private', 'public'] as const)( + 'can send an L1 to L2 message from a non-registered portal address consumed from %s repeatedly', + async (scope: 'private' | 'public') => { + // Generate and send the message to the L1 contract + const [secret, secretHash] = await generateClaimSecret(); + const message = { recipient: testContract.address, content: Fr.random(), secretHash }; + const { msgHash: message1Hash, globalLeafIndex: actualMessage1Index } = await sendL1ToL2Message( + message, + crossChainTestHarness, + ); + + await waitForMessageReady(message1Hash, scope); + + // The waitForMessageReady returns true earlier for public-land, so we can only check the membership + // witness for private-land here. + if (scope === 'private') { + const [message1Index] = (await aztecNode.getL1ToL2MessageMembershipWitness('latest', message1Hash))!; + expect(actualMessage1Index.toBigInt()).toBe(message1Index); + } + + // We consume the L1 to L2 message using the test contract either from private or public + await getConsumeMethod(scope)(message.content, secret, crossChainTestHarness.ethAccount, actualMessage1Index) .send({ from: user1Address }) .wait(); // We send and consume the exact same message the second time to test that oracles correctly return the new // non-nullified message - const [message2Hash, actualMessage2Index] = await sendL2Message(message); + const { msgHash: message2Hash, globalLeafIndex: actualMessage2Index } = await sendL1ToL2Message( + message, + crossChainTestHarness, + ); // We check that the duplicate message was correctly inserted by checking that its message index is defined - const [message2Index] = (await aztecNode.getL1ToL2MessageMembershipWitness('latest', message2Hash))!; + await waitForMessageReady(message2Hash, scope); - expect(message2Index).toBeDefined(); - expect(message2Index).toBeGreaterThan(message1Index); - expect(actualMessage2Index.toBigInt()).toBe(message2Index); + if (scope === 'private') { + const [message2Index] = (await aztecNode.getL1ToL2MessageMembershipWitness('latest', message2Hash))!; + expect(message2Index).toBeDefined(); + expect(message2Index).toBeGreaterThan(actualMessage1Index.toBigInt()); + expect(actualMessage2Index.toBigInt()).toBe(message2Index); + } // Now we consume the message again. Everything should pass because oracle should return the duplicate message // which is not nullified - await consumeMethod(message.content, secret, crossChainTestHarness.ethAccount, message2Index) + await getConsumeMethod(scope)(message.content, secret, crossChainTestHarness.ethAccount, actualMessage2Index) .send({ from: user1Address }) .wait(); }, 120_000, ); - const sendL2Message = async (message: { recipient: AztecAddress; content: Fr; secretHash: Fr }) => { - const { msgHash, globalLeafIndex } = await sendL1ToL2Message(message, crossChainTestHarness); - await crossChainTestHarness.makeMessageConsumable(msgHash); - return [msgHash, globalLeafIndex]; - }; + // Inbox block number can drift on two scenarios: if the rollup reorgs and rolls back its own + // block number, or if the inbox receives too many messages and they are inserted faster than + // they are consumed. In this test, we mine several blocks without marking them as proven until + // we can trigger a reorg, and then wait until the message can be processed to consume it. + it.each(['private', 'public'] as const)( + 'can consume L1 to L2 message in %s after inbox drifts away from the rollup', + async (scope: 'private' | 'public') => { + // Stop proving + const lastProven = await aztecNode.getBlockNumber(); + log.warn(`Stopping proof submission at block ${lastProven} to allow drift`); + t.ctx.watcher.setIsMarkingAsProven(false); + + // Mine several blocks to ensure drift + log.warn(`Mining blocks to allow drift`); + await timesAsync(4, advanceBlock); + + // Generate and send the message to the L1 contract + log.warn(`Sending L1 to L2 message`); + const [secret, secretHash] = await generateClaimSecret(); + const message = { recipient: testContract.address, content: Fr.random(), secretHash }; + const { msgHash, globalLeafIndex } = await sendL1ToL2Message(message, crossChainTestHarness); + + // Wait until the Aztec node has synced it + const msgBlockNumber = await waitForMessageFetched(msgHash); + log.warn(`Message synced for block ${msgBlockNumber}`); + expect(lastProven + 4).toBeLessThan(msgBlockNumber); + + // And keep mining until we prune back to the original block number. Now the "waiting for two blocks" + // strategy for the message to be ready to use shouldn't work, since the lastProven block is more than + // two blocks behind the message block. This is the scenario we want to test. + log.warn(`Waiting until we prune back to ${lastProven}`); + await retryUntil( + async () => + (await aztecNode.getBlockNumber().then(b => b === lastProven || b === lastProven + 1)) || + (await tryAdvanceBlock()), + 'wait for prune', + 40, + ); + + // Check that there is no witness yet + expect(await aztecNode.getL1ToL2MessageMembershipWitness('latest', msgHash)).toBeUndefined(); + + // Define L2 function to consume the message + const consume = () => + getConsumeMethod(scope)(message.content, secret, crossChainTestHarness.ethAccount, globalLeafIndex); + + // Wait until the message is ready to be consumed, checking that it cannot be consumed beforehand + await waitForMessageReady(msgHash, scope, async () => { + if (scope === 'private') { + // On private, we simulate the tx locally and check that we get a missing message error, then we advance to the next block + await expect(() => consume().simulate({ from: user1Address })).rejects.toThrow(/No L1 to L2 message found/); + await advanceBlock(); + await t.ctx.watcher.markAsProven(); + } else { + // On public, we actually send the tx and check that it reverts due to the missing message. + // This advances the block too as a side-effect. Note that we do not rely on a simulation since the cross chain messages + // do not get added at the beginning of the block during node_simulatePublicCalls (maybe they should?). + const { status } = await consume().send({ from: user1Address }).wait({ dontThrowOnRevert: true }); + expect(status).toEqual(TxStatus.APP_LOGIC_REVERTED); + await t.ctx.watcher.markAsProven(); + } + }); + + // Verify the membership witness is available for creating the tx (private-land only) + if (scope === 'private') { + const [messageIndex] = (await aztecNode.getL1ToL2MessageMembershipWitness('latest', msgHash))!; + expect(messageIndex).toEqual(globalLeafIndex.toBigInt()); + } + + // And consume the message + await consume().send({ from: user1Address }).wait(); + }, + ); }); diff --git a/yarn-project/ethereum/src/test/eth_cheat_codes.ts b/yarn-project/ethereum/src/test/eth_cheat_codes.ts index 6b62abec4266..3be187663dbc 100644 --- a/yarn-project/ethereum/src/test/eth_cheat_codes.ts +++ b/yarn-project/ethereum/src/test/eth_cheat_codes.ts @@ -277,14 +277,21 @@ export class EthCheatCodes { * @param slot - The storage slot * @param value - The value to set the storage slot to */ - public async store(contract: EthAddress, slot: bigint, value: bigint): Promise { + public async store( + contract: EthAddress, + slot: bigint, + value: bigint, + opts: { silent?: boolean } = {}, + ): Promise { // for the rpc call, we need to change value to be a 32 byte hex string. try { await this.rpcCall('hardhat_setStorageAt', [contract.toString(), toHex(slot), toHex(value, true)]); } catch (err) { throw new Error(`Error setting storage for contract ${contract} at ${slot}: ${err}`); } - this.logger.warn(`Set L1 storage for contract ${contract} at ${slot} to ${value}`); + if (!opts.silent) { + this.logger.warn(`Set L1 storage for contract ${contract} at ${slot} to ${value}`); + } } /** @@ -434,7 +441,7 @@ export class EthCheatCodes { return this.rpcCall('trace_transaction', [txHash]); } - public async execWithPausedAnvil(fn: () => Promise): Promise { + public async execWithPausedAnvil(fn: () => Promise): Promise { const [blockInterval, wasAutoMining] = await Promise.all([this.getIntervalMining(), this.isAutoMining()]); try { if (blockInterval !== null) { @@ -445,7 +452,7 @@ export class EthCheatCodes { await this.setAutomine(false, { silent: true }); } - await fn(); + return await fn(); } finally { try { // restore automine if necessary diff --git a/yarn-project/ethereum/src/test/rollup_cheat_codes.test.ts b/yarn-project/ethereum/src/test/rollup_cheat_codes.test.ts new file mode 100644 index 000000000000..dc4d7713d948 --- /dev/null +++ b/yarn-project/ethereum/src/test/rollup_cheat_codes.test.ts @@ -0,0 +1,98 @@ +import { getPublicClient } from '@aztec/ethereum'; +import { Fr } from '@aztec/foundation/fields'; +import { type Logger, createLogger } from '@aztec/foundation/log'; +import { InboxAbi } from '@aztec/l1-artifacts/InboxAbi'; + +import type { Anvil } from '@viem/anvil'; +import { type PrivateKeyAccount, privateKeyToAccount } from 'viem/accounts'; +import { foundry } from 'viem/chains'; + +import { DefaultL1ContractsConfig } from '../config.js'; +import { deployL1Contracts } from '../deploy_l1_contracts.js'; +import type { ViemClient } from '../types.js'; +import { EthCheatCodes } from './eth_cheat_codes.js'; +import { RollupCheatCodes } from './rollup_cheat_codes.js'; +import { startAnvil } from './start_anvil.js'; + +describe('RollupCheatCodes', () => { + let anvil: Anvil; + let rpcUrl: string; + let privateKey: PrivateKeyAccount; + let logger: Logger; + let publicClient: ViemClient; + let cheatCodes: EthCheatCodes; + let rollupCheatCodes: RollupCheatCodes; + + let vkTreeRoot: Fr; + let protocolContractTreeRoot: Fr; + let deployedL1Contracts: Awaited>; + + beforeAll(async () => { + logger = createLogger('ethereum:test:rollup_cheat_codes'); + // this is the 6th address that gets funded by the junk mnemonic + privateKey = privateKeyToAccount('0x8b3a350cf5c34c9194ca85829a2df0ec3153be0318b5e2d3348e872092edffba'); + vkTreeRoot = Fr.random(); + protocolContractTreeRoot = Fr.random(); + + ({ anvil, rpcUrl } = await startAnvil()); + + publicClient = getPublicClient({ l1RpcUrls: [rpcUrl], l1ChainId: 31337 }); + cheatCodes = new EthCheatCodes([rpcUrl]); + + deployedL1Contracts = await deployL1Contracts([rpcUrl], privateKey, foundry, logger, { + ...DefaultL1ContractsConfig, + salt: undefined, + vkTreeRoot, + protocolContractTreeRoot, + genesisArchiveRoot: Fr.random(), + realVerifier: false, + }); + + rollupCheatCodes = RollupCheatCodes.create([rpcUrl], deployedL1Contracts.l1ContractAddresses); + }); + + afterAll(async () => { + await cheatCodes.setIntervalMining(0); + await anvil?.stop().catch(err => createLogger('cleanup').error(err)); + }); + + describe('advanceInboxInProgress', () => { + it('should advance the inbox inProgress field correctly', async () => { + const inboxAddress = deployedL1Contracts.l1ContractAddresses.inboxAddress.toString(); + + // Read initial state directly from contract + const initialState = await publicClient.readContract({ + address: inboxAddress as `0x${string}`, + abi: InboxAbi, + functionName: 'getState', + }); + + const initialInProgress = initialState.inProgress; + const initialRollingHash = initialState.rollingHash; + const initialTotalMessagesInserted = initialState.totalMessagesInserted; + + // Advance the inbox inProgress by a large amount + const advanceBy = 1000n; + const newInProgress = await rollupCheatCodes.advanceInboxInProgress(advanceBy); + + // Read state after advancement + const finalState = await publicClient.readContract({ + address: inboxAddress as `0x${string}`, + abi: InboxAbi, + functionName: 'getState', + }); + + const finalInProgress = finalState.inProgress; + const finalRollingHash = finalState.rollingHash; + const finalTotalMessagesInserted = finalState.totalMessagesInserted; + + // Check that the advancement worked + expect(newInProgress).toBe(initialInProgress + advanceBy); + expect(finalInProgress).toBe(initialInProgress + advanceBy); + + // Check that all other fields remain unchanged + expect(finalRollingHash).toBe(initialRollingHash); + expect(finalTotalMessagesInserted).toBe(initialTotalMessagesInserted); + }); + }); +}); diff --git a/yarn-project/ethereum/src/test/rollup_cheat_codes.ts b/yarn-project/ethereum/src/test/rollup_cheat_codes.ts index d05597d0ef2e..f60473bdbecf 100644 --- a/yarn-project/ethereum/src/test/rollup_cheat_codes.ts +++ b/yarn-project/ethereum/src/test/rollup_cheat_codes.ts @@ -208,6 +208,47 @@ export class RollupCheatCodes { }); } + /** + * Overrides the inProgress field of the Inbox contract state + * @param howMuch - How many blocks to move it forward + */ + public advanceInboxInProgress(howMuch: number | bigint): Promise { + return this.ethCheatCodes.execWithPausedAnvil(async () => { + // Storage slot 2 contains the InboxState struct + const inboxStateSlot = 2n; + + // Get inbox and its current state values + const inboxAddress = await this.rollup.read.getInbox(); + const currentStateValue = await this.ethCheatCodes.load(EthAddress.fromString(inboxAddress), inboxStateSlot); + + // Extract current values from the packed storage slot + // Storage layout: rollingHash (128 bits) | totalMessagesInserted (64 bits) | inProgress (64 bits) + const currentRollingHash = currentStateValue & ((1n << 128n) - 1n); + const currentTotalMessages = (currentStateValue >> 128n) & ((1n << 64n) - 1n); + const currentInProgress = currentStateValue >> 192n; + const newInProgress = currentInProgress + BigInt(howMuch); + + // Pack new values: rollingHash (low 128 bits) | totalMessages (middle 64 bits) | inProgress (high 64 bits) + const newValue = (BigInt(newInProgress) << 192n) | (currentTotalMessages << 128n) | currentRollingHash; + + await this.ethCheatCodes.store(EthAddress.fromString(inboxAddress), inboxStateSlot, newValue, { + silent: true, + }); + + this.logger.warn(`Inbox inProgress advanced from ${currentInProgress} to ${newInProgress}`, { + inbox: inboxAddress, + oldValue: '0x' + currentStateValue.toString(16), + newValue: '0x' + newValue.toString(16), + rollingHash: currentRollingHash, + totalMessages: currentTotalMessages, + oldInProgress: currentInProgress, + newInProgress, + }); + + return newInProgress; + }); + } + /** * Executes an action impersonated as the owner of the Rollup contract. * @param action - The action to execute diff --git a/yarn-project/pxe/src/pxe_service/pxe_service.ts b/yarn-project/pxe/src/pxe_service/pxe_service.ts index c91e8dfe9307..4504ca5ebf3b 100644 --- a/yarn-project/pxe/src/pxe_service/pxe_service.ts +++ b/yarn-project/pxe/src/pxe_service/pxe_service.ts @@ -206,6 +206,10 @@ export class PXEService implements PXE { return this.node.isL1ToL2MessageSynced(l1ToL2Message); } + public getL1ToL2MessageBlock(l1ToL2Message: Fr): Promise { + return this.node.getL1ToL2MessageBlock(l1ToL2Message); + } + public async getL2ToL1MembershipWitness( blockNumber: number, l2Tol1Message: Fr, diff --git a/yarn-project/slasher/src/watchers/epoch_prune_watcher.ts b/yarn-project/slasher/src/watchers/epoch_prune_watcher.ts index e8f6c3207c37..87cb535abcb1 100644 --- a/yarn-project/slasher/src/watchers/epoch_prune_watcher.ts +++ b/yarn-project/slasher/src/watchers/epoch_prune_watcher.ts @@ -93,7 +93,9 @@ export class EpochPruneWatcher extends (EventEmitter as new () => WatcherEmitter }) .catch(async error => { if (error instanceof TransactionsNotAvailableError) { - this.log.info(`Data for pruned epoch ${epochNumber} was not available. Will want to slash.`, error); + this.log.info(`Data for pruned epoch ${epochNumber} was not available. Will want to slash.`, { + message: error.message, + }); const validators = await this.getValidatorsForEpoch(epochNumber); return { validators, diff --git a/yarn-project/stdlib/src/interfaces/aztec-node.test.ts b/yarn-project/stdlib/src/interfaces/aztec-node.test.ts index 4344beef3bbf..17ed537238e8 100644 --- a/yarn-project/stdlib/src/interfaces/aztec-node.test.ts +++ b/yarn-project/stdlib/src/interfaces/aztec-node.test.ts @@ -119,6 +119,11 @@ describe('AztecNodeApiSchema', () => { expect(response).toEqual([1n, expect.any(SiblingPath)]); }); + it('getL1ToL2MessageBlock', async () => { + const response = await context.client.getL1ToL2MessageBlock(Fr.random()); + expect(response).toEqual(5); + }); + it('isL1ToL2MessageSynced', async () => { const response = await context.client.isL1ToL2MessageSynced(Fr.random()); expect(response).toBe(true); @@ -534,6 +539,10 @@ class MockAztecNode implements AztecNode { expect(noteHash).toBeInstanceOf(Fr); return Promise.resolve(MembershipWitness.random(NOTE_HASH_TREE_HEIGHT)); } + getL1ToL2MessageBlock(l1ToL2Message: Fr): Promise { + expect(l1ToL2Message).toBeInstanceOf(Fr); + return Promise.resolve(5); + } isL1ToL2MessageSynced(l1ToL2Message: Fr): Promise { expect(l1ToL2Message).toBeInstanceOf(Fr); return Promise.resolve(true); diff --git a/yarn-project/stdlib/src/interfaces/aztec-node.ts b/yarn-project/stdlib/src/interfaces/aztec-node.ts index e63e5bcdf313..11b5b2bee02f 100644 --- a/yarn-project/stdlib/src/interfaces/aztec-node.ts +++ b/yarn-project/stdlib/src/interfaces/aztec-node.ts @@ -199,10 +199,14 @@ export interface AztecNode l1ToL2Message: Fr, ): Promise<[bigint, SiblingPath] | undefined>; + /** Returns the L2 block number in which this L1 to L2 message becomes available, or undefined if not found. */ + getL1ToL2MessageBlock(l1ToL2Message: Fr): Promise; + /** - * Returns whether an L1 to L2 message is synced by archiver and if it's ready to be included in a block. + * Returns whether an L1 to L2 message is synced by archiver. * @param l1ToL2Message - The L1 to L2 message to check. - * @returns Whether the message is synced and ready to be included in a block. + * @returns Whether the message is synced. + * @deprecated Use `getL1ToL2MessageBlock` instead. This method may return true even if the message is not ready to use. */ isL1ToL2MessageSynced(l1ToL2Message: Fr): Promise; @@ -501,6 +505,8 @@ export const AztecNodeApiSchema: ApiSchemaFor = { .args(L2BlockNumberSchema, schemas.Fr) .returns(z.tuple([schemas.BigInt, SiblingPath.schemaFor(L1_TO_L2_MSG_TREE_HEIGHT)]).optional()), + getL1ToL2MessageBlock: z.function().args(schemas.Fr).returns(z.number().optional()), + isL1ToL2MessageSynced: z.function().args(schemas.Fr).returns(z.boolean()), getL2ToL1Messages: z diff --git a/yarn-project/stdlib/src/interfaces/pxe.test.ts b/yarn-project/stdlib/src/interfaces/pxe.test.ts index 16d5f830440e..323a448faca1 100644 --- a/yarn-project/stdlib/src/interfaces/pxe.test.ts +++ b/yarn-project/stdlib/src/interfaces/pxe.test.ts @@ -104,6 +104,11 @@ describe('PXESchema', () => { await context.client.isL1ToL2MessageSynced(Fr.random()); }); + it('getL1ToL2MessageBlock', async () => { + const result = await context.client.getL1ToL2MessageBlock(Fr.random()); + expect(result).toEqual(5); + }); + it('registerAccount', async () => { const result = await context.client.registerAccount(Fr.random(), Fr.random()); expect(result).toBeInstanceOf(CompleteAddress); @@ -324,6 +329,9 @@ class MockPXE implements PXE { isL1ToL2MessageSynced(_l1ToL2Message: Fr): Promise { return Promise.resolve(false); } + getL1ToL2MessageBlock(_l1ToL2Message: Fr): Promise { + return Promise.resolve(5); + } registerAccount(secretKey: Fr, partialAddress: Fr): Promise { expect(secretKey).toBeInstanceOf(Fr); expect(partialAddress).toBeInstanceOf(Fr); diff --git a/yarn-project/stdlib/src/interfaces/pxe.ts b/yarn-project/stdlib/src/interfaces/pxe.ts index 770d3677d870..d525423cb954 100644 --- a/yarn-project/stdlib/src/interfaces/pxe.ts +++ b/yarn-project/stdlib/src/interfaces/pxe.ts @@ -56,12 +56,20 @@ import { */ export interface PXE { /** - * Returns whether an L1 to L2 message is synced by archiver and if it's ready to be included in a block. + * Returns whether an L1 to L2 message is synced by archiver. * @param l1ToL2Message - The L1 to L2 message to check. * @returns Whether the message is synced and ready to be included in a block. + * @deprecated Use `waitForL1ToL2MessageReady` and `isL1ToL2MessageReady` instead. */ isL1ToL2MessageSynced(l1ToL2Message: Fr): Promise; + /** + * Returns the L2 block number in which this L1 to L2 message becomes available, or undefined if not found. + * @param l1ToL2Message - The L1 to L2 message to check. + * @returns The L2 block number or undefined if not synced yet. + */ + getL1ToL2MessageBlock(l1ToL2Message: Fr): Promise; + /** * Registers a user account in PXE given its master encryption private key. * Once a new account is registered, the PXE Service will trial-decrypt all published notes on @@ -447,6 +455,7 @@ const PXEInfoSchema = z.object({ export const PXESchema: ApiSchemaFor = { isL1ToL2MessageSynced: z.function().args(schemas.Fr).returns(z.boolean()), + getL1ToL2MessageBlock: z.function().args(schemas.Fr).returns(z.number().optional()), registerAccount: z.function().args(schemas.Fr, schemas.Fr).returns(CompleteAddress.schema), getRegisteredAccounts: z.function().returns(z.array(CompleteAddress.schema)), registerSender: z.function().args(schemas.AztecAddress).returns(schemas.AztecAddress), diff --git a/yarn-project/stdlib/src/messaging/inbox_leaf.ts b/yarn-project/stdlib/src/messaging/inbox_leaf.ts index 335ea77a562c..d0f43e3d9052 100644 --- a/yarn-project/stdlib/src/messaging/inbox_leaf.ts +++ b/yarn-project/stdlib/src/messaging/inbox_leaf.ts @@ -35,4 +35,9 @@ export class InboxLeaf { const end = start + BigInt(NUMBER_OF_L1_L2_MESSAGES_PER_ROLLUP); return [start, end]; } + + /** Returns the L2 block number for a given leaf index */ + static l2BlockFromIndex(index: bigint): number { + return Number(index / BigInt(NUMBER_OF_L1_L2_MESSAGES_PER_ROLLUP)) + INITIAL_L2_BLOCK_NUM; + } }