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; + } }