diff --git a/.test_patterns.yml b/.test_patterns.yml index e4800e12b11c..81ca79396330 100644 --- a/.test_patterns.yml +++ b/.test_patterns.yml @@ -126,19 +126,19 @@ tests: - regex: "src/single-node/cross-chain/l1_to_l2" owners: - *palla - - regex: "src/single-node/cross-chain/token_bridge_private" + - regex: "src/single-node/cross-chain/token_bridge" error_regex: "✕ Claim secret is enough to consume the message" owners: - *lasse - - regex: "src/single-node/cross-chain/token_bridge_public" + - regex: "src/single-node/cross-chain/token_bridge" error_regex: "✕ Someone else can mint funds to me on my behalf" owners: - *lasse - - regex: "src/single-node/cross-chain/token_bridge_public" + - regex: "src/single-node/cross-chain/token_bridge" error_regex: "✕ Publicly deposit funds" owners: - *lasse - - regex: "src/single-node/cross-chain/token_bridge_failure_cases" + - regex: "src/single-node/cross-chain/token_bridge" error_regex: "✕ Can't claim funds" owners: - *lasse @@ -397,6 +397,10 @@ tests: owners: - *spyros + - regex: "yarn-project/end-to-end/scripts/run_test.sh ha src/composed/ha/e2e_ha_distribute_work.test.ts" + owners: + - *spyros + # http://ci.aztec-labs.com/98d59d04f85223f8 # Build-cache flake: module not found during Jest startup - regex: "src/single-node/sequencer/gov_proposal.parallel.test.ts" @@ -418,7 +422,7 @@ tests: # http://ci.aztec-labs.com/930ddc9ede87059f # Pipelining race: slasher doesn't record the duplicate proposal offense before # the test's wait timeout — same family as #23501. - - regex: "src/multi-node/slashing/duplicate_proposal.test.ts.*duplicate proposals" + - regex: "src/multi-node/slashing/equivocation_offenses.test.ts.*duplicate proposals" error_regex: "TimeoutError: Timeout awaiting duplicate proposal offense" owners: - *palla diff --git a/docs/docs-developers/docs/aztec-js/how_to_pay_fees.md b/docs/docs-developers/docs/aztec-js/how_to_pay_fees.md index d918cfe6d487..0669fa11d26a 100644 --- a/docs/docs-developers/docs/aztec-js/how_to_pay_fees.md +++ b/docs/docs-developers/docs/aztec-js/how_to_pay_fees.md @@ -101,7 +101,7 @@ You can derive the Sponsored FPC address from its deployment parameters, registe Here's a simpler example from the test suite: -#include_code sponsored_fpc_simple yarn-project/end-to-end/src/single-node/fees/sponsored_payments.test.ts typescript +#include_code sponsored_fpc_simple yarn-project/end-to-end/src/single-node/fees/private_payments.parallel.test.ts typescript ### Private fee payment diff --git a/yarn-project/archiver/src/modules/data_store_updater.test.ts b/yarn-project/archiver/src/modules/data_store_updater.test.ts index 4a2c8f659e3d..ff086c1c6a27 100644 --- a/yarn-project/archiver/src/modules/data_store_updater.test.ts +++ b/yarn-project/archiver/src/modules/data_store_updater.test.ts @@ -341,6 +341,63 @@ describe('ArchiverDataStoreUpdater', () => { expect(await store.contractClasses.getContractClass(contractClassId)).toBeUndefined(); expect(await store.contractInstances.getContractInstance(instanceAddress, timestamp)).toBeUndefined(); }); + + it('accepts a re-included already-stored checkpoint carrying contract data (A-1350)', async () => { + const block = await L2Block.random(BlockNumber(1), { + checkpointNumber: CheckpointNumber(1), + indexWithinCheckpoint: IndexWithinCheckpoint(0), + }); + block.body.txEffects[0].contractClassLogs = [contractClassLog]; + block.body.txEffects[0].privateLogs = [PrivateLog.fromBuffer(getSampleContractInstancePublishedEventPayload())]; + + const checkpoint = makeCheckpoint([block]); + await updater.addCheckpoints([makePublishedCheckpoint(checkpoint, 10)]); + expect(await store.contractClasses.getContractClass(contractClassId)).toBeDefined(); + + // Simulate an L1 reorg that re-includes the same checkpoint at a later L1 block. + await expect(updater.addCheckpoints([makePublishedCheckpoint(checkpoint, 999)])).resolves.toBeDefined(); + + // L1 metadata must reflect the re-inclusion and contract data must still be present. + const stored = await store.blocks.getCheckpointData(CheckpointNumber(1)); + expect(stored?.l1.blockNumber).toBe(999n); + expect(await store.contractClasses.getContractClass(contractClassId)).toBeDefined(); + const timestamp = block.header.globalVariables.timestamp + 1n; + expect(await store.contractInstances.getContractInstance(instanceAddress, timestamp)).toBeDefined(); + }); + + it('extracts only the newly-inserted suffix when a re-included checkpoint is batched with a new one (A-1350)', async () => { + // Checkpoint 1 (block 1) carries the contract class log; checkpoint 2 (block 2) carries the + // contract instance log. Ingest checkpoint 1, then re-present it (at a new L1 block) batched with + // the brand-new checkpoint 2. Only checkpoint 2's block is new, so its instance must be extracted + // while re-extracting checkpoint 1's already-stored class is skipped rather than throwing. + const block1 = await L2Block.random(BlockNumber(1), { + checkpointNumber: CheckpointNumber(1), + indexWithinCheckpoint: IndexWithinCheckpoint(0), + }); + block1.body.txEffects[0].contractClassLogs = [contractClassLog]; + + const checkpoint1 = makeCheckpoint([block1]); + await updater.addCheckpoints([makePublishedCheckpoint(checkpoint1, 10)]); + expect(await store.contractClasses.getContractClass(contractClassId)).toBeDefined(); + + const block2 = await L2Block.random(BlockNumber(2), { + checkpointNumber: CheckpointNumber(2), + indexWithinCheckpoint: IndexWithinCheckpoint(0), + lastArchive: block1.archive, + }); + block2.body.txEffects[0].privateLogs = [PrivateLog.fromBuffer(getSampleContractInstancePublishedEventPayload())]; + const checkpoint2 = makeCheckpoint([block2], CheckpointNumber(2)); + + // Re-present checkpoint 1 at a new L1 block, batched with the new checkpoint 2. + await expect( + updater.addCheckpoints([makePublishedCheckpoint(checkpoint1, 999), makePublishedCheckpoint(checkpoint2, 20)]), + ).resolves.toBeDefined(); + + // Checkpoint 1's class stays stored (not re-extracted), and checkpoint 2's instance was extracted. + expect(await store.contractClasses.getContractClass(contractClassId)).toBeDefined(); + const timestamp = block2.header.globalVariables.timestamp + 1n; + expect(await store.contractInstances.getContractInstance(instanceAddress, timestamp)).toBeDefined(); + }); }); describe('logs handling', () => { diff --git a/yarn-project/archiver/src/modules/data_store_updater.ts b/yarn-project/archiver/src/modules/data_store_updater.ts index bf87942d24dc..8ab9997c91d6 100644 --- a/yarn-project/archiver/src/modules/data_store_updater.ts +++ b/yarn-project/archiver/src/modules/data_store_updater.ts @@ -123,10 +123,11 @@ export class ArchiverDataStoreUpdater { // Before adding checkpoints, check for conflicts with local blocks if any const { prunedBlocks, lastAlreadyInsertedBlockNumber } = await this.pruneMismatchingLocalBlocks(checkpoints); - await this.stores.blocks.addCheckpoints(checkpoints); + const insertedCheckpoints = await this.stores.blocks.addCheckpoints(checkpoints); - // Filter out blocks that were already inserted via addProposedBlock() to avoid duplicating logs/contract data - const newBlocks = checkpoints + // Skip blocks already inserted via addProposedBlock() and blocks of already-stored checkpoints + // re-included by an L1 reorg: their logs/contract data were extracted when first inserted. + const newBlocks = insertedCheckpoints .flatMap((ch: PublishedCheckpoint) => ch.checkpoint.blocks) .filter(b => lastAlreadyInsertedBlockNumber === undefined || b.number > lastAlreadyInsertedBlockNumber); diff --git a/yarn-project/archiver/src/store/block_store.test.ts b/yarn-project/archiver/src/store/block_store.test.ts index 31dbb3fe47a6..39b7efebc43e 100644 --- a/yarn-project/archiver/src/store/block_store.test.ts +++ b/yarn-project/archiver/src/store/block_store.test.ts @@ -98,7 +98,7 @@ describe('BlockStore', () => { describe('addCheckpoints', () => { it('returns success when adding checkpoints', async () => { - await expect(blockStore.addCheckpoints(publishedCheckpoints)).resolves.toBe(true); + await expect(blockStore.addCheckpoints(publishedCheckpoints)).resolves.toEqual(publishedCheckpoints); }); it('accepts duplicate checkpoints with matching archives and updates L1 info', async () => { @@ -118,8 +118,10 @@ describe('BlockStore', () => { makeL1PublishedData(999), first3[2].attestations, ); - // Also add checkpoint 4 (the next one) in the same batch - await blockStore.addCheckpoints([cp3WithNewL1, publishedCheckpoints[3]]); + // Also add checkpoint 4 (the next one) in the same batch; only checkpoint 4 is newly inserted. + await expect(blockStore.addCheckpoints([cp3WithNewL1, publishedCheckpoints[3]])).resolves.toEqual([ + publishedCheckpoints[3], + ]); // Checkpoint 3's L1 info should be updated const afterData = await blockStore.getCheckpointData(CheckpointNumber(3)); @@ -135,8 +137,8 @@ describe('BlockStore', () => { const first3 = publishedCheckpoints.slice(0, 3); await blockStore.addCheckpoints(first3); - // Re-add the same 3 checkpoints — should succeed without error - await expect(blockStore.addCheckpoints(first3)).resolves.toBe(true); + // Re-add the same 3 checkpoints — should succeed without inserting anything new + await expect(blockStore.addCheckpoints(first3)).resolves.toEqual([]); }); it('throws on duplicate checkpoints with mismatching archives', async () => { @@ -284,7 +286,7 @@ describe('BlockStore', () => { ); const publishedCheckpoint = makePublishedCheckpoint(checkpoint, 10); - await expect(blockStore.addCheckpoints([publishedCheckpoint])).resolves.toBe(true); + await expect(blockStore.addCheckpoints([publishedCheckpoint])).resolves.toEqual([publishedCheckpoint]); }); it('throws on duplicate checkpoint with different content', async () => { @@ -314,7 +316,7 @@ describe('BlockStore', () => { ); const publishedCheckpoint2 = makePublishedCheckpoint(checkpoint2, 10); - await expect(blockStore.addCheckpoints([publishedCheckpoint])).resolves.toBe(true); + await expect(blockStore.addCheckpoints([publishedCheckpoint])).resolves.toEqual([publishedCheckpoint]); await expect(blockStore.addCheckpoints([publishedCheckpoint2])).rejects.toThrow( 'already exists in store but with a different archive', ); @@ -352,7 +354,7 @@ describe('BlockStore', () => { blocksPerCheckpoint: 2, }); - await expect(blockStore.addCheckpoints(checkpoints)).resolves.toBe(true); + await expect(blockStore.addCheckpoints(checkpoints)).resolves.toEqual(checkpoints); // Verify blocks have correct checkpoint assignments const block1 = await blockStore.getBlock({ number: BlockNumber(1) }); @@ -2081,7 +2083,7 @@ describe('BlockStore', () => { const publishedCheckpoint2 = makePublishedCheckpoint(checkpoint2, 10); // This should NOT throw - addCheckpoints uses .set() which is idempotent - await expect(blockStore.addCheckpoints([publishedCheckpoint2])).resolves.toBe(true); + await expect(blockStore.addCheckpoints([publishedCheckpoint2])).resolves.toEqual([publishedCheckpoint2]); // Verify block exists and is consistent const storedBlock = await blockStore.getBlock({ number: BlockNumber(2) }); diff --git a/yarn-project/archiver/src/store/block_store.ts b/yarn-project/archiver/src/store/block_store.ts index c1615ef601e5..37d7ec5b5ce2 100644 --- a/yarn-project/archiver/src/store/block_store.ts +++ b/yarn-project/archiver/src/store/block_store.ts @@ -306,12 +306,18 @@ export class BlockStore { /** * Append new checkpoints to the store's list. + * Checkpoints at the start of the batch that are already stored (e.g. re-included by an L1 reorg) + * are accepted if their archive root matches: their L1 metadata is updated but they are not + * re-inserted, and they are excluded from the returned array. * @param checkpoints - The L2 checkpoints to be added to the store. - * @returns True if the operation is successful. + * @returns The checkpoints that were actually inserted (excluding already-stored ones). */ - async addCheckpoints(checkpoints: PublishedCheckpoint[], opts: { force?: boolean } = {}): Promise { + async addCheckpoints( + checkpoints: PublishedCheckpoint[], + opts: { force?: boolean } = {}, + ): Promise { if (checkpoints.length === 0) { - return true; + return []; } return await this.db.transactionAsync(async () => { @@ -324,7 +330,7 @@ export class BlockStore { if (!opts.force && firstCheckpointNumber <= previousCheckpointNumber) { checkpoints = await this.skipOrUpdateAlreadyStoredCheckpoints(checkpoints, previousCheckpointNumber); if (checkpoints.length === 0) { - return true; + return []; } // Re-check sequentiality after skipping const newFirstNumber = checkpoints[0].checkpoint.number; @@ -387,7 +393,7 @@ export class BlockStore { } await this.advanceSynchedL1BlockNumber(checkpoints[checkpoints.length - 1].l1.blockNumber); - return true; + return checkpoints; }); } diff --git a/yarn-project/archiver/src/store/contract_class_store.test.ts b/yarn-project/archiver/src/store/contract_class_store.test.ts index 375bd3bb7320..4a7a9b3661e0 100644 --- a/yarn-project/archiver/src/store/contract_class_store.test.ts +++ b/yarn-project/archiver/src/store/contract_class_store.test.ts @@ -44,12 +44,22 @@ describe('ContractClassStore', () => { await expect(contractClassStore.getContractClass(contractClass.id)).resolves.toBeUndefined(); }); - it('throws if the same contract class is added again', async () => { + it('throws if the same contract class is added again at a different block', async () => { await expect( contractClassStore.addContractClasses([await withCommitment(contractClass)], BlockNumber(blockNum + 1)), ).rejects.toThrow(/already exists/); }); + it('treats re-adding the same contract class at the same block as a no-op (A-1350)', async () => { + // An L1 reorg can re-present an already-stored checkpoint, replaying this class at the same block. + const originalCommitment = await computePublicBytecodeCommitment(contractClass.packedBytecode); + await expect( + contractClassStore.addContractClasses([await withCommitment(contractClass)], BlockNumber(blockNum)), + ).resolves.not.toThrow(); + await expect(contractClassStore.getContractClass(contractClass.id)).resolves.toMatchObject(contractClass); + await expect(contractClassStore.getBytecodeCommitment(contractClass.id)).resolves.toEqual(originalCommitment); + }); + it('returns contract class if deleted at a later block number', async () => { await contractClassStore.deleteContractClasses([contractClass], BlockNumber(blockNum + 1)); await expect(contractClassStore.getContractClass(contractClass.id)).resolves.toMatchObject(contractClass); diff --git a/yarn-project/archiver/src/store/contract_class_store.ts b/yarn-project/archiver/src/store/contract_class_store.ts index 09e27c73c5fd..b6558c003f70 100644 --- a/yarn-project/archiver/src/store/contract_class_store.ts +++ b/yarn-project/archiver/src/store/contract_class_store.ts @@ -50,13 +50,19 @@ export class ContractClassStore { ): Promise { await this.db.transactionAsync(async () => { const key = contractClass.id.toString(); - if (await this.#contractClasses.hasAsync(key)) { + const existing = await this.#contractClasses.getAsync(key); + if (existing !== undefined) { // Protocol contracts are preloaded at block 0, so a later on-chain (re-)publish of a bundled // protocol class id is valid and must be a no-op. Keep the existing block-0 entry untouched // (do not bump its block number) so it survives reorgs of the publishing block. if (isProtocolContractClass(contractClass.id)) { return; } + // An L1 reorg can re-present an already-stored checkpoint, replaying this class at the same + // block; treat that as a no-op. A duplicate at a different block still signals double-processing. + if (deserializeContractClassPublic(existing).l2BlockNumber === blockNumber) { + return; + } throw new Error(`Contract class ${key} already exists, cannot add again at block ${blockNumber}`); } await this.#contractClasses.set( diff --git a/yarn-project/archiver/src/store/contract_instance_store.test.ts b/yarn-project/archiver/src/store/contract_instance_store.test.ts index 2cd7490945c6..17dd14ddd18b 100644 --- a/yarn-project/archiver/src/store/contract_instance_store.test.ts +++ b/yarn-project/archiver/src/store/contract_instance_store.test.ts @@ -49,11 +49,24 @@ describe('ContractInstanceStore', () => { ).resolves.toBeUndefined(); }); - it('throws when adding the same contract instance twice', async () => { + it('throws when adding the same contract instance again at a different block', async () => { await expect(contractInstanceStore.addContractInstances([contractInstance], BlockNumber(2))).rejects.toThrow( /already exists/, ); }); + + it('treats re-adding the same contract instance at the same block as a no-op (A-1350)', async () => { + // An L1 reorg can re-present an already-stored checkpoint, replaying this instance at the same block. + await expect( + contractInstanceStore.addContractInstances([contractInstance], BlockNumber(blockNum)), + ).resolves.not.toThrow(); + await expect( + contractInstanceStore.getContractInstance(contractInstance.address, timestamp), + ).resolves.toMatchObject(contractInstance); + await expect( + contractInstanceStore.getContractInstanceDeploymentBlockNumber(contractInstance.address), + ).resolves.toEqual(blockNum); + }); }); describe('protocol contract instances (A-1257)', () => { diff --git a/yarn-project/archiver/src/store/contract_instance_store.ts b/yarn-project/archiver/src/store/contract_instance_store.ts index 3c7da25f9b9d..dd58039cb978 100644 --- a/yarn-project/archiver/src/store/contract_instance_store.ts +++ b/yarn-project/archiver/src/store/contract_instance_store.ts @@ -78,8 +78,14 @@ export class ContractInstanceStore { if (isProtocolContract(contractInstance.address)) { return; } + const existingBlockNumber = await this.#contractInstancePublishedAt.getAsync(key); + // An L1 reorg can re-present an already-stored checkpoint, replaying this instance at the same + // block; treat that as a no-op. A duplicate at a different block still signals double-processing. + if (existingBlockNumber === blockNumber) { + return; + } throw new Error( - `Contract instance at ${key} already exists (deployed at block ${await this.#contractInstancePublishedAt.getAsync(key)}), cannot add again at block ${blockNumber}`, + `Contract instance at ${key} already exists (deployed at block ${existingBlockNumber}), cannot add again at block ${blockNumber}`, ); } await this.#contractInstances.set(key, new SerializableContractInstance(contractInstance).toBuffer()); diff --git a/yarn-project/end-to-end/bootstrap.sh b/yarn-project/end-to-end/bootstrap.sh index 428646156342..8ea2bb11b9c7 100755 --- a/yarn-project/end-to-end/bootstrap.sh +++ b/yarn-project/end-to-end/bootstrap.sh @@ -73,8 +73,21 @@ function test_cmds { multi-node/governance/add_rollup) test_prefix="$prefix:TIMEOUT=20m" ;; - single-node/cross-chain/l1_to_l2.parallel) - test_prefix="$prefix:TIMEOUT=20m" + single-node/cross-chain/l1_to_l2) + # The duplicate-message scenario runs over private and public scope serially in one container. + test_prefix="$prefix:TIMEOUT=25m" + ;; + single-node/cross-chain/l1_to_l2_inbox_drift) + # The inbox-drift scenario runs over private and public scope serially in one container. + test_prefix="$prefix:TIMEOUT=25m" + ;; + single-node/cross-chain/l2_to_l1) + # Six message-shape / reorg its run serially in one container (was .parallel, one per it). + test_prefix="$prefix:TIMEOUT=25m" + ;; + single-node/cross-chain/token_bridge) + # Private + public round trips and the failure cases share one node and run serially. + test_prefix="$prefix:TIMEOUT=25m" ;; single-node/block-building/block_building) # Block-building covers the full multi-tx / reorg surface and dumps AVM circuit inputs @@ -106,6 +119,10 @@ function test_cmds { # compose-based tests (use running local network) tests=( + # integration_proof_verification and e2e_persistence are excluded and run nowhere: the former's committed + # epoch-proof fixture is stale (the proof no longer verifies), and the latter's beforeAll no longer + # completes on the current branch (the single-node sequencer stalls in checkpoint proposal). See each + # file's header comment. Both stay excluded until fixed/regenerated. src/composed/!(integration_proof_verification|e2e_persistence).test.ts src/guides/*.test.ts ) diff --git a/yarn-project/end-to-end/src/automine/accounts/scope_isolation.test.ts b/yarn-project/end-to-end/src/automine/accounts/scope_isolation.test.ts index 67bc78be7aa5..6d28950e9c0b 100644 --- a/yarn-project/end-to-end/src/automine/accounts/scope_isolation.test.ts +++ b/yarn-project/end-to-end/src/automine/accounts/scope_isolation.test.ts @@ -1,4 +1,5 @@ import type { AztecAddress } from '@aztec/aztec.js/addresses'; +import type { ContractFunctionInteraction } from '@aztec/aztec.js/contracts'; import type { Wallet } from '@aztec/aztec.js/wallet'; import { ScopeTestContract } from '@aztec/noir-test-contracts.js/ScopeTest'; @@ -6,7 +7,8 @@ import { AutomineTestContext } from '../automine_test_context.js'; // Verifies that PXE note access and key-derivation are scoped per account: a different account // cannot read another's notes or derive their nullifier hiding key. Uses a single node with -// AutomineSequencer and three accounts (alice, bob, charlie). +// AutomineSequencer and three accounts (alice, bob, charlie). The same isolation checks run for both +// the external-private and external-utility function variants, which differ only by a `_utility` suffix. describe('automine/accounts/scope_isolation', () => { let wallet: Wallet; let accounts: AztecAddress[]; @@ -33,62 +35,44 @@ describe('automine/accounts/scope_isolation', () => { afterAll(() => teardown()); - // Tests for external private functions: read_note (scoped to owner) and get_nhk (scoped to key holder). - describe('external private', () => { - // Alice simulates read_note from her own scope; asserts the correct stored value is returned. + const variants: { + context: string; + readNote: (owner: AztecAddress) => ContractFunctionInteraction; + getNhk: (owner: AztecAddress) => ContractFunctionInteraction; + }[] = [ + { + context: 'external private', + readNote: owner => contract.methods.read_note(owner), + getNhk: owner => contract.methods.get_nhk(owner), + }, + { + context: 'external utility', + readNote: owner => contract.methods.read_note_utility(owner), + getNhk: owner => contract.methods.get_nhk_utility(owner), + }, + ]; + + describe.each(variants)('$context', ({ readNote, getNhk }) => { + // Alice reads her own note from her own scope; asserts the correct stored value is returned. it('owner can read own notes', async () => { - const { result: value } = await contract.methods.read_note(alice).simulate({ from: alice }); + const { result: value } = await readNote(alice).simulate({ from: alice }); expect(value).toEqual(ALICE_NOTE_VALUE); }); // Bob attempts to read Alice's note from his scope; asserts simulation throws 'Failed to get a note'. it('cannot read notes belonging to a different account', async () => { - await expect(contract.methods.read_note(alice).simulate({ from: bob })).rejects.toThrow('Failed to get a note'); + await expect(readNote(alice).simulate({ from: bob })).rejects.toThrow('Failed to get a note'); }); // Bob attempts to derive Charlie's nullifier hiding key; asserts 'Key validation request denied'. it('cannot access nullifier hiding key of a different account', async () => { - await expect(contract.methods.get_nhk(charlie).simulate({ from: bob })).rejects.toThrow( - 'Key validation request denied', - ); + await expect(getNhk(charlie).simulate({ from: bob })).rejects.toThrow('Key validation request denied'); }); // Both Alice and Bob read their own notes on the shared wallet; asserts each sees only their value. it('each account can access their isolated state on a shared wallet', async () => { - const { result: aliceValue } = await contract.methods.read_note(alice).simulate({ from: alice }); - const { result: bobValue } = await contract.methods.read_note(bob).simulate({ from: bob }); - - expect(aliceValue).toEqual(ALICE_NOTE_VALUE); - expect(bobValue).toEqual(BOB_NOTE_VALUE); - }); - }); - - // Same isolation checks repeated for external utility functions (read_note_utility, get_nhk_utility). - describe('external utility', () => { - // Alice simulates read_note_utility from her own scope; asserts the correct stored value is returned. - it('owner can read own notes', async () => { - const { result: value } = await contract.methods.read_note_utility(alice).simulate({ from: alice }); - expect(value).toEqual(ALICE_NOTE_VALUE); - }); - - // Bob attempts to read Alice's note via utility scope; asserts simulation throws 'Failed to get a note'. - it('cannot read notes belonging to a different account', async () => { - await expect(contract.methods.read_note_utility(alice).simulate({ from: bob })).rejects.toThrow( - 'Failed to get a note', - ); - }); - - // Bob attempts to derive Charlie's NHK via utility scope; asserts 'Key validation request denied'. - it('cannot access nullifier hiding key of a different account', async () => { - await expect(contract.methods.get_nhk_utility(charlie).simulate({ from: bob })).rejects.toThrow( - 'Key validation request denied', - ); - }); - - // Both Alice and Bob read via utility on the shared wallet; asserts each sees only their value. - it('each account can access their isolated state on a shared wallet', async () => { - const { result: aliceValue } = await contract.methods.read_note_utility(alice).simulate({ from: alice }); - const { result: bobValue } = await contract.methods.read_note_utility(bob).simulate({ from: bob }); + const { result: aliceValue } = await readNote(alice).simulate({ from: alice }); + const { result: bobValue } = await readNote(bob).simulate({ from: bob }); expect(aliceValue).toEqual(ALICE_NOTE_VALUE); expect(bobValue).toEqual(BOB_NOTE_VALUE); diff --git a/yarn-project/end-to-end/src/automine/contracts/nested/importer.parallel.test.ts b/yarn-project/end-to-end/src/automine/contracts/nested/importer.parallel.test.ts index eabb2b861f4b..526c8d497aa1 100644 --- a/yarn-project/end-to-end/src/automine/contracts/nested/importer.parallel.test.ts +++ b/yarn-project/end-to-end/src/automine/contracts/nested/importer.parallel.test.ts @@ -1,3 +1,4 @@ +import { Fr } from '@aztec/aztec.js/fields'; import { ImportTestContract } from '@aztec/noir-test-contracts.js/ImportTest'; import { TestContract } from '@aztec/noir-test-contracts.js/Test'; @@ -25,21 +26,37 @@ describe('automine/contracts/nested/importer', () => { await t.teardown(); }); - // Calls importerContract.call_no_args(testContract.address) and awaits inclusion. + // call_no_args routes a private call into Test.get_this_address; asserts the imported call returns the + // Test contract's own address, and that the tx is included on-chain. it('calls a method no arguments', async () => { logger.info(`Calling noargs on importer contract`); + const { result } = await importerContract.methods + .call_no_args(testContract.address) + .simulate({ from: defaultAccountAddress }); + expect(result).toEqual(testContract.address); + await importerContract.methods.call_no_args(testContract.address).send({ from: defaultAccountAddress }); }); - // Calls importerContract.call_public_fn(testContract.address) and awaits inclusion. + // call_public_fn enqueues Test.emit_nullifier_public(1); asserts the nullifier landed by checking that + // re-emitting the same nullifier on the Test contract is rejected as a duplicate. it('calls a public function', async () => { logger.info(`Calling public_fn on importer contract`); await importerContract.methods.call_public_fn(testContract.address).send({ from: defaultAccountAddress }); + + await expect( + testContract.methods.emit_nullifier_public(new Fr(1)).simulate({ from: defaultAccountAddress }), + ).rejects.toThrow(/duplicate nullifier/); }); - // Calls importerContract.pub_call_public_fn(testContract.address) and awaits inclusion. + // pub_call_public_fn calls Test.emit_nullifier_public(1) from a public function; asserts the nullifier + // landed by checking that re-emitting the same nullifier on the Test contract is rejected as a duplicate. it('calls a public function from a public function', async () => { logger.info(`Calling pub_public_fn on importer contract`); await importerContract.methods.pub_call_public_fn(testContract.address).send({ from: defaultAccountAddress }); + + await expect( + testContract.methods.emit_nullifier_public(new Fr(1)).simulate({ from: defaultAccountAddress }), + ).rejects.toThrow(/duplicate nullifier/); }); }); diff --git a/yarn-project/end-to-end/src/automine/contracts/nested/manual_calls.test.ts b/yarn-project/end-to-end/src/automine/contracts/nested/manual_calls.test.ts new file mode 100644 index 000000000000..c61610b3c75e --- /dev/null +++ b/yarn-project/end-to-end/src/automine/contracts/nested/manual_calls.test.ts @@ -0,0 +1,131 @@ +import type { AztecAddress } from '@aztec/aztec.js/addresses'; +import { BatchCall } from '@aztec/aztec.js/contracts'; +import { Fr } from '@aztec/aztec.js/fields'; +import { toBigIntBE } from '@aztec/foundation/bigint-buffer'; +import { serializeToBuffer } from '@aztec/foundation/serialize'; +import { ChildContract } from '@aztec/noir-test-contracts.js/Child'; + +import { AutomineTestContext } from '../../automine_test_context.js'; + +// Nested contract calls between Parent and Child. A single account runs a shared Parent/Child deployed in +// beforeAll via applyManualParentChild(); the public and enqueued-call suites redeploy a fresh Child per +// test because each asserts an absolute child storage value that only holds if the child starts at zero. +describe('automine/contracts/nested/manual_calls', () => { + const t = new AutomineTestContext(); + let { wallet, parentContract, childContract, defaultAccountAddress, aztecNode } = t; + + const getChildStoredValue = (child: { address: AztecAddress }) => + aztecNode.getPublicStorageAt('latest', child.address, new Fr(1)); + + beforeAll(async () => { + await t.setup(); + await t.applyManualParentChild(); + ({ wallet, parentContract, childContract, defaultAccountAddress, aztecNode } = t); + }); + + afterAll(async () => { + await t.teardown(); + }); + + describe('private calls', () => { + // Routes a private call through the parent into child.value(0). Asserts the nested call returns the + // same preimage as calling child.value(0) directly, and that the tx is included on-chain. + it('performs a nested private call returning the child value', async () => { + const selector = await childContract.methods.value.selector(); + + const { result } = await parentContract.methods + .entry_point(childContract.address, selector) + .simulate({ from: defaultAccountAddress }); + const { result: direct } = await childContract.methods.value(0n).simulate({ from: defaultAccountAddress }); + expect(result).toEqual(direct); + + await parentContract.methods.entry_point(childContract.address, selector).send({ from: defaultAccountAddress }); + }); + }); + + describe('public calls', () => { + let child: ChildContract; + + beforeEach(async () => { + ({ contract: child } = await ChildContract.deploy(wallet).send({ from: defaultAccountAddress })); + }); + + // Routes a public call through the parent into child.pub_inc_value(42); asserts the child's storage + // slot holds 42 afterwards, confirming the nested public call executed and wrote state. + it('performs public nested calls', async () => { + await parentContract.methods + .pub_entry_point(child.address, await child.methods.pub_inc_value.selector(), 42n) + .send({ from: defaultAccountAddress }); + expect(await getChildStoredValue(child)).toEqual(new Fr(42n)); + }); + + // Regression for https://github.com/AztecProtocol/aztec-packages/issues/640 + // Calls pub_entry_point_twice so pub_inc_value runs twice in one tx; asserts storage is 84 (not 42). + it('reads fresh value after write within the same tx', async () => { + await parentContract.methods + .pub_entry_point_twice(child.address, await child.methods.pub_inc_value.selector(), 42n) + .send({ from: defaultAccountAddress }); + expect(await getChildStoredValue(child)).toEqual(new Fr(84n)); + }); + + // Regression for https://github.com/AztecProtocol/aztec-packages/issues/1645 + // Executes a public call first and then a private call (which enqueues another public call) + // through the account contract, if the account entrypoint behaves properly, it will honor + // this order and not run the private call first which results in the public calls being inverted. + // Batches pub_set_value(20) and parent.enqueue(pub_set_value(40)); reads public logs to assert [20, 40]. + it('executes public calls in expected order', async () => { + const pubSetValueSelector = await child.methods.pub_set_value.selector(); + const actions = [ + child.methods.pub_set_value(20n), + parentContract.methods.enqueue_call_to_child(child.address, pubSetValueSelector, 40n), + ]; + + const { receipt: tx } = await new BatchCall(wallet, actions).send({ from: defaultAccountAddress }); + const block = (await aztecNode.getBlock({ number: tx.blockNumber! }, { includeTransactions: true }))!; + const allPublicLogs = block.body.txEffects.flatMap(effect => effect.publicLogs); + const processedLogs = allPublicLogs.map(log => toBigIntBE(serializeToBuffer(log.getEmittedFields()))); + expect(processedLogs).toEqual([20n, 40n]); + expect(await getChildStoredValue(child)).toEqual(new Fr(40n)); + }); + }); + + describe('enqueued public calls', () => { + let child: ChildContract; + + beforeEach(async () => { + ({ contract: child } = await ChildContract.deploy(wallet).send({ from: defaultAccountAddress })); + }); + + // Enqueues one pub_inc_value(42) call via the parent and asserts child storage equals 42. + it('enqueues a single public call', async () => { + await parentContract.methods + .enqueue_call_to_child(child.address, await child.methods.pub_inc_value.selector(), 42n) + .send({ from: defaultAccountAddress }); + expect(await getChildStoredValue(child)).toEqual(new Fr(42n)); + }); + + // Enqueues pub_inc_value(42) then pub_inc_value(43) via enqueue_call_to_child_twice; asserts 85. + it('enqueues multiple public calls', async () => { + await parentContract.methods + .enqueue_call_to_child_twice(child.address, await child.methods.pub_inc_value.selector(), 42n) + .send({ from: defaultAccountAddress }); + expect(await getChildStoredValue(child)).toEqual(new Fr(85n)); + }); + + // Calls enqueue_call_to_pub_entry_point which enqueues pub_entry_point → pub_inc_value; asserts 42. + it('enqueues a public call with nested public calls', async () => { + await parentContract.methods + .enqueue_call_to_pub_entry_point(child.address, await child.methods.pub_inc_value.selector(), 42n) + .send({ from: defaultAccountAddress }); + expect(await getChildStoredValue(child)).toEqual(new Fr(42n)); + }); + + // Calls enqueue_calls_to_pub_entry_point which enqueues pub_entry_point twice; asserts 85. + it('enqueues multiple public calls with nested public calls', async () => { + await parentContract.methods + .enqueue_calls_to_pub_entry_point(child.address, await child.methods.pub_inc_value.selector(), 42n) + .send({ from: defaultAccountAddress }); + expect(await getChildStoredValue(child)).toEqual(new Fr(85n)); + }); + }); +}); diff --git a/yarn-project/end-to-end/src/automine/contracts/nested/manual_private_call.test.ts b/yarn-project/end-to-end/src/automine/contracts/nested/manual_private_call.test.ts deleted file mode 100644 index 618d3819d261..000000000000 --- a/yarn-project/end-to-end/src/automine/contracts/nested/manual_private_call.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { AutomineTestContext } from '../../automine_test_context.js'; - -// Tests a nested private call from ParentContract into ChildContract's value() function. -// Runs on a single account. applyManualParentChild() deploys Parent and Child contracts in beforeAll. -describe('automine/contracts/nested/manual_private_call', () => { - const t = new AutomineTestContext(); - let { parentContract, childContract, defaultAccountAddress } = t; - - beforeAll(async () => { - await t.setup(); - await t.applyManualParentChild(); - ({ parentContract, childContract, defaultAccountAddress } = t); - }); - - afterAll(async () => { - await t.teardown(); - }); - - // Calls parent.entry_point(child.address, child.value.selector()) and awaits inclusion. - it('performs nested calls', async () => { - await parentContract.methods - .entry_point(childContract.address, await childContract.methods.value.selector()) - .send({ from: defaultAccountAddress }); - }); -}); diff --git a/yarn-project/end-to-end/src/automine/contracts/nested/manual_private_enqueue.parallel.test.ts b/yarn-project/end-to-end/src/automine/contracts/nested/manual_private_enqueue.parallel.test.ts deleted file mode 100644 index c243fc5ec51b..000000000000 --- a/yarn-project/end-to-end/src/automine/contracts/nested/manual_private_enqueue.parallel.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -import type { AztecAddress } from '@aztec/aztec.js/addresses'; -import { Fr } from '@aztec/aztec.js/fields'; -import { ChildContract } from '@aztec/noir-test-contracts.js/Child'; -import { ParentContract } from '@aztec/noir-test-contracts.js/Parent'; - -import { AutomineTestContext } from '../../automine_test_context.js'; - -// Tests parent contracts enqueuing public calls on a child contract via various call patterns. -// Runs on a single account. Parent and Child are deployed fresh per test in beforeEach. -describe('automine/contracts/nested/manual_private_enqueue', () => { - const t = new AutomineTestContext(); - let { wallet, parentContract, childContract, defaultAccountAddress, aztecNode } = t; - - const getChildStoredValue = (child: { address: AztecAddress }) => - aztecNode.getPublicStorageAt('latest', child.address, new Fr(1)); - - beforeAll(async () => { - // We don't deploy contracts in beforeAll because every test requires a fresh setup - await t.setup(); - ({ wallet, defaultAccountAddress, aztecNode } = t); - }); - - beforeEach(async () => { - ({ contract: parentContract } = await ParentContract.deploy(wallet).send({ from: defaultAccountAddress })); - ({ contract: childContract } = await ChildContract.deploy(wallet).send({ from: defaultAccountAddress })); - }); - - afterAll(async () => { - await t.teardown(); - }); - - // Enqueues one pub_inc_value(42) call via the parent and asserts child storage equals 42. - it('enqueues a single public call', async () => { - await parentContract.methods - .enqueue_call_to_child(childContract.address, await childContract.methods.pub_inc_value.selector(), 42n) - .send({ from: defaultAccountAddress }); - expect(await getChildStoredValue(childContract)).toEqual(new Fr(42n)); - }); - - // Enqueues pub_inc_value(42) twice via enqueue_call_to_child_twice and asserts child storage is 85. - it('enqueues multiple public calls', async () => { - await parentContract.methods - .enqueue_call_to_child_twice(childContract.address, await childContract.methods.pub_inc_value.selector(), 42n) - .send({ from: defaultAccountAddress }); - expect(await getChildStoredValue(childContract)).toEqual(new Fr(85n)); - }); - - // Calls enqueue_call_to_pub_entry_point which enqueues pub_entry_point → pub_inc_value; asserts 42. - it('enqueues a public call with nested public calls', async () => { - await parentContract.methods - .enqueue_call_to_pub_entry_point(childContract.address, await childContract.methods.pub_inc_value.selector(), 42n) - .send({ from: defaultAccountAddress }); - expect(await getChildStoredValue(childContract)).toEqual(new Fr(42n)); - }); - - // Calls enqueue_calls_to_pub_entry_point which enqueues pub_entry_point twice; asserts 85. - it('enqueues multiple public calls with nested public calls', async () => { - await parentContract.methods - .enqueue_calls_to_pub_entry_point( - childContract.address, - await childContract.methods.pub_inc_value.selector(), - 42n, - ) - .send({ from: defaultAccountAddress }); - expect(await getChildStoredValue(childContract)).toEqual(new Fr(85n)); - }); -}); diff --git a/yarn-project/end-to-end/src/automine/contracts/nested/manual_public.parallel.test.ts b/yarn-project/end-to-end/src/automine/contracts/nested/manual_public.parallel.test.ts deleted file mode 100644 index bb163922df50..000000000000 --- a/yarn-project/end-to-end/src/automine/contracts/nested/manual_public.parallel.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -import type { AztecAddress } from '@aztec/aztec.js/addresses'; -import { BatchCall } from '@aztec/aztec.js/contracts'; -import { Fr } from '@aztec/aztec.js/fields'; -import { toBigIntBE } from '@aztec/foundation/bigint-buffer'; -import { serializeToBuffer } from '@aztec/foundation/serialize'; - -import { AutomineTestContext } from '../../automine_test_context.js'; - -// Tests public-to-public nested calls and ordering guarantees (public before private enqueue). -// Runs on a single account. applyManualParentChild() deploys Parent and Child contracts in beforeAll. -describe('automine/contracts/nested/manual_public', () => { - const t = new AutomineTestContext(); - let { wallet, parentContract, childContract, defaultAccountAddress, aztecNode } = t; - - const getChildStoredValue = (child: { address: AztecAddress }) => - aztecNode.getPublicStorageAt('latest', child.address, new Fr(1)); - - beforeAll(async () => { - await t.setup(); - await t.applyManualParentChild(); - ({ wallet, parentContract, childContract, defaultAccountAddress, aztecNode } = t); - }); - - afterAll(async () => { - await t.teardown(); - }); - - // Calls parent.pub_entry_point(child, pub_get_value, 42) and awaits inclusion. - it('performs public nested calls', async () => { - await parentContract.methods - .pub_entry_point(childContract.address, await childContract.methods.pub_get_value.selector(), 42n) - .send({ from: defaultAccountAddress }); - }); - - // Regression for https://github.com/AztecProtocol/aztec-packages/issues/640 - // Calls pub_entry_point_twice so pub_inc_value runs twice in one tx; asserts storage is 84 (not 42). - it('reads fresh value after write within the same tx', async () => { - await parentContract.methods - .pub_entry_point_twice(childContract.address, await childContract.methods.pub_inc_value.selector(), 42n) - .send({ from: defaultAccountAddress }); - expect(await getChildStoredValue(childContract)).toEqual(new Fr(84n)); - }); - - // Regression for https://github.com/AztecProtocol/aztec-packages/issues/1645 - // Executes a public call first and then a private call (which enqueues another public call) - // through the account contract, if the account entrypoint behaves properly, it will honor - // this order and not run the private call first which results in the public calls being inverted. - // Batches pub_set_value(20) and parent.enqueue(pub_set_value(40)); reads public logs to assert [20, 40]. - it('executes public calls in expected order', async () => { - const pubSetValueSelector = await childContract.methods.pub_set_value.selector(); - const actions = [ - childContract.methods.pub_set_value(20n), - parentContract.methods.enqueue_call_to_child(childContract.address, pubSetValueSelector, 40n), - ]; - - const { receipt: tx } = await new BatchCall(wallet, actions).send({ from: defaultAccountAddress }); - const block = (await aztecNode.getBlock({ number: tx.blockNumber! }, { includeTransactions: true }))!; - const allPublicLogs = block.body.txEffects.flatMap(tx => tx.publicLogs); - const processedLogs = allPublicLogs.map(log => toBigIntBE(serializeToBuffer(log.getEmittedFields()))); - expect(processedLogs).toEqual([20n, 40n]); - expect(await getChildStoredValue(childContract)).toEqual(new Fr(40n)); - }); -}); diff --git a/yarn-project/end-to-end/src/automine/contracts/nested_utility_calls.parallel.test.ts b/yarn-project/end-to-end/src/automine/contracts/nested_utility_calls.test.ts similarity index 53% rename from yarn-project/end-to-end/src/automine/contracts/nested_utility_calls.parallel.test.ts rename to yarn-project/end-to-end/src/automine/contracts/nested_utility_calls.test.ts index 6dd1ccbc2c20..6c164725bc4b 100644 --- a/yarn-project/end-to-end/src/automine/contracts/nested_utility_calls.parallel.test.ts +++ b/yarn-project/end-to-end/src/automine/contracts/nested_utility_calls.test.ts @@ -1,5 +1,6 @@ import { NO_FROM } from '@aztec/aztec.js/account'; import type { AztecAddress } from '@aztec/aztec.js/addresses'; +import type { ContractFunctionInteraction } from '@aztec/aztec.js/contracts'; import type { Wallet } from '@aztec/aztec.js/wallet'; import type { Fr } from '@aztec/foundation/curves/bn254'; import { NestedUtilityContract } from '@aztec/noir-test-contracts.js/NestedUtility'; @@ -48,27 +49,27 @@ describe('automine/contracts/nested_utility_calls', () => { expect(result).toEqual(2n ** 10n); }); - // Simulates pow_private(2, 10) which calls pow_utility from a private function context; expects - // 1024. + // Simulates pow_private(2, 10) which calls pow_utility from a private function context; expects 1024. it('pow_private 2 to the 10 returns 1024 - private function calling utility', async () => { const { result } = await contractA.methods.pow_private(2n, 10).simulate({ from: defaultAccountAddress }); expect(result).toEqual(2n ** 10n); }); - // Simulates contractA.delegate_pow_utility(contractB, 2, 3) with no hook registered; expects - // 'Cross-contract utility call denied'. - it('denies cross-contract utility call from utility context by default', async () => { - await expect( - contractA.methods.delegate_pow_utility(contractB.address, 2n, 3n).simulate({ from: defaultAccountAddress }), - ).rejects.toThrow('Cross-contract utility call denied'); - }); - - // Simulates contractA.delegate_pow_private(contractB, 2, 3) with no hook; expects 'Cross-contract - // utility call denied'. - it('denies cross-contract utility call from private function by default', async () => { - await expect( - contractA.methods.delegate_pow_private(contractB.address, 2n, 3n).simulate({ from: defaultAccountAddress }), - ).rejects.toThrow('Cross-contract utility call denied'); + // With no authorizeUtilityCall hook registered, a cross-contract utility call is denied by default + // whether it originates from a utility or a private function. + it.each([ + { + context: 'utility context', + getInteraction: () => contractA.methods.delegate_pow_utility(contractB.address, 2n, 3n), + }, + { + context: 'private function', + getInteraction: () => contractA.methods.delegate_pow_private(contractB.address, 2n, 3n), + }, + ])('denies cross-contract utility call from $context by default', async ({ getInteraction }) => { + await expect(getInteraction().simulate({ from: defaultAccountAddress })).rejects.toThrow( + 'Cross-contract utility call denied', + ); }); it('top-level utility has no caller, so its msg_sender is none', async () => { @@ -128,39 +129,58 @@ describe('authorizeUtilityCall hook', () => { lastRequest = undefined; }); - // Calls delegate_pow_utility with hookAllows=false; expects denial and checks lastRequest fields. - it('denies cross-contract utility call from utility context when hook returns false', async () => { - await expect( - contractA.methods.delegate_pow_utility(contractB.address, 2n, 3n).simulate({ from: defaultAccountAddress }), - ).rejects.toThrow('Cross-contract utility call denied'); - expect(lastRequest).toMatchObject({ - caller: contractA.address, - callerClassId: contractClassId, - target: contractB.address, - targetClassId: contractClassId, - functionSelector: await contractB.methods.pow_utility.selector(), - functionName: 'pow_utility', - callerContext: 'utility', - }); - }); - - // Sets hookAllows=true, calls delegate_pow_utility, and asserts result=8 and lastRequest fields. - it('allows cross-contract utility call from utility context when hook returns true', async () => { - hookAllows = true; - const { result } = await contractA.methods - .delegate_pow_utility(contractB.address, 2n, 3n) - .simulate({ from: defaultAccountAddress }); - expect(result).toEqual(8n); // 2^3 - expect(lastRequest).toMatchObject({ - caller: contractA.address, - callerClassId: contractClassId, - target: contractB.address, - targetClassId: contractClassId, - functionSelector: await contractB.methods.pow_utility.selector(), - functionName: 'pow_utility', + const hookRows: { + context: string; + callerContext: string; + allow: boolean; + getInteraction: () => ContractFunctionInteraction; + }[] = [ + { + context: 'utility', callerContext: 'utility', - }); - }); + getInteraction: () => contractA.methods.delegate_pow_utility(contractB.address, 2n, 3n), + }, + { + context: 'private function', + callerContext: 'private', + getInteraction: () => contractA.methods.delegate_pow_private(contractB.address, 2n, 3n), + }, + { + context: 'view function', + callerContext: 'private view', + getInteraction: () => contractA.methods.delegate_pow_view(contractB.address, 2n, 3n), + }, + ].flatMap(row => [ + { ...row, allow: false }, + { ...row, allow: true }, + ]); + + // The hook is consulted for every cross-contract utility call; its boolean return governs access, and + // the request it receives carries the caller/target class ids, the target selector, and the caller + // context (utility / private / private view). A denied call throws; an allowed one returns 2^3 = 8. + it.each(hookRows)( + '$context context: hook returning $allow governs the cross-contract utility call', + async ({ getInteraction, callerContext, allow }) => { + hookAllows = allow; + if (allow) { + const { result } = await getInteraction().simulate({ from: defaultAccountAddress }); + expect(result).toEqual(8n); + } else { + await expect(getInteraction().simulate({ from: defaultAccountAddress })).rejects.toThrow( + 'Cross-contract utility call denied', + ); + } + expect(lastRequest).toMatchObject({ + caller: contractA.address, + callerClassId: contractClassId, + target: contractB.address, + targetClassId: contractClassId, + functionSelector: await contractB.methods.pow_utility.selector(), + functionName: 'pow_utility', + callerContext, + }); + }, + ); it('nested utility call sees the calling contract as its msg_sender', async () => { hookAllows = true; @@ -170,75 +190,6 @@ describe('authorizeUtilityCall hook', () => { expect(result).toEqual(contractA.address); }); - // Calls delegate_pow_private with hookAllows=false; expects denial and checks lastRequest - // callerContext is 'private'. - it('denies cross-contract utility call from private function when hook returns false', async () => { - await expect( - contractA.methods.delegate_pow_private(contractB.address, 2n, 3n).simulate({ from: defaultAccountAddress }), - ).rejects.toThrow('Cross-contract utility call denied'); - expect(lastRequest).toMatchObject({ - caller: contractA.address, - callerClassId: contractClassId, - target: contractB.address, - targetClassId: contractClassId, - functionSelector: await contractB.methods.pow_utility.selector(), - functionName: 'pow_utility', - callerContext: 'private', - }); - }); - - // Sets hookAllows=true, calls delegate_pow_private, and asserts result=8 with 'private' context. - it('allows cross-contract utility call from private function when hook returns true', async () => { - hookAllows = true; - const { result } = await contractA.methods - .delegate_pow_private(contractB.address, 2n, 3n) - .simulate({ from: defaultAccountAddress }); - expect(result).toEqual(8n); // 2^3 - expect(lastRequest).toMatchObject({ - caller: contractA.address, - callerClassId: contractClassId, - target: contractB.address, - targetClassId: contractClassId, - functionSelector: await contractB.methods.pow_utility.selector(), - functionName: 'pow_utility', - callerContext: 'private', - }); - }); - - // Calls delegate_pow_view with hookAllows=false; expects denial with 'private view' context. - it('denies cross-contract utility call from view function when hook returns false', async () => { - await expect( - contractA.methods.delegate_pow_view(contractB.address, 2n, 3n).simulate({ from: defaultAccountAddress }), - ).rejects.toThrow('Cross-contract utility call denied'); - expect(lastRequest).toMatchObject({ - caller: contractA.address, - callerClassId: contractClassId, - target: contractB.address, - targetClassId: contractClassId, - functionSelector: await contractB.methods.pow_utility.selector(), - functionName: 'pow_utility', - callerContext: 'private view', - }); - }); - - // Sets hookAllows=true, calls delegate_pow_view, and asserts result=8 with 'private view' context. - it('allows cross-contract utility call from view function when hook returns true', async () => { - hookAllows = true; - const { result } = await contractA.methods - .delegate_pow_view(contractB.address, 2n, 3n) - .simulate({ from: defaultAccountAddress }); - expect(result).toEqual(8n); - expect(lastRequest).toMatchObject({ - caller: contractA.address, - callerClassId: contractClassId, - target: contractB.address, - targetClassId: contractClassId, - functionSelector: await contractB.methods.pow_utility.selector(), - functionName: 'pow_utility', - callerContext: 'private view', - }); - }); - // Stores pow args as notes on contractB, then calls delegate_pow_from_storage from contractA // (cross-contract). Asserts that contractB's notes are synced before the utility call so that // the stored values are discoverable. diff --git a/yarn-project/end-to-end/src/automine/contracts/option_params.parallel.test.ts b/yarn-project/end-to-end/src/automine/contracts/option_params.parallel.test.ts deleted file mode 100644 index b73ce33dcf74..000000000000 --- a/yarn-project/end-to-end/src/automine/contracts/option_params.parallel.test.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { AztecAddress } from '@aztec/aztec.js/addresses'; -import type { Wallet } from '@aztec/aztec.js/wallet'; -import { MAX_FIELD_VALUE } from '@aztec/constants'; -import { OptionParamContract } from '@aztec/noir-test-contracts.js/OptionParam'; - -import { jest } from '@jest/globals'; - -import { AutomineTestContext } from '../automine_test_context.js'; - -const TIMEOUT = 300_000; - -const U64_MAX = 2n ** 64n - 1n; -const I64_MIN = -(2n ** 63n); - -// Verifies that the Aztec.js ABI layer correctly serialises/deserialises Noir Option parameters -// for public, utility, and private functions. Single node with AutomineSequencer; all calls are -// simulate()-only (no on-chain state changes). -describe('automine/contracts/option_params', () => { - let contract: OptionParamContract; - let wallet: Wallet; - let defaultAccountAddress: AztecAddress; - let teardown: () => Promise; - - const someValue = { - w: MAX_FIELD_VALUE, - x: true, - y: U64_MAX, - z: I64_MIN, - }; - - jest.setTimeout(TIMEOUT); - - beforeAll(async () => { - ({ - teardown, - wallet, - accounts: [defaultAccountAddress], - } = (await AutomineTestContext.setup({ numberOfAccounts: 1 })).context); - - contract = (await OptionParamContract.deploy(wallet).send({ from: defaultAccountAddress })).contract; - }); - - afterAll(() => teardown()); - - // Simulates a public function accepting Option with undefined, null, and a real value, - // asserting each maps to None / None / Some correctly. - it('accepts ergonomic Option params for public functions', async () => { - const { result } = await contract.methods - .return_public_optional_struct(undefined) - .simulate({ from: defaultAccountAddress }); - expect(result).toBeUndefined(); - - const { result: nullResult } = await contract.methods - .return_public_optional_struct(null) - .simulate({ from: defaultAccountAddress }); - expect(nullResult).toBeUndefined(); - - const { result: someResult } = await contract.methods - .return_public_optional_struct(someValue) - .simulate({ from: defaultAccountAddress }); - expect(someResult).toEqual(someValue); - }); - - // Same Option round-trip check for a Noir utility function via simulate(). - it('accepts ergonomic Option params for utility functions', async () => { - const { result: undefinedResult } = await contract.methods - .return_utility_optional_struct(undefined) - .simulate({ from: defaultAccountAddress }); - expect(undefinedResult).toBeUndefined(); - - const { result: nullResult } = await contract.methods - .return_utility_optional_struct(null) - .simulate({ from: defaultAccountAddress }); - expect(nullResult).toBeUndefined(); - - const { result: someResult } = await contract.methods - .return_utility_optional_struct(someValue) - .simulate({ from: defaultAccountAddress }); - expect(someResult).toEqual(someValue); - }); - - // Same Option round-trip check for a Noir private function via simulate(). - it('accepts ergonomic Option params for private functions', async () => { - const { result: undefinedResult } = await contract.methods - .return_private_optional_struct(undefined) - .simulate({ from: defaultAccountAddress }); - expect(undefinedResult).toBeUndefined(); - - const { result: nullResult } = await contract.methods - .return_private_optional_struct(null) - .simulate({ from: defaultAccountAddress }); - expect(nullResult).toBeUndefined(); - - const { result: someResult } = await contract.methods - .return_private_optional_struct(someValue) - .simulate({ from: defaultAccountAddress }); - expect(someResult).toEqual(someValue); - }); -}); diff --git a/yarn-project/end-to-end/src/automine/contracts/option_params.test.ts b/yarn-project/end-to-end/src/automine/contracts/option_params.test.ts new file mode 100644 index 000000000000..26679ce0761d --- /dev/null +++ b/yarn-project/end-to-end/src/automine/contracts/option_params.test.ts @@ -0,0 +1,66 @@ +import type { AztecAddress } from '@aztec/aztec.js/addresses'; +import type { ContractFunctionInteraction } from '@aztec/aztec.js/contracts'; +import type { Wallet } from '@aztec/aztec.js/wallet'; +import { MAX_FIELD_VALUE } from '@aztec/constants'; +import { OptionParamContract } from '@aztec/noir-test-contracts.js/OptionParam'; + +import { jest } from '@jest/globals'; + +import { AutomineTestContext } from '../automine_test_context.js'; + +const TIMEOUT = 300_000; + +const U64_MAX = 2n ** 64n - 1n; +const I64_MIN = -(2n ** 63n); + +// Verifies that the Aztec.js ABI layer correctly serialises/deserialises Noir Option parameters +// for public, utility, and private functions. Single node with AutomineSequencer; all calls are +// simulate()-only (no on-chain state changes). +describe('automine/contracts/option_params', () => { + let contract: OptionParamContract; + let wallet: Wallet; + let defaultAccountAddress: AztecAddress; + let teardown: () => Promise; + + const someValue = { + w: MAX_FIELD_VALUE, + x: true, + y: U64_MAX, + z: I64_MIN, + }; + + type OptionalStructArg = Parameters[0]; + + jest.setTimeout(TIMEOUT); + + beforeAll(async () => { + ({ + teardown, + wallet, + accounts: [defaultAccountAddress], + } = (await AutomineTestContext.setup({ numberOfAccounts: 1 })).context); + + contract = (await OptionParamContract.deploy(wallet).send({ from: defaultAccountAddress })).contract; + }); + + afterAll(() => teardown()); + + const variants: { kind: string; call: (arg: OptionalStructArg) => ContractFunctionInteraction }[] = [ + { kind: 'public', call: arg => contract.methods.return_public_optional_struct(arg) }, + { kind: 'utility', call: arg => contract.methods.return_utility_optional_struct(arg) }, + { kind: 'private', call: arg => contract.methods.return_private_optional_struct(arg) }, + ]; + + // For each function kind, an Option param round-trips: undefined and null both map to None, + // and a real value maps to Some. + it.each(variants)('accepts ergonomic Option params for $kind functions', async ({ call }) => { + const { result: undefinedResult } = await call(undefined).simulate({ from: defaultAccountAddress }); + expect(undefinedResult).toBeUndefined(); + + const { result: nullResult } = await call(null).simulate({ from: defaultAccountAddress }); + expect(nullResult).toBeUndefined(); + + const { result: someResult } = await call(someValue).simulate({ from: defaultAccountAddress }); + expect(someResult).toEqual(someValue); + }); +}); diff --git a/yarn-project/end-to-end/src/automine/contracts/state_vars.test.ts b/yarn-project/end-to-end/src/automine/contracts/state_vars.test.ts index 6695d135d630..c5765a035569 100644 --- a/yarn-project/end-to-end/src/automine/contracts/state_vars.test.ts +++ b/yarn-project/end-to-end/src/automine/contracts/state_vars.test.ts @@ -1,5 +1,5 @@ import { AztecAddress } from '@aztec/aztec.js/addresses'; -import { BatchCall } from '@aztec/aztec.js/contracts'; +import { BatchCall, type ContractFunctionInteraction } from '@aztec/aztec.js/contracts'; import type { AztecNode } from '@aztec/aztec.js/node'; import { DefaultL1ContractsConfig } from '@aztec/ethereum/config'; import { AuthContract } from '@aztec/noir-contracts.js/Auth'; @@ -42,6 +42,12 @@ describe('automine/contracts/state_vars', () => { afterAll(() => teardown()); + // Simulates an `is_*_initialized` view and asserts its boolean result, collapsing the repeated + // initialization-status checks across the PrivateMutable and PrivateImmutable suites. + const expectInitialized = async (isInitialized: ContractFunctionInteraction, expected: boolean) => { + expect((await isInitialized.simulate({ from: defaultAccountAddress })).result).toEqual(expected); + }; + // Tests for PublicImmutable: initialize-once semantics, reading from private/public/utility contexts, // and rejection of double-initialization. describe('PublicImmutable', () => { @@ -139,13 +145,7 @@ describe('automine/contracts/state_vars', () => { // Asserts is_private_mutable_initialized returns false before initialization, then confirms // get_private_mutable throws on an uninitialized slot. it('fail to read uninitialized PrivateMutable', async () => { - expect( - ( - await contract.methods - .is_private_mutable_initialized(defaultAccountAddress) - .simulate({ from: defaultAccountAddress }) - ).result, - ).toEqual(false); + await expectInitialized(contract.methods.is_private_mutable_initialized(defaultAccountAddress), false); await expect( contract.methods.get_private_mutable(defaultAccountAddress).simulate({ from: defaultAccountAddress }), ).rejects.toThrow(); @@ -154,13 +154,7 @@ describe('automine/contracts/state_vars', () => { // Sends initialize_private(RANDOMNESS, VALUE), verifies the tx produces 2 nullifiers (one for the // tx and one for the initializer), and asserts is_private_mutable_initialized returns true after. it('initialize PrivateMutable', async () => { - expect( - ( - await contract.methods - .is_private_mutable_initialized(defaultAccountAddress) - .simulate({ from: defaultAccountAddress }) - ).result, - ).toEqual(false); + await expectInitialized(contract.methods.is_private_mutable_initialized(defaultAccountAddress), false); // Send the transaction and wait for it to be mined (wait function throws if the tx is not mined) const { receipt: txReceipt } = await contract.methods .initialize_private(RANDOMNESS, VALUE) @@ -170,46 +164,22 @@ describe('automine/contracts/state_vars', () => { // 1 for the tx, another for the initializer expect(txEffects?.data.nullifiers.length).toEqual(2); - expect( - ( - await contract.methods - .is_private_mutable_initialized(defaultAccountAddress) - .simulate({ from: defaultAccountAddress }) - ).result, - ).toEqual(true); + await expectInitialized(contract.methods.is_private_mutable_initialized(defaultAccountAddress), true); }); // Attempts to call initialize_private a second time; asserts it throws and the initialized flag // remains true. it('fail to reinitialize', async () => { - expect( - ( - await contract.methods - .is_private_mutable_initialized(defaultAccountAddress) - .simulate({ from: defaultAccountAddress }) - ).result, - ).toEqual(true); + await expectInitialized(contract.methods.is_private_mutable_initialized(defaultAccountAddress), true); await expect( contract.methods.initialize_private(RANDOMNESS, VALUE).send({ from: defaultAccountAddress }), ).rejects.toThrow(); - expect( - ( - await contract.methods - .is_private_mutable_initialized(defaultAccountAddress) - .simulate({ from: defaultAccountAddress }) - ).result, - ).toEqual(true); + await expectInitialized(contract.methods.is_private_mutable_initialized(defaultAccountAddress), true); }); // Reads the PrivateMutable after initialization; asserts the stored value matches VALUE. it('read initialized PrivateMutable', async () => { - expect( - ( - await contract.methods - .is_private_mutable_initialized(defaultAccountAddress) - .simulate({ from: defaultAccountAddress }) - ).result, - ).toEqual(true); + await expectInitialized(contract.methods.is_private_mutable_initialized(defaultAccountAddress), true); const { result: { value }, } = await contract.methods.get_private_mutable(defaultAccountAddress).simulate({ from: defaultAccountAddress }); @@ -219,13 +189,7 @@ describe('automine/contracts/state_vars', () => { // Calls update_private_mutable with the same RANDOMNESS and VALUE; asserts one new note hash and // 2 nullifiers (tx + old note), and the stored value is unchanged. it('replace with same value', async () => { - expect( - ( - await contract.methods - .is_private_mutable_initialized(defaultAccountAddress) - .simulate({ from: defaultAccountAddress }) - ).result, - ).toEqual(true); + await expectInitialized(contract.methods.is_private_mutable_initialized(defaultAccountAddress), true); const { result: noteBefore } = await contract.methods .get_private_mutable(defaultAccountAddress) .simulate({ from: defaultAccountAddress }); @@ -249,13 +213,7 @@ describe('automine/contracts/state_vars', () => { // Calls update_private_mutable with different RANDOMNESS and VALUE; asserts one new note hash, // 2 nullifiers, and the stored value matches the new VALUE. it('replace PrivateMutable with other values', async () => { - expect( - ( - await contract.methods - .is_private_mutable_initialized(defaultAccountAddress) - .simulate({ from: defaultAccountAddress }) - ).result, - ).toEqual(true); + await expectInitialized(contract.methods.is_private_mutable_initialized(defaultAccountAddress), true); const { receipt: txReceipt } = await contract.methods .update_private_mutable(RANDOMNESS + 2n, VALUE + 1n) .send({ from: defaultAccountAddress }); @@ -275,13 +233,7 @@ describe('automine/contracts/state_vars', () => { // Calls increase_private_value (reads then updates in private); asserts the new value is exactly // the prior value + 1, verifying read-then-write consistency. it('replace PrivateMutable dependent on prior value', async () => { - expect( - ( - await contract.methods - .is_private_mutable_initialized(defaultAccountAddress) - .simulate({ from: defaultAccountAddress }) - ).result, - ).toEqual(true); + await expectInitialized(contract.methods.is_private_mutable_initialized(defaultAccountAddress), true); const { result: noteBefore } = await contract.methods .get_private_mutable(defaultAccountAddress) .simulate({ from: defaultAccountAddress }); @@ -307,13 +259,7 @@ describe('automine/contracts/state_vars', () => { describe('PrivateImmutable', () => { // Asserts is_priv_imm_initialized is false before initialization and that view_private_immutable throws. it('fail to read uninitialized PrivateImmutable', async () => { - expect( - ( - await contract.methods - .is_priv_imm_initialized(defaultAccountAddress) - .simulate({ from: defaultAccountAddress }) - ).result, - ).toEqual(false); + await expectInitialized(contract.methods.is_priv_imm_initialized(defaultAccountAddress), false); await expect( contract.methods.view_private_immutable(defaultAccountAddress).simulate({ from: defaultAccountAddress }), ).rejects.toThrow(); @@ -322,13 +268,7 @@ describe('automine/contracts/state_vars', () => { // Calls initialize_private_immutable(RANDOMNESS, VALUE); asserts 1 note hash and 2 nullifiers are // emitted, and is_priv_imm_initialized becomes true. it('initialize PrivateImmutable', async () => { - expect( - ( - await contract.methods - .is_priv_imm_initialized(defaultAccountAddress) - .simulate({ from: defaultAccountAddress }) - ).result, - ).toEqual(false); + await expectInitialized(contract.methods.is_priv_imm_initialized(defaultAccountAddress), false); const { receipt: txReceipt } = await contract.methods .initialize_private_immutable(RANDOMNESS, VALUE) .send({ from: defaultAccountAddress }); @@ -338,45 +278,21 @@ describe('automine/contracts/state_vars', () => { expect(txEffects?.data.noteHashes.length).toEqual(1); // 1 for the tx, another for the initializer expect(txEffects?.data.nullifiers.length).toEqual(2); - expect( - ( - await contract.methods - .is_priv_imm_initialized(defaultAccountAddress) - .simulate({ from: defaultAccountAddress }) - ).result, - ).toEqual(true); + await expectInitialized(contract.methods.is_priv_imm_initialized(defaultAccountAddress), true); }); // Calls initialize_private_immutable a second time; asserts it throws and the flag remains true. it('fail to reinitialize', async () => { - expect( - ( - await contract.methods - .is_priv_imm_initialized(defaultAccountAddress) - .simulate({ from: defaultAccountAddress }) - ).result, - ).toEqual(true); + await expectInitialized(contract.methods.is_priv_imm_initialized(defaultAccountAddress), true); await expect( contract.methods.initialize_private_immutable(RANDOMNESS, VALUE).send({ from: defaultAccountAddress }), ).rejects.toThrow(); - expect( - ( - await contract.methods - .is_priv_imm_initialized(defaultAccountAddress) - .simulate({ from: defaultAccountAddress }) - ).result, - ).toEqual(true); + await expectInitialized(contract.methods.is_priv_imm_initialized(defaultAccountAddress), true); }); // Reads the PrivateImmutable after initialization; asserts the stored value matches VALUE. it('read initialized PrivateImmutable', async () => { - expect( - ( - await contract.methods - .is_priv_imm_initialized(defaultAccountAddress) - .simulate({ from: defaultAccountAddress }) - ).result, - ).toEqual(true); + await expectInitialized(contract.methods.is_priv_imm_initialized(defaultAccountAddress), true); const { result: { value }, } = await contract.methods diff --git a/yarn-project/end-to-end/src/automine/effects/custom_message.parallel.test.ts b/yarn-project/end-to-end/src/automine/effects/custom_message.parallel.test.ts deleted file mode 100644 index 8be39d89cfd2..000000000000 --- a/yarn-project/end-to-end/src/automine/effects/custom_message.parallel.test.ts +++ /dev/null @@ -1,93 +0,0 @@ -import type { AztecAddress } from '@aztec/aztec.js/addresses'; -import { BatchCall } from '@aztec/aztec.js/contracts'; -import { Fr } from '@aztec/aztec.js/fields'; -import type { Wallet } from '@aztec/aztec.js/wallet'; -import { BlockNumber } from '@aztec/foundation/branded-types'; -import { CustomMessageContract, type MultiLogEvent } from '@aztec/noir-test-contracts.js/CustomMessage'; - -import { jest } from '@jest/globals'; - -import { AutomineTestContext } from '../automine_test_context.js'; - -const TIMEOUT = 300_000; - -// Tests the CustomMessage contract's multi-log event pattern: emitting a single event split across -// multiple private logs and reassembling it via wallet.getPrivateEvents. -// Uses setup(1, AUTOMINE_E2E_OPTS) with one node, automine sequencer, one account. -describe('automine/effects/custom_message', () => { - let contract: CustomMessageContract; - jest.setTimeout(TIMEOUT); - - let wallet: Wallet; - let account: AztecAddress; - let teardown: () => Promise; - - beforeAll(async () => { - ({ - teardown, - wallet, - accounts: [account], - } = (await AutomineTestContext.setup({ numberOfAccounts: 1 })).context); - ({ contract } = await CustomMessageContract.deploy(wallet).send({ from: account })); - }); - - afterAll(() => teardown()); - - // Emits one MultiLogEvent via emit_multi_log_event, retrieves it via getPrivateEvents, and - // asserts all four field values match. - it('reassembles a multi-log event from multiple private logs', async () => { - const values = [Fr.random(), Fr.random(), Fr.random(), Fr.random()]; - - const { receipt: tx } = await contract.methods - .emit_multi_log_event(values[0], values[1], values[2], values[3], account) - .send({ from: account }); - - const events = await wallet.getPrivateEvents(CustomMessageContract.events.MultiLogEvent, { - contractAddress: contract.address, - fromBlock: BlockNumber(tx.blockNumber!), - toBlock: BlockNumber(tx.blockNumber! + 1), - scopes: [account], - }); - - expect(events.length).toBe(1); - expect(events[0].event.value0).toBe(values[0].toBigInt()); - expect(events[0].event.value1).toBe(values[1].toBigInt()); - expect(events[0].event.value2).toBe(values[2].toBigInt()); - expect(events[0].event.value3).toBe(values[3].toBigInt()); - }); - - // Emits two MultiLogEvents in a single BatchCall, retrieves both, and asserts all eight field - // values match by matching on value0. - it('reassembles multiple multi-log events from the same transaction', async () => { - const valuesA = [Fr.random(), Fr.random(), Fr.random(), Fr.random()]; - const valuesB = [Fr.random(), Fr.random(), Fr.random(), Fr.random()]; - - const { receipt: tx } = await new BatchCall(wallet, [ - contract.methods.emit_multi_log_event(valuesA[0], valuesA[1], valuesA[2], valuesA[3], account), - contract.methods.emit_multi_log_event(valuesB[0], valuesB[1], valuesB[2], valuesB[3], account), - ]).send({ from: account }); - - const events = await wallet.getPrivateEvents(CustomMessageContract.events.MultiLogEvent, { - contractAddress: contract.address, - fromBlock: BlockNumber(tx.blockNumber!), - toBlock: BlockNumber(tx.blockNumber! + 1), - scopes: [account], - }); - - expect(events.length).toBe(2); - - // Events may arrive in any order, so match by value0 - const eventA = events.find(e => e.event.value0 === valuesA[0].toBigInt())!; - const eventB = events.find(e => e.event.value0 === valuesB[0].toBigInt())!; - - expect(eventA).toBeDefined(); - expect(eventA.event.value1).toBe(valuesA[1].toBigInt()); - expect(eventA.event.value2).toBe(valuesA[2].toBigInt()); - expect(eventA.event.value3).toBe(valuesA[3].toBigInt()); - - expect(eventB).toBeDefined(); - expect(eventB.event.value1).toBe(valuesB[1].toBigInt()); - expect(eventB.event.value2).toBe(valuesB[2].toBigInt()); - expect(eventB.event.value3).toBe(valuesB[3].toBigInt()); - }); -}); diff --git a/yarn-project/end-to-end/src/automine/effects/event_only.test.ts b/yarn-project/end-to-end/src/automine/effects/event_only.test.ts deleted file mode 100644 index 7ce0e5d05772..000000000000 --- a/yarn-project/end-to-end/src/automine/effects/event_only.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { AztecAddress } from '@aztec/aztec.js/addresses'; -import { Fr } from '@aztec/aztec.js/fields'; -import type { Wallet } from '@aztec/aztec.js/wallet'; -import { BlockNumber } from '@aztec/foundation/branded-types'; -import { EventOnlyContract, type TestEvent } from '@aztec/noir-test-contracts.js/EventOnly'; - -import { jest } from '@jest/globals'; - -import { AutomineTestContext } from '../automine_test_context.js'; - -const TIMEOUT = 300_000; - -/// Tests that a private event can be obtained for a contract that does not work with notes. -// Single automine node, one genesis-funded account, EventOnlyContract deployed in beforeAll. -describe('automine/effects/event_only', () => { - let eventOnlyContract: EventOnlyContract; - jest.setTimeout(TIMEOUT); - - let wallet: Wallet; - let defaultAccountAddress: AztecAddress; - let teardown: () => Promise; - - beforeAll(async () => { - ({ - teardown, - wallet, - accounts: [defaultAccountAddress], - } = (await AutomineTestContext.setup({ numberOfAccounts: 1 })).context); - ({ contract: eventOnlyContract } = await EventOnlyContract.deploy(wallet).send({ from: defaultAccountAddress })); - }); - - afterAll(() => teardown()); - - // Sends emit_event_for_msg_sender, then calls getPrivateEvents for TestEvent and asserts that - // exactly one event is returned with the correct value field. - it('emits and retrieves a private event for a contract with no notes', async () => { - const value = Fr.random(); - const { receipt: tx } = await eventOnlyContract.methods - .emit_event_for_msg_sender(value) - .send({ from: defaultAccountAddress }); - - const events = await wallet.getPrivateEvents(EventOnlyContract.events.TestEvent, { - contractAddress: eventOnlyContract.address, - fromBlock: BlockNumber(tx.blockNumber!), - toBlock: BlockNumber(tx.blockNumber! + 1), - scopes: [defaultAccountAddress], - }); - - expect(events.length).toBe(1); - expect(events[0].event.value).toBe(value.toBigInt()); - }); -}); diff --git a/yarn-project/end-to-end/src/automine/effects/events.test.ts b/yarn-project/end-to-end/src/automine/effects/events.test.ts new file mode 100644 index 000000000000..aa8455be59c2 --- /dev/null +++ b/yarn-project/end-to-end/src/automine/effects/events.test.ts @@ -0,0 +1,149 @@ +import type { AztecAddress } from '@aztec/aztec.js/addresses'; +import { BatchCall } from '@aztec/aztec.js/contracts'; +import { getPublicEvents } from '@aztec/aztec.js/events'; +import { Fr } from '@aztec/aztec.js/fields'; +import type { AztecNode } from '@aztec/aztec.js/node'; +import type { Wallet } from '@aztec/aztec.js/wallet'; +import { BlockNumber } from '@aztec/foundation/branded-types'; +import { CustomMessageContract, type MultiLogEvent } from '@aztec/noir-test-contracts.js/CustomMessage'; +import { EventOnlyContract, type TestEvent } from '@aztec/noir-test-contracts.js/EventOnly'; +import { type LargeEvent, LargePublicEventContract } from '@aztec/noir-test-contracts.js/LargePublicEvent'; + +import { jest } from '@jest/globals'; + +import { AutomineTestContext } from '../automine_test_context.js'; + +const TIMEOUT = 300_000; + +// Consolidated event emission/retrieval tests. A single automine node with one funded account deploys the +// EventOnly, CustomMessage, and LargePublicEvent contracts in beforeAll; each per-contract describe below +// exercises one event round-trip via wallet.getPrivateEvents / getPublicEvents. +describe('automine/effects/events', () => { + jest.setTimeout(TIMEOUT); + + let wallet: Wallet; + let aztecNode: AztecNode; + let account: AztecAddress; + let teardown: () => Promise; + + let eventOnlyContract: EventOnlyContract; + let customMessageContract: CustomMessageContract; + let largePublicEventContract: LargePublicEventContract; + + beforeAll(async () => { + ({ + teardown, + wallet, + aztecNode, + accounts: [account], + } = (await AutomineTestContext.setup({ numberOfAccounts: 1 })).context); + + ({ contract: eventOnlyContract } = await EventOnlyContract.deploy(wallet).send({ from: account })); + ({ contract: customMessageContract } = await CustomMessageContract.deploy(wallet).send({ from: account })); + ({ contract: largePublicEventContract } = await LargePublicEventContract.deploy(wallet).send({ from: account })); + }); + + afterAll(() => teardown()); + + // A private event can be obtained for a contract that does not work with notes. + describe('EventOnly', () => { + // Sends emit_event_for_msg_sender, then calls getPrivateEvents for TestEvent and asserts that + // exactly one event is returned with the correct value field. + it('emits and retrieves a private event for a contract with no notes', async () => { + const value = Fr.random(); + const { receipt: tx } = await eventOnlyContract.methods.emit_event_for_msg_sender(value).send({ from: account }); + + const events = await wallet.getPrivateEvents(EventOnlyContract.events.TestEvent, { + contractAddress: eventOnlyContract.address, + fromBlock: BlockNumber(tx.blockNumber!), + toBlock: BlockNumber(tx.blockNumber! + 1), + scopes: [account], + }); + + expect(events.length).toBe(1); + expect(events[0].event.value).toBe(value.toBigInt()); + }); + }); + + // The CustomMessage contract's multi-log event pattern: emitting a single event split across multiple + // private logs and reassembling it via wallet.getPrivateEvents. + describe('CustomMessage', () => { + // Emits one MultiLogEvent via emit_multi_log_event, retrieves it via getPrivateEvents, and + // asserts all four field values match. + it('reassembles a multi-log event from multiple private logs', async () => { + const values = [Fr.random(), Fr.random(), Fr.random(), Fr.random()]; + + const { receipt: tx } = await customMessageContract.methods + .emit_multi_log_event(values[0], values[1], values[2], values[3], account) + .send({ from: account }); + + const events = await wallet.getPrivateEvents(CustomMessageContract.events.MultiLogEvent, { + contractAddress: customMessageContract.address, + fromBlock: BlockNumber(tx.blockNumber!), + toBlock: BlockNumber(tx.blockNumber! + 1), + scopes: [account], + }); + + expect(events.length).toBe(1); + expect(events[0].event.value0).toBe(values[0].toBigInt()); + expect(events[0].event.value1).toBe(values[1].toBigInt()); + expect(events[0].event.value2).toBe(values[2].toBigInt()); + expect(events[0].event.value3).toBe(values[3].toBigInt()); + }); + + // Emits two MultiLogEvents in a single BatchCall, retrieves both, and asserts all eight field + // values match by matching on value0. + it('reassembles multiple multi-log events from the same transaction', async () => { + const valuesA = [Fr.random(), Fr.random(), Fr.random(), Fr.random()]; + const valuesB = [Fr.random(), Fr.random(), Fr.random(), Fr.random()]; + + const { receipt: tx } = await new BatchCall(wallet, [ + customMessageContract.methods.emit_multi_log_event(valuesA[0], valuesA[1], valuesA[2], valuesA[3], account), + customMessageContract.methods.emit_multi_log_event(valuesB[0], valuesB[1], valuesB[2], valuesB[3], account), + ]).send({ from: account }); + + const events = await wallet.getPrivateEvents(CustomMessageContract.events.MultiLogEvent, { + contractAddress: customMessageContract.address, + fromBlock: BlockNumber(tx.blockNumber!), + toBlock: BlockNumber(tx.blockNumber! + 1), + scopes: [account], + }); + + expect(events.length).toBe(2); + + // Events may arrive in any order, so match by value0 + const eventA = events.find(e => e.event.value0 === valuesA[0].toBigInt())!; + const eventB = events.find(e => e.event.value0 === valuesB[0].toBigInt())!; + + expect(eventA).toBeDefined(); + expect(eventA.event.value1).toBe(valuesA[1].toBigInt()); + expect(eventA.event.value2).toBe(valuesA[2].toBigInt()); + expect(eventA.event.value3).toBe(valuesA[3].toBigInt()); + + expect(eventB).toBeDefined(); + expect(eventB.event.value1).toBe(valuesB[1].toBigInt()); + expect(eventB.event.value2).toBe(valuesB[2].toBigInt()); + expect(eventB.event.value3).toBe(valuesB[3].toBigInt()); + }); + }); + + // Events exceeding MAX_EVENT_SERIALIZED_LEN can be emitted publicly and reassembled on retrieval. + describe('LargePublicEvent', () => { + // Sends emit_large_event with 11 random Fr fields, retrieves via getPublicEvents, and asserts + // the returned event's data array matches. + it('emits and retrieves a public event with more than MAX_EVENT_SERIALIZED_LEN fields', async () => { + const data = Array.from({ length: 11 }, () => Fr.random()); + + const { receipt: tx } = await largePublicEventContract.methods.emit_large_event(data).send({ from: account }); + + const { events } = await getPublicEvents(aztecNode, LargePublicEventContract.events.LargeEvent, { + contractAddress: largePublicEventContract.address, + fromBlock: BlockNumber(tx.blockNumber!), + toBlock: BlockNumber(tx.blockNumber! + 1), + }); + + expect(events.length).toBe(1); + expect(events[0].event.data).toEqual(data.map(f => f.toBigInt())); + }); + }); +}); diff --git a/yarn-project/end-to-end/src/automine/effects/large_public_event.test.ts b/yarn-project/end-to-end/src/automine/effects/large_public_event.test.ts deleted file mode 100644 index a31ab8cd55d8..000000000000 --- a/yarn-project/end-to-end/src/automine/effects/large_public_event.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { getPublicEvents } from '@aztec/aztec.js/events'; -import { Fr } from '@aztec/aztec.js/fields'; -import type { AztecNode } from '@aztec/aztec.js/node'; -import type { Wallet } from '@aztec/aztec.js/wallet'; -import { BlockNumber } from '@aztec/foundation/branded-types'; -import { type LargeEvent, LargePublicEventContract } from '@aztec/noir-test-contracts.js/LargePublicEvent'; -import type { AztecAddress } from '@aztec/stdlib/aztec-address'; - -import { jest } from '@jest/globals'; - -import { AutomineTestContext } from '../automine_test_context.js'; - -const TIMEOUT = 300_000; - -/// Tests that events exceeding MAX_EVENT_SERIALIZED_LEN can be emitted publicly. -// Single automine node, one funded account, LargePublicEventContract deployed in beforeAll. -describe('automine/effects/large_public_event', () => { - let contract: LargePublicEventContract; - jest.setTimeout(TIMEOUT); - - let wallet: Wallet; - let aztecNode: AztecNode; - let accountAddress: AztecAddress; - let teardown: () => Promise; - - beforeAll(async () => { - ({ - teardown, - wallet, - aztecNode, - accounts: [accountAddress], - } = (await AutomineTestContext.setup({ numberOfAccounts: 1 })).context); - ({ contract } = await LargePublicEventContract.deploy(wallet).send({ from: accountAddress })); - }); - - afterAll(() => teardown()); - - // Sends emit_large_event with 11 random Fr fields, retrieves via getPublicEvents, and asserts - // the returned event's data array matches. - it('emits and retrieves a public event with more than MAX_EVENT_SERIALIZED_LEN fields', async () => { - const data = Array.from({ length: 11 }, () => Fr.random()); - - const { receipt: tx } = await contract.methods.emit_large_event(data).send({ from: accountAddress }); - - const { events } = await getPublicEvents(aztecNode, LargePublicEventContract.events.LargeEvent, { - contractAddress: contract.address, - fromBlock: BlockNumber(tx.blockNumber!), - toBlock: BlockNumber(tx.blockNumber! + 1), - }); - - expect(events.length).toBe(1); - expect(events[0].event.data).toEqual(data.map(f => f.toBigInt())); - }); -}); diff --git a/yarn-project/end-to-end/src/automine/mempool_limit.test.ts b/yarn-project/end-to-end/src/automine/mempool_limit.test.ts index 28f8a5dc3258..a69525e0b89b 100644 --- a/yarn-project/end-to-end/src/automine/mempool_limit.test.ts +++ b/yarn-project/end-to-end/src/automine/mempool_limit.test.ts @@ -26,12 +26,7 @@ describe('automine/mempool_limit', () => { aztecNodeAdmin, wallet, accounts: [defaultAccountAddress], - } = ( - await AutomineTestContext.setup({ - numberOfAccounts: 1, - proverTestVerificationDelayMs: undefined, - }) - ).context); + } = (await AutomineTestContext.setup({ numberOfAccounts: 1 })).context); if (!aztecNodeAdmin) { throw new Error('Aztec node admin API must be available for this test'); diff --git a/yarn-project/end-to-end/src/automine/simulation/kernelless_simulation.test.ts b/yarn-project/end-to-end/src/automine/simulation/kernelless_simulation.test.ts index e1ba22b90a42..3faf22984d58 100644 --- a/yarn-project/end-to-end/src/automine/simulation/kernelless_simulation.test.ts +++ b/yarn-project/end-to-end/src/automine/simulation/kernelless_simulation.test.ts @@ -8,7 +8,7 @@ import type { Logger } from '@aztec/aztec.js/log'; import type { AztecNode } from '@aztec/aztec.js/node'; import { randomBytes } from '@aztec/foundation/crypto/random'; import { Fq } from '@aztec/foundation/curves/bn254'; -import { AMMContract } from '@aztec/noir-contracts.js/AMM'; +import type { AMMContract } from '@aztec/noir-contracts.js/AMM'; import { type TokenContract, TokenContractArtifact } from '@aztec/noir-contracts.js/Token'; import { AuthWitTestContract, AuthWitTestContractArtifact } from '@aztec/noir-test-contracts.js/AuthWitTest'; import { GenericProxyContract } from '@aztec/noir-test-contracts.js/GenericProxy'; @@ -20,9 +20,10 @@ import { MerkleTreeId } from '@aztec/stdlib/trees'; import { jest } from '@jest/globals'; import { simulateThroughAuthwitProxy } from '../../fixtures/authwit_proxy.js'; -import { deployToken, mintTokensToPrivate } from '../../fixtures/token_utils.js'; +import { deployToken } from '../../fixtures/token_utils.js'; import type { TestWallet } from '../../test-wallet/test_wallet.js'; import { AutomineTestContext } from '../automine_test_context.js'; +import { deployAmmWithTokens } from '../token/token_test_helpers.js'; /* * Demonstrates the capability of simulating a transaction without executing the kernels, allowing @@ -46,7 +47,6 @@ describe('automine/simulation/kernelless_simulation', () => { let token0: TokenContract; let token1: TokenContract; - let liquidityToken: TokenContract; let amm: AMMContract; @@ -61,22 +61,12 @@ describe('automine/simulation/kernelless_simulation', () => { logger, } = (await AutomineTestContext.setup({ numberOfAccounts: 3 })).context); - ({ contract: token0 } = await deployToken(wallet, adminAddress, 0n, logger)); - ({ contract: token1 } = await deployToken(wallet, adminAddress, 0n, logger)); - ({ contract: liquidityToken } = await deployToken(wallet, adminAddress, 0n, logger)); - - ({ contract: amm } = await AMMContract.deploy(wallet, token0.address, token1.address, liquidityToken.address).send({ - from: adminAddress, + ({ token0, token1, amm } = await deployAmmWithTokens(wallet, adminAddress, deployToken, { + liquidityProviders: [liquidityProviderAddress], + swapper: swapperAddress, + initialBalance: INITIAL_TOKEN_BALANCE, + logger, })); - - await liquidityToken.methods.set_minter(amm.address, true).send({ from: adminAddress }); - - // We mint the tokens to the liquidity provider - await mintTokensToPrivate(token0, adminAddress, liquidityProviderAddress, INITIAL_TOKEN_BALANCE); - await mintTokensToPrivate(token1, adminAddress, liquidityProviderAddress, INITIAL_TOKEN_BALANCE); - - // Note that the swapper only holds token0, not token1 - await mintTokensToPrivate(token0, adminAddress, swapperAddress, INITIAL_TOKEN_BALANCE); }); afterAll(() => teardown()); diff --git a/yarn-project/end-to-end/src/automine/token/access_control.parallel.test.ts b/yarn-project/end-to-end/src/automine/token/access_control.parallel.test.ts index 4806a4c74be7..d12f72184301 100644 --- a/yarn-project/end-to-end/src/automine/token/access_control.parallel.test.ts +++ b/yarn-project/end-to-end/src/automine/token/access_control.parallel.test.ts @@ -19,33 +19,33 @@ describe('automine/token/access_control', () => { await t.tokenSim.check(); }); - // Exercises the full admin/minter role narrative as one test: adminAddress hands admin to account1, - // account1 (now admin) grants itself minter, then revokes it. These steps form an ordered chain + // Exercises the full admin/minter role narrative as one test: adminAddress hands admin to other, + // other (now admin) grants itself minter, then revokes it. These steps form an ordered chain // (each builds on the previous), so they live in a single it() — the .parallel split runs every // top-level it() in its own isolated container, where separate ordered tests could not see each // other's state. it('Manages admin and minter roles', async () => { - await t.asset.methods.set_admin(t.account1Address).send({ from: t.adminAddress }); + await t.asset.methods.set_admin(t.otherAddress).send({ from: t.adminAddress }); expect((await t.asset.methods.get_admin().simulate({ from: t.adminAddress })).result).toBe( - t.account1Address.toBigInt(), + t.otherAddress.toBigInt(), ); - await t.asset.methods.set_minter(t.account1Address, true).send({ from: t.account1Address }); - expect((await t.asset.methods.is_minter(t.account1Address).simulate({ from: t.adminAddress })).result).toBe(true); + await t.asset.methods.set_minter(t.otherAddress, true).send({ from: t.otherAddress }); + expect((await t.asset.methods.is_minter(t.otherAddress).simulate({ from: t.adminAddress })).result).toBe(true); - await t.asset.methods.set_minter(t.account1Address, false).send({ from: t.account1Address }); - expect((await t.asset.methods.is_minter(t.account1Address).simulate({ from: t.adminAddress })).result).toBe(false); + await t.asset.methods.set_minter(t.otherAddress, false).send({ from: t.otherAddress }); + expect((await t.asset.methods.is_minter(t.otherAddress).simulate({ from: t.adminAddress })).result).toBe(false); }); // Error cases: unauthorized set_admin and unauthorized set_minter. These assert that calls from - // t.adminAddress revert once it is no longer admin, so the block transfers admin to account1 in its + // t.adminAddress revert once it is no longer admin, so the block transfers admin to other in its // own beforeAll rather than relying on the 'Set admin' test above having run (CI runs tests in // isolation). The transfer is idempotent: it is skipped if admin was already moved. describe('failure cases', () => { beforeAll(async () => { const currentAdmin = (await t.asset.methods.get_admin().simulate({ from: t.adminAddress })).result; if (currentAdmin === t.adminAddress.toBigInt()) { - await t.asset.methods.set_admin(t.account1Address).send({ from: t.adminAddress }); + await t.asset.methods.set_admin(t.otherAddress).send({ from: t.adminAddress }); } }); diff --git a/yarn-project/end-to-end/src/automine/token/amm.test.ts b/yarn-project/end-to-end/src/automine/token/amm.test.ts index 2874ce8e4106..e31a14a60363 100644 --- a/yarn-project/end-to-end/src/automine/token/amm.test.ts +++ b/yarn-project/end-to-end/src/automine/token/amm.test.ts @@ -1,14 +1,15 @@ import { AztecAddress } from '@aztec/aztec.js/addresses'; import { Fr } from '@aztec/aztec.js/fields'; import type { Logger } from '@aztec/aztec.js/log'; -import { AMMContract } from '@aztec/noir-contracts.js/AMM'; +import type { AMMContract } from '@aztec/noir-contracts.js/AMM'; import type { TestTokenContract } from '@aztec/noir-test-contracts.js/TestToken'; import { jest } from '@jest/globals'; -import { deployTestToken, mintTokensToPrivate } from '../../fixtures/token_utils.js'; +import { deployTestToken } from '../../fixtures/token_utils.js'; import type { TestWallet } from '../../test-wallet/test_wallet.js'; import { AutomineTestContext } from '../automine_test_context.js'; +import { deployAmmWithTokens } from './token_test_helpers.js'; const TIMEOUT = 900_000; @@ -55,27 +56,12 @@ describe('automine/token/amm', () => { logger, } = (await AutomineTestContext.setup({ numberOfAccounts: 4, pxeOpts: { syncChainTip: 'checkpointed' } })).context); - ({ contract: token0 } = await deployTestToken(wallet, adminAddress, 0n, logger)); - ({ contract: token1 } = await deployTestToken(wallet, adminAddress, 0n, logger)); - ({ contract: liquidityToken } = await deployTestToken(wallet, adminAddress, 0n, logger)); - - ({ contract: amm } = await AMMContract.deploy(wallet, token0.address, token1.address, liquidityToken.address).send({ - from: adminAddress, + ({ token0, token1, liquidityToken, amm } = await deployAmmWithTokens(wallet, adminAddress, deployTestToken, { + liquidityProviders: [liquidityProviderAddress, otherLiquidityProviderAddress], + swapper: swapperAddress, + initialBalance: INITIAL_TOKEN_BALANCE, + logger, })); - - // TODO(#9480): consider deploying the token by some factory when the AMM is deployed, and making the AMM be the - // minter there. - await liquidityToken.methods.set_minter(amm.address, true).send({ from: adminAddress }); - - // We mint the tokens to both liquidity providers and the swapper - await mintTokensToPrivate(token0, adminAddress, liquidityProviderAddress, INITIAL_TOKEN_BALANCE); - await mintTokensToPrivate(token1, adminAddress, liquidityProviderAddress, INITIAL_TOKEN_BALANCE); - - await mintTokensToPrivate(token0, adminAddress, otherLiquidityProviderAddress, INITIAL_TOKEN_BALANCE); - await mintTokensToPrivate(token1, adminAddress, otherLiquidityProviderAddress, INITIAL_TOKEN_BALANCE); - - // Note that the swapper only holds token0, not token1 - await mintTokensToPrivate(token0, adminAddress, swapperAddress, INITIAL_TOKEN_BALANCE); }); afterAll(() => teardown()); diff --git a/yarn-project/end-to-end/src/automine/token/blacklist_burn.test.ts b/yarn-project/end-to-end/src/automine/token/blacklist_burn.test.ts deleted file mode 100644 index 4ca9b5a0a314..000000000000 --- a/yarn-project/end-to-end/src/automine/token/blacklist_burn.test.ts +++ /dev/null @@ -1,318 +0,0 @@ -import { computeAuthWitMessageHash } from '@aztec/aztec.js/authorization'; -import { Fr } from '@aztec/aztec.js/fields'; - -import { sendThroughAuthwitProxy, simulateThroughAuthwitProxy } from '../../fixtures/authwit_proxy.js'; -import { DUPLICATE_NULLIFIER_ERROR, U128_UNDERFLOW_ERROR } from '../../fixtures/index.js'; -import { BlacklistTokenContractTest } from './blacklist_token_contract_test.js'; - -// Covers public and private burn operations on TokenBlacklist, including authwit-delegated burns and -// blacklist enforcement. Setup: single node with AutomineSequencer, 3 accounts, TokenBlacklist deployed, -// initial mint applied (admin has both public and private balances). Time-warp required to cross -// role-change delay (86400s) during setup. -describe('automine/token/blacklist_burn', () => { - const t = new BlacklistTokenContractTest('burn'); - let { asset, tokenSim, wallet, adminAddress, otherAddress, blacklistedAddress } = t; - - beforeAll(async () => { - await t.setup(); - // Beware that we are adding the wallet as minter here, which is very slow because it needs multiple blocks. - await t.applyMint(); - // Have to destructure again to ensure we have latest refs. - ({ asset, tokenSim, wallet, adminAddress, otherAddress, blacklistedAddress } = t); - }, 600_000); - - afterAll(async () => { - await t.teardown(); - }); - - afterEach(async () => { - await t.tokenSim.check(); - }); - - // Public burn path: direct burns and authwit-delegated burns. - describe('public', () => { - // Burns half the admin's public balance and verifies via TokenSimulator. - it('burn less than balance', async () => { - const balance0 = await asset.methods - .balance_of_public(adminAddress) - .simulate({ from: adminAddress }) - .then(r => r.result); - const amount = balance0 / 2n; - expect(amount).toBeGreaterThan(0n); - await asset.methods.burn_public(adminAddress, amount, 0).send({ from: adminAddress }); - - tokenSim.burnPublic(adminAddress, amount); - }); - - // Grants a public authwit for burn, burns via otherAddress, then asserts the authwit is consumed - // (replay reverts with unauthorized). - it('burn on behalf of other', async () => { - const balance0 = await asset.methods - .balance_of_public(adminAddress) - .simulate({ from: adminAddress }) - .then(r => r.result); - const amount = balance0 / 2n; - expect(amount).toBeGreaterThan(0n); - const authwitNonce = Fr.random(); - - // We need to compute the message we want to sign and add it to the wallet as approved - const action = asset.methods.burn_public(adminAddress, amount, authwitNonce); - const validateActionInteraction = await wallet.setPublicAuthWit( - adminAddress, - { caller: otherAddress, action }, - true, - ); - await validateActionInteraction.send(); - - await action.send({ from: otherAddress }); - - tokenSim.burnPublic(adminAddress, amount); - - await expect( - asset.methods.burn_public(adminAddress, amount, authwitNonce).simulate({ from: otherAddress }), - ).rejects.toThrow(/unauthorized/); - }); - - // Error paths for public burn: overflow, nonce, missing approval, wrong caller, blacklist. - describe('failure cases', () => { - // Attempts to burn more than the current balance and expects U128_UNDERFLOW_ERROR. - it('burn more than balance', async () => { - const balance0 = await asset.methods - .balance_of_public(adminAddress) - .simulate({ from: adminAddress }) - .then(r => r.result); - const amount = balance0 + 1n; - const authwitNonce = 0; - await expect( - asset.methods.burn_public(adminAddress, amount, authwitNonce).simulate({ from: adminAddress }), - ).rejects.toThrow(U128_UNDERFLOW_ERROR); - }); - - // Verifies that self-burn with a non-zero nonce reverts with the invalid-nonce assertion. - it('burn on behalf of self with non-zero nonce', async () => { - const balance0 = await asset.methods - .balance_of_public(adminAddress) - .simulate({ from: adminAddress }) - .then(r => r.result); - const amount = balance0 - 1n; - expect(amount).toBeGreaterThan(0n); - const authwitNonce = 1; - await expect( - asset.methods.burn_public(adminAddress, amount, authwitNonce).simulate({ from: adminAddress }), - ).rejects.toThrow( - "Assertion failed: Invalid authwit nonce. When 'from' and 'msg_sender' are the same, 'authwit_nonce' must be zero", - ); - }); - - // Calls burn_public on behalf of admin from otherAddress without any authwit and expects unauthorized. - it('burn on behalf of other without "approval"', async () => { - const balance0 = await asset.methods - .balance_of_public(adminAddress) - .simulate({ from: adminAddress }) - .then(r => r.result); - const amount = balance0 + 1n; - const authwitNonce = Fr.random(); - await expect( - asset.methods.burn_public(adminAddress, amount, authwitNonce).simulate({ from: otherAddress }), - ).rejects.toThrow(/unauthorized/); - }); - - // Approves a burn of more than balance via authwit, then expects U128_UNDERFLOW_ERROR on simulate. - it('burn more than balance on behalf of other', async () => { - const balance0 = await asset.methods - .balance_of_public(adminAddress) - .simulate({ from: adminAddress }) - .then(r => r.result); - const amount = balance0 + 1n; - const authwitNonce = Fr.random(); - expect(amount).toBeGreaterThan(0n); - - // We need to compute the message we want to sign and add it to the wallet as approved - const action = asset.methods.burn_public(adminAddress, amount, authwitNonce); - const validateActionInteraction = await wallet.setPublicAuthWit( - adminAddress, - { caller: otherAddress, action }, - true, - ); - await validateActionInteraction.send(); - - await expect(action.simulate({ from: otherAddress })).rejects.toThrow(U128_UNDERFLOW_ERROR); - }); - - // Creates an authwit designating adminAddress as the caller but executes from otherAddress; expects - // unauthorized because the caller doesn't match the authwit. - it('burn on behalf of other, wrong designated caller', async () => { - const balance0 = await asset.methods - .balance_of_public(adminAddress) - .simulate({ from: adminAddress }) - .then(r => r.result); - const amount = balance0 + 2n; - const authwitNonce = Fr.random(); - expect(amount).toBeGreaterThan(0n); - - // We need to compute the message we want to sign and add it to the wallet as approved - const action = asset.methods.burn_public(adminAddress, amount, authwitNonce); - const validateActionInteraction = await wallet.setPublicAuthWit( - adminAddress, - { caller: adminAddress, action }, - true, - ); - await validateActionInteraction.send(); - - await expect( - asset.methods.burn_public(adminAddress, amount, authwitNonce).simulate({ from: otherAddress }), - ).rejects.toThrow(/unauthorized/); - }); - - // Verifies that a blacklisted account cannot burn its own tokens (Blacklisted: Sender). - it('burn from blacklisted account', async () => { - await expect( - asset.methods.burn_public(blacklistedAddress, 1n, 0).simulate({ from: blacklistedAddress }), - ).rejects.toThrow(/Assertion failed: Blacklisted: Sender/); - }); - }); - }); - - // Private burn path: direct burns and authwit-delegated burns via proxy. - describe('private', () => { - // Burns half the admin's private balance and verifies via TokenSimulator. - it('burn less than balance', async () => { - const balance0 = await asset.methods - .balance_of_private(adminAddress) - .simulate({ from: adminAddress }) - .then(r => r.result); - const amount = balance0 / 2n; - expect(amount).toBeGreaterThan(0n); - await asset.methods.burn(adminAddress, amount, 0).send({ from: adminAddress }); - tokenSim.burnPrivate(adminAddress, amount); - }); - - // Creates a private authwit for burn, sends it through the proxy (so msg_sender differs from note owner), - // verifies TokenSimulator, then asserts replay reverts with DUPLICATE_NULLIFIER_ERROR. - it('burn on behalf of other', async () => { - const balance0 = await asset.methods - .balance_of_private(adminAddress) - .simulate({ from: adminAddress }) - .then(r => r.result); - const amount = balance0 / 2n; - const authwitNonce = Fr.random(); - expect(amount).toBeGreaterThan(0n); - - const action = asset.methods.burn(adminAddress, amount, authwitNonce); - const witness = await wallet.createAuthWit(adminAddress, { caller: t.authwitProxy.address, action }); - - // Admin sends through proxy so their keys are in scope, while proxy becomes msg_sender to trigger authwit. - await sendThroughAuthwitProxy(t.authwitProxy, action, { from: adminAddress, authWitnesses: [witness] }); - tokenSim.burnPrivate(adminAddress, amount); - - // Perform the transfer again, should fail - await expect( - sendThroughAuthwitProxy(t.authwitProxy, action, { from: adminAddress, authWitnesses: [witness] }), - ).rejects.toThrow(DUPLICATE_NULLIFIER_ERROR); - }); - - // Error paths for private burn: overflow, nonce, missing approval, wrong caller, blacklist. - describe('failure cases', () => { - // Attempts to burn more than private balance and expects the 'Balance too low' assertion. - it('burn more than balance', async () => { - const balance0 = await asset.methods - .balance_of_private(adminAddress) - .simulate({ from: adminAddress }) - .then(r => r.result); - const amount = balance0 + 1n; - expect(amount).toBeGreaterThan(0n); - await expect(asset.methods.burn(adminAddress, amount, 0).simulate({ from: adminAddress })).rejects.toThrow( - 'Assertion failed: Balance too low', - ); - }); - - // Verifies that self-burn with nonce=1 reverts with the invalid-nonce assertion. - it('burn on behalf of self with non-zero nonce', async () => { - const balance0 = await asset.methods - .balance_of_private(adminAddress) - .simulate({ from: adminAddress }) - .then(r => r.result); - const amount = balance0 - 1n; - expect(amount).toBeGreaterThan(0n); - await expect(asset.methods.burn(adminAddress, amount, 1).simulate({ from: adminAddress })).rejects.toThrow( - "Assertion failed: Invalid authwit nonce. When 'from' and 'msg_sender' are the same, 'authwit_nonce' must be zero", - ); - }); - - // Creates authwit for a burn exceeding balance; expects 'Balance too low' when simulated through proxy. - it('burn more than balance on behalf of other', async () => { - const balance0 = await asset.methods - .balance_of_private(adminAddress) - .simulate({ from: adminAddress }) - .then(r => r.result); - const amount = balance0 + 1n; - const authwitNonce = Fr.random(); - expect(amount).toBeGreaterThan(0n); - - const action = asset.methods.burn(adminAddress, amount, authwitNonce); - const witness = await wallet.createAuthWit(adminAddress, { caller: t.authwitProxy.address, action }); - - // Admin sends through proxy so their keys are in scope, while proxy becomes msg_sender to trigger authwit. - await expect( - simulateThroughAuthwitProxy(t.authwitProxy, action, { from: adminAddress, authWitnesses: [witness] }), - ).rejects.toThrow('Assertion failed: Balance too low'); - }); - - // Simulates burn through proxy without providing a witness; expects unknown-authwit error. - it('burn on behalf of other without approval', async () => { - const balance0 = await asset.methods - .balance_of_private(adminAddress) - .simulate({ from: adminAddress }) - .then(r => r.result); - const amount = balance0 / 2n; - const authwitNonce = Fr.random(); - expect(amount).toBeGreaterThan(0n); - - const action = asset.methods.burn(adminAddress, amount, authwitNonce); - const call = await action.getFunctionCall(); - const messageHash = await computeAuthWitMessageHash( - { caller: t.authwitProxy.address, call }, - await wallet.getChainInfo(), - ); - - // Admin sends through proxy so their keys are in scope, while proxy becomes msg_sender to trigger authwit. - await expect(simulateThroughAuthwitProxy(t.authwitProxy, action, { from: adminAddress })).rejects.toThrow( - `Unknown auth witness for message hash ${messageHash.toString()}`, - ); - }); - - // Creates authwit designating otherAddress as caller but sends through proxy; expects unknown-authwit error - // because the computed message hash doesn't match the proxy's address. - it('on behalf of other (invalid designated caller)', async () => { - const balance0 = await asset.methods - .balance_of_private(adminAddress) - .simulate({ from: adminAddress }) - .then(r => r.result); - const amount = balance0 + 2n; - const authwitNonce = Fr.random(); - expect(amount).toBeGreaterThan(0n); - - const action = asset.methods.burn(adminAddress, amount, authwitNonce); - const call = await action.getFunctionCall(); - const expectedMessageHash = await computeAuthWitMessageHash( - { caller: t.authwitProxy.address, call }, - await wallet.getChainInfo(), - ); - - const witness = await wallet.createAuthWit(adminAddress, { caller: otherAddress, action }); - - // Admin sends through proxy so their keys are in scope, while proxy becomes msg_sender to trigger authwit. - await expect( - simulateThroughAuthwitProxy(t.authwitProxy, action, { from: adminAddress, authWitnesses: [witness] }), - ).rejects.toThrow(`Unknown auth witness for message hash ${expectedMessageHash.toString()}`); - }); - - // Verifies that a blacklisted account cannot private-burn its tokens (Blacklisted: Sender). - it('burn from blacklisted account', async () => { - await expect( - asset.methods.burn(blacklistedAddress, 1n, 0).simulate({ from: blacklistedAddress }), - ).rejects.toThrow('Assertion failed: Blacklisted: Sender'); - }); - }); - }); -}); diff --git a/yarn-project/end-to-end/src/automine/token/blacklist_shielding.test.ts b/yarn-project/end-to-end/src/automine/token/blacklist_shielding.test.ts index be9bda564c61..9fc18af355e7 100644 --- a/yarn-project/end-to-end/src/automine/token/blacklist_shielding.test.ts +++ b/yarn-project/end-to-end/src/automine/token/blacklist_shielding.test.ts @@ -3,6 +3,7 @@ import { Fr } from '@aztec/aztec.js/fields'; import { U128_UNDERFLOW_ERROR } from '../../fixtures/index.js'; import { BlacklistTokenContractTest } from './blacklist_token_contract_test.js'; +import { INVALID_AUTHWIT_NONCE_ERROR, amountAboveBalance, halfBalanceOf } from './token_test_helpers.js'; // Covers the shield (public→private) and redeem_shield operations on TokenBlacklist, including // authwit-delegated shielding and blacklist enforcement. Setup: single node with AutomineSequencer, @@ -36,12 +37,7 @@ describe('automine/token/blacklist_shielding', () => { // Shields half the admin's public balance to private, registers the note in PXE, redeems it, and // verifies the result against TokenSimulator. it('on behalf of self', async () => { - const balancePub = await asset.methods - .balance_of_public(adminAddress) - .simulate({ from: adminAddress }) - .then(r => r.result); - const amount = balancePub / 2n; - expect(amount).toBeGreaterThan(0n); + const amount = await halfBalanceOf(asset, 'public', adminAddress); const { receipt } = await asset.methods.shield(adminAddress, amount, secretHash, 0).send({ from: adminAddress }); @@ -57,13 +53,8 @@ describe('automine/token/blacklist_shielding', () => { // Sets a public authwit allowing otherAddress to shield admin's tokens, executes the shield from // otherAddress, verifies replay fails (unauthorized), redeems, and checks TokenSimulator. it('on behalf of other', async () => { - const balancePub = await asset.methods - .balance_of_public(adminAddress) - .simulate({ from: adminAddress }) - .then(r => r.result); - const amount = balancePub / 2n; + const amount = await halfBalanceOf(asset, 'public', adminAddress); const authwitNonce = Fr.random(); - expect(amount).toBeGreaterThan(0n); // We need to compute the message we want to sign and add it to the wallet as approved const action = asset.methods.shield(adminAddress, amount, secretHash, authwitNonce); @@ -94,13 +85,7 @@ describe('automine/token/blacklist_shielding', () => { describe('failure cases', () => { // Shields more than public balance (self); expects U128_UNDERFLOW_ERROR. it('on behalf of self (more than balance)', async () => { - const balancePub = await asset.methods - .balance_of_public(adminAddress) - .simulate({ from: adminAddress }) - .then(r => r.result); - const amount = balancePub + 1n; - expect(amount).toBeGreaterThan(0n); - + const amount = await amountAboveBalance(asset, 'public', adminAddress); await expect( asset.methods.shield(adminAddress, amount, secretHash, 0).simulate({ from: adminAddress }), ).rejects.toThrow(U128_UNDERFLOW_ERROR); @@ -108,32 +93,18 @@ describe('automine/token/blacklist_shielding', () => { // Self-shield with nonce=1; expects invalid-nonce assertion failure. it('on behalf of self (invalid authwit nonce)', async () => { - const balancePub = await asset.methods - .balance_of_public(adminAddress) - .simulate({ from: adminAddress }) - .then(r => r.result); - const amount = balancePub + 1n; - expect(amount).toBeGreaterThan(0n); - + const amount = await amountAboveBalance(asset, 'public', adminAddress); await expect( asset.methods.shield(adminAddress, amount, secretHash, 1).simulate({ from: adminAddress }), - ).rejects.toThrow( - "Assertion failed: Invalid authwit nonce. When 'from' and 'msg_sender' are the same, 'authwit_nonce' must be zero", - ); + ).rejects.toThrow(INVALID_AUTHWIT_NONCE_ERROR); }); // Authwit-shields more than balance via otherAddress; expects U128_UNDERFLOW_ERROR. it('on behalf of other (more than balance)', async () => { - const balancePub = await asset.methods - .balance_of_public(adminAddress) - .simulate({ from: adminAddress }) - .then(r => r.result); - const amount = balancePub + 1n; - const authwitNonce = Fr.random(); - expect(amount).toBeGreaterThan(0n); + const amount = await amountAboveBalance(asset, 'public', adminAddress); // We need to compute the message we want to sign and add it to the wallet as approved - const action = asset.methods.shield(adminAddress, amount, secretHash, authwitNonce); + const action = asset.methods.shield(adminAddress, amount, secretHash, Fr.random()); const validateActionInteraction = await wallet.setPublicAuthWit( adminAddress, { caller: otherAddress, action }, @@ -146,16 +117,10 @@ describe('automine/token/blacklist_shielding', () => { // Approves otherAddress as caller, executes from blacklistedAddress; expects unauthorized. it('on behalf of other (wrong designated caller)', async () => { - const balancePub = await asset.methods - .balance_of_public(adminAddress) - .simulate({ from: adminAddress }) - .then(r => r.result); - const amount = balancePub + 1n; - const authwitNonce = Fr.random(); - expect(amount).toBeGreaterThan(0n); + const amount = await amountAboveBalance(asset, 'public', adminAddress); // We need to compute the message we want to sign and add it to the wallet as approved - const action = asset.methods.shield(adminAddress, amount, secretHash, authwitNonce); + const action = asset.methods.shield(adminAddress, amount, secretHash, Fr.random()); const validateActionInteraction = await wallet.setPublicAuthWit( adminAddress, { caller: otherAddress, action }, @@ -168,16 +133,9 @@ describe('automine/token/blacklist_shielding', () => { // Calls shield for admin from otherAddress without any authwit; expects unauthorized. it('on behalf of other (without approval)', async () => { - const balance = await asset.methods - .balance_of_public(adminAddress) - .simulate({ from: adminAddress }) - .then(r => r.result); - const amount = balance / 2n; - const authwitNonce = Fr.random(); - expect(amount).toBeGreaterThan(0n); - + const amount = await halfBalanceOf(asset, 'public', adminAddress); await expect( - asset.methods.shield(adminAddress, amount, secretHash, authwitNonce).simulate({ from: otherAddress }), + asset.methods.shield(adminAddress, amount, secretHash, Fr.random()).simulate({ from: otherAddress }), ).rejects.toThrow(/unauthorized/); }); diff --git a/yarn-project/end-to-end/src/automine/token/blacklist_transfer.test.ts b/yarn-project/end-to-end/src/automine/token/blacklist_transfer.test.ts new file mode 100644 index 000000000000..15b69150ac5d --- /dev/null +++ b/yarn-project/end-to-end/src/automine/token/blacklist_transfer.test.ts @@ -0,0 +1,210 @@ +import type { AztecAddress } from '@aztec/aztec.js/addresses'; +import { computeAuthWitMessageHash } from '@aztec/aztec.js/authorization'; +import type { ContractFunctionInteraction } from '@aztec/aztec.js/contracts'; +import { Fr } from '@aztec/aztec.js/fields'; + +import { simulateThroughAuthwitProxy } from '../../fixtures/authwit_proxy.js'; +import { U128_UNDERFLOW_ERROR } from '../../fixtures/index.js'; +import { BlacklistTokenContractTest } from './blacklist_token_contract_test.js'; +import { + type BalanceKind, + INVALID_AUTHWIT_NONCE_ERROR, + amountAboveBalance, + assertAuthwitProxyReplayRejected, + assertPublicAuthwitReplayRejected, + balanceOf, + halfBalanceOf, +} from './token_test_helpers.js'; + +const BALANCE_TOO_LOW = 'Assertion failed: Balance too low'; + +/** A TokenBlacklist transfer mechanism: private transfers use a proxy authwit, public a public authwit. */ +interface TransferMechanism { + name: string; + authwitKind: 'public' | 'private-proxy'; + balanceKind: BalanceKind; + overBalanceError: string; + transfer(from: AztecAddress, to: AztecAddress, amount: bigint, nonce: Fr | number): ContractFunctionInteraction; + updateSim(from: AztecAddress, to: AztecAddress, amount: bigint): void; +} + +// Private and public token transfers on TokenBlacklist, parameterized by authwit mechanism (private proxy +// vs public authwit): direct and self transfers, delegated transfers with single-use replay protection, +// the failure cases, and blacklist enforcement on both sender and recipient. Both mechanisms share one +// harness — private transfers only touch private balances and public transfers only public balances, so +// neither disturbs the other's starting mint. Setup: single node with AutomineSequencer, 3 accounts + +// authwit proxy, TokenBlacklist deployed with initial mint (warps past the 86400s role-change delay). +describe('automine/token/blacklist_transfer', () => { + const t = new BlacklistTokenContractTest('blacklist_transfer'); + + beforeAll(async () => { + await t.setup(); + // Adds the admin as minter, which is slow because it needs multiple blocks and role-change warps. + await t.applyMint(); + }, 600_000); + + afterAll(async () => { + await t.teardown(); + }); + + afterEach(async () => { + await t.tokenSim.check(); + }); + + const mechanisms: TransferMechanism[] = [ + { + name: 'private', + authwitKind: 'private-proxy', + balanceKind: 'private', + overBalanceError: BALANCE_TOO_LOW, + transfer: (from, to, amount, nonce) => t.asset.methods.transfer(from, to, amount, nonce), + updateSim: (from, to, amount) => t.tokenSim.transferPrivate(from, to, amount), + }, + { + name: 'public', + authwitKind: 'public', + balanceKind: 'public', + overBalanceError: U128_UNDERFLOW_ERROR, + transfer: (from, to, amount, nonce) => t.asset.methods.transfer_public(from, to, amount, nonce), + updateSim: (from, to, amount) => t.tokenSim.transferPublic(from, to, amount), + }, + ]; + + describe.each(mechanisms)('$name', m => { + // Transfers half of admin's balance to other and verifies via TokenSimulator. + it('transfer less than balance', async () => { + const amount = await halfBalanceOf(t.asset, m.balanceKind, t.adminAddress); + await m.transfer(t.adminAddress, t.otherAddress, amount, 0).send({ from: t.adminAddress }); + m.updateSim(t.adminAddress, t.otherAddress, amount); + }); + + // Transfers half of admin's balance to themselves; verifies balance is unchanged via TokenSimulator. + it('transfer to self', async () => { + const amount = await halfBalanceOf(t.asset, m.balanceKind, t.adminAddress); + await m.transfer(t.adminAddress, t.adminAddress, amount, 0).send({ from: t.adminAddress }); + m.updateSim(t.adminAddress, t.adminAddress, amount); + }); + + // Delegates a transfer to other, verifies via TokenSimulator, then confirms the authwit is single-use. + it('transfer on behalf of other', async () => { + const amount = await halfBalanceOf(t.asset, m.balanceKind, t.adminAddress); + const action = m.transfer(t.adminAddress, t.otherAddress, amount, Fr.random()); + const updateSim = () => m.updateSim(t.adminAddress, t.otherAddress, amount); + if (m.authwitKind === 'public') { + await assertPublicAuthwitReplayRejected(t.wallet, t.adminAddress, action, t.otherAddress, updateSim); + } else { + await assertAuthwitProxyReplayRejected(t.authwitProxy, t.wallet, t.adminAddress, action, updateSim); + } + }); + + describe('failure cases', () => { + it('transfer more than balance', async () => { + const amount = await amountAboveBalance(t.asset, m.balanceKind, t.adminAddress); + await expect( + m.transfer(t.adminAddress, t.otherAddress, amount, 0).simulate({ from: t.adminAddress }), + ).rejects.toThrow(m.overBalanceError); + }); + + it('transfer on behalf of self with non-zero nonce', async () => { + const amount = await halfBalanceOf(t.asset, m.balanceKind, t.adminAddress); + await expect( + m.transfer(t.adminAddress, t.otherAddress, amount, 1).simulate({ from: t.adminAddress }), + ).rejects.toThrow(INVALID_AUTHWIT_NONCE_ERROR); + }); + + it('transfer on behalf of other without approval', async () => { + if (m.authwitKind === 'public') { + const amount = await amountAboveBalance(t.asset, m.balanceKind, t.adminAddress); + await expect( + m.transfer(t.adminAddress, t.otherAddress, amount, Fr.random()).simulate({ from: t.otherAddress }), + ).rejects.toThrow(/unauthorized/); + } else { + const amount = await halfBalanceOf(t.asset, m.balanceKind, t.adminAddress); + const action = m.transfer(t.adminAddress, t.otherAddress, amount, Fr.random()); + const call = await action.getFunctionCall(); + const messageHash = await computeAuthWitMessageHash( + { caller: t.authwitProxy.address, call }, + await t.wallet.getChainInfo(), + ); + await expect(simulateThroughAuthwitProxy(t.authwitProxy, action, { from: t.adminAddress })).rejects.toThrow( + `Unknown auth witness for message hash ${messageHash.toString()}`, + ); + } + }); + + it('transfer more than balance on behalf of other', async () => { + const amount = await amountAboveBalance(t.asset, m.balanceKind, t.adminAddress); + const action = m.transfer(t.adminAddress, t.otherAddress, amount, Fr.random()); + const ownerBefore = await balanceOf(t.asset, m.balanceKind, t.adminAddress); + const otherBefore = await balanceOf(t.asset, m.balanceKind, t.otherAddress); + + if (m.authwitKind === 'public') { + const grant = await t.wallet.setPublicAuthWit(t.adminAddress, { caller: t.otherAddress, action }, true); + await grant.send(); + await expect(action.simulate({ from: t.otherAddress })).rejects.toThrow(m.overBalanceError); + } else { + const witness = await t.wallet.createAuthWit(t.adminAddress, { caller: t.authwitProxy.address, action }); + await expect( + simulateThroughAuthwitProxy(t.authwitProxy, action, { from: t.adminAddress, authWitnesses: [witness] }), + ).rejects.toThrow(m.overBalanceError); + } + + expect(await balanceOf(t.asset, m.balanceKind, t.adminAddress)).toEqual(ownerBefore); + expect(await balanceOf(t.asset, m.balanceKind, t.otherAddress)).toEqual(otherBefore); + }); + + it('transfer on behalf of other, wrong designated caller', async () => { + if (m.authwitKind === 'public') { + const amount = await amountAboveBalance(t.asset, m.balanceKind, t.adminAddress, 2n); + const action = m.transfer(t.adminAddress, t.otherAddress, amount, Fr.random()); + const ownerBefore = await balanceOf(t.asset, m.balanceKind, t.adminAddress); + const otherBefore = await balanceOf(t.asset, m.balanceKind, t.otherAddress); + // Approve the owner as caller, but execute from `other`: the message hashes don't match. + const grant = await t.wallet.setPublicAuthWit(t.adminAddress, { caller: t.adminAddress, action }, true); + await grant.send(); + await expect(action.simulate({ from: t.otherAddress })).rejects.toThrow(/unauthorized/); + expect(await balanceOf(t.asset, m.balanceKind, t.adminAddress)).toEqual(ownerBefore); + expect(await balanceOf(t.asset, m.balanceKind, t.otherAddress)).toEqual(otherBefore); + } else { + const amount = await halfBalanceOf(t.asset, m.balanceKind, t.adminAddress); + const action = m.transfer(t.adminAddress, t.otherAddress, amount, Fr.random()); + const call = await action.getFunctionCall(); + const expectedMessageHash = await computeAuthWitMessageHash( + { caller: t.authwitProxy.address, call }, + await t.wallet.getChainInfo(), + ); + // Designate `other` as caller (not the proxy), then send through the proxy: the hashes don't match. + const witness = await t.wallet.createAuthWit(t.adminAddress, { caller: t.otherAddress, action }); + const ownerBefore = await balanceOf(t.asset, m.balanceKind, t.adminAddress); + const otherBefore = await balanceOf(t.asset, m.balanceKind, t.otherAddress); + await expect( + simulateThroughAuthwitProxy(t.authwitProxy, action, { from: t.adminAddress, authWitnesses: [witness] }), + ).rejects.toThrow(`Unknown auth witness for message hash ${expectedMessageHash.toString()}`); + expect(await balanceOf(t.asset, m.balanceKind, t.adminAddress)).toEqual(ownerBefore); + expect(await balanceOf(t.asset, m.balanceKind, t.otherAddress)).toEqual(otherBefore); + } + }); + + it.skip('transfer into account to overflow', () => { + // This should already be covered by the mint case earlier. e.g., since we cannot mint to overflow, there is + // not a way to get funds enough to overflow. + // Require direct storage manipulation for us to perform a nice explicit case though. + // See https://github.com/AztecProtocol/aztec-packages/issues/1259 + }); + + // A blacklisted account cannot send (Blacklisted: Sender). + it('transfer from a blacklisted account', async () => { + await expect( + m.transfer(t.blacklistedAddress, t.adminAddress, 1n, 0).simulate({ from: t.blacklistedAddress }), + ).rejects.toThrow('Assertion failed: Blacklisted: Sender'); + }); + + // A blacklisted account cannot receive (Blacklisted: Recipient). + it('transfer to a blacklisted account', async () => { + await expect( + m.transfer(t.adminAddress, t.blacklistedAddress, 1n, 0).simulate({ from: t.adminAddress }), + ).rejects.toThrow('Assertion failed: Blacklisted: Recipient'); + }); + }); + }); +}); diff --git a/yarn-project/end-to-end/src/automine/token/blacklist_transfer_private.test.ts b/yarn-project/end-to-end/src/automine/token/blacklist_transfer_private.test.ts deleted file mode 100644 index 38b70529cdc8..000000000000 --- a/yarn-project/end-to-end/src/automine/token/blacklist_transfer_private.test.ts +++ /dev/null @@ -1,225 +0,0 @@ -import { computeAuthWitMessageHash } from '@aztec/aztec.js/authorization'; -import { Fr } from '@aztec/aztec.js/fields'; - -import { sendThroughAuthwitProxy, simulateThroughAuthwitProxy } from '../../fixtures/authwit_proxy.js'; -import { DUPLICATE_NULLIFIER_ERROR } from '../../fixtures/fixtures.js'; -import { BlacklistTokenContractTest } from './blacklist_token_contract_test.js'; - -// Covers private token transfers on TokenBlacklist: direct, self-transfer, authwit-delegated, and -// blacklist enforcement. Setup: single node with AutomineSequencer, 3 accounts, initial mint applied. -// Time-warp required during setup to cross role-change delay. -describe('automine/token/blacklist_transfer_private', () => { - const t = new BlacklistTokenContractTest('transfer_private'); - let { asset, tokenSim, wallet, adminAddress, otherAddress, blacklistedAddress } = t; - - beforeAll(async () => { - await t.setup(); - // Beware that we are adding the admin as minter here, which is very slow because it needs multiple blocks. - await t.applyMint(); - // Have to destructure again to ensure we have latest refs. - ({ asset, tokenSim, wallet, adminAddress, otherAddress, blacklistedAddress } = t); - }, 600_000); - - afterAll(async () => { - await t.teardown(); - }); - - afterEach(async () => { - await t.tokenSim.check(); - }); - - // Transfers half of admin's private balance to other and verifies via TokenSimulator. - it('transfer less than balance', async () => { - const balance0 = await asset.methods - .balance_of_private(adminAddress) - .simulate({ from: adminAddress }) - .then(r => r.result); - const amount = balance0 / 2n; - expect(amount).toBeGreaterThan(0n); - const tokenTransferInteraction = asset.methods.transfer(adminAddress, otherAddress, amount, 0); - await tokenTransferInteraction.send({ from: adminAddress }); - tokenSim.transferPrivate(adminAddress, otherAddress, amount); - }); - - // Transfers half of admin's private balance to themselves and verifies balance is unchanged. - it('transfer to self', async () => { - const balance0 = await asset.methods - .balance_of_private(adminAddress) - .simulate({ from: adminAddress }) - .then(r => r.result); - const amount = balance0 / 2n; - expect(amount).toBeGreaterThan(0n); - - await asset.methods.transfer(adminAddress, adminAddress, amount, 0).send({ from: adminAddress }); - tokenSim.transferPrivate(adminAddress, adminAddress, amount); - }); - - // Creates a private authwit for transfer, sends through proxy, verifies TokenSimulator, then asserts - // replay fails with DUPLICATE_NULLIFIER_ERROR. - it('transfer on behalf of other', async () => { - const balance0 = await asset.methods - .balance_of_private(adminAddress) - .simulate({ from: adminAddress }) - .then(r => r.result); - const amount = balance0 / 2n; - const authwitNonce = Fr.random(); - expect(amount).toBeGreaterThan(0n); - - const action = asset.methods.transfer(adminAddress, otherAddress, amount, authwitNonce); - const witness = await wallet.createAuthWit(adminAddress, { caller: t.authwitProxy.address, action }); - - // Admin sends through proxy so their keys are in scope, while proxy becomes msg_sender to trigger authwit. - await sendThroughAuthwitProxy(t.authwitProxy, action, { from: adminAddress, authWitnesses: [witness] }); - tokenSim.transferPrivate(adminAddress, otherAddress, amount); - - // Perform the transfer again, should fail - await expect( - sendThroughAuthwitProxy(t.authwitProxy, action, { from: adminAddress, authWitnesses: [witness] }), - ).rejects.toThrow(DUPLICATE_NULLIFIER_ERROR); - }); - - // Error paths: over-balance, invalid nonce, over-balance via authwit, missing approval, wrong caller, - // sender blacklisted, recipient blacklisted. - describe('failure cases', () => { - // Attempts to transfer more than private balance; expects 'Balance too low'. - it('transfer more than balance', async () => { - const balance0 = await asset.methods - .balance_of_private(adminAddress) - .simulate({ from: adminAddress }) - .then(r => r.result); - const amount = balance0 + 1n; - expect(amount).toBeGreaterThan(0n); - - await expect( - asset.methods.transfer(adminAddress, otherAddress, amount, 0).simulate({ from: adminAddress }), - ).rejects.toThrow('Assertion failed: Balance too low'); - }); - - // Self-transfer with nonce=1; expects invalid-nonce assertion. - it('transfer on behalf of self with non-zero nonce', async () => { - const balance0 = await asset.methods - .balance_of_private(adminAddress) - .simulate({ from: adminAddress }) - .then(r => r.result); - const amount = balance0 - 1n; - expect(amount).toBeGreaterThan(0n); - - await expect( - asset.methods.transfer(adminAddress, otherAddress, amount, 1).simulate({ from: adminAddress }), - ).rejects.toThrow( - "Assertion failed: Invalid authwit nonce. When 'from' and 'msg_sender' are the same, 'authwit_nonce' must be zero", - ); - }); - - // Authwit-transfers more than balance via proxy; expects 'Balance too low' and verifies balances unchanged. - it('transfer more than balance on behalf of other', async () => { - const balance0 = await asset.methods - .balance_of_private(adminAddress) - .simulate({ from: adminAddress }) - .then(r => r.result); - const balance1 = await asset.methods - .balance_of_private(otherAddress) - .simulate({ from: otherAddress }) - .then(r => r.result); - const amount = balance0 + 1n; - const authwitNonce = Fr.random(); - expect(amount).toBeGreaterThan(0n); - - const action = asset.methods.transfer(adminAddress, otherAddress, amount, authwitNonce); - const witness = await wallet.createAuthWit(adminAddress, { caller: t.authwitProxy.address, action }); - - // Admin sends through proxy so their keys are in scope, while proxy becomes msg_sender to trigger authwit. - await expect( - simulateThroughAuthwitProxy(t.authwitProxy, action, { from: adminAddress, authWitnesses: [witness] }), - ).rejects.toThrow('Assertion failed: Balance too low'); - expect( - await asset.methods - .balance_of_private(adminAddress) - .simulate({ from: adminAddress }) - .then(r => r.result), - ).toEqual(balance0); - expect( - await asset.methods - .balance_of_private(otherAddress) - .simulate({ from: otherAddress }) - .then(r => r.result), - ).toEqual(balance1); - }); - - it.skip('transfer into account to overflow', () => { - // This should already be covered by the mint case earlier. e.g., since we cannot mint to overflow, there is not - // a way to get funds enough to overflow. - // Require direct storage manipulation for us to perform a nice explicit case though. - // See https://github.com/AztecProtocol/aztec-packages/issues/1259 - }); - - // Simulates transfer through proxy without providing a witness; expects unknown-authwit error. - it('transfer on behalf of other without approval', async () => { - const balance0 = await asset.methods - .balance_of_private(adminAddress) - .simulate({ from: adminAddress }) - .then(r => r.result); - const amount = balance0 / 2n; - const authwitNonce = Fr.random(); - expect(amount).toBeGreaterThan(0n); - - const action = asset.methods.transfer(adminAddress, otherAddress, amount, authwitNonce); - const call = await action.getFunctionCall(); - const messageHash = await computeAuthWitMessageHash( - { caller: t.authwitProxy.address, call }, - await wallet.getChainInfo(), - ); - - // Admin sends through proxy so their keys are in scope, while proxy becomes msg_sender to trigger authwit. - await expect(simulateThroughAuthwitProxy(t.authwitProxy, action, { from: adminAddress })).rejects.toThrow( - `Unknown auth witness for message hash ${messageHash.toString()}`, - ); - }); - - // Creates authwit designating otherAddress as caller but sends through proxy; expects unknown-authwit - // because the message hash references the proxy address, not otherAddress. - it('transfer on behalf of other, wrong designated caller', async () => { - const balance0 = await asset.methods - .balance_of_private(adminAddress) - .simulate({ from: adminAddress }) - .then(r => r.result); - const amount = balance0 / 2n; - const authwitNonce = Fr.random(); - expect(amount).toBeGreaterThan(0n); - - const action = asset.methods.transfer(adminAddress, otherAddress, amount, authwitNonce); - const call = await action.getFunctionCall(); - const expectedMessageHash = await computeAuthWitMessageHash( - { caller: t.authwitProxy.address, call }, - await wallet.getChainInfo(), - ); - - const witness = await wallet.createAuthWit(adminAddress, { caller: otherAddress, action }); - - // Admin sends through proxy so their keys are in scope, while proxy becomes msg_sender to trigger authwit. - await expect( - simulateThroughAuthwitProxy(t.authwitProxy, action, { from: adminAddress, authWitnesses: [witness] }), - ).rejects.toThrow(`Unknown auth witness for message hash ${expectedMessageHash.toString()}`); - expect( - await asset.methods - .balance_of_private(adminAddress) - .simulate({ from: adminAddress }) - .then(r => r.result), - ).toEqual(balance0); - }); - - // Attempts transfer from blacklistedAddress as sender; expects 'Blacklisted: Sender'. - it('transfer from a blacklisted account', async () => { - await expect( - asset.methods.transfer(blacklistedAddress, adminAddress, 1n, 0).simulate({ from: blacklistedAddress }), - ).rejects.toThrow('Assertion failed: Blacklisted: Sender'); - }); - - // Attempts transfer to blacklistedAddress as recipient; expects 'Blacklisted: Recipient'. - it('transfer to a blacklisted account', async () => { - await expect( - asset.methods.transfer(adminAddress, blacklistedAddress, 1n, 0).simulate({ from: adminAddress }), - ).rejects.toThrow('Assertion failed: Blacklisted: Recipient'); - }); - }); -}); diff --git a/yarn-project/end-to-end/src/automine/token/blacklist_transfer_public.test.ts b/yarn-project/end-to-end/src/automine/token/blacklist_transfer_public.test.ts deleted file mode 100644 index 3717a08239dd..000000000000 --- a/yarn-project/end-to-end/src/automine/token/blacklist_transfer_public.test.ts +++ /dev/null @@ -1,239 +0,0 @@ -import { Fr } from '@aztec/aztec.js/fields'; - -import { U128_UNDERFLOW_ERROR } from '../../fixtures/index.js'; -import { BlacklistTokenContractTest } from './blacklist_token_contract_test.js'; - -// Covers public token transfers on TokenBlacklist: direct, self, authwit-delegated, and blacklist -// enforcement. Setup: single node with AutomineSequencer, 3 accounts, initial mint applied. -// Time-warp required during setup to cross role-change delay. -describe('automine/token/blacklist_transfer_public', () => { - const t = new BlacklistTokenContractTest('transfer_public'); - let { asset, tokenSim, wallet, adminAddress, otherAddress, blacklistedAddress } = t; - - beforeAll(async () => { - await t.setup(); - // Beware that we are adding the admin as minter here, which is very slow because it needs multiple blocks. - await t.applyMint(); - // Have to destructure again to ensure we have latest refs. - ({ asset, tokenSim, wallet, adminAddress, otherAddress, blacklistedAddress } = t); - }, 600_000); - - afterAll(async () => { - await t.teardown(); - }); - - afterEach(async () => { - await t.tokenSim.check(); - }); - - // Transfers half of admin's public balance to other and verifies via TokenSimulator. - it('transfer less than balance', async () => { - const balance0 = await asset.methods - .balance_of_public(adminAddress) - .simulate({ from: adminAddress }) - .then(r => r.result); - const amount = balance0 / 2n; - expect(amount).toBeGreaterThan(0n); - await asset.methods.transfer_public(adminAddress, otherAddress, amount, 0).send({ from: adminAddress }); - - tokenSim.transferPublic(adminAddress, otherAddress, amount); - }); - - // Transfers half of admin's public balance to themselves; verifies balance unchanged via TokenSimulator. - it('transfer to self', async () => { - const balance = await asset.methods - .balance_of_public(adminAddress) - .simulate({ from: adminAddress }) - .then(r => r.result); - const amount = balance / 2n; - expect(amount).toBeGreaterThan(0n); - await asset.methods.transfer_public(adminAddress, adminAddress, amount, 0).send({ from: adminAddress }); - - tokenSim.transferPublic(adminAddress, adminAddress, amount); - }); - - // Sets a public authwit allowing otherAddress to transfer admin's tokens, executes, verifies TokenSimulator, - // then confirms replay reverts with unauthorized. - it('transfer on behalf of other', async () => { - const balance0 = await asset.methods - .balance_of_public(adminAddress) - .simulate({ from: adminAddress }) - .then(r => r.result); - const amount = balance0 / 2n; - expect(amount).toBeGreaterThan(0n); - const authwitNonce = Fr.random(); - - const action = asset.methods.transfer_public(adminAddress, otherAddress, amount, authwitNonce); - - const validateActionInteraction = await wallet.setPublicAuthWit( - adminAddress, - { caller: otherAddress, action }, - true, - ); - await validateActionInteraction.send(); - - // Perform the transfer - await action.send({ from: otherAddress }); - - tokenSim.transferPublic(adminAddress, otherAddress, amount); - - await expect( - asset.methods.transfer_public(adminAddress, otherAddress, amount, authwitNonce).simulate({ from: otherAddress }), - ).rejects.toThrow(/unauthorized/); - }); - - // Error paths: over-balance, invalid nonce, no approval, over-balance via authwit, wrong caller, - // sender blacklisted, recipient blacklisted. - describe('failure cases', () => { - // Attempts to transfer more than public balance; expects U128_UNDERFLOW_ERROR. - it('transfer more than balance', async () => { - const balance0 = await asset.methods - .balance_of_public(adminAddress) - .simulate({ from: adminAddress }) - .then(r => r.result); - const amount = balance0 + 1n; - const authwitNonce = 0; - await expect( - asset.methods - .transfer_public(adminAddress, otherAddress, amount, authwitNonce) - .simulate({ from: adminAddress }), - ).rejects.toThrow(U128_UNDERFLOW_ERROR); - }); - - // Self-transfer with nonce=1; expects the invalid-nonce assertion failure. - it('transfer on behalf of self with non-zero nonce', async () => { - const balance0 = await asset.methods - .balance_of_public(adminAddress) - .simulate({ from: adminAddress }) - .then(r => r.result); - const amount = balance0 - 1n; - const authwitNonce = 1; - await expect( - asset.methods - .transfer_public(adminAddress, otherAddress, amount, authwitNonce) - .simulate({ from: adminAddress }), - ).rejects.toThrow( - "Assertion failed: Invalid authwit nonce. When 'from' and 'msg_sender' are the same, 'authwit_nonce' must be zero", - ); - }); - - // Calls transfer_public on behalf of admin without authwit; expects unauthorized. - it('transfer on behalf of other without "approval"', async () => { - const balance0 = await asset.methods - .balance_of_public(adminAddress) - .simulate({ from: adminAddress }) - .then(r => r.result); - const amount = balance0 + 1n; - const authwitNonce = Fr.random(); - await expect( - asset.methods - .transfer_public(adminAddress, otherAddress, amount, authwitNonce) - .simulate({ from: otherAddress }), - ).rejects.toThrow(/unauthorized/); - }); - - // Approves a transfer exceeding balance via authwit; expects U128_UNDERFLOW_ERROR and verifies - // balances unchanged after simulate. - it('transfer more than balance on behalf of other', async () => { - const balance0 = await asset.methods - .balance_of_public(adminAddress) - .simulate({ from: adminAddress }) - .then(r => r.result); - const balance1 = await asset.methods - .balance_of_public(otherAddress) - .simulate({ from: otherAddress }) - .then(r => r.result); - const amount = balance0 + 1n; - const authwitNonce = Fr.random(); - expect(amount).toBeGreaterThan(0n); - - const action = asset.methods.transfer_public(adminAddress, otherAddress, amount, authwitNonce); - - // We need to compute the message we want to sign and add it to the wallet as approved - const validateActionInteraction = await wallet.setPublicAuthWit( - adminAddress, - { caller: otherAddress, action }, - true, - ); - await validateActionInteraction.send(); - // Perform the transfer - await expect(action.simulate({ from: otherAddress })).rejects.toThrow(U128_UNDERFLOW_ERROR); - - expect( - await asset.methods - .balance_of_public(adminAddress) - .simulate({ from: adminAddress }) - .then(r => r.result), - ).toEqual(balance0); - expect( - await asset.methods - .balance_of_public(otherAddress) - .simulate({ from: otherAddress }) - .then(r => r.result), - ).toEqual(balance1); - }); - - // Approves adminAddress as the caller but executes from otherAddress; expects unauthorized and verifies - // balances unchanged. - it('transfer on behalf of other, wrong designated caller', async () => { - const balance0 = await asset.methods - .balance_of_public(adminAddress) - .simulate({ from: adminAddress }) - .then(r => r.result); - const balance1 = await asset.methods - .balance_of_public(otherAddress) - .simulate({ from: otherAddress }) - .then(r => r.result); - const amount = balance0 + 2n; - const authwitNonce = Fr.random(); - expect(amount).toBeGreaterThan(0n); - - // We need to compute the message we want to sign and add it to the wallet as approved - const action = asset.methods.transfer_public(adminAddress, otherAddress, amount, authwitNonce); - - const validateActionInteraction = await wallet.setPublicAuthWit( - adminAddress, - { caller: adminAddress, action }, - true, - ); - await validateActionInteraction.send(); - - // Perform the transfer - await expect(action.simulate({ from: otherAddress })).rejects.toThrow(/unauthorized/); - - expect( - await asset.methods - .balance_of_public(adminAddress) - .simulate({ from: adminAddress }) - .then(r => r.result), - ).toEqual(balance0); - expect( - await asset.methods - .balance_of_public(otherAddress) - .simulate({ from: otherAddress }) - .then(r => r.result), - ).toEqual(balance1); - }); - - it.skip('transfer into account to overflow', () => { - // This should already be covered by the mint case earlier. e.g., since we cannot mint to overflow, there is not - // a way to get funds enough to overflow. - // Require direct storage manipulation for us to perform a nice explicit case though. - // See https://github.com/AztecProtocol/aztec-packages/issues/1259 - }); - - // Attempts transfer_public from the blacklisted account; expects 'Blacklisted: Sender'. - it('transfer from a blacklisted account', async () => { - await expect( - asset.methods.transfer_public(blacklistedAddress, adminAddress, 1n, 0n).simulate({ from: blacklistedAddress }), - ).rejects.toThrow('Assertion failed: Blacklisted: Sender'); - }); - - // Attempts transfer_public to the blacklisted account; expects 'Blacklisted: Recipient'. - it('transfer to a blacklisted account', async () => { - await expect( - asset.methods.transfer_public(adminAddress, blacklistedAddress, 1n, 0n).simulate({ from: adminAddress }), - ).rejects.toThrow('Assertion failed: Blacklisted: Recipient'); - }); - }); -}); diff --git a/yarn-project/end-to-end/src/automine/token/blacklist_unshielding.test.ts b/yarn-project/end-to-end/src/automine/token/blacklist_unshielding.test.ts index f1355426843e..9ae03264e907 100644 --- a/yarn-project/end-to-end/src/automine/token/blacklist_unshielding.test.ts +++ b/yarn-project/end-to-end/src/automine/token/blacklist_unshielding.test.ts @@ -1,9 +1,14 @@ import { computeAuthWitMessageHash } from '@aztec/aztec.js/authorization'; import { Fr } from '@aztec/aztec.js/fields'; -import { sendThroughAuthwitProxy, simulateThroughAuthwitProxy } from '../../fixtures/authwit_proxy.js'; -import { DUPLICATE_NULLIFIER_ERROR } from '../../fixtures/fixtures.js'; +import { simulateThroughAuthwitProxy } from '../../fixtures/authwit_proxy.js'; import { BlacklistTokenContractTest } from './blacklist_token_contract_test.js'; +import { + INVALID_AUTHWIT_NONCE_ERROR, + amountAboveBalance, + assertAuthwitProxyReplayRejected, + halfBalanceOf, +} from './token_test_helpers.js'; // Covers the unshield (private→public) operation on TokenBlacklist, including authwit-delegated unshielding // and blacklist enforcement on sender and recipient. Setup: single node with AutomineSequencer, 3 accounts, @@ -30,53 +35,26 @@ describe('automine/token/blacklist_unshielding', () => { // Unshields half of admin's private balance to admin's public balance and verifies via TokenSimulator. it('on behalf of self', async () => { - const balancePriv = await asset.methods - .balance_of_private(adminAddress) - .simulate({ from: adminAddress }) - .then(r => r.result); - const amount = balancePriv / 2n; - expect(amount).toBeGreaterThan(0n); - + const amount = await halfBalanceOf(asset, 'private', adminAddress); await asset.methods.unshield(adminAddress, adminAddress, amount, 0).send({ from: adminAddress }); - tokenSim.transferToPublic(adminAddress, adminAddress, amount); }); // Creates a private authwit for unshield, sends through proxy to other's public balance, verifies - // TokenSimulator, then asserts replay fails with DUPLICATE_NULLIFIER_ERROR. + // TokenSimulator, then asserts replay fails with a duplicate-nullifier error. it('on behalf of other', async () => { - const balancePriv0 = await asset.methods - .balance_of_private(adminAddress) - .simulate({ from: adminAddress }) - .then(r => r.result); - const amount = balancePriv0 / 2n; - const authwitNonce = Fr.random(); - expect(amount).toBeGreaterThan(0n); - - const action = asset.methods.unshield(adminAddress, otherAddress, amount, authwitNonce); - const witness = await wallet.createAuthWit(adminAddress, { caller: t.authwitProxy.address, action }); - - // Admin sends through proxy so their keys are in scope, while proxy becomes msg_sender to trigger authwit. - await sendThroughAuthwitProxy(t.authwitProxy, action, { from: adminAddress, authWitnesses: [witness] }); - tokenSim.transferToPublic(adminAddress, otherAddress, amount); - - // Perform the transfer again, should fail - await expect( - sendThroughAuthwitProxy(t.authwitProxy, action, { from: adminAddress, authWitnesses: [witness] }), - ).rejects.toThrow(DUPLICATE_NULLIFIER_ERROR); + const amount = await halfBalanceOf(asset, 'private', adminAddress); + const action = asset.methods.unshield(adminAddress, otherAddress, amount, Fr.random()); + await assertAuthwitProxyReplayRejected(t.authwitProxy, wallet, adminAddress, action, () => + tokenSim.transferToPublic(adminAddress, otherAddress, amount), + ); }); // Error paths: more-than-balance, invalid nonce, over-balance via authwit, wrong caller, blacklist. describe('failure cases', () => { // Unshields more than private balance (self); expects 'Balance too low'. it('on behalf of self (more than balance)', async () => { - const balancePriv = await asset.methods - .balance_of_private(adminAddress) - .simulate({ from: adminAddress }) - .then(r => r.result); - const amount = balancePriv + 1n; - expect(amount).toBeGreaterThan(0n); - + const amount = await amountAboveBalance(asset, 'private', adminAddress); await expect( asset.methods.unshield(adminAddress, adminAddress, amount, 0).simulate({ from: adminAddress }), ).rejects.toThrow('Assertion failed: Balance too low'); @@ -84,31 +62,16 @@ describe('automine/token/blacklist_unshielding', () => { // Self-unshield with nonce=1; expects the invalid-nonce assertion failure. it('on behalf of self (invalid authwit nonce)', async () => { - const balancePriv = await asset.methods - .balance_of_private(adminAddress) - .simulate({ from: adminAddress }) - .then(r => r.result); - const amount = balancePriv + 1n; - expect(amount).toBeGreaterThan(0n); - + const amount = await amountAboveBalance(asset, 'private', adminAddress); await expect( asset.methods.unshield(adminAddress, adminAddress, amount, 1).simulate({ from: adminAddress }), - ).rejects.toThrow( - "Assertion failed: Invalid authwit nonce. When 'from' and 'msg_sender' are the same, 'authwit_nonce' must be zero", - ); + ).rejects.toThrow(INVALID_AUTHWIT_NONCE_ERROR); }); // Authwit-unshields more than private balance via proxy; expects 'Balance too low'. it('on behalf of other (more than balance)', async () => { - const balancePriv0 = await asset.methods - .balance_of_private(adminAddress) - .simulate({ from: adminAddress }) - .then(r => r.result); - const amount = balancePriv0 + 2n; - const authwitNonce = Fr.random(); - expect(amount).toBeGreaterThan(0n); - - const action = asset.methods.unshield(adminAddress, otherAddress, amount, authwitNonce); + const amount = await amountAboveBalance(asset, 'private', adminAddress, 2n); + const action = asset.methods.unshield(adminAddress, otherAddress, amount, Fr.random()); const witness = await wallet.createAuthWit(adminAddress, { caller: t.authwitProxy.address, action }); // Admin sends through proxy so their keys are in scope, while proxy becomes msg_sender to trigger authwit. @@ -120,15 +83,8 @@ describe('automine/token/blacklist_unshielding', () => { // Creates authwit designating otherAddress as caller but sends through proxy; expects unknown-authwit // error because the message hash references the proxy address. it('on behalf of other (invalid designated caller)', async () => { - const balancePriv0 = await asset.methods - .balance_of_private(adminAddress) - .simulate({ from: adminAddress }) - .then(r => r.result); - const amount = balancePriv0 + 2n; - const authwitNonce = Fr.random(); - expect(amount).toBeGreaterThan(0n); - - const action = asset.methods.unshield(adminAddress, otherAddress, amount, authwitNonce); + const amount = await amountAboveBalance(asset, 'private', adminAddress, 2n); + const action = asset.methods.unshield(adminAddress, otherAddress, amount, Fr.random()); const call = await action.getFunctionCall(); const expectedMessageHash = await computeAuthWitMessageHash( { caller: t.authwitProxy.address, call }, diff --git a/yarn-project/end-to-end/src/automine/token/burn.test.ts b/yarn-project/end-to-end/src/automine/token/burn.test.ts index b317cb9b41e0..a5ea45f545eb 100644 --- a/yarn-project/end-to-end/src/automine/token/burn.test.ts +++ b/yarn-project/end-to-end/src/automine/token/burn.test.ts @@ -1,285 +1,228 @@ +import type { AztecAddress } from '@aztec/aztec.js/addresses'; import { computeAuthWitMessageHash } from '@aztec/aztec.js/authorization'; +import type { ContractFunctionInteraction } from '@aztec/aztec.js/contracts'; import { Fr } from '@aztec/aztec.js/fields'; -import { sendThroughAuthwitProxy, simulateThroughAuthwitProxy } from '../../fixtures/authwit_proxy.js'; -import { DUPLICATE_NULLIFIER_ERROR, U128_UNDERFLOW_ERROR } from '../../fixtures/index.js'; +import { simulateThroughAuthwitProxy } from '../../fixtures/authwit_proxy.js'; +import { U128_UNDERFLOW_ERROR } from '../../fixtures/index.js'; +import { BlacklistTokenContractTest } from './blacklist_token_contract_test.js'; import { TokenContractTest } from './token_contract_test.js'; - -// Covers public and private burn on Token contract: direct, authwit-delegated via proxy, and error paths. -// Setup: single node with AutomineSequencer, 3 accounts, Token deployed with initial public and private mint. -describe('automine/token/burn', () => { - const t = new TokenContractTest('burn'); - let { asset, tokenSim, wallet, adminAddress, account1Address } = t; +import { + INVALID_AUTHWIT_NONCE_ERROR, + amountAboveBalance, + assertAuthwitProxyReplayRejected, + assertPublicAuthwitReplayRejected, + halfBalanceOf, +} from './token_test_helpers.js'; + +const BALANCE_TOO_LOW = 'Assertion failed: Balance too low'; +const BLACKLISTED_SENDER = 'Assertion failed: Blacklisted: Sender'; + +/** + * A ready-to-exercise burn harness: the underlying {@link TokenContractTest} or + * {@link BlacklistTokenContractTest} plus the one operation whose method name differs between them + * (`burn_private` vs `burn`). `burn_public` is identical on both, so tests call it off `t` directly. + */ +interface BurnHarness { + t: TokenContractTest | BlacklistTokenContractTest; + privateBurn: (from: AztecAddress, amount: bigint, nonce: Fr | number) => ContractFunctionInteraction; + blacklistedAddress?: AztecAddress; +} + +const scenarios: { name: string; setup: () => Promise }[] = [ + { + name: 'Token', + setup: async () => { + const t = new TokenContractTest('burn'); + t.applyBaseSnapshots(); + await t.setup(); + await t.applyMint(); + return { t, privateBurn: (from, amount, nonce) => t.asset.methods.burn_private(from, amount, nonce) }; + }, + }, + { + name: 'TokenBlacklist', + setup: async () => { + const t = new BlacklistTokenContractTest('blacklist_burn'); + await t.setup(); + // Adds the admin as minter, which is slow because it needs multiple blocks and role-change warps. + await t.applyMint(); + return { + t, + privateBurn: (from, amount, nonce) => t.asset.methods.burn(from, amount, nonce), + blacklistedAddress: t.blacklistedAddress, + }; + }, + }, +]; + +// Public and private burn coverage across both the plain Token and the TokenBlacklist contracts: direct +// burns, authwit-delegated burns (public authwit / private proxy), the failure cases, and the +// blacklist-only "sender is blacklisted" cases. Setup per harness: single node with AutomineSequencer, +// 3 accounts + authwit proxy, token deployed with initial public and private mint (the blacklist harness +// additionally warps past the 86400s role-change delay). +describe.each(scenarios)('automine/token/burn ($name)', ({ name, setup }) => { + let s: BurnHarness; + const isBlacklist = name === 'TokenBlacklist'; beforeAll(async () => { - t.applyBaseSnapshots(); - t.applyMintSnapshot(); - await t.setup(); - // Have to destructure again to ensure we have latest refs. - ({ asset, wallet, adminAddress, tokenSim, adminAddress, account1Address } = t); - }); + s = await setup(); + }, 600_000); afterAll(async () => { - await t.teardown(); + await s.t.teardown(); }); afterEach(async () => { - await t.tokenSim.check(); + await s.t.tokenSim.check(); }); - // Public burn: direct burn, authwit-delegated burn, and error cases. describe('public', () => { // Burns half the admin's public balance and verifies via TokenSimulator. it('burn less than balance', async () => { - const { result: balance0 } = await asset.methods.balance_of_public(adminAddress).simulate({ from: adminAddress }); - const amount = balance0 / 2n; - expect(amount).toBeGreaterThan(0n); - await asset.methods.burn_public(adminAddress, amount, 0).send({ from: adminAddress }); - - tokenSim.burnPublic(adminAddress, amount); + const amount = await halfBalanceOf(s.t.asset, 'public', s.t.adminAddress); + await s.t.asset.methods.burn_public(s.t.adminAddress, amount, 0).send({ from: s.t.adminAddress }); + s.t.tokenSim.burnPublic(s.t.adminAddress, amount); }); - // Grants a public authwit for burn to account1, burns, verifies TokenSimulator, then confirms replay - // reverts with unauthorized. + // Grants a public authwit for burn, burns via the delegated caller, then confirms the authwit is + // single-use (replay reverts with unauthorized). it('burn on behalf of other', async () => { - const { result: balance0 } = await asset.methods.balance_of_public(adminAddress).simulate({ from: adminAddress }); - const amount = balance0 / 2n; - expect(amount).toBeGreaterThan(0n); - const authwitNonce = Fr.random(); - - // We need to compute the message we want to sign and add it to the wallet as approved - const action = asset.methods.burn_public(adminAddress, amount, authwitNonce); - const validateActionInteraction = await wallet.setPublicAuthWit( - adminAddress, - { caller: account1Address, action }, - true, + const amount = await halfBalanceOf(s.t.asset, 'public', s.t.adminAddress); + const action = s.t.asset.methods.burn_public(s.t.adminAddress, amount, Fr.random()); + await assertPublicAuthwitReplayRejected(s.t.wallet, s.t.adminAddress, action, s.t.otherAddress, () => + s.t.tokenSim.burnPublic(s.t.adminAddress, amount), ); - await validateActionInteraction.send(); - - await action.send({ from: account1Address }); - - tokenSim.burnPublic(adminAddress, amount); - - await expect( - asset.methods.burn_public(adminAddress, amount, authwitNonce).simulate({ from: account1Address }), - ).rejects.toThrow(/unauthorized/); }); - // Error paths for public burn. describe('failure cases', () => { - // Attempts to burn more than public balance; expects U128_UNDERFLOW_ERROR. it('burn more than balance', async () => { - const { result: balance0 } = await asset.methods - .balance_of_public(adminAddress) - .simulate({ from: adminAddress }); - const amount = balance0 + 1n; - const authwitNonce = 0; + const amount = await amountAboveBalance(s.t.asset, 'public', s.t.adminAddress); await expect( - asset.methods.burn_public(adminAddress, amount, authwitNonce).simulate({ from: adminAddress }), + s.t.asset.methods.burn_public(s.t.adminAddress, amount, 0).simulate({ from: s.t.adminAddress }), ).rejects.toThrow(U128_UNDERFLOW_ERROR); }); - // Self-burn with nonce=1; expects the invalid-nonce assertion. it('burn on behalf of self with non-zero nonce', async () => { - const { result: balance0 } = await asset.methods - .balance_of_public(adminAddress) - .simulate({ from: adminAddress }); - const amount = balance0 - 1n; - expect(amount).toBeGreaterThan(0n); - const authwitNonce = 1; + const amount = await halfBalanceOf(s.t.asset, 'public', s.t.adminAddress); await expect( - asset.methods.burn_public(adminAddress, amount, authwitNonce).simulate({ from: adminAddress }), - ).rejects.toThrow( - "Assertion failed: Invalid authwit nonce. When 'from' and 'msg_sender' are the same, 'authwit_nonce' must be zero", - ); + s.t.asset.methods.burn_public(s.t.adminAddress, amount, 1).simulate({ from: s.t.adminAddress }), + ).rejects.toThrow(INVALID_AUTHWIT_NONCE_ERROR); }); - // Burn from account1 without authwit; expects unauthorized. it('burn on behalf of other without "approval"', async () => { - const { result: balance0 } = await asset.methods - .balance_of_public(adminAddress) - .simulate({ from: adminAddress }); - const amount = balance0 + 1n; - const authwitNonce = Fr.random(); + const amount = await amountAboveBalance(s.t.asset, 'public', s.t.adminAddress); await expect( - asset.methods.burn_public(adminAddress, amount, authwitNonce).simulate({ from: account1Address }), + s.t.asset.methods.burn_public(s.t.adminAddress, amount, Fr.random()).simulate({ from: s.t.otherAddress }), ).rejects.toThrow(/unauthorized/); }); - // Approves a burn exceeding balance via authwit; expects U128_UNDERFLOW_ERROR on simulate. it('burn more than balance on behalf of other', async () => { - const { result: balance0 } = await asset.methods - .balance_of_public(adminAddress) - .simulate({ from: adminAddress }); - const amount = balance0 + 1n; - const authwitNonce = Fr.random(); - expect(amount).toBeGreaterThan(0n); - - // We need to compute the message we want to sign and add it to the wallet as approved - const action = asset.methods.burn_public(adminAddress, amount, authwitNonce); - const validateActionInteraction = await wallet.setPublicAuthWit( - adminAddress, - { caller: account1Address, action }, - true, - ); - await validateActionInteraction.send(); - - await expect(action.simulate({ from: account1Address })).rejects.toThrow(U128_UNDERFLOW_ERROR); + const amount = await amountAboveBalance(s.t.asset, 'public', s.t.adminAddress); + const action = s.t.asset.methods.burn_public(s.t.adminAddress, amount, Fr.random()); + const grant = await s.t.wallet.setPublicAuthWit(s.t.adminAddress, { caller: s.t.otherAddress, action }, true); + await grant.send(); + await expect(action.simulate({ from: s.t.otherAddress })).rejects.toThrow(U128_UNDERFLOW_ERROR); }); - // Approves adminAddress as caller but tries from account1; expects unauthorized. it('burn on behalf of other, wrong designated caller', async () => { - const { result: balance0 } = await asset.methods - .balance_of_public(adminAddress) - .simulate({ from: adminAddress }); - const amount = balance0 + 2n; - const authwitNonce = Fr.random(); - expect(amount).toBeGreaterThan(0n); - - // We need to compute the message we want to sign and add it to the wallet as approved - const action = asset.methods.burn_public(adminAddress, amount, authwitNonce); - const validateActionInteraction = await wallet.setPublicAuthWit( - adminAddress, - { caller: adminAddress, action }, - true, - ); - await validateActionInteraction.send(); - - await expect( - asset.methods.burn_public(adminAddress, amount, authwitNonce).simulate({ from: account1Address }), - ).rejects.toThrow(/unauthorized/); + const amount = await amountAboveBalance(s.t.asset, 'public', s.t.adminAddress, 2n); + const action = s.t.asset.methods.burn_public(s.t.adminAddress, amount, Fr.random()); + // Approve the owner as caller, but execute from `other`: the message hashes don't match. + const grant = await s.t.wallet.setPublicAuthWit(s.t.adminAddress, { caller: s.t.adminAddress, action }, true); + await grant.send(); + await expect(action.simulate({ from: s.t.otherAddress })).rejects.toThrow(/unauthorized/); }); + + if (isBlacklist) { + // Blacklist-only: a blacklisted account cannot burn its own tokens. + it('burn from blacklisted account', async () => { + await expect( + s.t.asset.methods.burn_public(s.blacklistedAddress!, 1n, 0).simulate({ from: s.blacklistedAddress! }), + ).rejects.toThrow(BLACKLISTED_SENDER); + }); + } }); }); - // Private burn: direct burn, authwit-delegated burn via proxy, and error cases. describe('private', () => { // Burns half the admin's private balance and verifies via TokenSimulator. it('burn less than balance', async () => { - const { result: balance0 } = await asset.methods - .balance_of_private(adminAddress) - .simulate({ from: adminAddress }); - const amount = balance0 / 2n; - expect(amount).toBeGreaterThan(0n); - await asset.methods.burn_private(adminAddress, amount, 0).send({ from: adminAddress }); - tokenSim.burnPrivate(adminAddress, amount); + const amount = await halfBalanceOf(s.t.asset, 'private', s.t.adminAddress); + await s.privateBurn(s.t.adminAddress, amount, 0).send({ from: s.t.adminAddress }); + s.t.tokenSim.burnPrivate(s.t.adminAddress, amount); }); - // Creates a private authwit for burn_private, sends through proxy, verifies TokenSimulator, then asserts - // replay fails with DUPLICATE_NULLIFIER_ERROR. + // Creates a private authwit for burn, sends through the proxy, then confirms replay reverts with a + // duplicate-nullifier error. it('burn on behalf of other', async () => { - const { result: balance0 } = await asset.methods - .balance_of_private(adminAddress) - .simulate({ from: adminAddress }); - const amount = balance0 / 2n; - const authwitNonce = Fr.random(); - expect(amount).toBeGreaterThan(0n); - - const action = asset.methods.burn_private(adminAddress, amount, authwitNonce); - const witness = await wallet.createAuthWit(adminAddress, { caller: t.authwitProxy.address, action }); - - // Admin sends through proxy so their keys are in scope, while proxy becomes msg_sender to trigger authwit. - await sendThroughAuthwitProxy(t.authwitProxy, action, { from: adminAddress, authWitnesses: [witness] }); - tokenSim.burnPrivate(adminAddress, amount); - - // Perform the transfer again, should fail - await expect( - sendThroughAuthwitProxy(t.authwitProxy, action, { from: adminAddress, authWitnesses: [witness] }), - ).rejects.toThrow(DUPLICATE_NULLIFIER_ERROR); + const amount = await halfBalanceOf(s.t.asset, 'private', s.t.adminAddress); + const action = s.privateBurn(s.t.adminAddress, amount, Fr.random()); + await assertAuthwitProxyReplayRejected(s.t.authwitProxy, s.t.wallet, s.t.adminAddress, action, () => + s.t.tokenSim.burnPrivate(s.t.adminAddress, amount), + ); }); - // Error paths for private burn. describe('failure cases', () => { - // Attempts to burn more than private balance; expects 'Balance too low'. it('burn more than balance', async () => { - const { result: balance0 } = await asset.methods - .balance_of_private(adminAddress) - .simulate({ from: adminAddress }); - const amount = balance0 + 1n; - expect(amount).toBeGreaterThan(0n); - await expect( - asset.methods.burn_private(adminAddress, amount, 0).simulate({ from: adminAddress }), - ).rejects.toThrow('Assertion failed: Balance too low'); + const amount = await amountAboveBalance(s.t.asset, 'private', s.t.adminAddress); + await expect(s.privateBurn(s.t.adminAddress, amount, 0).simulate({ from: s.t.adminAddress })).rejects.toThrow( + BALANCE_TOO_LOW, + ); }); - // Self-burn with nonce=1; expects the invalid-nonce assertion. it('burn on behalf of self with non-zero nonce', async () => { - const { result: balance0 } = await asset.methods - .balance_of_private(adminAddress) - .simulate({ from: adminAddress }); - const amount = balance0 - 1n; - expect(amount).toBeGreaterThan(0n); - await expect( - asset.methods.burn_private(adminAddress, amount, 1).simulate({ from: adminAddress }), - ).rejects.toThrow( - "Assertion failed: Invalid authwit nonce. When 'from' and 'msg_sender' are the same, 'authwit_nonce' must be zero", + const amount = await halfBalanceOf(s.t.asset, 'private', s.t.adminAddress); + await expect(s.privateBurn(s.t.adminAddress, amount, 1).simulate({ from: s.t.adminAddress })).rejects.toThrow( + INVALID_AUTHWIT_NONCE_ERROR, ); }); - // Creates authwit for burn exceeding balance via proxy; expects 'Balance too low' on simulate. it('burn more than balance on behalf of other', async () => { - const { result: balance0 } = await asset.methods - .balance_of_private(adminAddress) - .simulate({ from: adminAddress }); - const amount = balance0 + 1n; - const authwitNonce = Fr.random(); - expect(amount).toBeGreaterThan(0n); - - const action = asset.methods.burn_private(adminAddress, amount, authwitNonce); - const witness = await wallet.createAuthWit(adminAddress, { caller: t.authwitProxy.address, action }); - - // Admin sends through proxy so their keys are in scope, while proxy becomes msg_sender to trigger authwit. + const amount = await amountAboveBalance(s.t.asset, 'private', s.t.adminAddress); + const action = s.privateBurn(s.t.adminAddress, amount, Fr.random()); + const witness = await s.t.wallet.createAuthWit(s.t.adminAddress, { caller: s.t.authwitProxy.address, action }); await expect( - simulateThroughAuthwitProxy(t.authwitProxy, action, { from: adminAddress, authWitnesses: [witness] }), - ).rejects.toThrow('Assertion failed: Balance too low'); + simulateThroughAuthwitProxy(s.t.authwitProxy, action, { from: s.t.adminAddress, authWitnesses: [witness] }), + ).rejects.toThrow(BALANCE_TOO_LOW); }); - // Simulates burn through proxy without a witness; expects unknown-authwit error. it('burn on behalf of other without approval', async () => { - const { result: balance0 } = await asset.methods - .balance_of_private(adminAddress) - .simulate({ from: adminAddress }); - const amount = balance0 / 2n; - const authwitNonce = Fr.random(); - expect(amount).toBeGreaterThan(0n); - - const action = asset.methods.burn_private(adminAddress, amount, authwitNonce); + const amount = await halfBalanceOf(s.t.asset, 'private', s.t.adminAddress); + const action = s.privateBurn(s.t.adminAddress, amount, Fr.random()); const call = await action.getFunctionCall(); const messageHash = await computeAuthWitMessageHash( - { caller: t.authwitProxy.address, call }, - await wallet.getChainInfo(), + { caller: s.t.authwitProxy.address, call }, + await s.t.wallet.getChainInfo(), ); - - // Admin sends through proxy so their keys are in scope, while proxy becomes msg_sender to trigger authwit. - await expect(simulateThroughAuthwitProxy(t.authwitProxy, action, { from: adminAddress })).rejects.toThrow( + await expect(simulateThroughAuthwitProxy(s.t.authwitProxy, action, { from: s.t.adminAddress })).rejects.toThrow( `Unknown auth witness for message hash ${messageHash.toString()}`, ); }); - // Creates authwit designating account1 as caller but sends through proxy; expects unknown-authwit error - // because the message hash references the proxy, not account1. it('on behalf of other (invalid designated caller)', async () => { - const { result: balancePriv0 } = await asset.methods - .balance_of_private(adminAddress) - .simulate({ from: adminAddress }); - const amount = balancePriv0 + 2n; - const authwitNonce = Fr.random(); - expect(amount).toBeGreaterThan(0n); - - const action = asset.methods.burn_private(adminAddress, amount, authwitNonce); + const amount = await halfBalanceOf(s.t.asset, 'private', s.t.adminAddress); + const action = s.privateBurn(s.t.adminAddress, amount, Fr.random()); const call = await action.getFunctionCall(); const expectedMessageHash = await computeAuthWitMessageHash( - { caller: t.authwitProxy.address, call }, - await wallet.getChainInfo(), + { caller: s.t.authwitProxy.address, call }, + await s.t.wallet.getChainInfo(), ); - - const witness = await wallet.createAuthWit(adminAddress, { caller: account1Address, action }); - - // Admin sends through proxy so their keys are in scope, while proxy becomes msg_sender to trigger authwit. + // Designate `other` as caller (not the proxy), then send through the proxy: the hashes don't match. + const witness = await s.t.wallet.createAuthWit(s.t.adminAddress, { caller: s.t.otherAddress, action }); await expect( - simulateThroughAuthwitProxy(t.authwitProxy, action, { from: adminAddress, authWitnesses: [witness] }), + simulateThroughAuthwitProxy(s.t.authwitProxy, action, { from: s.t.adminAddress, authWitnesses: [witness] }), ).rejects.toThrow(`Unknown auth witness for message hash ${expectedMessageHash.toString()}`); }); + + if (isBlacklist) { + // Blacklist-only: a blacklisted account cannot private-burn its own tokens. + it('burn from blacklisted account', async () => { + await expect( + s.privateBurn(s.blacklistedAddress!, 1n, 0).simulate({ from: s.blacklistedAddress! }), + ).rejects.toThrow(BLACKLISTED_SENDER); + }); + } }); }); }); diff --git a/yarn-project/end-to-end/src/automine/token/minting.test.ts b/yarn-project/end-to-end/src/automine/token/minting.test.ts index 7e128d6bd899..c55afff81f20 100644 --- a/yarn-project/end-to-end/src/automine/token/minting.test.ts +++ b/yarn-project/end-to-end/src/automine/token/minting.test.ts @@ -1,16 +1,29 @@ +import type { AztecAddress } from '@aztec/aztec.js/addresses'; +import type { ContractFunctionInteraction } from '@aztec/aztec.js/contracts'; + import { U128_OVERFLOW_ERROR } from '../../fixtures/fixtures.js'; import { TokenContractTest } from './token_contract_test.js'; +import { type BalanceKind, balanceOf } from './token_test_helpers.js'; + +/** A mint path (public or private) with the differing method, balance kind, and simulator hooks. */ +interface MintPath { + name: string; + balanceKind: BalanceKind; + mint(recipient: AztecAddress, amount: bigint): ContractFunctionInteraction; + updateSim(recipient: AztecAddress, amount: bigint): void; + simBalance(account: AztecAddress): bigint; +} -// Covers public and private minting on Token contract, including minter role enforcement and overflow checks. -// Setup: single node with AutomineSequencer, 3 accounts deployed, Token contract deployed (no initial mint). +// Covers public and private minting on Token, mirrored via describe.each: minter-role enforcement and u128 +// overflow guards (recipient balance and total supply). The private-only ABI-encoding overflow case is kept +// explicit since it has no public mirror. Setup: single node with AutomineSequencer, 3 accounts, Token +// deployed (no initial mint). describe('automine/token/minting', () => { const t = new TokenContractTest('minting'); - let { asset, tokenSim, adminAddress, account1Address } = t; beforeAll(async () => { t.applyBaseSnapshots(); await t.setup(); - ({ asset, tokenSim, adminAddress, account1Address } = t); }); afterAll(async () => { @@ -21,100 +34,68 @@ describe('automine/token/minting', () => { await t.tokenSim.check(); }); - // Public mint path: success and overflow/permission failure cases. - describe('Public', () => { - // Mints 10000 tokens publicly as admin-minter and verifies balance and total supply via TokenSimulator. + const paths: MintPath[] = [ + { + name: 'Public', + balanceKind: 'public', + mint: (recipient, amount) => t.asset.methods.mint_to_public(recipient, amount), + updateSim: (recipient, amount) => t.tokenSim.mintPublic(recipient, amount), + simBalance: account => t.tokenSim.balanceOfPublic(account), + }, + { + name: 'Private', + balanceKind: 'private', + mint: (recipient, amount) => t.asset.methods.mint_to_private(recipient, amount), + updateSim: (recipient, amount) => t.tokenSim.mintPrivate(recipient, amount), + simBalance: account => t.tokenSim.balanceOfPrivate(account), + }, + ]; + + describe.each(paths)('$name', p => { + // Mints 10000 tokens as the admin-minter and verifies balance and total supply via TokenSimulator. it('as minter', async () => { const amount = 10000n; - await asset.methods.mint_to_public(adminAddress, amount).send({ from: adminAddress }); + await p.mint(t.adminAddress, amount).send({ from: t.adminAddress }); - tokenSim.mintPublic(adminAddress, amount); - expect((await asset.methods.balance_of_public(adminAddress).simulate({ from: adminAddress })).result).toEqual( - tokenSim.balanceOfPublic(adminAddress), - ); - expect((await asset.methods.total_supply().simulate({ from: adminAddress })).result).toEqual( - tokenSim.totalSupply, + p.updateSim(t.adminAddress, amount); + expect(await balanceOf(t.asset, p.balanceKind, t.adminAddress)).toEqual(p.simBalance(t.adminAddress)); + expect((await t.asset.methods.total_supply().simulate({ from: t.adminAddress })).result).toEqual( + t.tokenSim.totalSupply, ); }); - // Error paths for public mint. describe('failure cases', () => { - // Attempts mint_to_public from account1 (not a minter); expects 'caller is not minter'. + // Attempts to mint from other (not a minter); expects 'caller is not minter'. it('as non-minter', async () => { - const amount = 10000n; - await expect( - asset.methods.mint_to_public(adminAddress, amount).simulate({ from: account1Address }), - ).rejects.toThrow('Assertion failed: caller is not minter'); + await expect(p.mint(t.adminAddress, 10000n).simulate({ from: t.otherAddress })).rejects.toThrow( + 'Assertion failed: caller is not minter', + ); }); - // Mints an amount that would overflow the recipient's u128 public balance; expects U128_OVERFLOW_ERROR. + // Mints an amount that would overflow the recipient's u128 balance; expects U128_OVERFLOW_ERROR. it('mint u128', async () => { - const amount = 2n ** 128n - tokenSim.balanceOfPublic(adminAddress); - await expect( - asset.methods.mint_to_public(adminAddress, amount).simulate({ from: adminAddress }), - ).rejects.toThrow(U128_OVERFLOW_ERROR); + const amount = 2n ** 128n - p.simBalance(t.adminAddress); + await expect(p.mint(t.adminAddress, amount).simulate({ from: t.adminAddress })).rejects.toThrow( + U128_OVERFLOW_ERROR, + ); }); // Mints an amount that would overflow total supply across accounts; expects U128_OVERFLOW_ERROR. it('mint u128', async () => { - const amount = 2n ** 128n - tokenSim.balanceOfPublic(adminAddress); - await expect( - asset.methods.mint_to_public(account1Address, amount).simulate({ from: adminAddress }), - ).rejects.toThrow(U128_OVERFLOW_ERROR); + const amount = 2n ** 128n - p.simBalance(t.adminAddress); + await expect(p.mint(t.otherAddress, amount).simulate({ from: t.adminAddress })).rejects.toThrow( + U128_OVERFLOW_ERROR, + ); }); }); }); - // Private mint path: success and overflow/permission failure cases. - describe('Private', () => { - // Mints 10000 tokens privately as admin-minter and verifies balance and total supply via TokenSimulator. - it('as minter', async () => { - const amount = 10000n; - await asset.methods.mint_to_private(adminAddress, amount).send({ from: adminAddress }); - - tokenSim.mintPrivate(adminAddress, amount); - expect((await asset.methods.balance_of_private(adminAddress).simulate({ from: adminAddress })).result).toEqual( - tokenSim.balanceOfPrivate(adminAddress), - ); - expect((await asset.methods.total_supply().simulate({ from: adminAddress })).result).toEqual( - tokenSim.totalSupply, - ); - }); - - // Error paths for private mint. - describe('failure cases', () => { - // Attempts mint_to_private from account1 (not a minter); expects 'caller is not minter'. - it('as non-minter', async () => { - const amount = 10000n; - await expect( - asset.methods.mint_to_private(adminAddress, amount).simulate({ from: account1Address }), - ).rejects.toThrow('Assertion failed: caller is not minter'); - }); - - // Passes an overflowed u128 to mint_to_private; expected to fail at ABI encoding, not contract logic. - // We keep the test to be defensive as it is the only e2e test with overflowed inputs. - it('mint >u128 tokens to overflow', async () => { - const overflowAmount = 2n ** 128n; - await expect( - asset.methods.mint_to_private(adminAddress, overflowAmount).simulate({ from: adminAddress }), - ).rejects.toThrow('does not fit in u128'); - }); - - // Mints an amount that would overflow the recipient's private u128 balance; expects U128_OVERFLOW_ERROR. - it('mint u128', async () => { - const amount = 2n ** 128n - tokenSim.balanceOfPrivate(adminAddress); - await expect( - asset.methods.mint_to_private(adminAddress, amount).simulate({ from: adminAddress }), - ).rejects.toThrow(U128_OVERFLOW_ERROR); - }); - - // Mints an amount that would overflow total supply (private path); expects U128_OVERFLOW_ERROR. - it('mint u128', async () => { - const amount = 2n ** 128n - tokenSim.balanceOfPrivate(adminAddress); - await expect( - asset.methods.mint_to_private(account1Address, amount).simulate({ from: adminAddress }), - ).rejects.toThrow(U128_OVERFLOW_ERROR); - }); - }); + // Private-only: passing an overflowed u128 to mint_to_private fails at ABI encoding, not contract logic. + // Kept as the only e2e test with overflowed inputs (no public mirror). + it('mint >u128 tokens to overflow (private)', async () => { + const overflowAmount = 2n ** 128n; + await expect( + t.asset.methods.mint_to_private(t.adminAddress, overflowAmount).simulate({ from: t.adminAddress }), + ).rejects.toThrow('does not fit in u128'); }); }); diff --git a/yarn-project/end-to-end/src/automine/token/private_transfer_recursion.parallel.test.ts b/yarn-project/end-to-end/src/automine/token/private_transfer_recursion.parallel.test.ts index 53123d182b7f..b08a5cb05145 100644 --- a/yarn-project/end-to-end/src/automine/token/private_transfer_recursion.parallel.test.ts +++ b/yarn-project/end-to-end/src/automine/token/private_transfer_recursion.parallel.test.ts @@ -9,19 +9,19 @@ import { TokenContractTest } from './token_contract_test.js'; // events are emitted and readable. Setup: single node with AutomineSequencer, 3 accounts, Token deployed. describe('automine/token/private_transfer_recursion', () => { const t = new TokenContractTest('odd_transfer_private'); - let { asset, wallet, adminAddress, account1Address, node } = t; + let { asset, wallet, adminAddress, otherAddress, node } = t; beforeAll(async () => { t.applyBaseSnapshots(); await t.setup(); - ({ asset, wallet, adminAddress, account1Address, node } = t); + ({ asset, wallet, adminAddress, otherAddress, node } = t); }); afterAll(async () => { await t.teardown(); }); - // Mints 16 separate notes of 10 tokens each, transfers the full balance to account1, then verifies that + // Mints 16 separate notes of 10 tokens each, transfers the full balance to other, then verifies that // all 16 notes were nullified, one new note was created, and the Transfer event is readable. it('transfer full balance', async () => { // We insert 16 notes, which is large enough to guarantee that the token will need to do two recursive calls to @@ -29,7 +29,7 @@ describe('automine/token/private_transfer_recursion', () => { const totalNotes = 16; const totalBalance = await mintNotes(wallet, adminAddress, adminAddress, asset, Array(totalNotes).fill(10n)); const { receipt: txReceipt } = await asset.methods - .transfer(account1Address, totalBalance) + .transfer(otherAddress, totalBalance) .send({ from: adminAddress }); const txEffects = await node.getTxEffect(txReceipt.txHash); @@ -42,13 +42,13 @@ describe('automine/token/private_transfer_recursion', () => { contractAddress: asset.address, fromBlock: BlockNumber(txReceipt.blockNumber!), toBlock: BlockNumber(txReceipt.blockNumber! + 1), - scopes: [account1Address], + scopes: [otherAddress], }); expect(events[0]).toEqual({ event: { from: adminAddress, - to: account1Address, + to: otherAddress, amount: totalBalance, }, metadata: { @@ -69,7 +69,7 @@ describe('automine/token/private_transfer_recursion', () => { const totalBalance = await mintNotes(wallet, adminAddress, adminAddress, asset, noteAmounts); const toSend = totalBalance - expectedChange; - const { receipt: txReceipt } = await asset.methods.transfer(account1Address, toSend).send({ from: adminAddress }); + const { receipt: txReceipt } = await asset.methods.transfer(otherAddress, toSend).send({ from: adminAddress }); const txEffects = await node.getTxEffect(txReceipt.txHash); // We should have nullified all notes, plus an extra nullifier for the transaction and one for the event commitment. @@ -86,13 +86,13 @@ describe('automine/token/private_transfer_recursion', () => { contractAddress: asset.address, fromBlock: BlockNumber(txReceipt.blockNumber!), toBlock: BlockNumber(txReceipt.blockNumber! + 1), - scopes: [account1Address], + scopes: [otherAddress], }); expect(events[0]).toEqual({ event: { from: adminAddress, - to: account1Address, + to: otherAddress, amount: toSend, }, metadata: { @@ -114,7 +114,7 @@ describe('automine/token/private_transfer_recursion', () => { const amount = balance0 + 1n; expect(amount).toBeGreaterThan(0n); - await expect(asset.methods.transfer(account1Address, amount).simulate({ from: adminAddress })).rejects.toThrow( + await expect(asset.methods.transfer(otherAddress, amount).simulate({ from: adminAddress })).rejects.toThrow( 'Assertion failed: Balance too low', ); }); diff --git a/yarn-project/end-to-end/src/automine/token/reading_constants.parallel.test.ts b/yarn-project/end-to-end/src/automine/token/reading_constants.parallel.test.ts deleted file mode 100644 index 48cedf500158..000000000000 --- a/yarn-project/end-to-end/src/automine/token/reading_constants.parallel.test.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { readFieldCompressedString } from '@aztec/aztec.js/utils'; - -import { TokenContractTest } from './token_contract_test.js'; - -// Verifies that Token contract constants (name, symbol, decimals) are readable from both private and public -// entry points and match the values supplied at deploy time. Setup: single node with AutomineSequencer, -// Token contract deployed with TOKEN_NAME/SYMBOL/DECIMALS. -describe('automine/token/reading_constants', () => { - const t = new TokenContractTest('reading_constants'); - const { TOKEN_DECIMALS, TOKEN_NAME, TOKEN_SYMBOL } = TokenContractTest; - - beforeAll(async () => { - t.applyBaseSnapshots(); - await t.setup(); - }); - - afterAll(async () => { - await t.teardown(); - }); - - beforeEach(async () => {}); - - afterEach(async () => { - await t.tokenSim.check(); - }); - - // Calls private_get_name via simulate and asserts it equals TOKEN_NAME after decoding the compressed string. - it('check name private', async () => { - const name = readFieldCompressedString( - (await t.asset.methods.private_get_name().simulate({ from: t.adminAddress })).result, - ); - expect(name).toBe(TOKEN_NAME); - }); - - // Calls public_get_name via simulate and asserts it equals TOKEN_NAME after decoding. - it('check name public', async () => { - const name = readFieldCompressedString( - (await t.asset.methods.public_get_name().simulate({ from: t.adminAddress })).result, - ); - expect(name).toBe(TOKEN_NAME); - }); - - // Calls private_get_symbol via simulate and asserts it equals TOKEN_SYMBOL after decoding. - it('check symbol private', async () => { - const sym = readFieldCompressedString( - (await t.asset.methods.private_get_symbol().simulate({ from: t.adminAddress })).result, - ); - expect(sym).toBe(TOKEN_SYMBOL); - }); - - // Calls public_get_symbol via simulate and asserts it equals TOKEN_SYMBOL after decoding. - it('check symbol public', async () => { - const sym = readFieldCompressedString( - (await t.asset.methods.public_get_symbol().simulate({ from: t.adminAddress })).result, - ); - expect(sym).toBe(TOKEN_SYMBOL); - }); - - // Calls private_get_decimals via simulate and asserts it equals TOKEN_DECIMALS (18n). - it('check decimals private', async () => { - const { result: dec } = await t.asset.methods.private_get_decimals().simulate({ from: t.adminAddress }); - expect(dec).toBe(TOKEN_DECIMALS); - }); - - // Calls public_get_decimals via simulate and asserts it equals TOKEN_DECIMALS (18n). - it('check decimals public', async () => { - const { result: dec } = await t.asset.methods.public_get_decimals().simulate({ from: t.adminAddress }); - expect(dec).toBe(TOKEN_DECIMALS); - }); -}); diff --git a/yarn-project/end-to-end/src/automine/token/reading_constants.test.ts b/yarn-project/end-to-end/src/automine/token/reading_constants.test.ts new file mode 100644 index 000000000000..7f3f538e1141 --- /dev/null +++ b/yarn-project/end-to-end/src/automine/token/reading_constants.test.ts @@ -0,0 +1,69 @@ +import { readFieldCompressedString } from '@aztec/aztec.js/utils'; + +import { TokenContractTest } from './token_contract_test.js'; + +// Verifies that Token contract constants (name, symbol, decimals) are readable from both private and public +// entry points and match the values supplied at deploy time. Setup: single node with AutomineSequencer, +// Token contract deployed with TOKEN_NAME/SYMBOL/DECIMALS. +describe('automine/token/reading_constants', () => { + const t = new TokenContractTest('reading_constants'); + const { TOKEN_DECIMALS, TOKEN_NAME, TOKEN_SYMBOL } = TokenContractTest; + + beforeAll(async () => { + t.applyBaseSnapshots(); + await t.setup(); + }); + + afterAll(async () => { + await t.teardown(); + }); + + afterEach(async () => { + await t.tokenSim.check(); + }); + + // Each constant (name/symbol/decimals) is exposed by both a private and a public getter. Name and symbol + // are compressed strings that need decoding; decimals is a plain field. + it.each<{ label: string; get: () => Promise; expected: string | bigint }>([ + { + label: 'name via private getter', + get: async () => + readFieldCompressedString((await t.asset.methods.private_get_name().simulate({ from: t.adminAddress })).result), + expected: TOKEN_NAME, + }, + { + label: 'name via public getter', + get: async () => + readFieldCompressedString((await t.asset.methods.public_get_name().simulate({ from: t.adminAddress })).result), + expected: TOKEN_NAME, + }, + { + label: 'symbol via private getter', + get: async () => + readFieldCompressedString( + (await t.asset.methods.private_get_symbol().simulate({ from: t.adminAddress })).result, + ), + expected: TOKEN_SYMBOL, + }, + { + label: 'symbol via public getter', + get: async () => + readFieldCompressedString( + (await t.asset.methods.public_get_symbol().simulate({ from: t.adminAddress })).result, + ), + expected: TOKEN_SYMBOL, + }, + { + label: 'decimals via private getter', + get: async () => (await t.asset.methods.private_get_decimals().simulate({ from: t.adminAddress })).result, + expected: TOKEN_DECIMALS, + }, + { + label: 'decimals via public getter', + get: async () => (await t.asset.methods.public_get_decimals().simulate({ from: t.adminAddress })).result, + expected: TOKEN_DECIMALS, + }, + ])('reads $label', async ({ get, expected }) => { + expect(await get()).toBe(expected); + }); +}); diff --git a/yarn-project/end-to-end/src/automine/token/token_contract_test.ts b/yarn-project/end-to-end/src/automine/token/token_contract_test.ts index dcd4043b3d31..5cc9aa19d8e1 100644 --- a/yarn-project/end-to-end/src/automine/token/token_contract_test.ts +++ b/yarn-project/end-to-end/src/automine/token/token_contract_test.ts @@ -17,8 +17,8 @@ const { METRICS_PORT: metricsPort } = process.env; /** * Token-domain harness over the automine topology: extends {@link AutomineTestContext} with a * {@link TokenSimulator}, the USDC Token deploy plus a bad-account and authwit proxy, and an optional - * mint. The base/mint steps are opt-in via {@link applyBaseSnapshots}/{@link applyMintSnapshot}, run - * after `super.setup()`. + * mint. Base setup is opt-in via {@link applyBaseSnapshots} (run during `setup()`); {@link applyMint} + * is called explicitly after `setup()`. */ export class TokenContractTest extends AutomineTestContext { static TOKEN_NAME = 'USDC'; @@ -32,11 +32,10 @@ export class TokenContractTest extends AutomineTestContext { badAccount!: InvalidAccountContract; authwitProxy!: GenericProxyContract; adminAddress!: AztecAddress; - account1Address!: AztecAddress; + otherAddress!: AztecAddress; account2Address!: AztecAddress; private shouldApplyBaseSetup = false; - private shouldApplyMint = false; private testName: string; constructor(testName: string) { @@ -53,14 +52,6 @@ export class TokenContractTest extends AutomineTestContext { this.shouldApplyBaseSetup = true; } - /** - * Registers that mint should be applied during setup(). - * Call this before setup() to mint tokens to the admin account. - */ - applyMintSnapshot() { - this.shouldApplyMint = true; - } - /** * Applies base setup: deploys 3 accounts, publicly deploys accounts, token contract and a "bad account". */ @@ -70,7 +61,7 @@ export class TokenContractTest extends AutomineTestContext { this.node = this.context.aztecNodeService; this.wallet = this.context.wallet; - [this.adminAddress, this.account1Address, this.account2Address] = this.context.accounts; + [this.adminAddress, this.otherAddress, this.account2Address] = this.context.accounts; this.logger.info('Applying base setup - deploying token contract'); await ensureAuthRegistryPublished(this.wallet, this.adminAddress); @@ -102,7 +93,7 @@ export class TokenContractTest extends AutomineTestContext { this.tokenSim = new TokenSimulator(this.asset, this.wallet, this.adminAddress, this.logger, [ this.adminAddress, - this.account1Address, + this.otherAddress, ]); expect((await this.asset.methods.get_admin().simulate({ from: this.adminAddress })).result).toBe( @@ -118,13 +109,10 @@ export class TokenContractTest extends AutomineTestContext { if (this.shouldApplyBaseSetup) { await this.applyBaseSetup(); } - - if (this.shouldApplyMint) { - await this.applyMint(); - } } - private async applyMint() { + /** Mints an initial public and private balance to the admin account. Call after {@link setup}. */ + async applyMint() { this.logger.info('Applying mint setup'); const { asset, adminAddress, tokenSim } = this; const amount = 10000n; diff --git a/yarn-project/end-to-end/src/automine/token/token_test_helpers.ts b/yarn-project/end-to-end/src/automine/token/token_test_helpers.ts new file mode 100644 index 000000000000..70c2c2695bdd --- /dev/null +++ b/yarn-project/end-to-end/src/automine/token/token_test_helpers.ts @@ -0,0 +1,138 @@ +import type { AztecAddress } from '@aztec/aztec.js/addresses'; +import type { ContractFunctionInteraction } from '@aztec/aztec.js/contracts'; +import type { Logger } from '@aztec/aztec.js/log'; +import type { Wallet } from '@aztec/aztec.js/wallet'; +import { AMMContract } from '@aztec/noir-contracts.js/AMM'; +import type { GenericProxyContract } from '@aztec/noir-test-contracts.js/GenericProxy'; + +import { sendThroughAuthwitProxy } from '../../fixtures/authwit_proxy.js'; +import { DUPLICATE_NULLIFIER_ERROR } from '../../fixtures/fixtures.js'; +import { type AnyTokenContract, mintTokensToPrivate } from '../../fixtures/token_utils.js'; +import type { TestWallet } from '../../test-wallet/test_wallet.js'; + +/** Whether an amount / balance is read from the private or public balance of the token. */ +export type BalanceKind = 'private' | 'public'; + +/** The invalid-authwit-nonce assertion the contract raises when `from == msg_sender` but nonce != 0. */ +export const INVALID_AUTHWIT_NONCE_ERROR = + "Assertion failed: Invalid authwit nonce. When 'from' and 'msg_sender' are the same, 'authwit_nonce' must be zero"; + +/** Minimal token shape needed to read balances — satisfied structurally by Token/TestToken/TokenBlacklist. */ +export interface BalanceReadable { + methods: { + balance_of_private(owner: AztecAddress): { simulate(opts: { from: AztecAddress }): Promise<{ result: bigint }> }; + balance_of_public(owner: AztecAddress): { simulate(opts: { from: AztecAddress }): Promise<{ result: bigint }> }; + }; +} + +/** Reads the private or public balance of an account, simulating from that account's own scope. */ +export async function balanceOf(asset: BalanceReadable, kind: BalanceKind, account: AztecAddress): Promise { + const interaction = + kind === 'private' ? asset.methods.balance_of_private(account) : asset.methods.balance_of_public(account); + return (await interaction.simulate({ from: account })).result; +} + +/** Returns half of an account's balance, asserting it is non-zero so the operation under test is meaningful. */ +export async function halfBalanceOf(asset: BalanceReadable, kind: BalanceKind, owner: AztecAddress): Promise { + const amount = (await balanceOf(asset, kind, owner)) / 2n; + expect(amount).toBeGreaterThan(0n); + return amount; +} + +/** Returns an amount just above an account's balance (balance + delta), asserting it is non-zero. */ +export async function amountAboveBalance( + asset: BalanceReadable, + kind: BalanceKind, + owner: AztecAddress, + delta = 1n, +): Promise { + const amount = (await balanceOf(asset, kind, owner)) + delta; + expect(amount).toBeGreaterThan(0n); + return amount; +} + +/** + * Grants a public authwit for `caller` to run `action` on behalf of `owner`, executes it from `caller`, + * then asserts a replay of the same call reverts as unauthorized (the authwit is single-use). + * + * `onExecuted` runs after the successful execution (before the replay attempt) so callers can update + * their {@link TokenSimulator} to match the state change the executed action produced. + */ +export async function assertPublicAuthwitReplayRejected( + wallet: TestWallet, + owner: AztecAddress, + action: ContractFunctionInteraction, + caller: AztecAddress, + onExecuted?: () => void, +): Promise { + const grant = await wallet.setPublicAuthWit(owner, { caller, action }, true); + await grant.send(); + + await action.send({ from: caller }); + onExecuted?.(); + + await expect(action.simulate({ from: caller })).rejects.toThrow(/unauthorized/); +} + +/** + * Creates a private authwit for `owner`, executes `action` through the authwit proxy (so `msg_sender` + * differs from the note owner), then asserts a replay reverts with a duplicate-nullifier error. + * + * `onExecuted` runs after the successful execution (before the replay attempt) so callers can update + * their {@link TokenSimulator} to match the state change the executed action produced. + */ +export async function assertAuthwitProxyReplayRejected( + proxy: GenericProxyContract, + wallet: TestWallet, + owner: AztecAddress, + action: ContractFunctionInteraction, + onExecuted?: () => void, +): Promise { + const witness = await wallet.createAuthWit(owner, { caller: proxy.address, action }); + + await sendThroughAuthwitProxy(proxy, action, { from: owner, authWitnesses: [witness] }); + onExecuted?.(); + + await expect(sendThroughAuthwitProxy(proxy, action, { from: owner, authWitnesses: [witness] })).rejects.toThrow( + DUPLICATE_NULLIFIER_ERROR, + ); +} + +/** + * Deploys an {@link AMMContract} over three freshly deployed tokens (token0, token1, and the liquidity + * token), makes the AMM the liquidity token's minter, and mints `initialBalance` of token0 and token1 to + * each liquidity provider plus token0 to the swapper. `deploy` selects the token flavour (Token vs + * TestToken), so the concrete token type flows through to the caller. + */ +export async function deployAmmWithTokens( + wallet: Wallet, + admin: AztecAddress, + deploy: (wallet: Wallet, admin: AztecAddress, initialBalance: bigint, logger: Logger) => Promise<{ contract: T }>, + opts: { liquidityProviders: AztecAddress[]; swapper: AztecAddress; initialBalance: bigint; logger: Logger }, +): Promise<{ token0: T; token1: T; liquidityToken: T; amm: AMMContract }> { + const { liquidityProviders, swapper, initialBalance, logger } = opts; + + const { contract: token0 } = await deploy(wallet, admin, 0n, logger); + const { contract: token1 } = await deploy(wallet, admin, 0n, logger); + const { contract: liquidityToken } = await deploy(wallet, admin, 0n, logger); + + const { contract: amm } = await AMMContract.deploy( + wallet, + token0.address, + token1.address, + liquidityToken.address, + ).send({ from: admin }); + + // TODO(#9480): consider deploying the token by some factory when the AMM is deployed, and making the AMM be the + // minter there. + await liquidityToken.methods.set_minter(amm.address, true).send({ from: admin }); + + for (const lp of liquidityProviders) { + await mintTokensToPrivate(token0, admin, lp, initialBalance); + await mintTokensToPrivate(token1, admin, lp, initialBalance); + } + // Note that the swapper only holds token0, not token1. + await mintTokensToPrivate(token0, admin, swapper, initialBalance); + + return { token0, token1, liquidityToken, amm }; +} diff --git a/yarn-project/end-to-end/src/automine/token/transfer.test.ts b/yarn-project/end-to-end/src/automine/token/transfer.test.ts index 6c00f1d9fc15..68df002daa65 100644 --- a/yarn-project/end-to-end/src/automine/token/transfer.test.ts +++ b/yarn-project/end-to-end/src/automine/token/transfer.test.ts @@ -1,108 +1,188 @@ import { AztecAddress, CompleteAddress } from '@aztec/aztec.js/addresses'; +import { Fr } from '@aztec/aztec.js/fields'; import { BlockNumber } from '@aztec/foundation/branded-types'; import { TokenContract, type Transfer } from '@aztec/noir-contracts.js/Token'; +import { type AlertConfig, GrafanaClient } from '../../quality_of_service/grafana_client.js'; import { TokenContractTest } from './token_contract_test.js'; - -// Covers the top-level transfer() entry point on Token contract (private-to-private), including transfer to -// non-deployed accounts and private Transfer event emission. Note: the describe title collides with -// transfer_in_private.test.ts — the tested contract methods differ (transfer vs transfer_in_private). -// Setup: single node with AutomineSequencer, 3 accounts, Token deployed with initial mint. +import { + assertAuthwitProxyReplayRejected, + assertPublicAuthwitReplayRejected, + halfBalanceOf, +} from './token_test_helpers.js'; + +const CHECK_ALERTS = process.env.CHECK_ALERTS === 'true'; + +const qosAlerts: AlertConfig[] = [ + { + // Dummy alert to check that the metric is being emitted. + // Separate benchmark tests will use dedicated machines with the published system requirements. + alert: 'publishing_mana_per_second', + expr: 'rate(aztec_public_executor_simulation_mana_per_second_per_second_sum[5m]) / rate(aztec_public_executor_simulation_mana_per_second_per_second_count[5m]) < 10', + for: '5m', + annotations: {}, + labels: {}, + }, +]; + +// Happy-path coverage for all five Token transfer entrypoints (transfer, transfer_in_private, +// transfer_in_public, transfer_to_private, transfer_to_public): balance movements, private Transfer event +// emission, and on-behalf-of delegation with single-use replay protection. Failure cases live in +// transfer_failures.test.ts. Setup: single node with AutomineSequencer, 3 accounts + authwit proxy, Token +// deployed with initial public and private mint. describe('automine/token/transfer', () => { - const t = new TokenContractTest('transfer_private'); - let { asset, adminAddress, wallet, account1Address, tokenSim } = t; + const t = new TokenContractTest('transfer'); + let { asset, adminAddress, wallet, otherAddress, tokenSim } = t; beforeAll(async () => { t.applyBaseSnapshots(); - t.applyMintSnapshot(); await t.setup(); - ({ asset, adminAddress, wallet, account1Address, tokenSim } = t); + await t.applyMint(); + ({ asset, adminAddress, wallet, otherAddress, tokenSim } = t); }); afterAll(async () => { await t.teardown(); + // Public transfers here exercise the public executor; when CHECK_ALERTS is set (e2e_test_with_alerts.sh) + // validate the mana-per-second metric was emitted. Previously lived on transfer_in_public.test.ts. + if (CHECK_ALERTS) { + const alertChecker = new GrafanaClient(t.logger); + await alertChecker.runAlertCheck(qosAlerts); + } }); afterEach(async () => { await t.tokenSim.check(); }); - // Transfers half of admin's private balance to account1, verifies via TokenSimulator, and asserts that - // the private Transfer event is emitted and readable in the recipient's scope. - it('transfer less than balance', async () => { - const { result: balance0 } = await asset.methods.balance_of_private(adminAddress).simulate({ from: adminAddress }); - const amount = balance0 / 2n; - expect(amount).toBeGreaterThan(0n); - - const { receipt: txReceipt } = await asset.methods.transfer(account1Address, amount).send({ from: adminAddress }); - tokenSim.transferPrivate(adminAddress, account1Address, amount); - - const events = await wallet.getPrivateEvents(TokenContract.events.Transfer, { - contractAddress: asset.address, - fromBlock: txReceipt.blockNumber!, - toBlock: BlockNumber(txReceipt.blockNumber! + 1), - scopes: [account1Address], + // Top-level private-to-private transfer() entry point. + describe('transfer', () => { + // Transfers half of admin's private balance to other, verifies via TokenSimulator, and asserts that + // the private Transfer event is emitted and readable in the recipient's scope. + it('transfer less than balance', async () => { + const amount = await halfBalanceOf(asset, 'private', adminAddress); + + const { receipt: txReceipt } = await asset.methods.transfer(otherAddress, amount).send({ from: adminAddress }); + tokenSim.transferPrivate(adminAddress, otherAddress, amount); + + const events = await wallet.getPrivateEvents(TokenContract.events.Transfer, { + contractAddress: asset.address, + fromBlock: txReceipt.blockNumber!, + toBlock: BlockNumber(txReceipt.blockNumber! + 1), + scopes: [otherAddress], + }); + + expect(events[0]).toEqual({ + event: { + from: adminAddress, + to: otherAddress, + amount: amount, + }, + metadata: { + l2BlockNumber: txReceipt.blockNumber, + l2BlockHash: txReceipt.blockHash, + txHash: txReceipt.txHash, + }, + }); }); - expect(events[0]).toEqual({ - event: { - from: adminAddress, - to: account1Address, - amount: amount, - }, - metadata: { - l2BlockNumber: txReceipt.blockNumber, - l2BlockHash: txReceipt.blockHash, - txHash: txReceipt.txHash, - }, + // Transfers to a randomly generated non-deployed address. Because the recipient's keys aren't in the PXE, + // the note can't be decrypted; TokenSimulator models this as a transfer to AztecAddress.ZERO. + it('transfer less than balance to non-deployed account', async () => { + const amount = await halfBalanceOf(asset, 'private', adminAddress); + + const nonDeployed = await CompleteAddress.random(); + + await asset.methods.transfer(nonDeployed.address, amount).send({ from: adminAddress }); + + // Add the account as balance we should change, but since we don't have the key, + // we cannot decrypt, and instead we simulate a transfer to address(0) + tokenSim.addAccount(nonDeployed.address); + tokenSim.transferPrivate(adminAddress, AztecAddress.ZERO, amount); + }); + + // Transfers half of admin's balance to themselves and verifies the balance is unchanged. + it('transfer to self', async () => { + const amount = await halfBalanceOf(asset, 'private', adminAddress); + await asset.methods.transfer(adminAddress, amount).send({ from: adminAddress }); + tokenSim.transferPrivate(adminAddress, adminAddress, amount); }); }); - // Transfers to a randomly generated non-deployed address. Because the recipient's keys aren't in the PXE, - // the note can't be decrypted; TokenSimulator models this as a transfer to AztecAddress.ZERO. - it('transfer less than balance to non-deployed account', async () => { - const { result: balance0 } = await asset.methods.balance_of_private(adminAddress).simulate({ from: adminAddress }); - const amount = balance0 / 2n; - expect(amount).toBeGreaterThan(0n); + // Private transfer delegated to a caller via a private authwit consumed through the proxy. + describe('transfer_in_private', () => { + // Creates a private authwit for transfer_in_private, sends through proxy, verifies TokenSimulator, + // then confirms replay reverts with a duplicate-nullifier error. + it('transfer on behalf of other', async () => { + const amount = await halfBalanceOf(asset, 'private', adminAddress); + const action = asset.methods.transfer_in_private(adminAddress, otherAddress, amount, Fr.random()); + await assertAuthwitProxyReplayRejected(t.authwitProxy, wallet, adminAddress, action, () => + tokenSim.transferPrivate(adminAddress, otherAddress, amount), + ); + }); + }); - const nonDeployed = await CompleteAddress.random(); + // Public transfer, direct and delegated via a public authwit. + describe('transfer_in_public', () => { + // Transfers half of admin's public balance to other and verifies via TokenSimulator. + it('transfer less than balance', async () => { + const amount = await halfBalanceOf(asset, 'public', adminAddress); + await asset.methods.transfer_in_public(adminAddress, otherAddress, amount, 0).send({ from: adminAddress }); + tokenSim.transferPublic(adminAddress, otherAddress, amount); + }); - await asset.methods.transfer(nonDeployed.address, amount).send({ from: adminAddress }); + // Transfers half of admin's public balance to themselves; verifies balance is unchanged via TokenSimulator. + it('transfer to self', async () => { + const amount = await halfBalanceOf(asset, 'public', adminAddress); + await asset.methods.transfer_in_public(adminAddress, adminAddress, amount, 0).send({ from: adminAddress }); + tokenSim.transferPublic(adminAddress, adminAddress, amount); + }); - // Add the account as balance we should change, but since we don't have the key, - // we cannot decrypt, and instead we simulate a transfer to address(0) - tokenSim.addAccount(nonDeployed.address); - tokenSim.transferPrivate(adminAddress, AztecAddress.ZERO, amount); + // Sets a public authwit allowing other to transfer admin's tokens, executes, verifies TokenSimulator, + // then confirms replay reverts with unauthorized. + it('transfer on behalf of other', async () => { + const amount = await halfBalanceOf(asset, 'public', adminAddress); + const action = asset.methods.transfer_in_public(adminAddress, otherAddress, amount, Fr.random()); + await assertPublicAuthwitReplayRejected(wallet, adminAddress, action, otherAddress, () => + tokenSim.transferPublic(adminAddress, otherAddress, amount), + ); + }); }); - // Transfers half of admin's balance to themselves and verifies the balance is unchanged. - it('transfer to self', async () => { - const { result: balance0 } = await asset.methods.balance_of_private(adminAddress).simulate({ from: adminAddress }); - const amount = balance0 / 2n; - expect(amount).toBeGreaterThan(0n); - await asset.methods.transfer(adminAddress, amount).send({ from: adminAddress }); - tokenSim.transferPrivate(adminAddress, adminAddress, amount); + // Public-to-private transfer (no on-behalf-of surface — takes no caller param). + describe('transfer_to_private', () => { + // Transfers half of admin's public balance to admin's own private balance and verifies via TokenSimulator. + it('to self', async () => { + const amount = await halfBalanceOf(asset, 'public', adminAddress); + await asset.methods.transfer_to_private(adminAddress, amount).send({ from: adminAddress }); + tokenSim.transferToPrivate(adminAddress, adminAddress, amount); + }); + + // Transfers half of admin's public balance to other's private balance and verifies via TokenSimulator. + it('to someone else', async () => { + const amount = await halfBalanceOf(asset, 'public', adminAddress); + await asset.methods.transfer_to_private(otherAddress, amount).send({ from: adminAddress }); + tokenSim.transferToPrivate(adminAddress, otherAddress, amount); + }); }); - // Error paths for transfer(). - describe('failure cases', () => { - // Attempts to transfer more than private balance; expects 'Balance too low'. - it('transfer more than balance', async () => { - const { result: balance0 } = await asset.methods - .balance_of_private(adminAddress) - .simulate({ from: adminAddress }); - const amount = balance0 + 1n; - expect(amount).toBeGreaterThan(0n); - await expect(asset.methods.transfer(account1Address, amount).simulate({ from: adminAddress })).rejects.toThrow( - 'Assertion failed: Balance too low', - ); + // Private-to-public transfer, direct and delegated via a private authwit consumed through the proxy. + describe('transfer_to_public', () => { + // Transfers half of admin's private balance to admin's public balance and verifies via TokenSimulator. + it('on behalf of self', async () => { + const amount = await halfBalanceOf(asset, 'private', adminAddress); + await asset.methods.transfer_to_public(adminAddress, adminAddress, amount, 0).send({ from: adminAddress }); + tokenSim.transferToPublic(adminAddress, adminAddress, amount); }); - it.skip('transfer into account to overflow', () => { - // This should already be covered by the mint case earlier. e.g., since we cannot mint to overflow, there is not - // a way to get funds enough to overflow. - // Require direct storage manipulation for us to perform a nice explicit case though. - // See https://github.com/AztecProtocol/aztec-packages/issues/1259 + // Creates a private authwit for transfer_to_public to other, sends through proxy, verifies + // TokenSimulator, then asserts replay reverts with a duplicate-nullifier error. + it('on behalf of other', async () => { + const amount = await halfBalanceOf(asset, 'private', adminAddress); + const action = asset.methods.transfer_to_public(adminAddress, otherAddress, amount, Fr.random()); + await assertAuthwitProxyReplayRejected(t.authwitProxy, wallet, adminAddress, action, () => + tokenSim.transferToPublic(adminAddress, otherAddress, amount), + ); }); }); }); diff --git a/yarn-project/end-to-end/src/automine/token/transfer_failures.test.ts b/yarn-project/end-to-end/src/automine/token/transfer_failures.test.ts new file mode 100644 index 000000000000..c8062c50c8e5 --- /dev/null +++ b/yarn-project/end-to-end/src/automine/token/transfer_failures.test.ts @@ -0,0 +1,287 @@ +import { computeAuthWitMessageHash, computeInnerAuthWitHashFromAction } from '@aztec/aztec.js/authorization'; +import { Fr } from '@aztec/aztec.js/fields'; + +import { sendThroughAuthwitProxy, simulateThroughAuthwitProxy } from '../../fixtures/authwit_proxy.js'; +import { DUPLICATE_NULLIFIER_ERROR, U128_UNDERFLOW_ERROR } from '../../fixtures/fixtures.js'; +import { TokenContractTest } from './token_contract_test.js'; +import { INVALID_AUTHWIT_NONCE_ERROR, amountAboveBalance, balanceOf, halfBalanceOf } from './token_test_helpers.js'; + +const BALANCE_TOO_LOW = 'Assertion failed: Balance too low'; + +// Failure-case coverage for the Token transfer entrypoints. Every entrypoint shares one TokenContractTest +// harness (base + mint); the failure cases are simulate-only or authwit grants/cancels that never move +// token balances, so the mint balances stay constant across all cases. Happy paths and event assertions +// live in transfer.test.ts. Setup: single node with AutomineSequencer, 3 accounts + InvalidAccount + +// authwit proxy, Token deployed with initial public and private mint. +describe('automine/token/transfer_failures', () => { + const t = new TokenContractTest('transfer_failures'); + + beforeAll(async () => { + t.applyBaseSnapshots(); + await t.setup(); + await t.applyMint(); + }); + + afterAll(async () => { + await t.teardown(); + }); + + afterEach(async () => { + await t.tokenSim.check(); + }); + + // Single kept copy of the triplicate skip stub that previously lived in transfer, transfer_in_public, and + // transfer_in_private. + it.skip('transfer into account to overflow', () => { + // This should already be covered by the mint case earlier. e.g., since we cannot mint to overflow, there is not + // a way to get funds enough to overflow. + // Require direct storage manipulation for us to perform a nice explicit case though. + // See https://github.com/AztecProtocol/aztec-packages/issues/1259 + }); + + describe('transfer', () => { + it('transfer more than balance', async () => { + const amount = await amountAboveBalance(t.asset, 'private', t.adminAddress); + await expect(t.asset.methods.transfer(t.otherAddress, amount).simulate({ from: t.adminAddress })).rejects.toThrow( + BALANCE_TOO_LOW, + ); + }); + }); + + describe('transfer_in_private', () => { + it('transfer on behalf of self with non-zero nonce', async () => { + const amount = await halfBalanceOf(t.asset, 'private', t.adminAddress); + await expect( + t.asset.methods + .transfer_in_private(t.adminAddress, t.otherAddress, amount, 1) + .simulate({ from: t.adminAddress }), + ).rejects.toThrow( + expect.objectContaining({ + message: expect.stringMatching(INVALID_AUTHWIT_NONCE_ERROR), + stack: expect.stringMatching(/at Token\.transfer_in_private.*/), + }), + ); + }); + + it('transfer more than balance on behalf of other', async () => { + const amount = await amountAboveBalance(t.asset, 'private', t.adminAddress); + const action = t.asset.methods.transfer_in_private(t.adminAddress, t.otherAddress, amount, Fr.random()); + const ownerBefore = await balanceOf(t.asset, 'private', t.adminAddress); + const otherBefore = await balanceOf(t.asset, 'private', t.otherAddress); + + const witness = await t.wallet.createAuthWit(t.adminAddress, { caller: t.authwitProxy.address, action }); + await expect( + simulateThroughAuthwitProxy(t.authwitProxy, action, { from: t.adminAddress, authWitnesses: [witness] }), + ).rejects.toThrow(BALANCE_TOO_LOW); + + expect(await balanceOf(t.asset, 'private', t.adminAddress)).toEqual(ownerBefore); + expect(await balanceOf(t.asset, 'private', t.otherAddress)).toEqual(otherBefore); + }); + + it('transfer on behalf of other without approval', async () => { + const amount = await halfBalanceOf(t.asset, 'private', t.adminAddress); + const action = t.asset.methods.transfer_in_private(t.adminAddress, t.otherAddress, amount, Fr.random()); + const call = await action.getFunctionCall(); + const messageHash = await computeAuthWitMessageHash( + { caller: t.authwitProxy.address, call }, + await t.wallet.getChainInfo(), + ); + await expect(simulateThroughAuthwitProxy(t.authwitProxy, action, { from: t.adminAddress })).rejects.toThrow( + `Unknown auth witness for message hash ${messageHash.toString()}`, + ); + }); + + it('transfer on behalf of other, wrong designated caller', async () => { + const amount = await halfBalanceOf(t.asset, 'private', t.adminAddress); + const action = t.asset.methods.transfer_in_private(t.adminAddress, t.otherAddress, amount, Fr.random()); + const call = await action.getFunctionCall(); + const expectedMessageHash = await computeAuthWitMessageHash( + { caller: t.authwitProxy.address, call }, + await t.wallet.getChainInfo(), + ); + + // Designate `other` as caller (not the proxy), then send through the proxy: the hashes don't match. + const witness = await t.wallet.createAuthWit(t.adminAddress, { caller: t.otherAddress, action }); + const ownerBefore = await balanceOf(t.asset, 'private', t.adminAddress); + const otherBefore = await balanceOf(t.asset, 'private', t.otherAddress); + await expect( + simulateThroughAuthwitProxy(t.authwitProxy, action, { from: t.adminAddress, authWitnesses: [witness] }), + ).rejects.toThrow(`Unknown auth witness for message hash ${expectedMessageHash.toString()}`); + + expect(await balanceOf(t.asset, 'private', t.adminAddress)).toEqual(ownerBefore); + expect(await balanceOf(t.asset, 'private', t.otherAddress)).toEqual(otherBefore); + }); + + it('transfer on behalf of other, cancelled authwit', async () => { + const amount = await halfBalanceOf(t.asset, 'private', t.adminAddress); + const action = t.asset.methods.transfer_in_private(t.adminAddress, t.otherAddress, amount, Fr.random()); + const witness = await t.wallet.createAuthWit(t.adminAddress, { caller: t.authwitProxy.address, action }); + const innerHash = await computeInnerAuthWitHashFromAction(t.authwitProxy.address, action); + await t.asset.methods.cancel_authwit(innerHash).send({ from: t.adminAddress }); + await expect( + sendThroughAuthwitProxy(t.authwitProxy, action, { from: t.adminAddress, authWitnesses: [witness] }), + ).rejects.toThrow(DUPLICATE_NULLIFIER_ERROR); + }); + + // Uses the InvalidAccount contract as the 'from' address; expects 'Message not authorized by account' + // because the bad contract returns a malformed validation response. + it('transfer on behalf of other, invalid verify_private_authwit on "from"', async () => { + await expect( + t.asset.methods + .transfer_in_private(t.badAccount.address, t.otherAddress, 0, Fr.random()) + .simulate({ from: t.otherAddress }), + ).rejects.toThrow('Assertion failed: Message not authorized by account'); + }); + }); + + describe('transfer_in_public', () => { + it('transfer more than balance', async () => { + const amount = await amountAboveBalance(t.asset, 'public', t.adminAddress); + await expect( + t.asset.methods + .transfer_in_public(t.adminAddress, t.otherAddress, amount, 0) + .simulate({ from: t.adminAddress }), + ).rejects.toThrow(U128_UNDERFLOW_ERROR); + }); + + it('transfer on behalf of self with non-zero nonce', async () => { + const amount = await halfBalanceOf(t.asset, 'public', t.adminAddress); + await expect( + t.asset.methods + .transfer_in_public(t.adminAddress, t.otherAddress, amount, 1) + .simulate({ from: t.adminAddress }), + ).rejects.toThrow(INVALID_AUTHWIT_NONCE_ERROR); + }); + + it('transfer on behalf of other without "approval"', async () => { + const amount = await amountAboveBalance(t.asset, 'public', t.adminAddress); + await expect( + t.asset.methods + .transfer_in_public(t.adminAddress, t.otherAddress, amount, Fr.random()) + .simulate({ from: t.otherAddress }), + ).rejects.toThrow(/unauthorized/); + }); + + it('transfer more than balance on behalf of other', async () => { + const amount = await amountAboveBalance(t.asset, 'public', t.adminAddress); + const action = t.asset.methods.transfer_in_public(t.adminAddress, t.otherAddress, amount, Fr.random()); + const ownerBefore = await balanceOf(t.asset, 'public', t.adminAddress); + const otherBefore = await balanceOf(t.asset, 'public', t.otherAddress); + + const grant = await t.wallet.setPublicAuthWit(t.adminAddress, { caller: t.otherAddress, action }, true); + await grant.send(); + await expect(action.simulate({ from: t.otherAddress })).rejects.toThrow(U128_UNDERFLOW_ERROR); + + expect(await balanceOf(t.asset, 'public', t.adminAddress)).toEqual(ownerBefore); + expect(await balanceOf(t.asset, 'public', t.otherAddress)).toEqual(otherBefore); + }); + + it('transfer on behalf of other, wrong designated caller', async () => { + const amount = await amountAboveBalance(t.asset, 'public', t.adminAddress, 2n); + const action = t.asset.methods.transfer_in_public(t.adminAddress, t.otherAddress, amount, Fr.random()); + const ownerBefore = await balanceOf(t.asset, 'public', t.adminAddress); + const otherBefore = await balanceOf(t.asset, 'public', t.otherAddress); + + // Approve the owner as caller, but execute from `other`: the message hashes don't match. + const grant = await t.wallet.setPublicAuthWit(t.adminAddress, { caller: t.adminAddress, action }, true); + await grant.send(); + await expect(action.simulate({ from: t.otherAddress })).rejects.toThrow(/unauthorized/); + + expect(await balanceOf(t.asset, 'public', t.adminAddress)).toEqual(ownerBefore); + expect(await balanceOf(t.asset, 'public', t.otherAddress)).toEqual(otherBefore); + }); + + it('transfer on behalf of other, cancelled authwit', async () => { + const amount = await halfBalanceOf(t.asset, 'public', t.adminAddress); + const action = t.asset.methods.transfer_in_public(t.adminAddress, t.otherAddress, amount, Fr.random()); + const grant = await t.wallet.setPublicAuthWit(t.adminAddress, { caller: t.otherAddress, action }, true); + await grant.send(); + const revoke = await t.wallet.setPublicAuthWit(t.adminAddress, { caller: t.otherAddress, action }, false); + await revoke.send(); + await expect(action.simulate({ from: t.otherAddress })).rejects.toThrow(/unauthorized/); + }); + + // Same grant-then-revoke flow as 'cancelled authwit' but reconstructs the method call for the final + // simulate rather than reusing the action object — verifies both call forms produce unauthorized. + it('transfer on behalf of other, cancelled authwit (reconstructed call)', async () => { + const amount = await halfBalanceOf(t.asset, 'public', t.adminAddress); + const authwitNonce = Fr.random(); + const action = t.asset.methods.transfer_in_public(t.adminAddress, t.otherAddress, amount, authwitNonce); + + const grant = await t.wallet.setPublicAuthWit(t.adminAddress, { caller: t.otherAddress, action }, true); + await grant.send(); + const revoke = await t.wallet.setPublicAuthWit(t.adminAddress, { caller: t.otherAddress, action }, false); + await revoke.send(); + + await expect( + t.asset.methods + .transfer_in_public(t.adminAddress, t.otherAddress, amount, authwitNonce) + .simulate({ from: t.otherAddress }), + ).rejects.toThrow(/unauthorized/); + }); + + // Uses the InvalidAccount contract as the 'from' address; expects unauthorized because the bad contract + // returns a malformed authwit validation value. + it('transfer on behalf of other, invalid spend_public_authwit on "from"', async () => { + await expect( + t.asset.methods + .transfer_in_public(t.badAccount.address, t.otherAddress, 0, Fr.random()) + .simulate({ from: t.otherAddress }), + ).rejects.toThrow(/unauthorized/); + }); + }); + + describe('transfer_to_private', () => { + it('to self (more than balance)', async () => { + const amount = await amountAboveBalance(t.asset, 'public', t.adminAddress); + await expect( + t.asset.methods.transfer_to_private(t.adminAddress, amount).simulate({ from: t.adminAddress }), + ).rejects.toThrow(U128_UNDERFLOW_ERROR); + }); + }); + + describe('transfer_to_public', () => { + it('on behalf of self (more than balance)', async () => { + const amount = await amountAboveBalance(t.asset, 'private', t.adminAddress); + await expect( + t.asset.methods + .transfer_to_public(t.adminAddress, t.otherAddress, amount, 0) + .simulate({ from: t.adminAddress }), + ).rejects.toThrow(BALANCE_TOO_LOW); + }); + + it('on behalf of self (invalid authwit nonce)', async () => { + const amount = await halfBalanceOf(t.asset, 'private', t.adminAddress); + await expect( + t.asset.methods + .transfer_to_public(t.adminAddress, t.otherAddress, amount, 1) + .simulate({ from: t.adminAddress }), + ).rejects.toThrow(INVALID_AUTHWIT_NONCE_ERROR); + }); + + it('on behalf of other (more than balance)', async () => { + const amount = await amountAboveBalance(t.asset, 'private', t.adminAddress); + const action = t.asset.methods.transfer_to_public(t.adminAddress, t.otherAddress, amount, Fr.random()); + const witness = await t.wallet.createAuthWit(t.adminAddress, { caller: t.authwitProxy.address, action }); + await expect( + simulateThroughAuthwitProxy(t.authwitProxy, action, { from: t.adminAddress, authWitnesses: [witness] }), + ).rejects.toThrow(BALANCE_TOO_LOW); + }); + + it('on behalf of other (invalid designated caller)', async () => { + const amount = await halfBalanceOf(t.asset, 'private', t.adminAddress); + const action = t.asset.methods.transfer_to_public(t.adminAddress, t.otherAddress, amount, Fr.random()); + const call = await action.getFunctionCall(); + const expectedMessageHash = await computeAuthWitMessageHash( + { caller: t.authwitProxy.address, call }, + await t.wallet.getChainInfo(), + ); + + // Designate `other` as caller (not the proxy), then send through the proxy: the hashes don't match. + const witness = await t.wallet.createAuthWit(t.adminAddress, { caller: t.otherAddress, action }); + await expect( + simulateThroughAuthwitProxy(t.authwitProxy, action, { from: t.adminAddress, authWitnesses: [witness] }), + ).rejects.toThrow(`Unknown auth witness for message hash ${expectedMessageHash.toString()}`); + }); + }); +}); diff --git a/yarn-project/end-to-end/src/automine/token/transfer_in_private.test.ts b/yarn-project/end-to-end/src/automine/token/transfer_in_private.test.ts deleted file mode 100644 index 5a093f4306e6..000000000000 --- a/yarn-project/end-to-end/src/automine/token/transfer_in_private.test.ts +++ /dev/null @@ -1,201 +0,0 @@ -import { computeAuthWitMessageHash, computeInnerAuthWitHashFromAction } from '@aztec/aztec.js/authorization'; -import { Fr } from '@aztec/aztec.js/fields'; - -import { sendThroughAuthwitProxy, simulateThroughAuthwitProxy } from '../../fixtures/authwit_proxy.js'; -import { DUPLICATE_NULLIFIER_ERROR } from '../../fixtures/fixtures.js'; -import { TokenContractTest } from './token_contract_test.js'; - -// Covers the transfer_in_private entry point on Token contract: authwit-delegated transfers via proxy, -// authwit cancellation, and error paths including bad-account validation. Setup: single node with -// AutomineSequencer, 3 accounts + InvalidAccount, Token deployed with initial mint. -describe('automine/token/transfer_in_private', () => { - const t = new TokenContractTest('transfer_private'); - let { asset, tokenSim, wallet, adminAddress, account1Address, badAccount } = t; - - beforeAll(async () => { - t.applyBaseSnapshots(); - t.applyMintSnapshot(); - await t.setup(); - ({ asset, tokenSim, wallet, adminAddress, account1Address, badAccount } = t); - }); - - afterAll(async () => { - await t.teardown(); - }); - - afterEach(async () => { - await t.tokenSim.check(); - }); - - // Creates a private authwit for transfer_in_private, sends through proxy, verifies TokenSimulator, - // then confirms replay reverts with DUPLICATE_NULLIFIER_ERROR. - it('transfer on behalf of other', async () => { - const { result: balance0 } = await asset.methods.balance_of_private(adminAddress).simulate({ from: adminAddress }); - const amount = balance0 / 2n; - const authwitNonce = Fr.random(); - expect(amount).toBeGreaterThan(0n); - - const action = asset.methods.transfer_in_private(adminAddress, account1Address, amount, authwitNonce); - const witness = await wallet.createAuthWit(adminAddress, { caller: t.authwitProxy.address, action }); - - // Admin sends through proxy so their keys are in scope, while proxy becomes msg_sender to trigger authwit. - await sendThroughAuthwitProxy(t.authwitProxy, action, { from: adminAddress, authWitnesses: [witness] }); - tokenSim.transferPrivate(adminAddress, account1Address, amount); - - // Perform the transfer again, should fail - await expect( - sendThroughAuthwitProxy(t.authwitProxy, action, { from: adminAddress, authWitnesses: [witness] }), - ).rejects.toThrow(DUPLICATE_NULLIFIER_ERROR); - }); - - // Error paths: invalid nonce, over-balance via authwit, no approval, wrong caller, cancelled authwit, - // and invalid verify_private_authwit from a bad account contract. - describe('failure cases', () => { - // Self-transfer via transfer_in_private with nonce=1; expects the invalid-nonce assertion with a stack - // trace matching Token.transfer_in_private. - it('transfer on behalf of self with non-zero nonce', async () => { - const { result: balance0 } = await asset.methods - .balance_of_private(adminAddress) - .simulate({ from: adminAddress }); - const amount = balance0 - 1n; - expect(amount).toBeGreaterThan(0n); - await expect( - asset.methods.transfer_in_private(adminAddress, account1Address, amount, 1).simulate({ from: adminAddress }), - ).rejects.toThrow( - expect.objectContaining({ - message: expect.stringMatching( - "Assertion failed: Invalid authwit nonce. When 'from' and 'msg_sender' are the same, 'authwit_nonce' must be zero", - ), - stack: expect.stringMatching(/at Token\.transfer_in_private.*/), - }), - ); - }); - - // Authwit-transfers more than private balance via proxy; expects 'Balance too low' and verifies balances - // unchanged. - it('transfer more than balance on behalf of other', async () => { - const { result: balance0 } = await asset.methods - .balance_of_private(adminAddress) - .simulate({ from: adminAddress }); - const { result: balance1 } = await asset.methods - .balance_of_private(account1Address) - .simulate({ from: account1Address }); - const amount = balance0 + 1n; - const authwitNonce = Fr.random(); - expect(amount).toBeGreaterThan(0n); - - const action = asset.methods.transfer_in_private(adminAddress, account1Address, amount, authwitNonce); - const witness = await wallet.createAuthWit(adminAddress, { caller: t.authwitProxy.address, action }); - - // Admin sends through proxy so their keys are in scope, while proxy becomes msg_sender to trigger authwit. - await expect( - simulateThroughAuthwitProxy(t.authwitProxy, action, { from: adminAddress, authWitnesses: [witness] }), - ).rejects.toThrow('Assertion failed: Balance too low'); - expect((await asset.methods.balance_of_private(adminAddress).simulate({ from: adminAddress })).result).toEqual( - balance0, - ); - expect( - (await asset.methods.balance_of_private(account1Address).simulate({ from: account1Address })).result, - ).toEqual(balance1); - }); - - it.skip('transfer into account to overflow', () => { - // This should already be covered by the mint case earlier. e.g., since we cannot mint to overflow, there is not - // a way to get funds enough to overflow. - // Require direct storage manipulation for us to perform a nice explicit case though. - // See https://github.com/AztecProtocol/aztec-packages/issues/1259 - }); - - // Simulates transfer_in_private through proxy without a witness; expects unknown-authwit error. - it('transfer on behalf of other without approval', async () => { - const { result: balance0 } = await asset.methods - .balance_of_private(adminAddress) - .simulate({ from: adminAddress }); - const amount = balance0 / 2n; - const authwitNonce = Fr.random(); - expect(amount).toBeGreaterThan(0n); - - const action = asset.methods.transfer_in_private(adminAddress, account1Address, amount, authwitNonce); - const call = await action.getFunctionCall(); - const messageHash = await computeAuthWitMessageHash( - { caller: t.authwitProxy.address, call }, - await wallet.getChainInfo(), - ); - - // Admin sends through proxy so their keys are in scope, while proxy becomes msg_sender to trigger authwit. - await expect(simulateThroughAuthwitProxy(t.authwitProxy, action, { from: adminAddress })).rejects.toThrow( - `Unknown auth witness for message hash ${messageHash.toString()}`, - ); - }); - - // Creates authwit designating account1 as caller but sends through proxy; expects unknown-authwit error. - it('transfer on behalf of other, wrong designated caller', async () => { - const { result: balance0 } = await asset.methods - .balance_of_private(adminAddress) - .simulate({ from: adminAddress }); - const amount = balance0 / 2n; - const authwitNonce = Fr.random(); - expect(amount).toBeGreaterThan(0n); - - const action = asset.methods.transfer_in_private(adminAddress, account1Address, amount, authwitNonce); - const call = await action.getFunctionCall(); - const expectedMessageHash = await computeAuthWitMessageHash( - { caller: t.authwitProxy.address, call }, - await wallet.getChainInfo(), - ); - - const witness = await wallet.createAuthWit(adminAddress, { caller: account1Address, action }); - - // Admin sends through proxy so their keys are in scope, while proxy becomes msg_sender to trigger authwit. - await expect( - simulateThroughAuthwitProxy(t.authwitProxy, action, { from: adminAddress, authWitnesses: [witness] }), - ).rejects.toThrow(`Unknown auth witness for message hash ${expectedMessageHash.toString()}`); - expect((await asset.methods.balance_of_private(adminAddress).simulate({ from: adminAddress })).result).toEqual( - balance0, - ); - }); - - // Creates a private authwit, cancels it via cancel_authwit (which nullifies the inner hash), then - // attempts the transfer through proxy and expects DUPLICATE_NULLIFIER_ERROR. - it('transfer on behalf of other, cancelled authwit', async () => { - const { result: balance0 } = await asset.methods - .balance_of_private(adminAddress) - .simulate({ from: adminAddress }); - const amount = balance0 / 2n; - const authwitNonce = Fr.random(); - expect(amount).toBeGreaterThan(0n); - - const action = asset.methods.transfer_in_private(adminAddress, account1Address, amount, authwitNonce); - - const intent = { caller: t.authwitProxy.address, action }; - - const witness = await wallet.createAuthWit(adminAddress, intent); - - const innerHash = await computeInnerAuthWitHashFromAction(t.authwitProxy.address, action); - await asset.methods.cancel_authwit(innerHash).send({ from: adminAddress }); - - // Admin sends through proxy so their keys are in scope, while proxy becomes msg_sender to trigger authwit. - // The transfer should fail because nullifier already emitted - await expect( - sendThroughAuthwitProxy(t.authwitProxy, action, { from: adminAddress, authWitnesses: [witness] }), - ).rejects.toThrow(DUPLICATE_NULLIFIER_ERROR); - }); - - // Uses the InvalidAccount contract as the 'from' address; expects 'Message not authorized by account' - // because the bad contract returns a malformed validation response. - it('transfer on behalf of other, invalid verify_private_authwit on "from"', async () => { - const authwitNonce = Fr.random(); - - // Should fail as the returned value from the badAccount is malformed - const txCancelledAuthwit = asset.methods.transfer_in_private( - badAccount.address, - account1Address, - 0, - authwitNonce, - ); - await expect(txCancelledAuthwit.simulate({ from: account1Address })).rejects.toThrow( - 'Assertion failed: Message not authorized by account', - ); - }); - }); -}); diff --git a/yarn-project/end-to-end/src/automine/token/transfer_in_public.test.ts b/yarn-project/end-to-end/src/automine/token/transfer_in_public.test.ts deleted file mode 100644 index a5a55aa13623..000000000000 --- a/yarn-project/end-to-end/src/automine/token/transfer_in_public.test.ts +++ /dev/null @@ -1,311 +0,0 @@ -import { Fr } from '@aztec/aztec.js/fields'; - -import { U128_UNDERFLOW_ERROR } from '../../fixtures/fixtures.js'; -import { type AlertConfig, GrafanaClient } from '../../quality_of_service/grafana_client.js'; -import { TokenContractTest } from './token_contract_test.js'; - -const CHECK_ALERTS = process.env.CHECK_ALERTS === 'true'; - -const qosAlerts: AlertConfig[] = [ - { - // Dummy alert to check that the metric is being emitted. - // Separate benchmark tests will use dedicated machines with the published system requirements. - alert: 'publishing_mana_per_second', - expr: 'rate(aztec_public_executor_simulation_mana_per_second_per_second_sum[5m]) / rate(aztec_public_executor_simulation_mana_per_second_per_second_count[5m]) < 10', - for: '5m', - annotations: {}, - labels: {}, - }, -]; - -// Covers the transfer_in_public entry point on Token contract: direct, self, authwit-delegated, authwit -// cancellation (two flows), and bad-account validation. Also conditionally checks Grafana QoS alerts when -// CHECK_ALERTS=true. Setup: single node with AutomineSequencer, Token deployed with initial mint. -describe('automine/token/transfer_in_public', () => { - const t = new TokenContractTest('transfer_in_public'); - let { asset, tokenSim, wallet, adminAddress, account1Address, badAccount } = t; - - beforeAll(async () => { - t.applyBaseSnapshots(); - t.applyMintSnapshot(); - await t.setup(); - // Have to destructure again to ensure we have latest refs. - ({ asset, tokenSim, wallet, adminAddress, account1Address, badAccount } = t); - }); - - afterAll(async () => { - await t.teardown(); - if (CHECK_ALERTS) { - const alertChecker = new GrafanaClient(t.logger); - await alertChecker.runAlertCheck(qosAlerts); - } - }); - - afterEach(async () => { - await t.tokenSim.check(); - }); - - // Transfers half of admin's public balance to account1 and verifies via TokenSimulator. - it('transfer less than balance', async () => { - const { result: balance0 } = await asset.methods.balance_of_public(adminAddress).simulate({ from: adminAddress }); - const amount = balance0 / 2n; - expect(amount).toBeGreaterThan(0n); - await asset.methods.transfer_in_public(adminAddress, account1Address, amount, 0).send({ from: adminAddress }); - - tokenSim.transferPublic(adminAddress, account1Address, amount); - }); - - // Transfers half of admin's public balance to themselves; verifies balance is unchanged via TokenSimulator. - it('transfer to self', async () => { - const { result: balance } = await asset.methods.balance_of_public(adminAddress).simulate({ from: adminAddress }); - const amount = balance / 2n; - expect(amount).toBeGreaterThan(0n); - await asset.methods.transfer_in_public(adminAddress, adminAddress, amount, 0).send({ from: adminAddress }); - - tokenSim.transferPublic(adminAddress, adminAddress, amount); - }); - - // Sets a public authwit allowing account1 to transfer admin's tokens, executes, verifies TokenSimulator, - // then confirms replay reverts with unauthorized. - it('transfer on behalf of other', async () => { - const { result: balance0 } = await asset.methods.balance_of_public(adminAddress).simulate({ from: adminAddress }); - const amount = balance0 / 2n; - expect(amount).toBeGreaterThan(0n); - const authwitNonce = Fr.random(); - - const action = asset.methods.transfer_in_public(adminAddress, account1Address, amount, authwitNonce); - - const validateActionInteraction = await wallet.setPublicAuthWit( - adminAddress, - { caller: account1Address, action }, - true, - ); - await validateActionInteraction.send(); - - // Perform the transfer - await action.send({ from: account1Address }); - - tokenSim.transferPublic(adminAddress, account1Address, amount); - - // Check that the message hash is no longer valid. - await expect( - asset.methods - .transfer_in_public(adminAddress, account1Address, amount, authwitNonce) - .simulate({ from: account1Address }), - ).rejects.toThrow(/unauthorized/); - }); - - // Error paths for transfer_in_public: overflow, nonce, no approval, over-balance via authwit, wrong - // caller (two variants), authwit cancellation (two flows), and bad-account authwit validation. - describe('failure cases', () => { - // Attempts to transfer more than public balance; expects U128_UNDERFLOW_ERROR. - it('transfer more than balance', async () => { - const { result: balance0 } = await asset.methods.balance_of_public(adminAddress).simulate({ from: adminAddress }); - const amount = balance0 + 1n; - const authwitNonce = 0; - await expect( - asset.methods - .transfer_in_public(adminAddress, account1Address, amount, authwitNonce) - .simulate({ from: adminAddress }), - ).rejects.toThrow(U128_UNDERFLOW_ERROR); - }); - - // Self-transfer with nonce=1; expects the invalid-nonce assertion. - it('transfer on behalf of self with non-zero nonce', async () => { - const { result: balance0 } = await asset.methods.balance_of_public(adminAddress).simulate({ from: adminAddress }); - const amount = balance0 - 1n; - const authwitNonce = 1; - await expect( - asset.methods - .transfer_in_public(adminAddress, account1Address, amount, authwitNonce) - .simulate({ from: adminAddress }), - ).rejects.toThrow( - "Assertion failed: Invalid authwit nonce. When 'from' and 'msg_sender' are the same, 'authwit_nonce' must be zero", - ); - }); - - // Calls transfer_in_public from account1 without an authwit; expects unauthorized. - it('transfer on behalf of other without "approval"', async () => { - const { result: balance0 } = await asset.methods.balance_of_public(adminAddress).simulate({ from: adminAddress }); - const amount = balance0 + 1n; - const authwitNonce = Fr.random(); - await expect( - asset.methods - .transfer_in_public(adminAddress, account1Address, amount, authwitNonce) - .simulate({ from: account1Address }), - ).rejects.toThrow(/unauthorized/); - }); - - // Approves a transfer exceeding balance via authwit; expects U128_UNDERFLOW_ERROR and verifies balances - // unchanged. - it('transfer more than balance on behalf of other', async () => { - const { result: balance0 } = await asset.methods.balance_of_public(adminAddress).simulate({ from: adminAddress }); - const { result: balance1 } = await asset.methods - .balance_of_public(account1Address) - .simulate({ from: account1Address }); - const amount = balance0 + 1n; - const authwitNonce = Fr.random(); - expect(amount).toBeGreaterThan(0n); - - const action = asset.methods.transfer_in_public(adminAddress, account1Address, amount, authwitNonce); - - const intent = { caller: account1Address, action }; - // We need to compute the message we want to sign and add it to the wallet as approved - const validateActionInteraction = await wallet.setPublicAuthWit(adminAddress, intent, true); - await validateActionInteraction.send(); - - const witness = await wallet.createAuthWit(adminAddress, { caller: account1Address, action }); - - // Perform the transfer - await expect(action.simulate({ from: account1Address, authWitnesses: [witness] })).rejects.toThrow( - U128_UNDERFLOW_ERROR, - ); - - expect((await asset.methods.balance_of_public(adminAddress).simulate({ from: adminAddress })).result).toEqual( - balance0, - ); - expect( - (await asset.methods.balance_of_public(account1Address).simulate({ from: account1Address })).result, - ).toEqual(balance1); - }); - - // Approves adminAddress as caller but executes from account1; expects unauthorized, balances unchanged. - it('transfer on behalf of other, wrong designated caller', async () => { - const { result: balance0 } = await asset.methods.balance_of_public(adminAddress).simulate({ from: adminAddress }); - const { result: balance1 } = await asset.methods - .balance_of_public(account1Address) - .simulate({ from: account1Address }); - const amount = balance0 + 2n; - const authwitNonce = Fr.random(); - expect(amount).toBeGreaterThan(0n); - - // We need to compute the message we want to sign and add it to the wallet as approved - const action = asset.methods.transfer_in_public(adminAddress, account1Address, amount, authwitNonce); - - const validateActionInteraction = await wallet.setPublicAuthWit( - adminAddress, - { caller: adminAddress, action }, - true, - ); - await validateActionInteraction.send(); - - // Perform the transfer - await expect(action.simulate({ from: account1Address })).rejects.toThrow(/unauthorized/); - - expect((await asset.methods.balance_of_public(adminAddress).simulate({ from: adminAddress })).result).toEqual( - balance0, - ); - expect( - (await asset.methods.balance_of_public(account1Address).simulate({ from: account1Address })).result, - ).toEqual(balance1); - }); - - // Duplicate of the preceding test — identical logic and title, likely a test authoring mistake. - // Approves adminAddress as caller but executes from account1; expects unauthorized, balances unchanged. - it('transfer on behalf of other, wrong designated caller', async () => { - const { result: balance0 } = await asset.methods.balance_of_public(adminAddress).simulate({ from: adminAddress }); - const { result: balance1 } = await asset.methods - .balance_of_public(account1Address) - .simulate({ from: account1Address }); - const amount = balance0 + 2n; - const authwitNonce = Fr.random(); - expect(amount).toBeGreaterThan(0n); - - // We need to compute the message we want to sign and add it to the wallet as approved - const action = asset.methods.transfer_in_public(adminAddress, account1Address, amount, authwitNonce); - const validateActionInteraction = await wallet.setPublicAuthWit( - adminAddress, - { caller: adminAddress, action }, - true, - ); - await validateActionInteraction.send(); - - // Perform the transfer - await expect(action.simulate({ from: account1Address })).rejects.toThrow(/unauthorized/); - - expect((await asset.methods.balance_of_public(adminAddress).simulate({ from: adminAddress })).result).toEqual( - balance0, - ); - expect( - (await asset.methods.balance_of_public(account1Address).simulate({ from: account1Address })).result, - ).toEqual(balance1); - }); - - // Grants a public authwit to account1, then revokes it via setPublicAuthWit(false), then confirms - // the transfer simulation reverts with unauthorized (uses fixed method call form for simulate). - it('transfer on behalf of other, cancelled authwit', async () => { - const { result: balance0 } = await asset.methods.balance_of_public(adminAddress).simulate({ from: adminAddress }); - const amount = balance0 / 2n; - expect(amount).toBeGreaterThan(0n); - const authwitNonce = Fr.random(); - - const action = asset.methods.transfer_in_public(adminAddress, account1Address, amount, authwitNonce); - - const validateActionInteraction = await wallet.setPublicAuthWit( - adminAddress, - { caller: account1Address, action }, - true, - ); - await validateActionInteraction.send(); - - const cancelActionInteraction = await wallet.setPublicAuthWit( - adminAddress, - { caller: account1Address, action }, - false, - ); - await cancelActionInteraction.send(); - - await expect( - asset.methods - .transfer_in_public(adminAddress, account1Address, amount, authwitNonce) - .simulate({ from: account1Address }), - ).rejects.toThrow(/unauthorized/); - }); - - // Same grant-and-revoke flow as 'cancelled authwit' but simulates via the action object directly rather - // than re-constructing the method call — verifies both call forms produce unauthorized. - it('transfer on behalf of other, cancelled authwit, flow 2', async () => { - const { result: balance0 } = await asset.methods.balance_of_public(adminAddress).simulate({ from: adminAddress }); - const amount = balance0 / 2n; - expect(amount).toBeGreaterThan(0n); - const authwitNonce = Fr.random(); - - const action = asset.methods.transfer_in_public(adminAddress, account1Address, amount, authwitNonce); - - const validateActionInteraction = await wallet.setPublicAuthWit( - adminAddress, - { caller: account1Address, action }, - true, - ); - await validateActionInteraction.send(); - - const cancelActionInteraction = await wallet.setPublicAuthWit( - adminAddress, - { caller: account1Address, action }, - false, - ); - await cancelActionInteraction.send(); - - await expect(action.simulate({ from: account1Address })).rejects.toThrow(/unauthorized/); - }); - - // Uses the InvalidAccount contract as the 'from' address; expects unauthorized because the bad contract - // returns a malformed authwit validation value. - it('transfer on behalf of other, invalid spend_public_authwit on "from"', async () => { - const authwitNonce = Fr.random(); - - await expect( - asset.methods - .transfer_in_public(badAccount.address, account1Address, 0, authwitNonce) - .simulate({ from: account1Address }), - ).rejects.toThrow(/unauthorized/); - }); - - it.skip('transfer into account to overflow', () => { - // This should already be covered by the mint case earlier. e.g., since we cannot mint to overflow, there is not - // a way to get funds enough to overflow. - // Require direct storage manipulation for us to perform a nice explicit case though. - // See https://github.com/AztecProtocol/aztec-packages/issues/1259 - }); - }); -}); diff --git a/yarn-project/end-to-end/src/automine/token/transfer_to_private.test.ts b/yarn-project/end-to-end/src/automine/token/transfer_to_private.test.ts deleted file mode 100644 index 7cca9f3ec34a..000000000000 --- a/yarn-project/end-to-end/src/automine/token/transfer_to_private.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { U128_UNDERFLOW_ERROR } from '../../fixtures/fixtures.js'; -import { TokenContractTest } from './token_contract_test.js'; - -// Covers the transfer_to_private entry point on Token contract (public→private), including self and -// cross-account transfers. Setup: single node with AutomineSequencer, 3 accounts, Token deployed with -// initial mint. -describe('automine/token/transfer_to_private', () => { - const t = new TokenContractTest('transfer_to_private'); - let { asset, adminAddress, account1Address, tokenSim } = t; - - beforeAll(async () => { - t.applyBaseSnapshots(); - t.applyMintSnapshot(); - await t.setup(); - // Have to destructure again to ensure we have latest refs. - ({ asset, adminAddress, account1Address, tokenSim } = t); - }); - - afterAll(async () => { - await t.teardown(); - }); - - afterEach(async () => { - await t.tokenSim.check(); - }); - - // Transfers half of admin's public balance to admin's own private balance and verifies via TokenSimulator. - it('to self', async () => { - const { result: balancePub } = await asset.methods.balance_of_public(adminAddress).simulate({ from: adminAddress }); - const amount = balancePub / 2n; - expect(amount).toBeGreaterThan(0n); - - await asset.methods.transfer_to_private(adminAddress, amount).send({ from: adminAddress }); - - // Check that the result matches token sim - tokenSim.transferToPrivate(adminAddress, adminAddress, amount); - await tokenSim.check(); - }); - - // Transfers half of admin's public balance to account1's private balance and verifies via TokenSimulator. - it('to someone else', async () => { - const { result: balancePub } = await asset.methods.balance_of_public(adminAddress).simulate({ from: adminAddress }); - const amount = balancePub / 2n; - expect(amount).toBeGreaterThan(0n); - - await asset.methods.transfer_to_private(account1Address, amount).send({ from: adminAddress }); - - // Check that the result matches token sim - tokenSim.transferToPrivate(adminAddress, account1Address, amount); - await tokenSim.check(); - }); - - // Error paths for transfer_to_private. - describe('failure cases', () => { - // Attempts to transfer more than public balance to private; expects U128_UNDERFLOW_ERROR. - it('to self (more than balance)', async () => { - const { result: balancePub } = await asset.methods - .balance_of_public(adminAddress) - .simulate({ from: adminAddress }); - const amount = balancePub + 1n; - expect(amount).toBeGreaterThan(0n); - - await expect( - asset.methods.transfer_to_private(adminAddress, amount).simulate({ from: adminAddress }), - ).rejects.toThrow(U128_UNDERFLOW_ERROR); - }); - }); -}); diff --git a/yarn-project/end-to-end/src/automine/token/transfer_to_public.test.ts b/yarn-project/end-to-end/src/automine/token/transfer_to_public.test.ts deleted file mode 100644 index db3df48724b4..000000000000 --- a/yarn-project/end-to-end/src/automine/token/transfer_to_public.test.ts +++ /dev/null @@ -1,139 +0,0 @@ -import { computeAuthWitMessageHash } from '@aztec/aztec.js/authorization'; -import { Fr } from '@aztec/aztec.js/fields'; - -import { sendThroughAuthwitProxy, simulateThroughAuthwitProxy } from '../../fixtures/authwit_proxy.js'; -import { DUPLICATE_NULLIFIER_ERROR } from '../../fixtures/fixtures.js'; -import { TokenContractTest } from './token_contract_test.js'; - -// Covers the transfer_to_public entry point on Token contract (private→public): direct, authwit-delegated -// via proxy, and error paths. Setup: single node with AutomineSequencer, 3 accounts, Token deployed with -// initial mint. -describe('automine/token/transfer_to_public', () => { - const t = new TokenContractTest('transfer_to_public'); - let { asset, wallet, adminAddress, account1Address, tokenSim } = t; - - beforeAll(async () => { - t.applyBaseSnapshots(); - t.applyMintSnapshot(); - await t.setup(); - // Have to destructure again to ensure we have latest refs. - ({ asset, wallet, adminAddress, account1Address, tokenSim } = t); - }); - - afterAll(async () => { - await t.teardown(); - }); - - afterEach(async () => { - await t.tokenSim.check(); - }); - - // Transfers half of admin's private balance to admin's public balance and verifies via TokenSimulator. - it('on behalf of self', async () => { - const { result: balancePriv } = await asset.methods - .balance_of_private(adminAddress) - .simulate({ from: adminAddress }); - const amount = balancePriv / 2n; - expect(amount).toBeGreaterThan(0n); - - await asset.methods.transfer_to_public(adminAddress, adminAddress, amount, 0).send({ from: adminAddress }); - - tokenSim.transferToPublic(adminAddress, adminAddress, amount); - }); - - // Creates a private authwit for transfer_to_public to account1, sends through proxy, verifies TokenSimulator, - // then asserts replay reverts with DUPLICATE_NULLIFIER_ERROR. - it('on behalf of other', async () => { - const { result: balancePriv0 } = await asset.methods - .balance_of_private(adminAddress) - .simulate({ from: adminAddress }); - const amount = balancePriv0 / 2n; - const authwitNonce = Fr.random(); - expect(amount).toBeGreaterThan(0n); - - const action = asset.methods.transfer_to_public(adminAddress, account1Address, amount, authwitNonce); - const witness = await wallet.createAuthWit(adminAddress, { caller: t.authwitProxy.address, action }); - - // Admin sends through proxy so their keys are in scope, while proxy becomes msg_sender to trigger authwit. - await sendThroughAuthwitProxy(t.authwitProxy, action, { from: adminAddress, authWitnesses: [witness] }); - tokenSim.transferToPublic(adminAddress, account1Address, amount); - - // Perform the transfer again, should fail - await expect( - sendThroughAuthwitProxy(t.authwitProxy, action, { from: adminAddress, authWitnesses: [witness] }), - ).rejects.toThrow(DUPLICATE_NULLIFIER_ERROR); - }); - - // Error paths: more-than-balance, invalid nonce, over-balance via authwit, wrong caller. - describe('failure cases', () => { - // Transfers more than private balance to public (self); expects 'Balance too low'. - it('on behalf of self (more than balance)', async () => { - const { result: balancePriv } = await asset.methods - .balance_of_private(adminAddress) - .simulate({ from: adminAddress }); - const amount = balancePriv + 1n; - expect(amount).toBeGreaterThan(0n); - - await expect( - asset.methods.transfer_to_public(adminAddress, adminAddress, amount, 0).simulate({ from: adminAddress }), - ).rejects.toThrow('Assertion failed: Balance too low'); - }); - - // Self-transfer_to_public with nonce=1; expects the invalid-nonce assertion. - it('on behalf of self (invalid authwit nonce)', async () => { - const { result: balancePriv } = await asset.methods - .balance_of_private(adminAddress) - .simulate({ from: adminAddress }); - const amount = balancePriv + 1n; - expect(amount).toBeGreaterThan(0n); - - await expect( - asset.methods.transfer_to_public(adminAddress, adminAddress, amount, 1).simulate({ from: adminAddress }), - ).rejects.toThrow( - "Assertion failed: Invalid authwit nonce. When 'from' and 'msg_sender' are the same, 'authwit_nonce' must be zero", - ); - }); - - // Creates authwit for a transfer_to_public exceeding private balance; expects 'Balance too low'. - it('on behalf of other (more than balance)', async () => { - const { result: balancePriv0 } = await asset.methods - .balance_of_private(adminAddress) - .simulate({ from: adminAddress }); - const amount = balancePriv0 + 2n; - const authwitNonce = Fr.random(); - expect(amount).toBeGreaterThan(0n); - - const action = asset.methods.transfer_to_public(adminAddress, account1Address, amount, authwitNonce); - const witness = await wallet.createAuthWit(adminAddress, { caller: t.authwitProxy.address, action }); - - // Admin sends through proxy so their keys are in scope, while proxy becomes msg_sender to trigger authwit. - await expect( - simulateThroughAuthwitProxy(t.authwitProxy, action, { from: adminAddress, authWitnesses: [witness] }), - ).rejects.toThrow('Assertion failed: Balance too low'); - }); - - // Creates authwit designating account1 as caller but sends through proxy; expects unknown-authwit error. - it('on behalf of other (invalid designated caller)', async () => { - const { result: balancePriv0 } = await asset.methods - .balance_of_private(adminAddress) - .simulate({ from: adminAddress }); - const amount = balancePriv0 + 2n; - const authwitNonce = Fr.random(); - expect(amount).toBeGreaterThan(0n); - - const action = asset.methods.transfer_to_public(adminAddress, account1Address, amount, authwitNonce); - const call = await action.getFunctionCall(); - const expectedMessageHash = await computeAuthWitMessageHash( - { caller: t.authwitProxy.address, call }, - await wallet.getChainInfo(), - ); - - const witness = await wallet.createAuthWit(adminAddress, { caller: account1Address, action }); - - // Admin sends through proxy so their keys are in scope, while proxy becomes msg_sender to trigger authwit. - await expect( - simulateThroughAuthwitProxy(t.authwitProxy, action, { from: adminAddress, authWitnesses: [witness] }), - ).rejects.toThrow(`Unknown auth witness for message hash ${expectedMessageHash.toString()}`); - }); - }); -}); diff --git a/yarn-project/end-to-end/src/composed/e2e_persistence.test.ts b/yarn-project/end-to-end/src/composed/e2e_persistence.test.ts index fe48d6a45c65..5a8dd8a9e222 100644 --- a/yarn-project/end-to-end/src/composed/e2e_persistence.test.ts +++ b/yarn-project/end-to-end/src/composed/e2e_persistence.test.ts @@ -22,9 +22,15 @@ import type { TestWallet } from '../test-wallet/test_wallet.js'; jest.setTimeout(15 * 60 * 1000); -// Node and PXE persistence tests. Uses setup() directly with PIPELINING_SETUP_OPTS; excluded from the -// compose glob for unknown reasons (migrate-later candidate). Spawns and tears down node/PXE with -// varying combinations of persisted vs empty data directories to cover five restart scenarios. +// Node and PXE persistence tests: an in-process single-node test (uses setup() directly with +// PIPELINING_SETUP_OPTS) that spawns and tears down node/PXE across five persisted-vs-empty data-directory +// restart scenarios. +// +// EXCLUDED from every CI test list (see bootstrap.sh) and does NOT run anywhere. It is a candidate to +// refile under single-node/, but on the current branch its beforeAll no longer completes: the single-node +// sequencer stalls in checkpoint proposal (waitForAttestationsAndEnqueueSubmissionAsync) and the 600s hook +// times out before setup finishes. Re-enabling it needs that setup stall fixed (the root cause is in the +// shared setup/sequencer, not this file); until then it stays excluded. describe('Aztec persistence', () => { /** * These tests check that the Aztec Node and PXE can be shutdown and restarted without losing data. diff --git a/yarn-project/end-to-end/src/composed/ha/e2e_ha_distribute_work.test.ts b/yarn-project/end-to-end/src/composed/ha/e2e_ha_distribute_work.test.ts new file mode 100644 index 000000000000..b61d207470d0 --- /dev/null +++ b/yarn-project/end-to-end/src/composed/ha/e2e_ha_distribute_work.test.ts @@ -0,0 +1,210 @@ +/** + * HA work-distribution resilience test. + * + * Extracted from `e2e_ha_full.parallel.test.ts`: this test kills each block's producer node in turn, so it + * leaves the cluster unusable and previously carried a "must run last" ordering contract. Giving it its + * own file (and therefore its own cluster via the shared `HaFullTestContext`) removes that contract while + * preserving every assertion. Requires the docker-compose HA suite (run_test.sh ha). + */ +import { type AttestationInfo, getAttestationInfoFromPublishedCheckpoint } from '@aztec/stdlib/block'; +import { Checkpoint } from '@aztec/stdlib/checkpoint'; +import { OffenseType } from '@aztec/stdlib/slashing'; + +import { jest } from '@jest/globals'; + +import { getValidatorDuties, verifyNoDuplicateAttestations } from '../../fixtures/ha_setup.js'; +import { COMMITTEE_SIZE, HaFullTestContext, NODE_COUNT } from './ha_full_setup.js'; + +describe('HA Distribute Work', () => { + jest.setTimeout(20 * 60 * 1000); // 20 minutes + + const t = new HaFullTestContext(); + + beforeAll(async () => { + await t.setup(); + }); + + afterAll(async () => { + await t.teardown(); + }); + + it('should distribute work across multiple HA nodes', async () => { + const { logger, haNodeServices, sendTriggerTx, aztecNode, mainPool, getSignatureContext, stopHANode } = t; + + logger.info('Testing HA resilience by killing nodes after they produce blocks'); + + // We'll produce NODE_COUNT blocks (5 total with NODE_COUNT=5) + // Each node produces exactly 1 block, and we kill it after it produces + // The last remaining node will produce the final block + const blockCount = NODE_COUNT; + const receipts = []; + const killedNodes: number[] = []; // Track indices of killed nodes + const blockProducers = new Map(); // Map block index to node ID + let previousBlockNumber: number | undefined; + + const nodeIds: string[] = []; + for (const service of haNodeServices) { + nodeIds.push((await service.getConfig()).nodeId); + } + + for (let i = 0; i < blockCount; i++) { + logger.info(`\n=== Producing block ${i + 1}/${blockCount} ===`); + logger.info(`Active nodes: ${haNodeServices.length - killedNodes.length}/${NODE_COUNT}`); + + const receipt = await sendTriggerTx(); + + expect(receipt.blockNumber).toBeDefined(); + + // Verify this transaction is in a different block than the previous one + if (previousBlockNumber !== undefined) { + expect(receipt.blockNumber).toBeGreaterThan(previousBlockNumber); + } + + previousBlockNumber = receipt.blockNumber; + receipts.push(receipt); + + // Find which node produced this block + const [block] = await aztecNode.getBlocks(receipt.blockNumber!, 1, { + includeL1PublishInfo: true, + includeAttestations: true, + includeTransactions: true, + onlyCheckpointed: true, + }); + if (!block) { + throw new Error(`Block ${receipt.blockNumber} not found`); + } + const slotNumber = BigInt(block.header.globalVariables.slotNumber); + const duties = await getValidatorDuties(mainPool, slotNumber); + const blockProposalDuty = duties.find(d => d.dutyType === 'BLOCK_PROPOSAL'); + + if (!blockProposalDuty) { + throw new Error(`No block proposal duty found for slot ${slotNumber}`); + } + + blockProducers.set(i, blockProposalDuty.nodeId); + logger.info(`Block ${receipt.blockNumber} produced by node ${blockProposalDuty.nodeId}`); + + const producerNodeId = blockProposalDuty.nodeId; + const producerNodeIndex = nodeIds.findIndex(nodeId => nodeId === producerNodeId); + + if (producerNodeIndex === -1) { + throw new Error(`Could not find active node with ID ${producerNodeId}`); + } + + // Kill the node that produced this block, unless it's the last block + if (i < blockCount - 1) { + logger.info(`Killing node ${producerNodeId} that produced this block`); + await stopHANode(producerNodeIndex); + killedNodes.push(producerNodeIndex); + } else { + // The final survivor is kept online for the slash-offense assertion below, but its sequencer + // is no longer needed. Stop it before running the remaining assertions so it cannot start a + // new empty checkpoint and then block service shutdown while awaiting a delayed L1 publish. + logger.info(`Last block produced; stopping sequencer for survivor ${producerNodeId}`); + await haNodeServices[producerNodeIndex].getSequencer()?.stop(); + } + + logger.info(`Block ${i + 1}/${blockCount} completed. Killed nodes: ${killedNodes.length}/${NODE_COUNT}`); + } + + // Verify we got the expected number of distinct blocks + const blockNumbers = receipts.map(r => r.blockNumber!).sort((a, b) => a - b); + const uniqueBlockNumbers = new Set(blockNumbers); + expect(uniqueBlockNumbers.size).toBe(blockCount); + logger.info(`Created ${uniqueBlockNumbers.size} distinct blocks: ${Array.from(uniqueBlockNumbers).join(', ')}`); + + // Verify each node produced at least 1 block + const nodeBlockCounts = new Map(); + for (const nodeId of blockProducers.values()) { + const count = nodeBlockCounts.get(nodeId) || 0; + nodeBlockCounts.set(nodeId, count + 1); + } + + logger.info(`Block production by node: ${JSON.stringify(Array.from(nodeBlockCounts.entries()))}`); + + // Verify: each node should have produced at least 1 block + // (there may be empty blocks produced during node transitions) + for (const [nodeId, count] of nodeBlockCounts.entries()) { + expect(count).toBeGreaterThanOrEqual(1); + logger.info(`Node ${nodeId} produced ${count} block(s) as expected`); + } + + // Verify all nodes participated (NODE_COUNT nodes total) + expect(nodeBlockCounts.size).toBe(NODE_COUNT); + logger.info(`All ${NODE_COUNT} nodes participated in block production`); + + // Verify no double-signing occurred across all blocks + const quorum = Math.floor((COMMITTEE_SIZE * 2) / 3) + 1; + for (const receipt of receipts) { + const [block] = await aztecNode.getBlocks(receipt.blockNumber!, 1, { + includeL1PublishInfo: true, + includeAttestations: true, + includeTransactions: true, + onlyCheckpointed: true, + }); + if (!block) { + throw new Error(`Block ${receipt.blockNumber} not found`); + } + const slotNumber = BigInt(block.header.globalVariables.slotNumber); + + // PRIMARY CHECK: Database records show all attestation duties attempted/completed + const duties = await getValidatorDuties(mainPool, slotNumber); + const attestationDuties = duties.filter(d => d.dutyType === 'ATTESTATION'); + + // Verify no duplicate attestation duties per validator (HA protection ensures 1 per validator) + const dutiesByValidator = verifyNoDuplicateAttestations(attestationDuties, logger); + expect(dutiesByValidator.size).toBeGreaterThanOrEqual(quorum); + logger.info( + `Block ${receipt.blockNumber}: Database shows ${dutiesByValidator.size} unique validators attested (quorum: ${quorum}), no double-signing detected in DB`, + ); + + // SECONDARY CHECK: Verify checkpoint attestations match database records + const [publishedCheckpoint] = await aztecNode.getCheckpoints(block.checkpointNumber, 1, { + includeAttestations: true, + }); + const attestationInfos = getAttestationInfoFromPublishedCheckpoint( + { + attestations: publishedCheckpoint.attestations ?? [], + checkpoint: new Checkpoint( + publishedCheckpoint.archive, + publishedCheckpoint.header, + [], + publishedCheckpoint.number, + publishedCheckpoint.feeAssetPriceModifier, + ), + }, + getSignatureContext(), + ); + + // Filter to only valid attestations with recovered addresses + const validAttestations = attestationInfos.filter( + (info: AttestationInfo) => info.status === 'recovered-from-signature' && info.address !== undefined, + ); + + // Verify checkpoint has exactly quorum attestations (trimmed to minimum required) + const checkpointValidatorAddresses = new Set(validAttestations.map(info => info.address!.toString())); + expect(checkpointValidatorAddresses.size).toBe(quorum); + + // Verify every validator in the checkpoint has a corresponding DB duty record + // (checkpoint is trimmed to quorum, so it's a subset of DB records) + for (const validatorAddress of checkpointValidatorAddresses) { + expect(dutiesByValidator.has(validatorAddress)).toBe(true); + } + } + + // GOSSIP-LAYER CHECK: each HA node's libp2p service detects when a signer attests to two + // distinct payloads at the same slot and fires `duplicateAttestationCallback` -> validator + // client emits WANT_TO_SLASH_EVENT -> SlashOffensesCollector persists a DUPLICATE_ATTESTATION + // offense. We assert no such offense (or DUPLICATE_PROPOSAL) was collected on any surviving + // HA node. Killed nodes are unreachable, but the surviving node — which has been alive the + // whole test — has observed all gossiped attestations and proposals across every slot. + const aliveNodes = haNodeServices.filter((_, idx) => !killedNodes.includes(idx)); + const allOffenses = (await Promise.all(aliveNodes.map(n => n.getSlashOffenses('all')))).flat(); + const equivocationOffenses = allOffenses.filter( + o => o.offenseType === OffenseType.DUPLICATE_ATTESTATION || o.offenseType === OffenseType.DUPLICATE_PROPOSAL, + ); + expect(equivocationOffenses).toEqual([]); + + await Promise.all(haNodeServices.map((_, nodeIndex) => stopHANode(nodeIndex))); + }); +}); diff --git a/yarn-project/end-to-end/src/composed/ha/e2e_ha_full.parallel.test.ts b/yarn-project/end-to-end/src/composed/ha/e2e_ha_full.parallel.test.ts index 28d3d75ebc20..da7a812b2e07 100644 --- a/yarn-project/end-to-end/src/composed/ha/e2e_ha_full.parallel.test.ts +++ b/yarn-project/end-to-end/src/composed/ha/e2e_ha_full.parallel.test.ts @@ -4,438 +4,49 @@ * Tests a complete HA setup with multiple nodes coordinating via PostgreSQL * and Web3Signer for remote signing. Verifies that blocks are produced, * attestations are signed, and no double-signing occurs. + * + * The cluster setup lives in `ha_full_setup.ts` and is shared with `e2e_ha_distribute_work.test.ts`. The + * node-killing "distribute work" resilience test lives in that separate file so it gets its own cluster + * instead of relying on running last. The clock-skew / timezone DB assertions stay here in the nested + * `Clock Skew and Timezone Safety` describe: they exercise the cluster's real dockerized PostgreSQL + * slashing-protection database, and timezone/clock divergence between the node and its database can only + * be reproduced against a genuine, separate Postgres process (not an in-process one). */ -import { type AztecNodeConfig, AztecNodeService, createAztecNodeService } from '@aztec/aztec-node'; import { AztecAddress, EthAddress } from '@aztec/aztec.js/addresses'; -import { NO_WAIT, getContractInstanceFromInstantiationParams } from '@aztec/aztec.js/contracts'; -import { Fr } from '@aztec/aztec.js/fields'; -import type { Logger } from '@aztec/aztec.js/log'; -import { type AztecNode, waitForTx } from '@aztec/aztec.js/node'; -import { GovernanceProposerContract } from '@aztec/ethereum/contracts'; -import type { DeployAztecL1ContractsReturnType } from '@aztec/ethereum/deploy-aztec-l1-contracts'; import { BlockNumber, CheckpointNumber, SlotNumber } from '@aztec/foundation/branded-types'; import { Buffer32 } from '@aztec/foundation/buffer'; -import { SecretValue } from '@aztec/foundation/config'; -import { withLoggerBindings } from '@aztec/foundation/log/server'; import { retryUntil } from '@aztec/foundation/retry'; import { sleep } from '@aztec/foundation/sleep'; -import type { TestDateProvider } from '@aztec/foundation/timer'; import { GovernanceProposerAbi } from '@aztec/l1-artifacts/GovernanceProposerAbi'; -import { TestContract } from '@aztec/noir-test-contracts.js/Test'; -import { type AttestationInfo, getAttestationInfoFromPublishedCheckpoint } from '@aztec/stdlib/block'; -import { Checkpoint } from '@aztec/stdlib/checkpoint'; -import { TopicType } from '@aztec/stdlib/p2p'; -import { OffenseType } from '@aztec/stdlib/slashing'; -import { TxHash, type TxReceipt, TxStatus } from '@aztec/stdlib/tx'; -import type { GenesisData } from '@aztec/stdlib/world-state'; import type { ValidatorClient } from '@aztec/validator-client'; import { PostgresSlashingProtectionDatabase } from '@aztec/validator-ha-signer/db'; import { type DutyRow, DutyStatus, DutyType } from '@aztec/validator-ha-signer/types'; import { jest } from '@jest/globals'; -import getPort, { portNumbers } from 'get-port'; -import { mkdtemp, rm, writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; +import { writeFile } from 'node:fs/promises'; import { join } from 'node:path'; -import { Pool } from 'pg'; -import { PIPELINING_SETUP_OPTS } from '../../fixtures/fixtures.js'; -import { - type HADatabaseConfig, - cleanupHADatabase, - createHADatabaseConfig, - createInitialValidatorsFromPrivateKeys, - getAddressesFromPrivateKeys, - getValidatorDuties, - setupHADatabase, - verifyNoDuplicateAttestations, -} from '../../fixtures/ha_setup.js'; -import { getPrivateKeyFromIndex, setup } from '../../fixtures/utils.js'; +import { getValidatorDuties, verifyNoDuplicateAttestations } from '../../fixtures/ha_setup.js'; import { - createWeb3SignerKeystore, - getWeb3SignerTestKeystoreDir, - getWeb3SignerUrl, - refreshWeb3Signer, -} from '../../fixtures/web3signer.js'; -import type { TestWallet } from '../../test-wallet/test_wallet.js'; -import { proveInteraction } from '../../test-wallet/utils.js'; - -const NODE_COUNT = 5; -const VALIDATOR_COUNT = 4; -const COMMITTEE_SIZE = 4; - -// Allocate p2p listen ports from above the OS ephemeral range (Linux default tops out at 60999) so they -// never collide with an ephemeral socket the OS may already have handed out -- e.g. the in-process prover -// node (which listens on p2pPort 0) or any outbound connection. The previous fixed 4040x ports sat inside -// the ephemeral range, so an ephemeral socket occasionally held a node's port at bind time, surfacing as -// libp2p ERR_NO_VALID_ADDRESSES and aborting beforeAll. get-port also locks each returned port briefly, so -// concurrent calls within this process never hand back the same one. -const getFreeP2PPort = () => getPort({ port: portNumbers(61000, 65535) }); - -async function registerTestContract(wallet: TestWallet): Promise { - const instance = await getContractInstanceFromInstantiationParams(TestContract.artifact, { - constructorArgs: [], - constructorArtifact: undefined, - salt: Fr.ZERO, - publicKeys: undefined, - deployer: undefined, - }); - await wallet.registerContract(instance, TestContract.artifact); - return TestContract.at(instance.address, wallet); -} - -async function submitTriggerTx(wallet: TestWallet, testContract: TestContract, from: AztecAddress): Promise { - const tx = await proveInteraction(wallet, testContract.methods.emit_nullifier(Fr.random()), { from }); - return await tx.send({ wait: NO_WAIT }); -} - -async function waitForTriggerTx(node: AztecNode, txHash: TxHash): Promise { - const receipt = await waitForTx(node, txHash, { waitForStatus: TxStatus.CHECKPOINTED }); - if (!receipt.blockNumber) { - throw new Error('Trigger tx was checkpointed without a block number'); - } - return receipt; -} - -// Requires the docker-compose HA suite (run_test.sh ha): live Postgres (DATABASE_URL) and Web3Signer -// sidecar. Uses setup() with PIPELINING_SETUP_OPTS; multiple in-proc AztecNodeService instances share the -// Postgres slashing-protection DB and Web3Signer keystore. + COMMITTEE_SIZE, + HaFullTestContext, + NODE_COUNT, + VALIDATOR_COUNT, + submitTriggerTx, + waitForTriggerTx, +} from './ha_full_setup.js'; + describe('HA Full Setup', () => { jest.setTimeout(20 * 60 * 1000); // 20 minutes - let logger: Logger; - let wallet: TestWallet; - let ownerAddress: AztecAddress; - let testContract: TestContract; - let aztecNode: AztecNode; - let config: AztecNodeConfig; - let teardown: () => Promise = async () => {}; - let accounts: AztecAddress[]; - let dateProvider: TestDateProvider; - let genesis: GenesisData | undefined; - - // HA specific resources - let haNodePools: Pool[]; // Database pools for HA nodes (for cleanup) - let haNodeServices: AztecNodeService[]; // All N HA peer nodes - let haSequencersStarted = false; - const stoppedHANodeIndexes = new Set(); - let haKeystoreDirs: string[]; - let mainPool: Pool; - let databaseConfig: HADatabaseConfig; - let attesterPrivateKeys: `0x${string}`[]; - let attesterAddresses: string[]; - let publisherPrivateKeys: `0x${string}`[]; - let publisherAddresses: string[]; - let web3SignerUrl: string; - let deployL1ContractsValues: DeployAztecL1ContractsReturnType; - let governanceProposer: GovernanceProposerContract; - /** Per-node initial keystore JSON (all 4 attesters, node's own publisher) for restore after reload test */ - let initialKeystoreJsons: string[]; - const getSignatureContext = () => ({ - chainId: config.l1ChainId, - rollupAddress: deployL1ContractsValues.l1ContractAddresses.rollupAddress, - }); - - const startHASequencers = async () => { - if (haSequencersStarted) { - return; - } - - await Promise.all( - haNodeServices.map(async (service, i) => { - logger.info(`Starting HA peer node ${i} sequencer`); - await service.getSequencer()?.start(); - }), - ); - haSequencersStarted = true; - logger.info('All HA peer sequencers started'); - }; - - const sendTriggerTx = async (): Promise => { - await startHASequencers(); - const txHash = await submitTriggerTx(wallet, testContract, ownerAddress); - return await waitForTriggerTx(aztecNode, txHash); - }; - - const stopHANode = async (nodeIndex: number) => { - if (stoppedHANodeIndexes.has(nodeIndex)) { - return; - } - - logger.info(`Stopping HA peer node ${nodeIndex}`); - await haNodeServices[nodeIndex].stop(); - stoppedHANodeIndexes.add(nodeIndex); - }; + const t = new HaFullTestContext(); beforeAll(async () => { - // Check required environment variables - if (!process.env.DATABASE_URL) { - throw new Error('DATABASE_URL environment variable must be set for HA tests'); - } - - web3SignerUrl = getWeb3SignerUrl(); - if (!web3SignerUrl) { - throw new Error('WEB3_SIGNER_URL environment variable must be set for HA tests'); - } - - // Setup database configuration - databaseConfig = createHADatabaseConfig('ha-full-test'); - - // Connect to database (migrations already run by docker-compose entrypoint) - mainPool = setupHADatabase(databaseConfig.databaseUrl.getValue()!); - - attesterPrivateKeys = Array.from( - { length: VALIDATOR_COUNT }, - (_, i) => `0x${getPrivateKeyFromIndex(i)!.toString('hex')}` as `0x${string}`, - ); - - publisherPrivateKeys = Array.from( - { length: NODE_COUNT }, - (_, i) => `0x${getPrivateKeyFromIndex(i + VALIDATOR_COUNT)!.toString('hex')}` as `0x${string}`, - ); - - const web3SignerDir = getWeb3SignerTestKeystoreDir(); - const allKeys = [...attesterPrivateKeys, ...publisherPrivateKeys]; - for (const key of allKeys) { - await createWeb3SignerKeystore(web3SignerDir, key); - } - - attesterAddresses = getAddressesFromPrivateKeys(attesterPrivateKeys); - - publisherAddresses = getAddressesFromPrivateKeys(publisherPrivateKeys); - - // Refresh Web3Signer to load all the keys (attesters + publishers) - await refreshWeb3Signer(web3SignerUrl, ...attesterAddresses, ...publisherAddresses); - - // Create database pools for HA nodes - haNodePools = Array.from( - { length: NODE_COUNT }, - () => new Pool({ connectionString: databaseConfig.databaseUrl.getValue()! }), - ); - - const initialValidators = createInitialValidatorsFromPrivateKeys(attesterPrivateKeys); - - const bootstrapP2PPort = await getFreeP2PPort(); - - ({ teardown, logger, wallet, aztecNode, config, accounts, dateProvider, deployL1ContractsValues, genesis } = - await setup( - // A single default initializerless account, created/funded/registered by setup with no on-chain - // deploy tx -- the bootstrap node can't build blocks (disableValidator), so the owner must be usable - // without one. - 1, - { - ...PIPELINING_SETUP_OPTS, - automineL1Setup: true, - initialValidators, - sequencerPublisherPrivateKeys: [new SecretValue(publisherPrivateKeys[0])], - aztecTargetCommitteeSize: COMMITTEE_SIZE, - // The full HA docker/Web3Signer stack can still be joining and syncing after the shared - // 12s pipelining preset's 2.5s start window has closed. Keep real sequencing, but give - // HA validators enough time to pass the enforced build-start gate in CI. - aztecSlotDuration: 16, - // This suite validates HA coordination on tx-bearing checkpoints. Requiring one tx avoids a startup empty - // checkpoint from occupying the shared HA publisher while the trigger tx is still being prepared. - minTxsPerBlock: 1, - archiverPollingIntervalMS: 200, - sequencerPollingIntervalMS: 200, - worldStateBlockCheckIntervalMS: 200, - blockCheckIntervalMS: 200, - startProverNode: true, - // The bootstrap node is only an RPC/P2P anchor. HA validators are the first block producers in this suite. - disableValidator: true, - // Enable P2P for transaction gossip - p2pEnabled: true, - // Bind the bootstrap node above the ephemeral range too (see getFreeP2PPort), so it can't lose - // its port to an ephemeral socket and abort the whole suite before any HA node is created. Set - // the broadcast port explicitly to the same value: discv5 otherwise defaults p2pBroadcastPort to - // p2pPort by mutating this config object in place, and that mutated value would then leak into the - // HA nodes' configs below (built by spreading `config`), making them advertise the wrong port. - p2pPort: bootstrapP2PPort, - p2pBroadcastPort: bootstrapP2PPort, - // Enable slashing for testing governance + slashing vote coordination - slasherEnabled: true, - slashingRoundSizeInEpochs: 1, // 32 slots (1 epoch) - slashingQuorum: 17, // >50% of 32 slots for tally quorum, - }, - { syncChainTip: 'proven' }, - )); - - ownerAddress = accounts[0]; - testContract = await registerTestContract(wallet); - - if (!dateProvider) { - throw new Error('dateProvider must be provided by setup for HA tests'); - } - - logger.info('Bootstrap node setup complete; funded initializerless account and test contract registered locally'); - - // Get bootstrap node's P2P ENR for HA nodes to connect to - const bootstrapNodeEnr = await aztecNode.getEncodedEnr(); - if (!bootstrapNodeEnr) { - throw new Error('Failed to get bootstrap node ENR - P2P may not be enabled'); - } - logger.info(`Bootstrap node ENR: ${bootstrapNodeEnr}`); - - // L1 contract wrappers for querying votes - governanceProposer = new GovernanceProposerContract( - deployL1ContractsValues.l1Client, - deployL1ContractsValues.l1ContractAddresses.governanceProposerAddress.toString(), - ); - logger.info('L1 contract wrappers initialized'); - - haNodeServices = []; - haKeystoreDirs = []; - logger.info(`Starting ${NODE_COUNT} HA peer nodes...`); - - // Per-node keystore: all attesters but only this node's publisher to avoid nonce conflicts. - // When keyStoreDirectory is set the node loads validators/publishers from file only, so we omit them from config. - initialKeystoreJsons = []; - - for (let i = 0; i < NODE_COUNT; i++) { - const nodeId = `${databaseConfig.nodeId}-${i + 1}`; - logger.info(`Starting HA peer node ${i} with nodeId: ${nodeId}`); - - const keystoreContent = { - schemaVersion: 1, - validators: [ - { - attester: attesterAddresses, - feeRecipient: AztecAddress.ZERO.toString(), - coinbase: EthAddress.fromString(attesterAddresses[0]).toChecksumString(), - remoteSigner: web3SignerUrl, - publisher: [publisherAddresses[i]], - }, - ], - }; - const keystoreJson = JSON.stringify(keystoreContent, null, 2); - initialKeystoreJsons.push(keystoreJson); - - const keystoreDir = await mkdtemp(join(tmpdir(), `ha-keystore-${i}-`)); - haKeystoreDirs.push(keystoreDir); - await writeFile(join(keystoreDir, 'keystore.json'), keystoreJson); - - const dataDirectory = config.dataDirectory ? `${config.dataDirectory}-${i}` : undefined; - - const nodeP2PPort = await getFreeP2PPort(); - const nodeConfig: AztecNodeConfig = { - ...config, - nodeId, - keyStoreDirectory: keystoreDir, - // Ensure txs are included in proposals to test full signing path - publishTxsWithProposals: true, - dataDirectory, - databaseUrl: databaseConfig.databaseUrl, - pollingIntervalMs: databaseConfig.pollingIntervalMs, - signingTimeoutMs: databaseConfig.signingTimeoutMs, - maxStuckDutiesAgeMs: databaseConfig.maxStuckDutiesAgeMs, - haSigningEnabled: true, - disableValidator: false, - // Enable P2P for transaction and block gossip - p2pEnabled: true, - // Each HA node gets its own free port above the ephemeral range. Override the broadcast port too: - // `...config` carries the bootstrap node's broadcast port (discv5 sets it in place), which would - // otherwise make every HA node advertise the bootstrap's port instead of its own. - p2pPort: nodeP2PPort, - p2pBroadcastPort: nodeP2PPort, - // Connect to bootstrap node for tx gossip - bootstrapNodes: [bootstrapNodeEnr], - web3SignerUrl, - }; - - const nodeService = await withLoggerBindings({ actor: `HA-${i}` }, async () => { - return await createAztecNodeService(nodeConfig, { dateProvider }, { genesis, dontStartSequencer: true }); - }); - - haNodeServices.push(nodeService); - logger.info(`HA peer node ${i} started successfully`); - } - - logger.info(`All ${NODE_COUNT} HA peer nodes started and coordinating via PostgreSQL database`); - logger.info('Waiting for HA peer nodes to join the tx gossip mesh'); - await retryUntil( - async () => { - const meshStates = await Promise.all( - haNodeServices.map(async (service, nodeIndex) => { - const p2p = service.getP2P(); - const [peers, txMeshPeerCount] = await Promise.all([ - p2p.getPeers(), - p2p.getGossipMeshPeerCount(TopicType.tx), - ]); - - return { nodeIndex, peerCount: peers.length, txMeshPeerCount }; - }), - ); - - logger.debug('HA tx gossip mesh status', { meshStates }); - return meshStates.every(({ peerCount, txMeshPeerCount }) => peerCount > 0 && txMeshPeerCount > 0) - ? true - : undefined; - }, - 'HA tx gossip mesh readiness', - 60, - 1, - ); - - // The owner is an initializerless account, so it needs no deployment tx -- it was funded at genesis - // and registered during setup, and is ready to transact as soon as the HA nodes start building blocks. - logger.info(`Test account ready at ${ownerAddress}`); + await t.setup(); }); afterAll(async () => { - // Stop all sequencers before tearing down the nodes: a sequencer stop awaits its in-flight - // iteration, which can spend tens of seconds finishing a vote or checkpoint publish on L1. - // Stops must be awaited fully — jest runs without forceExit, so a node abandoned mid-stop - // outlives the test environment and keeps the worker process alive until the CI job timeout. - // The dateProvider reset must wait until nodes are stopped: it rewinds the shared clock from - // chain time to wall time (minutes apart after the automine deploy burst), and any publisher - // deadline armed against the rewound clock would block shutdown until wall time catches up. - if (haNodeServices) { - await Promise.allSettled( - haNodeServices.map(async (service, i) => { - try { - await service.getSequencer()?.stop(); - } catch (error) { - logger.error(`Failed to stop sequencer of HA peer node ${i}: ${error}`); - } - }), - ); - await Promise.allSettled( - haNodeServices.map((_, i) => - stopHANode(i).catch(error => { - logger.error(`Failed to stop HA peer node ${i}: ${error}`); - }), - ), - ); - } - - dateProvider?.reset(); - - // Cleanup HA keystore temp directories - if (haKeystoreDirs) { - for (let i = 0; i < haKeystoreDirs.length; i++) { - try { - await rm(haKeystoreDirs[i], { recursive: true }); - } catch (error) { - logger.error(`Failed to remove HA keystore dir ${i}: ${error}`); - } - } - } - - // Cleanup HA resources (database pools, etc.) - if (haNodePools) { - for (const pool of haNodePools) { - try { - await pool.end(); - } catch (error) { - logger.error(`Failed to close HA node pool: ${error}`); - } - } - } - await cleanupHADatabase(mainPool, logger); - await mainPool.end(); - - // Cleanup bootstrap node and test infrastructure (this cleans up the shared data directory) - await teardown(); + await t.teardown(); }); afterEach(async () => { @@ -443,15 +54,12 @@ describe('HA Full Setup', () => { jest.restoreAllMocks(); // Clean up database state between tests - try { - await mainPool.query('DELETE FROM validator_duties'); - } catch (error) { - // Ignore cleanup errors (table might not exist on first run failure) - logger?.warn(`Failed to clean up validator_duties: ${error}`); - } + await t.resetDutiesTable(); }); it('should produce blocks with HA coordination and attestations', async () => { + const { logger, wallet, testContract, ownerAddress, aztecNode, mainPool, haNodeServices, startHASequencers } = t; + logger.info('Testing full HA setup: block production, attestations, and coordination'); // Send a tx to trigger block building. The account and contract are funded/registered at genesis, @@ -559,6 +167,9 @@ describe('HA Full Setup', () => { }); it('should coordinate governance voting across HA nodes', async () => { + const { logger, deployL1ContractsValues, haNodeServices, sendTriggerTx, aztecNode, governanceProposer, mainPool } = + t; + logger.info('Testing real governance voting with HA coordination'); const mockGovernancePayload = deployL1ContractsValues.l1ContractAddresses.governanceAddress; @@ -703,6 +314,18 @@ describe('HA Full Setup', () => { }); it('should reload keystore via admin API and keep building blocks after swapping attesters', async () => { + const { + logger, + attesterAddresses, + haKeystoreDirs, + web3SignerUrl, + publisherAddresses, + initialKeystoreJsons, + haNodeServices, + sendTriggerTx, + aztecNode, + } = t; + logger.info('Testing reloadKeystore: swap all attesters across HA nodes'); const groupA = attesterAddresses.slice(0, 2); @@ -778,189 +401,11 @@ describe('HA Full Setup', () => { } }); - // NOTE: this test needs to run last - it('should distribute work across multiple HA nodes', async () => { - logger.info('Testing HA resilience by killing nodes after they produce blocks'); - - // We'll produce NODE_COUNT blocks (5 total with NODE_COUNT=5) - // Each node produces exactly 1 block, and we kill it after it produces - // The last remaining node will produce the final block - const blockCount = NODE_COUNT; - const receipts = []; - const killedNodes: number[] = []; // Track indices of killed nodes - const blockProducers = new Map(); // Map block index to node ID - let previousBlockNumber: number | undefined; - - const nodeIds: string[] = []; - for (const service of haNodeServices) { - nodeIds.push((await service.getConfig()).nodeId); - } - - for (let i = 0; i < blockCount; i++) { - logger.info(`\n=== Producing block ${i + 1}/${blockCount} ===`); - logger.info(`Active nodes: ${haNodeServices.length - killedNodes.length}/${NODE_COUNT}`); - - const receipt = await sendTriggerTx(); - - expect(receipt.blockNumber).toBeDefined(); - - // Verify this transaction is in a different block than the previous one - if (previousBlockNumber !== undefined) { - expect(receipt.blockNumber).toBeGreaterThan(previousBlockNumber); - } - - previousBlockNumber = receipt.blockNumber; - receipts.push(receipt); - - // Find which node produced this block - const [block] = await aztecNode.getBlocks(receipt.blockNumber!, 1, { - includeL1PublishInfo: true, - includeAttestations: true, - includeTransactions: true, - onlyCheckpointed: true, - }); - if (!block) { - throw new Error(`Block ${receipt.blockNumber} not found`); - } - const slotNumber = BigInt(block.header.globalVariables.slotNumber); - const duties = await getValidatorDuties(mainPool, slotNumber); - const blockProposalDuty = duties.find(d => d.dutyType === 'BLOCK_PROPOSAL'); - - if (!blockProposalDuty) { - throw new Error(`No block proposal duty found for slot ${slotNumber}`); - } - - blockProducers.set(i, blockProposalDuty.nodeId); - logger.info(`Block ${receipt.blockNumber} produced by node ${blockProposalDuty.nodeId}`); - - const producerNodeId = blockProposalDuty.nodeId; - const producerNodeIndex = nodeIds.findIndex(nodeId => nodeId === producerNodeId); - - if (producerNodeIndex === -1) { - throw new Error(`Could not find active node with ID ${producerNodeId}`); - } - - // Kill the node that produced this block, unless it's the last block - if (i < blockCount - 1) { - logger.info(`Killing node ${producerNodeId} that produced this block`); - await stopHANode(producerNodeIndex); - killedNodes.push(producerNodeIndex); - } else { - // The final survivor is kept online for the slash-offense assertion below, but its sequencer - // is no longer needed. Stop it before running the remaining assertions so it cannot start a - // new empty checkpoint and then block service shutdown while awaiting a delayed L1 publish. - logger.info(`Last block produced; stopping sequencer for survivor ${producerNodeId}`); - await haNodeServices[producerNodeIndex].getSequencer()?.stop(); - } - - logger.info(`Block ${i + 1}/${blockCount} completed. Killed nodes: ${killedNodes.length}/${NODE_COUNT}`); - } - - // Verify we got the expected number of distinct blocks - const blockNumbers = receipts.map(r => r.blockNumber!).sort((a, b) => a - b); - const uniqueBlockNumbers = new Set(blockNumbers); - expect(uniqueBlockNumbers.size).toBe(blockCount); - logger.info(`Created ${uniqueBlockNumbers.size} distinct blocks: ${Array.from(uniqueBlockNumbers).join(', ')}`); - - // Verify each node produced at least 1 block - const nodeBlockCounts = new Map(); - for (const nodeId of blockProducers.values()) { - const count = nodeBlockCounts.get(nodeId) || 0; - nodeBlockCounts.set(nodeId, count + 1); - } - - logger.info(`Block production by node: ${JSON.stringify(Array.from(nodeBlockCounts.entries()))}`); - - // Verify: each node should have produced at least 1 block - // (there may be empty blocks produced during node transitions) - for (const [nodeId, count] of nodeBlockCounts.entries()) { - expect(count).toBeGreaterThanOrEqual(1); - logger.info(`Node ${nodeId} produced ${count} block(s) as expected`); - } - - // Verify all nodes participated (NODE_COUNT nodes total) - expect(nodeBlockCounts.size).toBe(NODE_COUNT); - logger.info(`All ${NODE_COUNT} nodes participated in block production`); - - // Verify no double-signing occurred across all blocks - const quorum = Math.floor((COMMITTEE_SIZE * 2) / 3) + 1; - for (const receipt of receipts) { - const [block] = await aztecNode.getBlocks(receipt.blockNumber!, 1, { - includeL1PublishInfo: true, - includeAttestations: true, - includeTransactions: true, - onlyCheckpointed: true, - }); - if (!block) { - throw new Error(`Block ${receipt.blockNumber} not found`); - } - const slotNumber = BigInt(block.header.globalVariables.slotNumber); - - // PRIMARY CHECK: Database records show all attestation duties attempted/completed - const duties = await getValidatorDuties(mainPool, slotNumber); - const attestationDuties = duties.filter(d => d.dutyType === 'ATTESTATION'); - - // Verify no duplicate attestation duties per validator (HA protection ensures 1 per validator) - const dutiesByValidator = verifyNoDuplicateAttestations(attestationDuties, logger); - expect(dutiesByValidator.size).toBeGreaterThanOrEqual(quorum); - logger.info( - `Block ${receipt.blockNumber}: Database shows ${dutiesByValidator.size} unique validators attested (quorum: ${quorum}), no double-signing detected in DB`, - ); - - // SECONDARY CHECK: Verify checkpoint attestations match database records - const [publishedCheckpoint] = await aztecNode.getCheckpoints(block.checkpointNumber, 1, { - includeAttestations: true, - }); - const attestationInfos = getAttestationInfoFromPublishedCheckpoint( - { - attestations: publishedCheckpoint.attestations ?? [], - checkpoint: new Checkpoint( - publishedCheckpoint.archive, - publishedCheckpoint.header, - [], - publishedCheckpoint.number, - publishedCheckpoint.feeAssetPriceModifier, - ), - }, - getSignatureContext(), - ); - - // Filter to only valid attestations with recovered addresses - const validAttestations = attestationInfos.filter( - (info: AttestationInfo) => info.status === 'recovered-from-signature' && info.address !== undefined, - ); - - // Verify checkpoint has exactly quorum attestations (trimmed to minimum required) - const checkpointValidatorAddresses = new Set(validAttestations.map(info => info.address!.toString())); - expect(checkpointValidatorAddresses.size).toBe(quorum); - - // Verify every validator in the checkpoint has a corresponding DB duty record - // (checkpoint is trimmed to quorum, so it's a subset of DB records) - for (const validatorAddress of checkpointValidatorAddresses) { - expect(dutiesByValidator.has(validatorAddress)).toBe(true); - } - } - - // GOSSIP-LAYER CHECK: each HA node's libp2p service detects when a signer attests to two - // distinct payloads at the same slot and fires `duplicateAttestationCallback` -> validator - // client emits WANT_TO_SLASH_EVENT -> SlashOffensesCollector persists a DUPLICATE_ATTESTATION - // offense. We assert no such offense (or DUPLICATE_PROPOSAL) was collected on any surviving - // HA node. Killed nodes are unreachable, but the surviving node — which has been alive the - // whole test — has observed all gossiped attestations and proposals across every slot. - const aliveNodes = haNodeServices.filter((_, idx) => !killedNodes.includes(idx)); - const allOffenses = (await Promise.all(aliveNodes.map(n => n.getSlashOffenses('all')))).flat(); - const equivocationOffenses = allOffenses.filter( - o => o.offenseType === OffenseType.DUPLICATE_ATTESTATION || o.offenseType === OffenseType.DUPLICATE_PROPOSAL, - ); - expect(equivocationOffenses).toEqual([]); - - await Promise.all(haNodeServices.map((_, nodeIndex) => stopHANode(nodeIndex))); - }); - describe('Clock Skew and Timezone Safety', () => { const rollupAddress = EthAddress.random(); const validatorAddress = EthAddress.random(); it('should not be affected by process.env.TZ changes', async () => { + const { mainPool } = t; const spDb = new PostgresSlashingProtectionDatabase(mainPool); const originalTZ = process.env.TZ; @@ -1034,6 +479,7 @@ describe('HA Full Setup', () => { }); it('should not delete recent duties via cleanupOldDuties when node clock is ahead', async () => { + const { mainPool, dateProvider } = t; const spDb = new PostgresSlashingProtectionDatabase(mainPool); // Ensure clean slate for this test @@ -1096,6 +542,7 @@ describe('HA Full Setup', () => { }); it('should delete old duties via cleanupOldDuties based on DB time, not node time', async () => { + const { mainPool, dateProvider } = t; const spDb = new PostgresSlashingProtectionDatabase(mainPool); // Ensure clean slate for this test @@ -1165,6 +612,7 @@ describe('HA Full Setup', () => { }); it('should not delete recent stuck duties via cleanupOwnStuckDuties when node clock is ahead', async () => { + const { mainPool, dateProvider } = t; const spDb = new PostgresSlashingProtectionDatabase(mainPool); // Create a signing duty (stuck, not completed) using our actual method diff --git a/yarn-project/end-to-end/src/composed/ha/ha_full_setup.ts b/yarn-project/end-to-end/src/composed/ha/ha_full_setup.ts new file mode 100644 index 000000000000..78e6a4090283 --- /dev/null +++ b/yarn-project/end-to-end/src/composed/ha/ha_full_setup.ts @@ -0,0 +1,463 @@ +/** + * Shared setup for the docker-compose HA full suite. + * + * Stands up the complete HA cluster used by `e2e_ha_full.parallel.test.ts` and + * `e2e_ha_distribute_work.test.ts`: a bootstrap RPC/P2P node plus NODE_COUNT in-proc + * `AztecNodeService` HA peers that share one PostgreSQL slashing-protection DB and a Web3Signer keystore. + * Requires the docker-compose HA suite (run_test.sh ha): live Postgres (DATABASE_URL) and Web3Signer + * sidecar. + * + * The suite is split across two files because the "distribute work" test kills nodes as it runs, leaving + * the cluster unusable; giving it its own file (its own cluster) removes the previous "must run last" + * ordering contract without changing what any test asserts. + */ +import { type AztecNodeConfig, AztecNodeService, createAztecNodeService } from '@aztec/aztec-node'; +import { AztecAddress, EthAddress } from '@aztec/aztec.js/addresses'; +import { NO_WAIT, getContractInstanceFromInstantiationParams } from '@aztec/aztec.js/contracts'; +import { Fr } from '@aztec/aztec.js/fields'; +import type { Logger } from '@aztec/aztec.js/log'; +import { type AztecNode, waitForTx } from '@aztec/aztec.js/node'; +import { GovernanceProposerContract } from '@aztec/ethereum/contracts'; +import type { DeployAztecL1ContractsReturnType } from '@aztec/ethereum/deploy-aztec-l1-contracts'; +import { SecretValue } from '@aztec/foundation/config'; +import { withLoggerBindings } from '@aztec/foundation/log/server'; +import { retryUntil } from '@aztec/foundation/retry'; +import type { TestDateProvider } from '@aztec/foundation/timer'; +import { TestContract } from '@aztec/noir-test-contracts.js/Test'; +import { TopicType } from '@aztec/stdlib/p2p'; +import { TxHash, type TxReceipt, TxStatus } from '@aztec/stdlib/tx'; +import type { GenesisData } from '@aztec/stdlib/world-state'; + +import getPort, { portNumbers } from 'get-port'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { Pool } from 'pg'; + +import { PIPELINING_SETUP_OPTS } from '../../fixtures/fixtures.js'; +import { + type HADatabaseConfig, + cleanupHADatabase, + createHADatabaseConfig, + createInitialValidatorsFromPrivateKeys, + getAddressesFromPrivateKeys, + setupHADatabase, +} from '../../fixtures/ha_setup.js'; +import { getPrivateKeyFromIndex, setup } from '../../fixtures/utils.js'; +import { + createWeb3SignerKeystore, + getWeb3SignerTestKeystoreDir, + getWeb3SignerUrl, + refreshWeb3Signer, +} from '../../fixtures/web3signer.js'; +import type { TestWallet } from '../../test-wallet/test_wallet.js'; +import { proveInteraction } from '../../test-wallet/utils.js'; + +export const NODE_COUNT = 5; +export const VALIDATOR_COUNT = 4; +export const COMMITTEE_SIZE = 4; + +// Allocate p2p listen ports from above the OS ephemeral range (Linux default tops out at 60999) so they +// never collide with an ephemeral socket the OS may already have handed out -- e.g. the in-process prover +// node (which listens on p2pPort 0) or any outbound connection. The previous fixed 4040x ports sat inside +// the ephemeral range, so an ephemeral socket occasionally held a node's port at bind time, surfacing as +// libp2p ERR_NO_VALID_ADDRESSES and aborting beforeAll. get-port also locks each returned port briefly, so +// concurrent calls within this process never hand back the same one. +const getFreeP2PPort = () => getPort({ port: portNumbers(61000, 65535) }); + +export async function registerTestContract(wallet: TestWallet): Promise { + const instance = await getContractInstanceFromInstantiationParams(TestContract.artifact, { + constructorArgs: [], + constructorArtifact: undefined, + salt: Fr.ZERO, + publicKeys: undefined, + deployer: undefined, + }); + await wallet.registerContract(instance, TestContract.artifact); + return TestContract.at(instance.address, wallet); +} + +export async function submitTriggerTx( + wallet: TestWallet, + testContract: TestContract, + from: AztecAddress, +): Promise { + const tx = await proveInteraction(wallet, testContract.methods.emit_nullifier(Fr.random()), { from }); + return await tx.send({ wait: NO_WAIT }); +} + +export async function waitForTriggerTx(node: AztecNode, txHash: TxHash): Promise { + const receipt = await waitForTx(node, txHash, { waitForStatus: TxStatus.CHECKPOINTED }); + if (!receipt.blockNumber) { + throw new Error('Trigger tx was checkpointed without a block number'); + } + return receipt; +} + +/** + * Owns the full HA cluster lifecycle and the shared helpers the two HA test files drive it with. Each + * test file constructs one instance and calls {@link setup} in `beforeAll` / {@link teardown} in + * `afterAll`; state fields are populated by {@link setup} and read directly by the tests. + */ +export class HaFullTestContext { + logger!: Logger; + wallet!: TestWallet; + ownerAddress!: AztecAddress; + testContract!: TestContract; + aztecNode!: AztecNode; + config!: AztecNodeConfig; + accounts!: AztecAddress[]; + dateProvider!: TestDateProvider; + genesis: GenesisData | undefined; + + haNodePools!: Pool[]; // Database pools for HA nodes (for cleanup) + haNodeServices!: AztecNodeService[]; // All N HA peer nodes + haKeystoreDirs!: string[]; + mainPool!: Pool; + databaseConfig!: HADatabaseConfig; + attesterPrivateKeys!: `0x${string}`[]; + attesterAddresses!: string[]; + publisherPrivateKeys!: `0x${string}`[]; + publisherAddresses!: string[]; + web3SignerUrl!: string; + deployL1ContractsValues!: DeployAztecL1ContractsReturnType; + governanceProposer!: GovernanceProposerContract; + /** Per-node initial keystore JSON (all 4 attesters, node's own publisher) for restore after reload test */ + initialKeystoreJsons!: string[]; + + private teardownBootstrap: () => Promise = async () => {}; + private haSequencersStarted = false; + private readonly stoppedHANodeIndexes = new Set(); + + getSignatureContext = () => ({ + chainId: this.config.l1ChainId, + rollupAddress: this.deployL1ContractsValues.l1ContractAddresses.rollupAddress, + }); + + startHASequencers = async () => { + if (this.haSequencersStarted) { + return; + } + + await Promise.all( + this.haNodeServices.map(async (service, i) => { + this.logger.info(`Starting HA peer node ${i} sequencer`); + await service.getSequencer()?.start(); + }), + ); + this.haSequencersStarted = true; + this.logger.info('All HA peer sequencers started'); + }; + + sendTriggerTx = async (): Promise => { + await this.startHASequencers(); + const txHash = await submitTriggerTx(this.wallet, this.testContract, this.ownerAddress); + return await waitForTriggerTx(this.aztecNode, txHash); + }; + + stopHANode = async (nodeIndex: number) => { + if (this.stoppedHANodeIndexes.has(nodeIndex)) { + return; + } + + this.logger.info(`Stopping HA peer node ${nodeIndex}`); + await this.haNodeServices[nodeIndex].stop(); + this.stoppedHANodeIndexes.add(nodeIndex); + }; + + async setup(): Promise { + // Check required environment variables + if (!process.env.DATABASE_URL) { + throw new Error('DATABASE_URL environment variable must be set for HA tests'); + } + + this.web3SignerUrl = getWeb3SignerUrl(); + if (!this.web3SignerUrl) { + throw new Error('WEB3_SIGNER_URL environment variable must be set for HA tests'); + } + + // Setup database configuration + this.databaseConfig = createHADatabaseConfig('ha-full-test'); + + // Connect to database (migrations already run by docker-compose entrypoint) + this.mainPool = setupHADatabase(this.databaseConfig.databaseUrl.getValue()!); + + this.attesterPrivateKeys = Array.from( + { length: VALIDATOR_COUNT }, + (_, i) => `0x${getPrivateKeyFromIndex(i)!.toString('hex')}` as `0x${string}`, + ); + + this.publisherPrivateKeys = Array.from( + { length: NODE_COUNT }, + (_, i) => `0x${getPrivateKeyFromIndex(i + VALIDATOR_COUNT)!.toString('hex')}` as `0x${string}`, + ); + + const web3SignerDir = getWeb3SignerTestKeystoreDir(); + const allKeys = [...this.attesterPrivateKeys, ...this.publisherPrivateKeys]; + for (const key of allKeys) { + await createWeb3SignerKeystore(web3SignerDir, key); + } + + this.attesterAddresses = getAddressesFromPrivateKeys(this.attesterPrivateKeys); + + this.publisherAddresses = getAddressesFromPrivateKeys(this.publisherPrivateKeys); + + // Refresh Web3Signer to load all the keys (attesters + publishers) + await refreshWeb3Signer(this.web3SignerUrl, ...this.attesterAddresses, ...this.publisherAddresses); + + // Create database pools for HA nodes + this.haNodePools = Array.from( + { length: NODE_COUNT }, + () => new Pool({ connectionString: this.databaseConfig.databaseUrl.getValue()! }), + ); + + const initialValidators = createInitialValidatorsFromPrivateKeys(this.attesterPrivateKeys); + + const bootstrapP2PPort = await getFreeP2PPort(); + + ({ + teardown: this.teardownBootstrap, + logger: this.logger, + wallet: this.wallet, + aztecNode: this.aztecNode, + config: this.config, + accounts: this.accounts, + dateProvider: this.dateProvider, + deployL1ContractsValues: this.deployL1ContractsValues, + genesis: this.genesis, + } = await setup( + // A single default initializerless account, created/funded/registered by setup with no on-chain + // deploy tx -- the bootstrap node can't build blocks (disableValidator), so the owner must be usable + // without one. + 1, + { + ...PIPELINING_SETUP_OPTS, + automineL1Setup: true, + initialValidators, + sequencerPublisherPrivateKeys: [new SecretValue(this.publisherPrivateKeys[0])], + aztecTargetCommitteeSize: COMMITTEE_SIZE, + // The full HA docker/Web3Signer stack can still be joining and syncing after the shared + // 12s pipelining preset's 2.5s start window has closed. Keep real sequencing, but give + // HA validators enough time to pass the enforced build-start gate in CI. + aztecSlotDuration: 16, + // This suite validates HA coordination on tx-bearing checkpoints. Requiring one tx avoids a startup empty + // checkpoint from occupying the shared HA publisher while the trigger tx is still being prepared. + minTxsPerBlock: 1, + archiverPollingIntervalMS: 200, + sequencerPollingIntervalMS: 200, + worldStateBlockCheckIntervalMS: 200, + blockCheckIntervalMS: 200, + startProverNode: true, + // The bootstrap node is only an RPC/P2P anchor. HA validators are the first block producers in this suite. + disableValidator: true, + // Enable P2P for transaction gossip + p2pEnabled: true, + // Bind the bootstrap node above the ephemeral range too (see getFreeP2PPort), so it can't lose + // its port to an ephemeral socket and abort the whole suite before any HA node is created. Set + // the broadcast port explicitly to the same value: discv5 otherwise defaults p2pBroadcastPort to + // p2pPort by mutating this config object in place, and that mutated value would then leak into the + // HA nodes' configs below (built by spreading `config`), making them advertise the wrong port. + p2pPort: bootstrapP2PPort, + p2pBroadcastPort: bootstrapP2PPort, + // Enable slashing for testing governance + slashing vote coordination + slasherEnabled: true, + slashingRoundSizeInEpochs: 1, // 32 slots (1 epoch) + slashingQuorum: 17, // >50% of 32 slots for tally quorum, + }, + { syncChainTip: 'proven' }, + )); + + this.ownerAddress = this.accounts[0]; + this.testContract = await registerTestContract(this.wallet); + + if (!this.dateProvider) { + throw new Error('dateProvider must be provided by setup for HA tests'); + } + + this.logger.info( + 'Bootstrap node setup complete; funded initializerless account and test contract registered locally', + ); + + // Get bootstrap node's P2P ENR for HA nodes to connect to + const bootstrapNodeEnr = await this.aztecNode.getEncodedEnr(); + if (!bootstrapNodeEnr) { + throw new Error('Failed to get bootstrap node ENR - P2P may not be enabled'); + } + this.logger.info(`Bootstrap node ENR: ${bootstrapNodeEnr}`); + + // L1 contract wrappers for querying votes + this.governanceProposer = new GovernanceProposerContract( + this.deployL1ContractsValues.l1Client, + this.deployL1ContractsValues.l1ContractAddresses.governanceProposerAddress.toString(), + ); + this.logger.info('L1 contract wrappers initialized'); + + this.haNodeServices = []; + this.haKeystoreDirs = []; + this.logger.info(`Starting ${NODE_COUNT} HA peer nodes...`); + + // Per-node keystore: all attesters but only this node's publisher to avoid nonce conflicts. + // When keyStoreDirectory is set the node loads validators/publishers from file only, so we omit them from config. + this.initialKeystoreJsons = []; + + for (let i = 0; i < NODE_COUNT; i++) { + const nodeId = `${this.databaseConfig.nodeId}-${i + 1}`; + this.logger.info(`Starting HA peer node ${i} with nodeId: ${nodeId}`); + + const keystoreContent = { + schemaVersion: 1, + validators: [ + { + attester: this.attesterAddresses, + feeRecipient: AztecAddress.ZERO.toString(), + coinbase: EthAddress.fromString(this.attesterAddresses[0]).toChecksumString(), + remoteSigner: this.web3SignerUrl, + publisher: [this.publisherAddresses[i]], + }, + ], + }; + const keystoreJson = JSON.stringify(keystoreContent, null, 2); + this.initialKeystoreJsons.push(keystoreJson); + + const keystoreDir = await mkdtemp(join(tmpdir(), `ha-keystore-${i}-`)); + this.haKeystoreDirs.push(keystoreDir); + await writeFile(join(keystoreDir, 'keystore.json'), keystoreJson); + + const dataDirectory = this.config.dataDirectory ? `${this.config.dataDirectory}-${i}` : undefined; + + const nodeP2PPort = await getFreeP2PPort(); + const nodeConfig: AztecNodeConfig = { + ...this.config, + nodeId, + keyStoreDirectory: keystoreDir, + // Ensure txs are included in proposals to test full signing path + publishTxsWithProposals: true, + dataDirectory, + databaseUrl: this.databaseConfig.databaseUrl, + pollingIntervalMs: this.databaseConfig.pollingIntervalMs, + signingTimeoutMs: this.databaseConfig.signingTimeoutMs, + maxStuckDutiesAgeMs: this.databaseConfig.maxStuckDutiesAgeMs, + haSigningEnabled: true, + disableValidator: false, + // Enable P2P for transaction and block gossip + p2pEnabled: true, + // Each HA node gets its own free port above the ephemeral range. Override the broadcast port too: + // `...config` carries the bootstrap node's broadcast port (discv5 sets it in place), which would + // otherwise make every HA node advertise the bootstrap's port instead of its own. + p2pPort: nodeP2PPort, + p2pBroadcastPort: nodeP2PPort, + // Connect to bootstrap node for tx gossip + bootstrapNodes: [bootstrapNodeEnr], + web3SignerUrl: this.web3SignerUrl, + }; + + const nodeService = await withLoggerBindings({ actor: `HA-${i}` }, async () => { + return await createAztecNodeService( + nodeConfig, + { dateProvider: this.dateProvider }, + { genesis: this.genesis, dontStartSequencer: true }, + ); + }); + + this.haNodeServices.push(nodeService); + this.logger.info(`HA peer node ${i} started successfully`); + } + + this.logger.info(`All ${NODE_COUNT} HA peer nodes started and coordinating via PostgreSQL database`); + this.logger.info('Waiting for HA peer nodes to join the tx gossip mesh'); + await retryUntil( + async () => { + const meshStates = await Promise.all( + this.haNodeServices.map(async (service, nodeIndex) => { + const p2p = service.getP2P(); + const [peers, txMeshPeerCount] = await Promise.all([ + p2p.getPeers(), + p2p.getGossipMeshPeerCount(TopicType.tx), + ]); + + return { nodeIndex, peerCount: peers.length, txMeshPeerCount }; + }), + ); + + this.logger.debug('HA tx gossip mesh status', { meshStates }); + return meshStates.every(({ peerCount, txMeshPeerCount }) => peerCount > 0 && txMeshPeerCount > 0) + ? true + : undefined; + }, + 'HA tx gossip mesh readiness', + 60, + 1, + ); + + // The owner is an initializerless account, so it needs no deployment tx -- it was funded at genesis + // and registered during setup, and is ready to transact as soon as the HA nodes start building blocks. + this.logger.info(`Test account ready at ${this.ownerAddress}`); + } + + async teardown(): Promise { + // Stop all sequencers before tearing down the nodes: a sequencer stop awaits its in-flight + // iteration, which can spend tens of seconds finishing a vote or checkpoint publish on L1. + // Stops must be awaited fully — jest runs without forceExit, so a node abandoned mid-stop + // outlives the test environment and keeps the worker process alive until the CI job timeout. + // The dateProvider reset must wait until nodes are stopped: it rewinds the shared clock from + // chain time to wall time (minutes apart after the automine deploy burst), and any publisher + // deadline armed against the rewound clock would block shutdown until wall time catches up. + if (this.haNodeServices) { + await Promise.allSettled( + this.haNodeServices.map(async (service, i) => { + try { + await service.getSequencer()?.stop(); + } catch (error) { + this.logger.error(`Failed to stop sequencer of HA peer node ${i}: ${error}`); + } + }), + ); + await Promise.allSettled( + this.haNodeServices.map((_, i) => + this.stopHANode(i).catch(error => { + this.logger.error(`Failed to stop HA peer node ${i}: ${error}`); + }), + ), + ); + } + + this.dateProvider?.reset(); + + // Cleanup HA keystore temp directories + if (this.haKeystoreDirs) { + for (let i = 0; i < this.haKeystoreDirs.length; i++) { + try { + await rm(this.haKeystoreDirs[i], { recursive: true }); + } catch (error) { + this.logger.error(`Failed to remove HA keystore dir ${i}: ${error}`); + } + } + } + + // Cleanup HA resources (database pools, etc.) + if (this.haNodePools) { + for (const pool of this.haNodePools) { + try { + await pool.end(); + } catch (error) { + this.logger.error(`Failed to close HA node pool: ${error}`); + } + } + } + await cleanupHADatabase(this.mainPool, this.logger); + await this.mainPool.end(); + + // Cleanup bootstrap node and test infrastructure (this cleans up the shared data directory) + await this.teardownBootstrap(); + } + + /** Clean up database state between tests. */ + async resetDutiesTable(): Promise { + try { + await this.mainPool.query('DELETE FROM validator_duties'); + } catch (error) { + // Ignore cleanup errors (table might not exist on first run failure) + this.logger?.warn(`Failed to clean up validator_duties: ${error}`); + } + } +} diff --git a/yarn-project/end-to-end/src/composed/integration_proof_verification.test.ts b/yarn-project/end-to-end/src/composed/integration_proof_verification.test.ts index f682cba794fc..64008d29bf9c 100644 --- a/yarn-project/end-to-end/src/composed/integration_proof_verification.test.ts +++ b/yarn-project/end-to-end/src/composed/integration_proof_verification.test.ts @@ -26,7 +26,14 @@ import { getLogger, startAnvil } from '../fixtures/utils.js'; */ // Standalone Honk proof verifier integration test. Starts its own anvil, deploys a HonkVerifier contract, // loads a serialised RootRollupPublicInputs fixture, and verifies the proof on-chain via BBCircuitVerifier. -// No Aztec node. Excluded from compose glob; requires a pre-generated proof fixture (AZTEC_GENERATE_TEST_DATA). +// No Aztec node. +// +// EXCLUDED from every CI test list (see bootstrap.sh) and does NOT run anywhere. The committed +// fixtures/dumps/epoch_proof_result.json is stale: it was last regenerated in Feb 2026, but the rollup +// circuits and verification key have changed since, so bb and the on-chain HonkVerifier both reject the +// proof ("Failed to verify RootRollupArtifact proof!"). Re-enabling it needs the fixture regenerated +// against the current circuits (see the command above) and is better relocated alongside the bb-prover +// circuit tests than kept here. describe('proof_verification', () => { let proof: Proof; let publicInputs: RootRollupPublicInputs; diff --git a/yarn-project/end-to-end/src/fixtures/setup.ts b/yarn-project/end-to-end/src/fixtures/setup.ts index 5c61aaba40dd..c96708cba2b2 100644 --- a/yarn-project/end-to-end/src/fixtures/setup.ts +++ b/yarn-project/end-to-end/src/fixtures/setup.ts @@ -541,8 +541,7 @@ async function setupInner( } if (enableAutomine) { - await ethCheatCodes.setAutomine(false); - await ethCheatCodes.setIntervalMining(config.ethereumSlotDuration); + await ethCheatCodes.startIntervalMiningWithFreshBlock(config.ethereumSlotDuration); } // In compose mode (no local anvil), sync dateProvider to L1 time since it may have drifted diff --git a/yarn-project/end-to-end/src/multi-node/README.md b/yarn-project/end-to-end/src/multi-node/README.md index 0e11b755de28..9751a9856240 100644 --- a/yarn-project/end-to-end/src/multi-node/README.md +++ b/yarn-project/end-to-end/src/multi-node/README.md @@ -56,7 +56,7 @@ suffix marks files with more than one top-level `it`; CI splits each `it` into i | `recovery/` | `MultiNodeTestContext` + wide-slot / block-production timing | The chain detects a bad/withheld/conflicting proposal and recovers: `proposal_failure_recovery.parallel` (all nodes prune and recover when a proposer fails to publish to L1), `pipeline_prune` (an uncheckpointed-blocks prune under pipelined MBPS, then recovery to a multi-block checkpoint), `equivocation_recovery` (an L1-confirmed checkpoint overrides a gossip-only equivocating proposal, the chain heals, and observers record the offense). | | `invalid-attestations/` | `MultiNodeTestContext` (slasher on) | Invalid checkpoints are detected, invalidated on L1, and the chain progresses: `invalidate_block.parallel`, a six-validator suite injecting insufficient/fake/high-s/unrecoverable/shuffled attestations and withheld blobs. | | `high-availability/` | `MultiNodeTestContext` + `setupHaPairs` | HA-pair sync and handoff between nodes that share validator keys: `ha_sync` (a peer that did not build a block syncs to the proposed chain tip over P2P), `ha_checkpoint_handoff` (a peer records and takes over a pipelined checkpoint when its partner proposes the previous slot). | -| `slashing/` | `MultiNodeTestContext` + `SLASHER_ENABLED_MULTI_VALIDATOR_OPTS` (`setup.ts`) | Offense detection and the sentinel that drives it. Equivocation (`duplicate_proposal`, `duplicate_attestation`), broadcast of an invalid block or checkpoint proposal (`broadcasted_invalid_block_proposal_slash`, `broadcasted_invalid_checkpoint_proposal_slash.parallel`), attesting to an invalid checkpoint (`attested_invalid_proposal.parallel`), data withholding (`data_withholding_slash`), inactivity (`inactivity_slash`, `inactivity_slash_with_consecutive_epochs`, sharing the `InactivityTest` fixture in `inactivity_setup.ts`), the slasher veto path (`slash_veto_demo`), and sentinel observability (`validators_sentinel.parallel`, `multiple_validators_sentinel.parallel`, `sentinel_status_slash.parallel`). `setup.ts` holds the offense-detection config and the shared slashing waiters (`awaitCommitteeExists`, `awaitOffenseDetected`, `advanceToEpochBeforeProposer`, `findUpcomingProposerSlot`, `awaitCommitteeKicked`, `awaitProposalExecution`). | +| `slashing/` | `MultiNodeTestContext` + `SLASHER_ENABLED_MULTI_VALIDATOR_OPTS` (`setup.ts`) | Offense detection and the sentinel that drives it. Equivocation — both duplicate-proposal and duplicate-attestation — (`equivocation_offenses`), broadcast of an invalid block or checkpoint proposal (`broadcasted_invalid_block_proposal_slash`, `broadcasted_invalid_checkpoint_proposal_slash.parallel`), attesting to an invalid checkpoint (`attested_invalid_proposal.parallel`), data withholding (`data_withholding_slash`), inactivity (`inactivity_slash`, `inactivity_slash_with_consecutive_epochs`, sharing the `InactivityTest` fixture in `inactivity_setup.ts`), the slasher veto path (`slash_veto_demo`), and sentinel observability (`validators_sentinel.parallel`, `multiple_validators_sentinel.parallel`, `sentinel_status_slash.parallel`). `setup.ts` holds the two timing profiles (`baseSlashingOpts`, `SENTINEL_TIMING`), the shared offense lookup (`findSlashOffense`), and the slashing waiters (`awaitCommitteeExists`, `awaitOffenseDetected`, `advanceToEpochBeforeProposer`, `findUpcomingProposerSlot`, `awaitCommitteeKicked`, `awaitProposalExecution`). | | `governance/` | `MultiNodeTestContext` + `MOCK_GOSSIP_MULTI_VALIDATOR_OPTS` + `GOVERNANCE_TIMING` (`setup.ts`) | A validator committee drives an L1 governance upgrade. `upgrade_governance_proposer` (the committee signals a new governance-proposer payload, reaches quorum, and the proposal executes) and `add_rollup` (a new rollup version is registered and the nodes migrate to it, exercising L1↔L2 bridging on both rollups). | ## Helper surface diff --git a/yarn-project/end-to-end/src/multi-node/block-production/blob_promotion.test.ts b/yarn-project/end-to-end/src/multi-node/block-production/blob_promotion.test.ts index 10dc50a6e584..d2f5a935e767 100644 --- a/yarn-project/end-to-end/src/multi-node/block-production/blob_promotion.test.ts +++ b/yarn-project/end-to-end/src/multi-node/block-production/blob_promotion.test.ts @@ -1,20 +1,14 @@ -import type { Archiver } from '@aztec/archiver'; -import type { AztecNodeConfig } from '@aztec/aztec-node'; import { Fr } from '@aztec/aztec.js/fields'; import { waitForTx } from '@aztec/aztec.js/node'; -import { asyncMap } from '@aztec/foundation/async-map'; import { BlockNumber, CheckpointNumber } from '@aztec/foundation/branded-types'; import { retryUntil } from '@aztec/foundation/retry'; import { executeTimeout } from '@aztec/foundation/timer'; -import type { TestWallet } from '../../test-wallet/test_wallet.js'; import { proveAndSendTxs } from '../../test-wallet/utils.js'; -import { MultiNodeTestContext, buildMockGossipValidators } from '../multi_node_test_context.js'; import { type BlockProductionWithProverFixture, - NODE_COUNT, - WIDE_SLOT_TIMING, jest, + setupBlockProductionWithProver, waitForProvenCheckpoint, } from './setup.js'; @@ -36,57 +30,6 @@ describe('multi-node/block-production/blob_promotion', () => { await fixture?.test?.teardown(); }); - /** - * Sets up the pipelining wide-slot context: same timing profile as {@link setupBlockProductionWithProver} plus 500ms mock - * gossip latency, a tighter `maxTxsPerCheckpoint`, and node-0 with checkpoint promotion disabled so - * the blob-promotion behavior of the other nodes can be asserted against it. - */ - async function setupBlobPromotion(): Promise { - const validators = buildMockGossipValidators(NODE_COUNT); - - const test = await MultiNodeTestContext.setup({ - ...WIDE_SLOT_TIMING, - numberOfAccounts: 0, - initialValidators: validators, - mockGossipSubNetwork: true, - mockGossipSubNetworkLatency: 500, // adverse network conditions - startProverNode: true, - maxTxsPerCheckpoint: 24, - inboxLag: 2, - minTxsPerBlock: 1, - maxTxsPerBlock: PIPELINE_MAX_TXS_PER_BLOCK, - pxeOpts: { syncChainTip: 'checkpointed' }, - skipInitialSequencer: true, - }); - - const { context, logger, rollup } = test; - const wallet = context.wallet as TestWallet; - const from = context.accounts[0]; // auto-created by setup - - logger.warn(`Initial setup complete. Starting ${NODE_COUNT} validator nodes.`); - // Clear inherited coinbase so each validator derives coinbase from its own attester key - const nodes = await asyncMap(validators, ({ privateKey }, i) => - test.createValidatorNode([privateKey], { - dontStartSequencer: true, - coinbase: undefined, - // Disable checkpoint promotion on the first node so it always fetches blobs, - // allowing us to assert that other nodes skip blob fetching via promotion. - ...(i === 0 ? { skipPromoteProposedCheckpointDuringL1Sync: true } : {}), - } as Partial), - ); - logger.warn(`Started ${NODE_COUNT} validator nodes.`, { validators: validators.map(v => v.attester.toString()) }); - - wallet.updateNode(nodes[0]); - const archiver = nodes[0].getBlockSource() as Archiver; - - const contract = await test.registerTestContract(wallet); - logger.warn(`Test setup completed.`, { validators: validators.map(v => v.attester.toString()) }); - - const { failEvents } = test.watchNodeSequencerEvents(nodes); - - return { test, context, logger, rollup, archiver, validators, nodes, contract, wallet, from, failEvents }; - } - /** * Waits until the archiver's checkpointed chain tip has reached `targetBlockNumber`, then retrieves all * checkpoints and returns the number of the first one with at least `targetBlockCount` blocks. Used to @@ -121,7 +64,18 @@ describe('multi-node/block-production/blob_promotion', () => { // mined, then verifies node-0 (promotion disabled) fetches blobs while nodes 1-3 (promotion enabled) // skip blob fetching entirely, and that a high-block-count checkpoint built under load still proves. it('promotion-disabled node fetches blobs while peers skip them, and the checkpoint proves', async () => { - fixture = await setupBlobPromotion(); + // Same wide-slot prover-backed cluster as the rest of this directory, plus adverse gossip latency, a + // tighter maxTxsPerCheckpoint, and node 0 with checkpoint promotion disabled so its blob-fetching can + // be contrasted with the promotion-enabled peers. + fixture = await setupBlockProductionWithProver({ + syncChainTip: 'checkpointed', + minTxsPerBlock: 1, + maxTxsPerBlock: PIPELINE_MAX_TXS_PER_BLOCK, + maxTxsPerCheckpoint: 24, + mockGossipSubNetworkLatency: 500, + clearInheritedCoinbase: true, + disableCheckpointPromotionOnFirstNode: true, + }); const { test, context, logger, nodes, contract, from } = fixture; // Spy on getBlobSidecar on all validator nodes before sequencers start, so we check that nodes diff --git a/yarn-project/end-to-end/src/multi-node/block-production/multi_validator_node.parallel.test.ts b/yarn-project/end-to-end/src/multi-node/block-production/multi_validator_node.parallel.test.ts index 7b2ce0b5150c..15084cbec107 100644 --- a/yarn-project/end-to-end/src/multi-node/block-production/multi_validator_node.parallel.test.ts +++ b/yarn-project/end-to-end/src/multi-node/block-production/multi_validator_node.parallel.test.ts @@ -17,6 +17,7 @@ import type { TestWallet } from '../../test-wallet/test_wallet.js'; import { MOCK_GOSSIP_MULTI_VALIDATOR_OPTS, MultiNodeTestContext, + NO_REORG_SUBMISSION_EPOCHS, buildMockGossipValidators, } from '../multi_node_test_context.js'; @@ -26,7 +27,7 @@ const COMMITTEE_SIZE = VALIDATOR_COUNT - 2; // Tests that a single AztecNodeService hosting multiple validator keys correctly signs attestations // and filters signing to only active committee members. One node, 5 validators staked, committee // size 3. Uses MultiNodeTestContext on the mock-gossip bus: all 5 validators on a single physical -// node, ethSlot=8s, aztecSlot=36s, epoch=2, proofSubEpochs=1024. Each it is an isolated CI job +// node, ethSlot=8s, aztecSlot=36s, epoch=2, proofSubEpochs=NO_REORG_SUBMISSION_EPOCHS. Each it is an isolated CI job // (parallel convention). describe('multi-node/block-production/multi_validator_node', () => { jest.setTimeout(15 * 60 * 1000); @@ -51,7 +52,7 @@ describe('multi-node/block-production/multi_validator_node', () => { aztecEpochDuration: 2, ethereumSlotDuration: 8, aztecSlotDuration: 36, - aztecProofSubmissionEpochs: 1024, + aztecProofSubmissionEpochs: NO_REORG_SUBMISSION_EPOCHS, anvilSlotsInAnEpoch: 4, blockDurationMs: 6000, minTxsPerBlock: 0, diff --git a/yarn-project/end-to-end/src/multi-node/block-production/setup.ts b/yarn-project/end-to-end/src/multi-node/block-production/setup.ts index d04826ca89cf..20a4bec1ca73 100644 --- a/yarn-project/end-to-end/src/multi-node/block-production/setup.ts +++ b/yarn-project/end-to-end/src/multi-node/block-production/setup.ts @@ -76,11 +76,14 @@ export type BlockProductionWithProverFixture = { failEvents: TrackedSequencerEvent[]; }; +/** Per-validator node config, or a function deriving it from the validator's 0-based index. */ +type ValidatorNodeOpts = Partial & { dontStartSequencer?: boolean }; + /** Shared spine: builds N mock-gossip validators, sets up the context, spawns one node per validator. */ async function buildValidatorCluster(opts: { nodeCount: number; setupOpts: Partial; - nodeOpts?: Partial & { dontStartSequencer?: boolean }; + nodeOpts?: ValidatorNodeOpts | ((index: number) => ValidatorNodeOpts); }): Promise { const validators = buildMockGossipValidators(opts.nodeCount); @@ -93,8 +96,11 @@ async function buildValidatorCluster(opts: { const from = context.accounts[0]; logger.warn(`Initial setup complete. Starting ${opts.nodeCount} validator nodes.`); - const nodes = await asyncMap(validators, ({ privateKey }) => - test.createValidatorNode([privateKey], { ...opts.nodeOpts }), + const nodes = await asyncMap(validators, ({ privateKey }, i) => + test.createValidatorNode( + [privateKey], + typeof opts.nodeOpts === 'function' ? opts.nodeOpts(i) : { ...opts.nodeOpts }, + ), ); logger.warn(`Started ${opts.nodeCount} validator nodes.`, { validators: validators.map(v => v.attester.toString()) }); @@ -124,16 +130,33 @@ export function setupSimpleBlockProduction(opts: { /** * Creates validators and sets up a wide-slot test context with the pipelining timing profile and a prover * node, then starts (paused) validator nodes and points the wallet at node 0. Mirrors the per-test - * setup from the dissolved `mbps.parallel` file. + * setup from the dissolved `mbps.parallel` file. The blob-promotion and pipeline-prune suites layer their + * adverse-network shape on via `mockGossipSubNetworkLatency`, `maxTxsPerCheckpoint`, `clearInheritedCoinbase`, + * and `disableCheckpointPromotionOnFirstNode`. */ export async function setupBlockProductionWithProver(opts: { syncChainTip: 'proposed' | 'checkpointed'; minTxsPerBlock?: number; maxTxsPerBlock?: number; + maxTxsPerCheckpoint?: number; buildCheckpointIfEmpty?: boolean; skipPushProposedBlocksToArchiver?: boolean; + /** Injects artificial mock-gossip propagation latency (ms) to model adverse network conditions. */ + mockGossipSubNetworkLatency?: number; + /** Clears each validator node's inherited coinbase so it derives one from its own attester key. */ + clearInheritedCoinbase?: boolean; + /** + * Disables checkpoint promotion on node 0 (`skipPromoteProposedCheckpointDuringL1Sync`), so node 0 fetches + * blobs during L1 sync while its peers promote their own proposed checkpoints and skip the blob fetch. + */ + disableCheckpointPromotionOnFirstNode?: boolean; }): Promise { - const { syncChainTip = 'checkpointed', ...setupOpts } = opts; + const { + syncChainTip = 'checkpointed', + clearInheritedCoinbase = false, + disableCheckpointPromotionOnFirstNode = false, + ...setupOpts + } = opts; // WIDE_SLOT_TIMING is the wide 72s/12s pipelining cadence (see A-914 on why the tighter 36s/4s breaks // non-proposer nodes); the JSDoc on the profile carries the full rationale. @@ -149,7 +172,13 @@ export async function setupBlockProductionWithProver(opts: { skipInitialSequencer: true, inboxLag: 2, }, - nodeOpts: { dontStartSequencer: true }, + nodeOpts: (index: number) => ({ + dontStartSequencer: true, + ...(clearInheritedCoinbase ? { coinbase: undefined } : {}), + ...(disableCheckpointPromotionOnFirstNode && index === 0 + ? { skipPromoteProposedCheckpointDuringL1Sync: true } + : {}), + }), }); const { rollup } = test; diff --git a/yarn-project/end-to-end/src/multi-node/governance/add_rollup.test.ts b/yarn-project/end-to-end/src/multi-node/governance/add_rollup.test.ts index 21272c08e4a7..a81fff2c73f8 100644 --- a/yarn-project/end-to-end/src/multi-node/governance/add_rollup.test.ts +++ b/yarn-project/end-to-end/src/multi-node/governance/add_rollup.test.ts @@ -6,7 +6,7 @@ import { generateClaimSecret } from '@aztec/aztec.js/ethereum'; import { Fr } from '@aztec/aztec.js/fields'; import { waitForL1ToL2MessageReady } from '@aztec/aztec.js/messaging'; import { RollupCheatCodes } from '@aztec/aztec/testing'; -import { FeeAssetHandlerContract, RegistryContract, RollupContract } from '@aztec/ethereum/contracts'; +import { FeeAssetHandlerContract, RegistryContract } from '@aztec/ethereum/contracts'; import { deployRollupForUpgrade } from '@aztec/ethereum/deploy-aztec-l1-contracts'; import { deployL1Contract } from '@aztec/ethereum/deploy-l1-contract'; import type { L1ContractAddresses } from '@aztec/ethereum/l1-contract-addresses'; @@ -17,7 +17,6 @@ import { retryUntil } from '@aztec/foundation/retry'; import { sleep } from '@aztec/foundation/sleep'; import { GovernanceAbi, - GovernanceProposerAbi, OutboxAbi, RegisterNewRollupVersionPayloadAbi, RegisterNewRollupVersionPayloadBytecode, @@ -42,7 +41,7 @@ import { MultiNodeTestContext, buildMockGossipValidators, } from '../multi_node_test_context.js'; -import { GOVERNANCE_TIMING, jest } from './setup.js'; +import { GOVERNANCE_TIMING, createGovernanceTestDriver, driveGovernanceRound, jest } from './setup.js'; // Don't set this to a higher value than 9 because each node will use a different L1 publisher account and anvil seeds const NUM_VALIDATORS = 4; @@ -112,44 +111,16 @@ describe('multi-node/governance/add_rollup', () => { it('Should cast votes to add new rollup to registry', async () => { const { context, logger } = test; + const driver = await createGovernanceTestDriver(test, l1TxUtils); + const { governance, governanceProposer, rollup } = driver; + const registry = getContract({ address: getAddress(context.deployL1ContractsValues.l1ContractAddresses.registryAddress.toString()), abi: RegistryAbi, client: context.deployL1ContractsValues.l1Client, }); - const governanceProposer = getContract({ - address: getAddress(context.deployL1ContractsValues.l1ContractAddresses.governanceProposerAddress.toString()), - abi: GovernanceProposerAbi, - client: context.deployL1ContractsValues.l1Client, - }); - - const roundSize = await governanceProposer.read.ROUND_SIZE(); - - const governance = getContract({ - address: getAddress(context.deployL1ContractsValues.l1ContractAddresses.governanceAddress.toString()), - abi: GovernanceAbi, - client: context.deployL1ContractsValues.l1Client, - }); - - const rollup = new RollupContract( - context.deployL1ContractsValues.l1Client, - context.deployL1ContractsValues.l1ContractAddresses.rollupAddress, - ); - - const emperor = context.deployL1ContractsValues.l1Client.account; - - const waitL1Block = async () => { - await l1TxUtils.sendAndMonitorTransaction({ - to: emperor.address, - value: 1n, - }); - }; - - const currentSlot = await rollup.getSlotNumber(); - const nextRoundSlot = SlotNumber.fromBigInt((BigInt(currentSlot) / roundSize) * roundSize + roundSize); - const nextRoundTimestamp = await rollup.getTimestampForSlot(nextRoundSlot); - await context.cheatCodes.eth.warp(Number(nextRoundTimestamp)); + await driver.warpToNextRound(); // Build the new rollup's genesis from the same funded-account set the context used (the additionally // funded accounts plus the sponsored FPC), so the second bridging step can fund `fundedAccounts[1]`. @@ -225,27 +196,7 @@ describe('multi-node/governance/add_rollup', () => { [context.deployL1ContractsValues.l1ContractAddresses.registryAddress.toString(), newRollup.address], ); - const govInfo = async () => { - const bn = await context.cheatCodes.eth.blockNumber(); - const slot = await rollup.getSlotNumber(); - const round = await governanceProposer.read.computeRound([BigInt(slot)]); - - const info = await governanceProposer.read.getRoundData([ - context.deployL1ContractsValues.l1ContractAddresses.rollupAddress.toString(), - round, - ]); - const leaderVotes = await governanceProposer.read.signalCount([ - context.deployL1ContractsValues.l1ContractAddresses.rollupAddress.toString(), - round, - info.payloadWithMostSignals, - ]); - logger.info( - `Governance stats for round ${round} (Slot: ${slot}, BN: ${bn}). Leader: ${info.payloadWithMostSignals} have ${leaderVotes} signals`, - ); - return { bn, slot, round, info, leaderVotes }; - }; - - await waitL1Block(); + await driver.waitL1Block(); logger.info('Creating nodes'); nodes = await Promise.all( @@ -262,7 +213,7 @@ describe('multi-node/governance/add_rollup', () => { logger.info('Start progressing time to cast votes'); const quorumSize = await governanceProposer.read.QUORUM_SIZE(); - logger.info(`Quorum size: ${quorumSize}, round size: ${await governanceProposer.read.ROUND_SIZE()}`); + logger.info(`Quorum size: ${quorumSize}, round size: ${driver.roundSize}`); const bridging = async ( node: AztecNodeService, @@ -429,56 +380,9 @@ describe('multi-node/governance/add_rollup', () => { context.aztecNodeConfig.l1RpcUrls, ); - // Poll once per L2 slot for the round leader to reach quorum, since validators signal once per slot - const govData = await retryUntil( - async () => { - const govData = await govInfo(); - if (govData.leaderVotes >= quorumSize) { - return govData; - } - }, - 'governance leader reaches quorum', - 600, - context.aztecNodeConfig.aztecSlotDuration, - ); - - const currentSlot2 = await rollup.getSlotNumber(); - const nextRoundSlot2 = SlotNumber.fromBigInt((BigInt(currentSlot2) / roundSize) * roundSize + roundSize); - const nextRoundTimestamp2 = await rollup.getTimestampForSlot(nextRoundSlot2); - logger.info(`Warpping to ${nextRoundTimestamp2}`); - await context.cheatCodes.eth.warp(Number(nextRoundTimestamp2)); - - await waitL1Block(); - - logger.info(`Executing proposal ${govData.round}`); - - await l1TxUtils.sendAndMonitorTransaction({ - to: governanceProposer.address, - data: encodeFunctionData({ - abi: GovernanceProposerAbi, - functionName: 'submitRoundWinner', - args: [govData.round], - }), - }); - logger.info(`Submitted winner for round ${govData.round}`); - - const proposal = await governance.read.getProposal([0n]); - - const timeToActive = proposal.creation + proposal.config.votingDelay; - logger.info(`Warping to ${timeToActive + 1n}`); - await context.cheatCodes.eth.warp(Number(timeToActive + 1n)); - logger.info(`Warped to ${timeToActive + 1n}`); - await waitL1Block(); - - logger.info(`Voting`); - await rollup.vote(l1TxUtils, 0n); - logger.info(`Voted`); - - const timeToExecutable = timeToActive + proposal.config.votingDuration + proposal.config.executionDelay + 1n; - logger.info(`Warping to ${timeToExecutable}`); - await context.cheatCodes.eth.warp(Number(timeToExecutable)); - logger.info(`Warped to ${timeToExecutable}`); - await waitL1Block(); + // Drive the signaled payload to quorum and vote it through to an executable proposal. The 600s quorum + // timeout covers the many slots validators need to accumulate signals while bridging runs concurrently. + await driveGovernanceRound(driver, { quorumTimeoutSeconds: 600 }); const canonicalBefore = EthAddress.fromString(await registry.read.getCanonicalRollup()); expect(canonicalBefore.equals(EthAddress.fromString(rollup.address))).toBe(true); @@ -534,7 +438,7 @@ describe('multi-node/governance/add_rollup', () => { const time = await newRollup.getTimestampForSlot(futureSlot); if (time > BigInt(await context.cheatCodes.eth.lastBlockTimestamp())) { await context.cheatCodes.eth.warp(Number(time)); - await waitL1Block(); + await driver.waitL1Block(); } const newVersion = await newRollup.getVersion(); diff --git a/yarn-project/end-to-end/src/multi-node/governance/setup.ts b/yarn-project/end-to-end/src/multi-node/governance/setup.ts index 186cdb7874a7..1f6124909054 100644 --- a/yarn-project/end-to-end/src/multi-node/governance/setup.ts +++ b/yarn-project/end-to-end/src/multi-node/governance/setup.ts @@ -1,4 +1,14 @@ +import { RollupContract } from '@aztec/ethereum/contracts'; +import type { L1TxUtils } from '@aztec/ethereum/l1-tx-utils'; +import type { ExtendedViemWalletClient } from '@aztec/ethereum/types'; +import { SlotNumber } from '@aztec/foundation/branded-types'; +import { retryUntil } from '@aztec/foundation/retry'; +import { GovernanceAbi, GovernanceProposerAbi } from '@aztec/l1-artifacts'; + import { jest } from '@jest/globals'; +import { type GetContractReturnType, encodeFunctionData, getAddress, getContract } from 'viem'; + +import type { MultiNodeTestContext } from '../multi_node_test_context.js'; jest.setTimeout(1000 * 60 * 10); @@ -14,4 +24,149 @@ export const GOVERNANCE_TIMING = { aztecProofSubmissionEpochs: 640, } as const; +/** The current round leader's payload and its signal count, as observed by {@link GovernanceTestDriver.govInfo}. */ +type GovRoundState = { round: bigint; leaderVotes: bigint }; + +/** + * Wraps the L1 governance-proposer / governance / rollup contracts and the round-driving mechanics shared by + * the governance suites (`add_rollup`, `upgrade_governance_proposer`): warping to round boundaries, polling + * round state, waiting for signal quorum, submitting the round winner, and voting a proposal through to + * executable. Scenario-specific payload construction, node signaling, and post-execution assertions stay in + * each test. + */ +export interface GovernanceTestDriver { + governanceProposer: GetContractReturnType; + governance: GetContractReturnType; + rollup: RollupContract; + roundSize: bigint; + emperor: ExtendedViemWalletClient['account']; + /** Sends a 1-wei self-transfer to mine a fresh L1 block, so warped time takes effect. */ + waitL1Block: () => Promise; + /** Reads the current round's leading payload and its signal count. */ + govInfo: () => Promise; + /** Warps L1 to the start of the next governance-proposer round. */ + warpToNextRound: () => Promise; + /** Polls once per L2 slot until the round leader reaches quorum, returning the round state at that point. */ + waitForQuorum: (quorumSize: bigint, timeoutSeconds: number) => Promise; + /** Submits the winning payload of `round` to the governance proposer, creating the on-chain proposal. */ + submitRoundWinner: (round: bigint) => Promise; + /** + * Warps past the proposal's voting delay, casts a yes vote (asserting it lands), then warps past the + * voting duration and execution delay so the proposal becomes executable. + */ + voteToExecutable: () => Promise; +} + +/** Builds a {@link GovernanceTestDriver} over the context's L1 governance contracts. */ +export async function createGovernanceTestDriver( + test: MultiNodeTestContext, + l1TxUtils: L1TxUtils, +): Promise { + const { l1Client, l1ContractAddresses } = test.context.deployL1ContractsValues; + const rollupAddress = l1ContractAddresses.rollupAddress.toString(); + + const governanceProposer = getContract({ + address: getAddress(l1ContractAddresses.governanceProposerAddress.toString()), + abi: GovernanceProposerAbi, + client: l1Client, + }); + const governance = getContract({ + address: getAddress(l1ContractAddresses.governanceAddress.toString()), + abi: GovernanceAbi, + client: l1Client, + }); + const rollup = new RollupContract(l1Client, l1ContractAddresses.rollupAddress); + const roundSize = await governanceProposer.read.ROUND_SIZE(); + const emperor = l1Client.account; + + const waitL1Block = async () => { + await l1TxUtils.sendAndMonitorTransaction({ to: emperor.address, value: 1n }); + }; + + const govInfo = async (): Promise => { + const bn = await test.context.cheatCodes.eth.blockNumber(); + const slot = await rollup.getSlotNumber(); + const round = await governanceProposer.read.computeRound([BigInt(slot)]); + const info = await governanceProposer.read.getRoundData([rollupAddress, round]); + const leaderVotes = await governanceProposer.read.signalCount([rollupAddress, round, info.payloadWithMostSignals]); + test.logger.info( + `Governance stats for round ${round} (Slot: ${slot}, BN: ${bn}). Leader: ${info.payloadWithMostSignals} have ${leaderVotes} signals`, + ); + return { round, leaderVotes }; + }; + + const warpToNextRound = async () => { + const currentSlot = await rollup.getSlotNumber(); + const nextRoundSlot = SlotNumber.fromBigInt((BigInt(currentSlot) / roundSize) * roundSize + roundSize); + const nextRoundTimestamp = await rollup.getTimestampForSlot(nextRoundSlot); + await test.context.cheatCodes.eth.warp(Number(nextRoundTimestamp)); + }; + + const waitForQuorum = (quorumSize: bigint, timeoutSeconds: number) => + retryUntil( + async () => { + const data = await govInfo(); + return data.leaderVotes >= quorumSize ? data : undefined; + }, + 'governance leader reaches quorum', + timeoutSeconds, + GOVERNANCE_TIMING.aztecSlotDuration, + ); + + const submitRoundWinner = async (round: bigint) => { + await l1TxUtils.sendAndMonitorTransaction({ + to: governanceProposer.address, + data: encodeFunctionData({ abi: GovernanceProposerAbi, functionName: 'submitRoundWinner', args: [round] }), + }); + }; + + const voteToExecutable = async () => { + const proposal = await governance.read.getProposal([0n]); + + const timeToActive = proposal.creation + proposal.config.votingDelay; + await test.context.cheatCodes.eth.warp(Number(timeToActive + 1n)); + await waitL1Block(); + + const voteTx = await rollup.vote(l1TxUtils, 0n); + expect(voteTx.receipt?.status).toBe('success'); + + const timeToExecutable = timeToActive + proposal.config.votingDuration + proposal.config.executionDelay + 1n; + await test.context.cheatCodes.eth.warp(Number(timeToExecutable)); + await waitL1Block(); + }; + + return { + governanceProposer, + governance, + rollup, + roundSize, + emperor, + waitL1Block, + govInfo, + warpToNextRound, + waitForQuorum, + submitRoundWinner, + voteToExecutable, + }; +} + +/** + * Drives one governance round to an executable proposal: waits for the signaled payload to reach quorum, + * warps to the next round, submits the round winner, then votes the resulting proposal through its voting + * and execution delays. Returns the round state observed at quorum; the caller performs the scenario-specific + * execution and assertions. + */ +export async function driveGovernanceRound( + driver: GovernanceTestDriver, + opts: { quorumTimeoutSeconds: number }, +): Promise<{ govData: GovRoundState }> { + const quorumSize = await driver.governanceProposer.read.QUORUM_SIZE(); + const govData = await driver.waitForQuorum(quorumSize, opts.quorumTimeoutSeconds); + await driver.warpToNextRound(); + await driver.waitL1Block(); + await driver.submitRoundWinner(govData.round); + await driver.voteToExecutable(); + return { govData }; +} + export { jest }; diff --git a/yarn-project/end-to-end/src/multi-node/governance/upgrade_governance_proposer.test.ts b/yarn-project/end-to-end/src/multi-node/governance/upgrade_governance_proposer.test.ts index 59bbcc337c55..36a6dd07bf23 100644 --- a/yarn-project/end-to-end/src/multi-node/governance/upgrade_governance_proposer.test.ts +++ b/yarn-project/end-to-end/src/multi-node/governance/upgrade_governance_proposer.test.ts @@ -1,24 +1,16 @@ -import { RollupContract } from '@aztec/ethereum/contracts'; import { deployL1Contract } from '@aztec/ethereum/deploy-l1-contract'; import { L1TxUtils, createL1TxUtils } from '@aztec/ethereum/l1-tx-utils'; -import { SlotNumber } from '@aztec/foundation/branded-types'; -import { retryUntil } from '@aztec/foundation/retry'; import { sleep } from '@aztec/foundation/sleep'; -import { - GovernanceAbi, - GovernanceProposerAbi, - NewGovernanceProposerPayloadAbi, - NewGovernanceProposerPayloadBytecode, -} from '@aztec/l1-artifacts'; +import { NewGovernanceProposerPayloadAbi, NewGovernanceProposerPayloadBytecode } from '@aztec/l1-artifacts'; -import { encodeFunctionData, getAddress, getContract } from 'viem'; +import { getAddress } from 'viem'; import { MOCK_GOSSIP_MULTI_VALIDATOR_OPTS, MultiNodeTestContext, buildMockGossipValidators, } from '../multi_node_test_context.js'; -import { GOVERNANCE_TIMING, jest } from './setup.js'; +import { GOVERNANCE_TIMING, createGovernanceTestDriver, driveGovernanceRound, jest } from './setup.js'; // Don't set this to a higher value than 9 because each node will use a different L1 publisher account and anvil seeds const NUM_VALIDATORS = 4; @@ -62,40 +54,12 @@ describe('multi-node/governance/upgrade_governance_proposer', () => { // warps past round boundary, submits the round winner, then drives the full governance lifecycle // (vote, execution delay, execute). Asserts the governance contract's governanceProposer changes. it('should cast votes to upgrade governanceProposer', async () => { - const governanceProposer = getContract({ - address: getAddress( - test.context.deployL1ContractsValues.l1ContractAddresses.governanceProposerAddress.toString(), - ), - abi: GovernanceProposerAbi, - client: test.context.deployL1ContractsValues.l1Client, - }); - - const roundSize = await governanceProposer.read.ROUND_SIZE(); - - const governance = getContract({ - address: getAddress(test.context.deployL1ContractsValues.l1ContractAddresses.governanceAddress.toString()), - abi: GovernanceAbi, - client: test.context.deployL1ContractsValues.l1Client, - }); - - const rollup = new RollupContract( - test.context.deployL1ContractsValues.l1Client, - test.context.deployL1ContractsValues.l1ContractAddresses.rollupAddress, - ); + const driver = await createGovernanceTestDriver(test, l1TxUtils); + const { governance, rollup } = driver; const gseAddress = await rollup.getGSE(); - const waitL1Block = async () => { - await l1TxUtils.sendAndMonitorTransaction({ - to: emperor.address, - value: 1n, - }); - }; - - const currentSlot = await rollup.getSlotNumber(); - const nextRoundSlot = SlotNumber.fromBigInt((BigInt(currentSlot) / roundSize) * roundSize + roundSize); - const nextRoundTimestamp = await rollup.getTimestampForSlot(nextRoundSlot); - await test.context.cheatCodes.eth.warp(Number(nextRoundTimestamp)); + await driver.warpToNextRound(); const { address: newPayloadAddress } = await deployL1Contract( test.context.deployL1ContractsValues.l1Client, @@ -103,34 +67,11 @@ describe('multi-node/governance/upgrade_governance_proposer', () => { NewGovernanceProposerPayloadBytecode, [test.context.deployL1ContractsValues.l1ContractAddresses.registryAddress.toString(), gseAddress.toString()], ); - test.logger.info(`Deployed new payload at ${newPayloadAddress}`); - const emperor = test.context.deployL1ContractsValues.l1Client.account; - - const govInfo = async () => { - const bn = await test.context.cheatCodes.eth.blockNumber(); - const slot = await rollup.getSlotNumber(); - const round = await governanceProposer.read.computeRound([BigInt(slot)]); - - const info = await governanceProposer.read.getRoundData([ - test.context.deployL1ContractsValues.l1ContractAddresses.rollupAddress.toString(), - round, - ]); - const leaderVotes = await governanceProposer.read.signalCount([ - test.context.deployL1ContractsValues.l1ContractAddresses.rollupAddress.toString(), - round, - info.payloadWithMostSignals, - ]); - test.logger.info( - `Governance stats for round ${round} (Slot: ${slot}, BN: ${bn}). Leader: ${info.payloadWithMostSignals} have ${leaderVotes} signals`, - ); - return { bn, slot, round, info, leaderVotes }; - }; - - await waitL1Block(); + await driver.waitL1Block(); - const govBefore = await govInfo(); + const govBefore = await driver.govInfo(); test.logger.info('Creating nodes'); // Nodes are torn down by test.teardown(); they only need to be running to signal the payload. @@ -143,78 +84,29 @@ describe('multi-node/governance/upgrade_governance_proposer', () => { await sleep(4000); test.logger.info('Start progressing time to cast signals'); - const quorumSize = await governanceProposer.read.QUORUM_SIZE(); - test.logger.info(`Quorum size: ${quorumSize}, round size: ${await governanceProposer.read.ROUND_SIZE()}`); - - const govData = await retryUntil( - async () => { - const data = await govInfo(); - return data.leaderVotes >= quorumSize ? data : undefined; - }, - 'quorum of signals', - Number(quorumSize) * GOVERNANCE_TIMING.aztecSlotDuration * 3, - GOVERNANCE_TIMING.aztecSlotDuration, - ); - - expect(govData.leaderVotes).toBeGreaterThan(govBefore.leaderVotes); - - const currentSlot2 = await rollup.getSlotNumber(); - const nextRoundSlot2 = SlotNumber.fromBigInt((BigInt(currentSlot2) / roundSize) * roundSize + roundSize); - const nextRoundTimestamp2 = await rollup.getTimestampForSlot(nextRoundSlot2); - test.logger.info(`Warping to ${nextRoundTimestamp2}`); - await test.context.cheatCodes.eth.warp(Number(nextRoundTimestamp2)); + const quorumSize = await driver.governanceProposer.read.QUORUM_SIZE(); + test.logger.info(`Quorum size: ${quorumSize}, round size: ${driver.roundSize}`); - await waitL1Block(); - - test.logger.info(`Submitting winner of round ${govData.round}`); - - await l1TxUtils.sendAndMonitorTransaction({ - to: governanceProposer.address, - data: encodeFunctionData({ - abi: GovernanceProposerAbi, - functionName: 'submitRoundWinner', - args: [govData.round], - }), + const { govData } = await driveGovernanceRound(driver, { + quorumTimeoutSeconds: Number(quorumSize) * GOVERNANCE_TIMING.aztecSlotDuration * 3, }); - test.logger.info(`Submitted winner of round ${govData.round}`); - - const proposal = await governance.read.getProposal([0n]); - - const timeToActive = proposal.creation + proposal.config.votingDelay; - test.logger.info(`Warping to ${timeToActive + 1n}`); - await test.context.cheatCodes.eth.warp(Number(timeToActive + 1n)); - test.logger.info(`Warped to ${timeToActive + 1n}`); - await waitL1Block(); - - test.logger.info(`Voting`); - const voteTx = await rollup.vote(l1TxUtils, 0n); - expect(voteTx.receipt?.status).toBe('success'); - test.logger.info(`Voted`); - - const proposalState = await governance.read.getProposal([0n]); - test.logger.info(`Proposal state`, proposalState); + expect(govData.leaderVotes).toBeGreaterThan(govBefore.leaderVotes); - const timeToExecutable = timeToActive + proposal.config.votingDuration + proposal.config.executionDelay + 1n; - test.logger.info(`Warping to ${timeToExecutable}`); - await test.context.cheatCodes.eth.warp(Number(timeToExecutable)); - test.logger.info(`Warped to ${timeToExecutable}`); - await waitL1Block(); + const governanceProposerAddress = getAddress( + test.context.deployL1ContractsValues.l1ContractAddresses.governanceProposerAddress.toString(), + ); test.logger.info(`Checking governance proposer`); - expect(await governance.read.governanceProposer()).toEqual( - getAddress(test.context.deployL1ContractsValues.l1ContractAddresses.governanceProposerAddress.toString()), - ); + expect(await governance.read.governanceProposer()).toEqual(governanceProposerAddress); test.logger.info(`Governance proposer is correct`); test.logger.info(`Executing proposal`); - const executeTx = await governance.write.execute([0n], { account: emperor }); + const executeTx = await governance.write.execute([0n], { account: driver.emperor }); await test.context.deployL1ContractsValues.l1Client.waitForTransactionReceipt({ hash: executeTx }); test.logger.info(`Executed proposal`); const newGovernanceProposer = await governance.read.governanceProposer(); - expect(newGovernanceProposer).not.toEqual( - getAddress(test.context.deployL1ContractsValues.l1ContractAddresses.governanceProposerAddress.toString()), - ); + expect(newGovernanceProposer).not.toEqual(governanceProposerAddress); expect(await governance.read.getProposalState([0n])).toEqual(5); test.logger.info(`Governance proposer is correct`); }, 1_000_000); diff --git a/yarn-project/end-to-end/src/multi-node/multi_node_test_context.ts b/yarn-project/end-to-end/src/multi-node/multi_node_test_context.ts index 26839b029986..79679f9ec698 100644 --- a/yarn-project/end-to-end/src/multi-node/multi_node_test_context.ts +++ b/yarn-project/end-to-end/src/multi-node/multi_node_test_context.ts @@ -21,12 +21,14 @@ import { privateKeyToAccount } from 'viem/accounts'; import { testSpan } from '../fixtures/timing.js'; import { getPrivateKeyFromIndex } from '../fixtures/utils.js'; +import { NO_REORG_SUBMISSION_EPOCHS } from '../single-node/setup.js'; import { SingleNodeTestContext, type SingleNodeTestOpts, type TrackedSequencerEvent, } from '../single-node/single_node_test_context.js'; +export { NO_REORG_SUBMISSION_EPOCHS, PROVING_SLOT_TIMING } from '../single-node/setup.js'; export { WORLD_STATE_CHECKPOINT_HISTORY, WORLD_STATE_BLOCK_CHECK_INTERVAL, @@ -73,7 +75,7 @@ export const MOCK_GOSSIP_MULTI_VALIDATOR_OPTS = { mockGossipSubNetwork: true, skipInitialSequencer: true, startProverNode: false, - aztecProofSubmissionEpochs: 1024, + aztecProofSubmissionEpochs: NO_REORG_SUBMISSION_EPOCHS, numberOfAccounts: 0, } as const; diff --git a/yarn-project/end-to-end/src/multi-node/recovery/pipeline_prune.test.ts b/yarn-project/end-to-end/src/multi-node/recovery/pipeline_prune.test.ts index 296aa8e06537..63308a4154f6 100644 --- a/yarn-project/end-to-end/src/multi-node/recovery/pipeline_prune.test.ts +++ b/yarn-project/end-to-end/src/multi-node/recovery/pipeline_prune.test.ts @@ -1,33 +1,23 @@ -import type { Archiver } from '@aztec/archiver'; -import type { AztecNodeService } from '@aztec/aztec-node'; -import type { AztecAddress, EthAddress } from '@aztec/aztec.js/addresses'; +import type { EthAddress } from '@aztec/aztec.js/addresses'; import { Fr } from '@aztec/aztec.js/fields'; -import type { Logger } from '@aztec/aztec.js/log'; import { waitForTx } from '@aztec/aztec.js/node'; import type { EpochCacheInterface } from '@aztec/epoch-cache'; -import { asyncMap } from '@aztec/foundation/async-map'; import { BlockNumber, CheckpointNumber, SlotNumber } from '@aztec/foundation/branded-types'; import { executeTimeout } from '@aztec/foundation/timer'; -import type { TestContract } from '@aztec/noir-test-contracts.js/Test'; import type { SequencerEvents } from '@aztec/sequencer-client'; import { L2BlockSourceEvents } from '@aztec/stdlib/block'; +import { getTimestampForSlot } from '@aztec/stdlib/epoch-helpers'; -import { jest } from '@jest/globals'; - -import type { EndToEndContext } from '../../fixtures/utils.js'; -import type { TestWallet } from '../../test-wallet/test_wallet.js'; import { proveAndSendTxs } from '../../test-wallet/utils.js'; import { + type BlockProductionWithProverFixture, type BlockProposedEvent, - MultiNodeTestContext, - type RegisteredValidator, - WIDE_SLOT_TIMING, - buildMockGossipValidators, -} from '../multi_node_test_context.js'; + jest, + setupBlockProductionWithProver, +} from '../block-production/setup.js'; jest.setTimeout(1000 * 60 * 20); -const NODE_COUNT = 4; const EXPECTED_BLOCKS_PER_CHECKPOINT = 8; // Send enough transactions to trigger multiple blocks within a checkpoint assuming 2 txs per block. @@ -45,68 +35,11 @@ const TX_COUNT = 34; * with mockGossipSubNetwork and no initial sequencer. */ describe('multi-node/recovery/pipeline_prune', () => { - let context: EndToEndContext; - let logger: Logger; - let archiver: Archiver; - - let test: MultiNodeTestContext; - let validators: RegisteredValidator[]; - let nodes: AztecNodeService[]; - let contract: TestContract; - let wallet: TestWallet; - let from: AztecAddress; - - /** Creates validators and sets up the test context with MBPS and proposer pipelining. */ - async function setupTest(opts: { - syncChainTip: 'proposed' | 'checkpointed'; - minTxsPerBlock?: number; - maxTxsPerBlock?: number; - }) { - const { syncChainTip = 'checkpointed', ...setupOpts } = opts; - - validators = buildMockGossipValidators(NODE_COUNT); - - test = await MultiNodeTestContext.setup({ - ...WIDE_SLOT_TIMING, - numberOfAccounts: 0, - initialValidators: validators, - mockGossipSubNetwork: true, - mockGossipSubNetworkLatency: 500, // adverse network conditions - startProverNode: true, - maxTxsPerCheckpoint: 24, - inboxLag: 2, - ...setupOpts, - pxeOpts: { syncChainTip }, - skipInitialSequencer: true, - }); - - ({ context, logger } = test); - wallet = context.wallet as TestWallet; - from = context.accounts[0]; // auto-created by setup - - logger.warn(`Initial setup complete. Starting ${NODE_COUNT} validator nodes.`); - // Clear inherited coinbase so each validator derives coinbase from its own attester key - nodes = await asyncMap(validators, ({ privateKey }, i) => - test.createValidatorNode([privateKey], { - dontStartSequencer: true, - coinbase: undefined, - // Disable checkpoint promotion on the first node so it always fetches blobs, - // allowing us to assert that other nodes skip blob fetching via promotion. - ...(i === 0 ? { skipPromoteProposedCheckpointDuringL1Sync: true } : {}), - }), - ); - logger.warn(`Started ${NODE_COUNT} validator nodes.`, { validators: validators.map(v => v.attester.toString()) }); - - wallet.updateNode(nodes[0]); - archiver = nodes[0].getBlockSource() as Archiver; - - contract = await test.registerTestContract(wallet); - logger.warn(`Test setup completed.`, { validators: validators.map(v => v.attester.toString()) }); - } + let fixture: BlockProductionWithProverFixture; afterEach(async () => { jest.restoreAllMocks(); - await test?.teardown(); + await fixture?.test?.teardown(); }); // Establishes a baseline at checkpoint 1. Identifies the next proposer and disables its @@ -114,7 +47,18 @@ describe('multi-node/recovery/pipeline_prune', () => { // re-enables publishing. Waits for all txs to be mined, asserts a MBPS checkpoint exists, // verifies the pipelining offset, and checks recovery blockNumber > baseline. it('prunes uncheckpointed blocks when proposer fails to deliver', async () => { - await setupTest({ syncChainTip: 'checkpointed', minTxsPerBlock: 1, maxTxsPerBlock: 2 }); + // Same wide-slot prover-backed cluster as block-production, under adverse gossip latency with node 0's + // checkpoint promotion disabled (see setupBlockProductionWithProver). + fixture = await setupBlockProductionWithProver({ + syncChainTip: 'checkpointed', + minTxsPerBlock: 1, + maxTxsPerBlock: 2, + maxTxsPerCheckpoint: 24, + mockGossipSubNetworkLatency: 500, + clearInheritedCoinbase: true, + disableCheckpointPromotionOnFirstNode: true, + }); + const { test, context, logger, archiver, validators, nodes, contract, from } = fixture; const blockProposedEvents: BlockProposedEvent[] = []; const sequencers = test.getSequencers(nodes); @@ -159,6 +103,29 @@ describe('multi-node/recovery/pipeline_prune', () => { // The sequencer keeps building blocks and broadcasting via P2P, but won't submit the checkpoint to L1 targetSequencer.updateConfig({ skipPublishingCheckpointsPercent: 100 }); + // Wait for the orphan blocks to actually exist before warping: the target proposer builds them during + // slot proposerSlotToNotPublish - 1 and broadcasts them via P2P carrying that submission slot, but never + // publishes the enclosing checkpoint. Only once node[0]'s archiver holds them as a proposed (uncheckpointed) + // tip is there anything for pruneOrphanProposedBlocks to prune — warping before they arrive would fire no prune. + await test.waitForAllNodesToReachBlockAtSlot( + proposerSlotToNotPublish, + 'proposed', + block => block.header.globalVariables.slotNumber >= proposerSlotToNotPublish, + { nodes: [nodes[0]], timeout: test.L2_SLOT_DURATION_IN_S * 3 }, + ); + logger.warn(`Orphan blocks for slot ${proposerSlotToNotPublish} are present; warping past the prune deadline`); + + // Collapse the ~2-minute dead gap where the chain just waits for the L1 clock to roll past the orphan + // slot's checkpoint-proposal-received deadline so pruneOrphanProposedBlocks fires. The archiver reads the + // shared TestDateProvider that eth.warp advances, so jumping the clock into the slot after the orphan one + // takes us safely past that deadline and the next archiver sync prunes. The sequencers are kept stopped + // (restart: false) until the prune is confirmed, so no proposer builds against the still-unpruned tip; + // they are restarted for recovery below. + const pruneWarpTarget = + getTimestampForSlot(SlotNumber(proposerSlotToNotPublish + 1), test.constants) + + BigInt(2 * test.constants.ethereumSlotDuration); + await test.warpWithSequencersPaused(nodes, test.context.cheatCodes, pruneWarpTarget, { restart: false }); + const pruneTimeout = test.L2_SLOT_DURATION_IN_S * 5 * 1000; logger.warn(`Waiting for uncheckpointed blocks to be pruned (timeout=${pruneTimeout}ms)`); await executeTimeout(() => prunePromise, pruneTimeout); @@ -176,9 +143,13 @@ describe('multi-node/recovery/pipeline_prune', () => { } logger.warn(`Pruning detected, block number now ${await archiver.getBlockNumber()}`); - // Re-enable checkpoint publishing + // Re-enable checkpoint publishing, then restart the sequencers to build the recovery checkpoint. + // Restarting only now (after the prune and after listeners are attached) keeps recovery from racing the + // prune and ensures every recovery block is captured for the pipelining assertion. logger.warn(`Re-enabling checkpoint publishing for validator ${proposerIndex}`); targetSequencer.updateConfig({ skipPublishingCheckpointsPercent: 0 }); + await test.startSequencers(nodes); + logger.warn(`Restarted all sequencers for recovery`); // Wait for a new checkpoint (recovery) - where all txs end up mined const timeout = test.L2_SLOT_DURATION_IN_S * 5; diff --git a/yarn-project/end-to-end/src/multi-node/slashing/attested_invalid_proposal.parallel.test.ts b/yarn-project/end-to-end/src/multi-node/slashing/attested_invalid_proposal.parallel.test.ts index b20424935ce8..cacd2fa10fe1 100644 --- a/yarn-project/end-to-end/src/multi-node/slashing/attested_invalid_proposal.parallel.test.ts +++ b/yarn-project/end-to-end/src/multi-node/slashing/attested_invalid_proposal.parallel.test.ts @@ -25,7 +25,7 @@ import { SLASHER_ENABLED_MULTI_VALIDATOR_OPTS, buildMockGossipValidators, } from '../multi_node_test_context.js'; -import { awaitCommitteeExists } from './setup.js'; +import { SENTINEL_TIMING, awaitCommitteeExists, findSlashOffense } from './setup.js'; const TEST_TIMEOUT = 1_000_000; @@ -33,7 +33,8 @@ jest.setTimeout(TEST_TIMEOUT); const NUM_VALIDATORS = 3; const COMMITTEE_SIZE = NUM_VALIDATORS; -const ETHEREUM_SLOT_DURATION = 4; +// Long 36s L2 slots (vs SENTINEL_TIMING's 8s): the bad proposer serializes an AVM-heavy 3-block +// checkpoint, which must fit inside a single slot for honest receivers to accept and attest to it. const AZTEC_SLOT_DURATION = 36; const BLOCK_DURATION_MS = 8_000; const BLOCKS_PER_CHECKPOINT = 3; @@ -44,16 +45,6 @@ const OFFENSE_DETECTION_TIMEOUT = AZTEC_SLOT_DURATION * 3; const INVALID_BLOCK_REMOVAL_TIMEOUT = AZTEC_SLOT_DURATION * 3; type BlockProposedEvent = Parameters[0]; -type SlashOffense = Awaited>[number]; - -function findSlashOffense(offenses: SlashOffense[], validator: EthAddress, offenseType: OffenseType, slot: SlotNumber) { - return offenses.find( - offense => - offense.validator.equals(validator) && - offense.offenseType === offenseType && - offense.epochOrSlot === BigInt(slot), - ); -} // Validators are keyed from `getPrivateKeyFromIndex(i + 3)` (the `buildMockGossipValidators` convention), // so the signer for validator `index` is derived from the same key its node signs proposals with. @@ -160,11 +151,9 @@ describe('multi-node/slashing/attested_invalid_proposal', () => { beforeEach(async () => { test = await MultiNodeTestContext.setup({ ...SLASHER_ENABLED_MULTI_VALIDATOR_OPTS, - anvilSlotsInAnEpoch: 4, - listenAddress: '127.0.0.1', - aztecEpochDuration: 2, - ethereumSlotDuration: ETHEREUM_SLOT_DURATION, - aztecSlotDuration: AZTEC_SLOT_DURATION, + ...SENTINEL_TIMING, + sentinelEnabled: false, // reuse only the fast base timing; this test does not use the sentinel + aztecSlotDuration: AZTEC_SLOT_DURATION, // override SENTINEL_TIMING's 8s slot with 36s (see const above) aztecTargetCommitteeSize: COMMITTEE_SIZE, aztecProofSubmissionEpochs: 1024, slashInactivityConsecutiveEpochThreshold: 32, diff --git a/yarn-project/end-to-end/src/multi-node/slashing/broadcasted_invalid_block_proposal_slash.test.ts b/yarn-project/end-to-end/src/multi-node/slashing/broadcasted_invalid_block_proposal_slash.test.ts index 1667279cfe5c..d1876afe573e 100644 --- a/yarn-project/end-to-end/src/multi-node/slashing/broadcasted_invalid_block_proposal_slash.test.ts +++ b/yarn-project/end-to-end/src/multi-node/slashing/broadcasted_invalid_block_proposal_slash.test.ts @@ -11,18 +11,15 @@ import { SLASHER_ENABLED_MULTI_VALIDATOR_OPTS, buildMockGossipValidators, } from '../multi_node_test_context.js'; -import { advanceToEpochBeforeProposer, awaitCommitteeExists } from './setup.js'; +import { SENTINEL_TIMING, advanceToEpochBeforeProposer, awaitCommitteeExists } from './setup.js'; const NUM_VALIDATORS = 4; const COMMITTEE_SIZE = NUM_VALIDATORS; -const ETHEREUM_SLOT_DURATION = 4; -const AZTEC_SLOT_DURATION = ETHEREUM_SLOT_DURATION * 2; -const BLOCK_DURATION_MS = 2000; +const AZTEC_SLOT_DURATION = SENTINEL_TIMING.aztecSlotDuration; const slashingUnit = BigInt(1e18); const slashingQuorum = 3; const slashingRoundSize = 4; -const aztecEpochDuration = 2; /** * Test that slashing occurs when a validator broadcasts an invalid block proposal. @@ -48,19 +45,16 @@ describe('multi-node/slashing/broadcasted_invalid_block_proposal_slash', () => { beforeEach(async () => { test = await MultiNodeTestContext.setup({ ...SLASHER_ENABLED_MULTI_VALIDATOR_OPTS, - anvilSlotsInAnEpoch: 4, - listenAddress: '127.0.0.1', - aztecEpochDuration, - ethereumSlotDuration: ETHEREUM_SLOT_DURATION, - aztecSlotDuration: AZTEC_SLOT_DURATION, - blockDurationMs: BLOCK_DURATION_MS, + ...SENTINEL_TIMING, + sentinelEnabled: false, // reuse only the fast 8s-slot timing; this test does not use the sentinel + blockDurationMs: 2000, aztecTargetCommitteeSize: COMMITTEE_SIZE, inboxLag: 2, aztecProofSubmissionEpochs: 1024, // effectively do not reorg slashInactivityConsecutiveEpochThreshold: 32, // effectively do not slash for inactivity minTxsPerBlock: 0, // always be building slashingQuorum, - slashingRoundSizeInEpochs: slashingRoundSize / aztecEpochDuration, + slashingRoundSizeInEpochs: slashingRoundSize / SENTINEL_TIMING.aztecEpochDuration, slashAmountSmall: slashingUnit, slashAmountMedium: slashingUnit * 2n, slashAmountLarge: slashingUnit * 3n, diff --git a/yarn-project/end-to-end/src/multi-node/slashing/broadcasted_invalid_checkpoint_proposal_slash.parallel.test.ts b/yarn-project/end-to-end/src/multi-node/slashing/broadcasted_invalid_checkpoint_proposal_slash.parallel.test.ts index 21acb78f0d7f..b207200e5a0a 100644 --- a/yarn-project/end-to-end/src/multi-node/slashing/broadcasted_invalid_checkpoint_proposal_slash.parallel.test.ts +++ b/yarn-project/end-to-end/src/multi-node/slashing/broadcasted_invalid_checkpoint_proposal_slash.parallel.test.ts @@ -1,5 +1,6 @@ import type { AztecNodeService } from '@aztec/aztec-node'; import type { TestAztecNodeService } from '@aztec/aztec-node/test'; +import type { EthAddress } from '@aztec/aztec.js/addresses'; import { Fr } from '@aztec/aztec.js/fields'; import { BlockNumber, EpochNumber, IndexWithinCheckpoint, SlotNumber } from '@aztec/foundation/branded-types'; import { Buffer32 } from '@aztec/foundation/buffer'; @@ -24,7 +25,13 @@ import { SLASHER_ENABLED_MULTI_VALIDATOR_OPTS, buildMockGossipValidators, } from '../multi_node_test_context.js'; -import { advanceToEpochBeforeProposer, awaitCommitteeExists } from './setup.js'; +import { + SENTINEL_TIMING, + type SlashOffense, + advanceToEpochBeforeProposer, + awaitCommitteeExists, + findSlashOffense, +} from './setup.js'; const TEST_TIMEOUT = 1_000_000; @@ -32,17 +39,12 @@ jest.setTimeout(TEST_TIMEOUT); const NUM_VALIDATORS = 2; const COMMITTEE_SIZE = NUM_VALIDATORS; -const ETHEREUM_SLOT_DURATION = 4; -const AZTEC_EPOCH_DURATION = 2; -const AZTEC_SLOT_DURATION = ETHEREUM_SLOT_DURATION * AZTEC_EPOCH_DURATION; -const BLOCK_DURATION_MS = 2000; +const AZTEC_SLOT_DURATION = SENTINEL_TIMING.aztecSlotDuration; const SLASHING_QUORUM = 5; const SLASHING_ROUND_SIZE = 8; const TERMINAL_BLOCK_INDEX = IndexWithinCheckpoint(1); const HIGHER_BLOCK_INDEX = IndexWithinCheckpoint(2); -type SlashOffense = Awaited>[number]; - // Validators are keyed from `getPrivateKeyFromIndex(i + 3)` (the `buildMockGossipValidators` convention), // so the signer for validator `index` is derived from the same key its node signs proposals with. function getAttesterSigner(validatorIndex: number) { @@ -52,15 +54,10 @@ function getAttesterSigner(validatorIndex: number) { function findBroadcastedInvalidCheckpointOffense( offenses: SlashOffense[], - validator: string, + validator: EthAddress, slot: SlotNumber, ): SlashOffense | undefined { - return offenses.find( - offense => - offense.validator.toString() === validator && - offense.offenseType === OffenseType.BROADCASTED_INVALID_CHECKPOINT_PROPOSAL && - offense.epochOrSlot === BigInt(slot), - ); + return findSlashOffense(offenses, validator, OffenseType.BROADCASTED_INVALID_CHECKPOINT_PROPOSAL, slot); } async function awaitBroadcastedInvalidCheckpointOffense({ @@ -69,7 +66,7 @@ async function awaitBroadcastedInvalidCheckpointOffense({ slot, }: { node: AztecNodeService; - validator: string; + validator: EthAddress; slot: SlotNumber; }) { return await retryUntil( @@ -88,14 +85,14 @@ async function awaitAnyBroadcastedInvalidCheckpointOffense({ validator, }: { nodes: AztecNodeService[]; - validator: string; + validator: EthAddress; }) { return await retryUntil( async () => { const offenses = (await Promise.all(nodes.map(node => node.getSlashOffenses('all')))).flat(); const matchingOffenses = offenses.filter( offense => - offense.validator.toString() === validator && + offense.validator.equals(validator) && offense.offenseType === OffenseType.BROADCASTED_INVALID_CHECKPOINT_PROPOSAL, ); return matchingOffenses.length > 0 ? matchingOffenses : undefined; @@ -112,7 +109,7 @@ async function expectNoBroadcastedInvalidCheckpointOffense({ slot, }: { node: AztecNodeService; - validator: string; + validator: EthAddress; slot: SlotNumber; }) { // The watcher polls every second with this test's slot timing; wait long enough @@ -237,18 +234,15 @@ describe('multi-node/slashing/broadcasted_invalid_checkpoint_proposal_slash', () beforeEach(async () => { test = await MultiNodeTestContext.setup({ ...SLASHER_ENABLED_MULTI_VALIDATOR_OPTS, - anvilSlotsInAnEpoch: 4, - listenAddress: '127.0.0.1', - aztecEpochDuration: AZTEC_EPOCH_DURATION, - ethereumSlotDuration: ETHEREUM_SLOT_DURATION, - aztecSlotDuration: AZTEC_SLOT_DURATION, - blockDurationMs: BLOCK_DURATION_MS, + ...SENTINEL_TIMING, + sentinelEnabled: false, // reuse only the fast 8s-slot timing; this test does not use the sentinel + blockDurationMs: 2000, aztecTargetCommitteeSize: COMMITTEE_SIZE, aztecProofSubmissionEpochs: 1024, minTxsPerBlock: 0, inboxLag: 2, slashingQuorum: SLASHING_QUORUM, - slashingRoundSizeInEpochs: SLASHING_ROUND_SIZE / AZTEC_EPOCH_DURATION, + slashingRoundSizeInEpochs: SLASHING_ROUND_SIZE / SENTINEL_TIMING.aztecEpochDuration, slashAmountSmall: slashingUnit, slashAmountMedium: slashingUnit * 2n, slashAmountLarge: slashingUnit * 3n, @@ -292,7 +286,7 @@ describe('multi-node/slashing/broadcasted_invalid_checkpoint_proposal_slash', () expect(currentSlot).toBeGreaterThan(2); const signer = getAttesterSigner(0); - const validator = test.addressAt(0).toString(); + const validator = test.addressAt(0); const signatureContext: CoordinationSignatureContext = { chainId: test.context.config.l1ChainId, rollupAddress: test.context.deployL1ContractsValues.l1ContractAddresses.rollupAddress, @@ -327,11 +321,11 @@ describe('multi-node/slashing/broadcasted_invalid_checkpoint_proposal_slash', () checkpointCount: 1, }); expect(firstProposals.blockProposals.map(proposal => proposal.getSender()?.toString())).toEqual([ - validator, - validator, - validator, + validator.toString(), + validator.toString(), + validator.toString(), ]); - expect(firstProposals.checkpointProposals[0].getSender()?.toString()).toEqual(validator); + expect(firstProposals.checkpointProposals[0].getSender()?.toString()).toEqual(validator.toString()); const firstOffense = await awaitBroadcastedInvalidCheckpointOffense({ node, @@ -387,7 +381,7 @@ describe('multi-node/slashing/broadcasted_invalid_checkpoint_proposal_slash', () const offenses = await awaitAnyBroadcastedInvalidCheckpointOffense({ nodes: honestNodes, - validator: invalidProposer.toString(), + validator: invalidProposer, }); test.logger.warn(`Collected broadcasted invalid checkpoint proposal offenses`, { offenses }); @@ -424,15 +418,15 @@ describe('multi-node/slashing/broadcasted_invalid_checkpoint_proposal_slash', () checkpointCount: 1, }); expect(validProposals.blockProposals.map(proposal => proposal.getSender()?.toString())).toEqual([ - validator, - validator, + validator.toString(), + validator.toString(), ]); const terminalProposal = validProposals.blockProposals.find( proposal => proposal.indexWithinCheckpoint === TERMINAL_BLOCK_INDEX, ); expect(terminalProposal?.archive.toString()).toEqual(lateHigherBlockProposals.terminalBlock.archive.toString()); - expect(terminalProposal?.getSender()?.toString()).toEqual(validator); - expect(validProposals.checkpointProposals[0].getSender()?.toString()).toEqual(validator); + expect(terminalProposal?.getSender()?.toString()).toEqual(validator.toString()); + expect(validProposals.checkpointProposals[0].getSender()?.toString()).toEqual(validator.toString()); await expectNoBroadcastedInvalidCheckpointOffense({ node, validator, slot: targetSlot }); await node.getP2P().broadcastProposal(lateHigherBlockProposals.higherBlock); @@ -444,9 +438,9 @@ describe('multi-node/slashing/broadcasted_invalid_checkpoint_proposal_slash', () checkpointCount: 1, }); expect(invalidProposals.blockProposals.map(proposal => proposal.getSender()?.toString())).toEqual([ - validator, - validator, - validator, + validator.toString(), + validator.toString(), + validator.toString(), ]); const offense = await awaitBroadcastedInvalidCheckpointOffense({ diff --git a/yarn-project/end-to-end/src/multi-node/slashing/data_withholding_slash.test.ts b/yarn-project/end-to-end/src/multi-node/slashing/data_withholding_slash.test.ts index edfbd4a37c1a..ea146ea02982 100644 --- a/yarn-project/end-to-end/src/multi-node/slashing/data_withholding_slash.test.ts +++ b/yarn-project/end-to-end/src/multi-node/slashing/data_withholding_slash.test.ts @@ -12,15 +12,22 @@ import { SLASHER_ENABLED_MULTI_VALIDATOR_OPTS, buildMockGossipValidators, } from '../multi_node_test_context.js'; -import { awaitCommitteeExists, awaitOffenseDetected, getMinedSlot, submitTxsThroughNode } from './setup.js'; +import { + SENTINEL_TIMING, + awaitCommitteeExists, + awaitOffenseDetected, + getMinedSlot, + submitTxsThroughNode, +} from './setup.js'; const TEST_TIMEOUT = 1_000_000; jest.setTimeout(TEST_TIMEOUT); const NUM_VALIDATORS = 4; const COMMITTEE_SIZE = NUM_VALIDATORS; -const ETHEREUM_SLOT_DURATION = 4; -const AZTEC_SLOT_DURATION = ETHEREUM_SLOT_DURATION * 3; +// Longer 12s L2 slots (3 eth-slots) than SENTINEL_TIMING's default 8s, giving the data-withholding +// tolerance window room to settle before the watchers probe their mempools. +const AZTEC_SLOT_DURATION = SENTINEL_TIMING.ethereumSlotDuration * 3; const TOLERANCE_SLOTS = 3; /** @@ -62,17 +69,15 @@ describe('multi-node/slashing/data_withholding_slash', () => { // (~23% probability). Extending slashOffenseExpirationRounds gives us several rounds to // hit quorum before the offense expires. const slashingRoundSize = 4; - const aztecEpochDuration = 2; + const aztecEpochDuration = SENTINEL_TIMING.aztecEpochDuration; const slashingAmount = slashingUnit * 3n; beforeEach(async () => { test = await MultiNodeTestContext.setup({ ...SLASHER_ENABLED_MULTI_VALIDATOR_OPTS, - anvilSlotsInAnEpoch: 4, - listenAddress: '127.0.0.1', - aztecEpochDuration, - ethereumSlotDuration: ETHEREUM_SLOT_DURATION, - aztecSlotDuration: AZTEC_SLOT_DURATION, + ...SENTINEL_TIMING, + sentinelEnabled: false, // reuse only the fast base timing; this test does not use the sentinel + aztecSlotDuration: AZTEC_SLOT_DURATION, // override SENTINEL_TIMING's 8s slot with 12s (see const above) aztecTargetCommitteeSize: COMMITTEE_SIZE, // Long proof submission window so the legacy L1-prune path is irrelevant. aztecProofSubmissionEpochs: 1024, diff --git a/yarn-project/end-to-end/src/multi-node/slashing/duplicate_attestation.test.ts b/yarn-project/end-to-end/src/multi-node/slashing/duplicate_attestation.test.ts deleted file mode 100644 index 3bc75c05ee37..000000000000 --- a/yarn-project/end-to-end/src/multi-node/slashing/duplicate_attestation.test.ts +++ /dev/null @@ -1,197 +0,0 @@ -import type { AztecNodeService } from '@aztec/aztec-node'; -import type { TestAztecNodeService } from '@aztec/aztec-node/test'; -import { EthAddress } from '@aztec/aztec.js/addresses'; -import { EpochNumber, SlotNumber } from '@aztec/foundation/branded-types'; -import { OffenseType } from '@aztec/slasher'; - -import { - MultiNodeTestContext, - SLASHER_ENABLED_MULTI_VALIDATOR_OPTS, - buildMockGossipValidators, -} from '../multi_node_test_context.js'; -import { - AZTEC_SLOT_DURATION, - NUM_VALIDATORS, - advanceToEpochBeforeProposer, - awaitCommitteeExists, - awaitOffenseDetected, - aztecEpochDuration, - baseSlashingOpts, - slashingRoundSize, - slashingUnit, -} from './setup.js'; - -/** - * Test that slashing occurs when a validator sends duplicate attestations (equivocation). - * - * The setup of the test is as follows: - * 1. Create 4 validator nodes total: - * - 2 honest validators with unique keys - * - 2 "malicious proposer" validators that share the SAME validator key but have DIFFERENT coinbase addresses - * (these will create duplicate proposals for the same slot) - * - The malicious proposer validators also have `attestToEquivocatedProposals: true` which makes them attest - * to BOTH proposals when they receive them - this is the attestation equivocation we want to detect - * 2. The two nodes with the same proposer key will both detect they are proposers for the same slot and race to propose - * 3. Since they have different coinbase addresses, their proposals will have different archives (different content) - * 4. The malicious attester nodes (with attestToEquivocatedProposals enabled) will attest to BOTH proposals - * 5. Honest validators will detect the duplicate attestations and emit a slash event - * - * NOTE: This test triggers BOTH duplicate proposal (from malicious proposers sharing a key) AND duplicate attestation - * (from the malicious proposers attesting to multiple proposals). We verify specifically that the duplicate - * attestation offense is recorded. - * - * Setup: MultiNodeTestContext on the in-memory mock-gossip bus (no real libp2p). 4 validators, ethSlot=8s, - * aztecSlot=24s, epoch=2, proofSubEpochs=1024, minTxsPerBlock=0, inboxLag=2 (v5 always enforces the timetable). - */ -describe('multi-node/slashing/duplicate_attestation', () => { - let test: MultiNodeTestContext; - let nodes: AztecNodeService[]; - - beforeEach(async () => { - test = await MultiNodeTestContext.setup({ - ...SLASHER_ENABLED_MULTI_VALIDATOR_OPTS, - ...baseSlashingOpts, - slashDuplicateAttestationPenalty: slashingUnit, - initialValidators: buildMockGossipValidators(NUM_VALIDATORS), - }); - }); - - afterEach(async () => { - await test.teardown(); - }); - - const cheatCodes = () => test.context.cheatCodes; - - const debugRollup = async () => { - await cheatCodes().rollup.debugRollup(); - }; - - // Two malicious nodes share a validator key and both attest to each other's proposals - // (attestToEquivocatedProposals:true). Honest nodes detect the DUPLICATE_ATTESTATION offense and verify - // the offending attester is the shared key's address. Also exercises DUPLICATE_PROPOSAL as a side effect - // but asserts specifically that DUPLICATE_ATTESTATION is recorded. - it('slashes validator who sends duplicate attestations', async () => { - const { rollup } = await test.getSlashingContracts(); - - // Jump forward to an epoch in the future such that the validator set is not empty - await cheatCodes().rollup.advanceToEpoch(EpochNumber(4)); - await debugRollup(); - - test.logger.warn('Creating nodes'); - - // Use validator index 0 for the "malicious" proposer validator key - const maliciousProposerIndex = 0; - const maliciousProposerAddress = test.addressAt(maliciousProposerIndex); - - test.logger.warn(`Malicious proposer address: ${maliciousProposerAddress.toString()}`); - - // Create two nodes with the SAME validator key but DIFFERENT coinbase addresses - // This will cause them to create proposals with different content for the same slot - // Additionally, enable attestToEquivocatedProposals so they will attest to BOTH proposals - const coinbase1 = EthAddress.random(); - const coinbase2 = EthAddress.random(); - - test.logger.warn(`Creating malicious proposer node 1 with coinbase ${coinbase1.toString()}`); - const maliciousNode1 = await test.createValidatorNodeAt(maliciousProposerIndex, { - coinbase: coinbase1, - attestToEquivocatedProposals: true, // Attest to all proposals - creates duplicate attestations - broadcastEquivocatedProposals: true, // Don't abort checkpoint building on duplicate block proposals - dontStartSequencer: true, - // Prevent HA peer proposals from being added to the archiver, so both - // malicious nodes build their own blocks instead of one yielding to the other. - skipPushProposedBlocksToArchiver: true, - }); - - test.logger.warn(`Creating malicious proposer node 2 with coinbase ${coinbase2.toString()}`); - const maliciousNode2 = await test.createValidatorNodeAt(maliciousProposerIndex, { - coinbase: coinbase2, - attestToEquivocatedProposals: true, // Attest to all proposals - creates duplicate attestations - broadcastEquivocatedProposals: true, // Don't abort checkpoint building on duplicate block proposals - dontStartSequencer: true, - // Prevent HA peer proposals from being added to the archiver, so both - // malicious nodes build their own blocks instead of one yielding to the other. - skipPushProposedBlocksToArchiver: true, - }); - - // Create honest nodes with unique validator keys (indices 1 and 2) - test.logger.warn('Creating honest nodes'); - const honestNode1 = await test.createValidatorNodeAt(1, { dontStartSequencer: true }); - const honestNode2 = await test.createValidatorNodeAt(2, { dontStartSequencer: true }); - - nodes = [maliciousNode1, maliciousNode2, honestNode1, honestNode2]; - - await awaitCommitteeExists({ rollup, logger: test.logger }); - - // Find an epoch where the malicious proposer is selected, stopping one epoch before - // so we have time to start sequencers before the target epoch arrives - const epochCache = (honestNode1 as TestAztecNodeService).epochCache; - const { targetEpoch, targetSlot } = await advanceToEpochBeforeProposer({ - epochCache, - cheatCodes: cheatCodes().rollup, - targetProposer: maliciousProposerAddress, - logger: test.logger, - }); - - // Start all sequencers while still one epoch before the target - test.logger.warn('Starting all sequencers'); - await Promise.all(nodes.map(n => n.getSequencer()!.start())); - - // Now warp to one slot before the target epoch — sequencers are already running. The helper - // picks a target slot at least one slot into the epoch, so warping here (rather than to the - // epoch start) leaves the freshly-started sequencers a full warm-up slot before the pipelined - // build for the malicious slot begins. Without that margin the duplicate proposals serialize - // past the slot boundary and receivers reject them as late, so the malicious nodes never get to - // attest to both and no duplicate attestation is produced. - test.logger.warn(`Advancing to one slot before target epoch ${targetEpoch} (target slot ${targetSlot})`); - await cheatCodes().rollup.advanceToEpoch(targetEpoch, { offset: -AZTEC_SLOT_DURATION }); - - // Wait for offenses to be detected - // We expect BOTH duplicate proposal AND duplicate attestation offenses - // The malicious proposer nodes create duplicate proposals (same key, different coinbase) - // The malicious proposer nodes also create duplicate attestations (attestToEquivocatedProposals enabled) - test.logger.warn('Waiting for duplicate attestation offense to be detected...'); - const offenses = await awaitOffenseDetected({ - epochDuration: aztecEpochDuration, - logger: test.logger, - nodeAdmin: honestNode1, // Use honest node to check for offenses - slashingRoundSize, - waitUntilOffenseCount: 2, // Wait for both duplicate proposal and duplicate attestation - timeoutSeconds: AZTEC_SLOT_DURATION * 16, - }); - - test.logger.warn(`Collected offenses`, { offenses }); - - // Verify we have detected the duplicate attestation offense - const duplicateAttestationOffenses = offenses.filter( - offense => offense.offenseType === OffenseType.DUPLICATE_ATTESTATION, - ); - const duplicateProposalOffenses = offenses.filter( - offense => offense.offenseType === OffenseType.DUPLICATE_PROPOSAL, - ); - - test.logger.info(`Found ${duplicateAttestationOffenses.length} duplicate attestation offenses`); - test.logger.info(`Found ${duplicateProposalOffenses.length} duplicate proposal offenses`); - - // We should have at least one duplicate attestation offense - expect(duplicateAttestationOffenses.length).toBeGreaterThan(0); - - // Verify the duplicate attestation offense is from the malicious proposer address - // (since they are the ones with attestToEquivocatedProposals enabled) - for (const offense of duplicateAttestationOffenses) { - expect(offense.offenseType).toEqual(OffenseType.DUPLICATE_ATTESTATION); - expect(offense.validator.toString()).toEqual(maliciousProposerAddress.toString()); - } - - // Verify that for each duplicate attestation offense, the attester for that slot is the malicious validator - for (const offense of duplicateAttestationOffenses) { - const offenseSlot = SlotNumber(Number(offense.epochOrSlot)); - const committeeInfo = await epochCache.getCommittee(offenseSlot); - test.logger.info( - `Offense slot ${offenseSlot}: committee includes attester ${maliciousProposerAddress.toString()}`, - ); - expect(committeeInfo.committee?.map(addr => addr.toString())).toContain(maliciousProposerAddress.toString()); - } - - test.logger.warn('Duplicate attestation offense correctly detected and recorded'); - }); -}); diff --git a/yarn-project/end-to-end/src/multi-node/slashing/duplicate_proposal.test.ts b/yarn-project/end-to-end/src/multi-node/slashing/duplicate_proposal.test.ts deleted file mode 100644 index 00def7f1eec0..000000000000 --- a/yarn-project/end-to-end/src/multi-node/slashing/duplicate_proposal.test.ts +++ /dev/null @@ -1,178 +0,0 @@ -import type { AztecNodeService } from '@aztec/aztec-node'; -import type { TestAztecNodeService } from '@aztec/aztec-node/test'; -import { EthAddress } from '@aztec/aztec.js/addresses'; -import { EpochNumber, SlotNumber } from '@aztec/foundation/branded-types'; -import { OffenseType } from '@aztec/slasher'; - -import { - MultiNodeTestContext, - SLASHER_ENABLED_MULTI_VALIDATOR_OPTS, - buildMockGossipValidators, -} from '../multi_node_test_context.js'; -import { - AZTEC_SLOT_DURATION, - NUM_VALIDATORS, - advanceToEpochBeforeProposer, - awaitCommitteeExists, - awaitOffenseDetected, - aztecEpochDuration, - baseSlashingOpts, - slashingRoundSize, -} from './setup.js'; - -/** - * Test that slashing occurs when a validator sends duplicate proposals (equivocation). - * - * The setup of the test is as follows: - * 1. Create 4 validator nodes total: - * - 2 honest validators with unique keys - * - 2 "malicious" validators that share the SAME validator key but have DIFFERENT coinbase addresses - * 2. The two nodes with the same key will both detect they are proposers for the same slot and naturally race to propose - * 3. Since they have different coinbase addresses, their proposals will have different archives (different content) - * 4. Other validators will detect the duplicate and emit a slash event - * - * Setup: MultiNodeTestContext on the in-memory mock-gossip bus (no real libp2p). 4 validators, ethSlot=8s, - * aztecSlot=24s, epoch=2, proofSubEpochs=1024, minTxsPerBlock=0, inboxLag=2 (v5 always enforces the timetable). - */ -describe('multi-node/slashing/duplicate_proposal', () => { - let test: MultiNodeTestContext; - let nodes: AztecNodeService[]; - - beforeEach(async () => { - test = await MultiNodeTestContext.setup({ - ...SLASHER_ENABLED_MULTI_VALIDATOR_OPTS, - ...baseSlashingOpts, - initialValidators: buildMockGossipValidators(NUM_VALIDATORS), - }); - }); - - afterEach(async () => { - await test.teardown(); - }); - - const cheatCodes = () => test.context.cheatCodes; - - const debugRollup = async () => { - await cheatCodes().rollup.debugRollup(); - }; - - // Two malicious nodes share a validator key but have different coinbase addresses so their proposals - // differ. Honest nodes receive both proposals via mock gossip, detect the equivocation, and record a - // DUPLICATE_PROPOSAL offense. The test collects offenses from all nodes (equivocation may only be - // observed by whichever node processed both proposals before the slot closed) and asserts the offense - // is attributed to the shared key's address. - it('slashes validator who sends duplicate proposals', async () => { - const { rollup } = await test.getSlashingContracts(); - - // Jump forward to an epoch in the future such that the validator set is not empty - await cheatCodes().rollup.advanceToEpoch(EpochNumber(4)); - await debugRollup(); - - test.logger.warn('Creating nodes'); - - // Use validator index 0 for the "malicious" validator key - const maliciousValidatorIndex = 0; - const maliciousValidatorAddress = test.addressAt(maliciousValidatorIndex); - - test.logger.warn(`Malicious proposer address: ${maliciousValidatorAddress.toString()}`); - - // Create two nodes with the SAME validator key but DIFFERENT coinbase addresses - // This will cause them to create proposals with different content for the same slot - const coinbase1 = EthAddress.random(); - const coinbase2 = EthAddress.random(); - - test.logger.warn(`Creating malicious node 1 with coinbase ${coinbase1.toString()}`); - const maliciousNode1 = await test.createValidatorNodeAt(maliciousValidatorIndex, { - coinbase: coinbase1, - broadcastEquivocatedProposals: true, - dontStartSequencer: true, - // Prevent HA peer proposals from being added to the archiver, so both - // malicious nodes build their own blocks instead of one yielding to the other. - skipPushProposedBlocksToArchiver: true, - }); - - test.logger.warn(`Creating malicious node 2 with coinbase ${coinbase2.toString()}`); - const maliciousNode2 = await test.createValidatorNodeAt(maliciousValidatorIndex, { - coinbase: coinbase2, - broadcastEquivocatedProposals: true, - dontStartSequencer: true, - // Prevent HA peer proposals from being added to the archiver, so both - // malicious nodes build their own blocks instead of one yielding to the other. - skipPushProposedBlocksToArchiver: true, - }); - - // Create honest nodes with unique validator keys (indices 1 and 2) - test.logger.warn('Creating honest nodes'); - const honestNode1 = await test.createValidatorNodeAt(1, { dontStartSequencer: true }); - const honestNode2 = await test.createValidatorNodeAt(2, { dontStartSequencer: true }); - - nodes = [maliciousNode1, maliciousNode2, honestNode1, honestNode2]; - - await awaitCommitteeExists({ rollup, logger: test.logger }); - - // Find an epoch where the malicious proposer is selected, stopping one epoch before - // so we have time to start sequencers before the target epoch arrives - const epochCache = (honestNode1 as TestAztecNodeService).epochCache; - const { targetEpoch, targetSlot } = await advanceToEpochBeforeProposer({ - epochCache, - cheatCodes: cheatCodes().rollup, - targetProposer: maliciousValidatorAddress, - logger: test.logger, - }); - - // Start all sequencers while still one epoch before the target - test.logger.warn('Starting all sequencers'); - await Promise.all(nodes.map(n => n.getSequencer()!.start())); - - // Now warp to one slot before the target epoch — sequencers are already running. - // Under proposer pipelining, the malicious proposers begin building for their slot one slot - // earlier; warping to the start of the epoch would force both AVM-heavy duplicate proposals to - // serialize past the slot boundary, after which honest receivers reject them as late. The helper - // picks a target slot at least one slot into the epoch, so warping here leaves a full warm-up - // slot before the build begins rather than starting it at the exact instant of the warp. - test.logger.warn(`Advancing to one slot before target epoch ${targetEpoch} (target slot ${targetSlot})`); - await cheatCodes().rollup.advanceToEpoch(targetEpoch, { offset: -AZTEC_SLOT_DURATION }); - - // Wait for offense to be detected. Under proposer pipelining, checkpoint proposals are broadcast - // at the slot boundary while the receivers' wall clocks may have already advanced past the build - // slot — when that happens, honest nodes reject the gossip with "invalid slot number" before - // duplicate detection runs, so DUPLICATE_PROPOSAL is only observed by whichever node managed to - // process both proposals while still in the build slot (often the other malicious node, since - // they receive each other's broadcasts immediately). We therefore collect offenses from every - // node in the network and assert that at least one of them recorded the duplicate proposal. - test.logger.warn('Waiting for duplicate proposal offense to be detected...'); - await awaitOffenseDetected({ - epochDuration: aztecEpochDuration, - logger: test.logger, - nodeAdmin: honestNode1, - slashingRoundSize, - waitUntilOffenseCount: 1, - timeoutSeconds: AZTEC_SLOT_DURATION * 16, - }); - - // Poll every node for DUPLICATE_PROPOSAL offenses, retrying briefly so any node that detected - // the duplicate after the initial offense was collected has time to flush it through the - // slasher's offenses-collector. - const proposalOffenses = await test.waitForOffenseOnNodes( - nodes, - o => o.offenseType === OffenseType.DUPLICATE_PROPOSAL, - { mode: 'any', timeout: AZTEC_SLOT_DURATION * 4 }, - ); - - test.logger.warn(`Collected duplicate proposal offenses`, { proposalOffenses }); - expect(proposalOffenses.length).toBeGreaterThan(0); - for (const offense of proposalOffenses) { - expect(offense.validator.toString()).toEqual(maliciousValidatorAddress.toString()); - } - - // Verify that for each offense, the proposer for that slot is the malicious validator - for (const offense of proposalOffenses) { - const offenseSlot = SlotNumber(Number(offense.epochOrSlot)); - const proposerForSlot = await epochCache.getProposerAttesterAddressInSlot(offenseSlot); - test.logger.info(`Offense slot ${offenseSlot}: proposer is ${proposerForSlot?.toString()}`); - expect(proposerForSlot?.toString()).toEqual(maliciousValidatorAddress.toString()); - } - - test.logger.warn('Duplicate proposal offense correctly detected and recorded'); - }); -}); diff --git a/yarn-project/end-to-end/src/multi-node/slashing/equivocation_offenses.test.ts b/yarn-project/end-to-end/src/multi-node/slashing/equivocation_offenses.test.ts new file mode 100644 index 000000000000..150ed960ae2d --- /dev/null +++ b/yarn-project/end-to-end/src/multi-node/slashing/equivocation_offenses.test.ts @@ -0,0 +1,247 @@ +import type { AztecNodeService } from '@aztec/aztec-node'; +import type { TestAztecNodeService } from '@aztec/aztec-node/test'; +import { EthAddress } from '@aztec/aztec.js/addresses'; +import type { EpochCacheInterface } from '@aztec/epoch-cache'; +import { EpochNumber, SlotNumber } from '@aztec/foundation/branded-types'; +import { OffenseType } from '@aztec/slasher'; + +import { + MultiNodeTestContext, + SLASHER_ENABLED_MULTI_VALIDATOR_OPTS, + buildMockGossipValidators, +} from '../multi_node_test_context.js'; +import { + AZTEC_SLOT_DURATION, + NUM_VALIDATORS, + advanceToEpochBeforeProposer, + awaitCommitteeExists, + awaitOffenseDetected, + aztecEpochDuration, + baseSlashingOpts, + slashingRoundSize, + slashingUnit, +} from './setup.js'; + +/** + * Slashing on validator equivocation. Two malicious nodes share the same validator key but use + * DIFFERENT coinbase addresses, so both detect they are proposers for the same slot, race to propose, + * and produce proposals with different archives. Honest validators receive both proposals over the + * mock-gossip bus and record the equivocation. + * + * The two suites differ only in whether the malicious nodes also attest to both proposals: + * - `duplicate proposal`: the malicious nodes equivocate but do not double-attest. Honest nodes + * record a DUPLICATE_PROPOSAL offense for the shared key. + * - `duplicate attestation`: the malicious nodes additionally run with `attestToEquivocatedProposals`, + * so they attest to BOTH proposals they see. This exercises DUPLICATE_PROPOSAL as a side effect but + * the suite asserts specifically that DUPLICATE_ATTESTATION is recorded. + * + * The two node-setup configs (the extra `slashDuplicateAttestationPenalty` and the + * `attestToEquivocatedProposals` flag) differ per case, so each runs in its own describe with its own + * `beforeEach`/`afterEach` rather than sharing one setup. + * + * Setup: MultiNodeTestContext on the in-memory mock-gossip bus (no real libp2p). 4 validators, ethSlot=8s, + * aztecSlot=24s, epoch=2, proofSubEpochs=1024, minTxsPerBlock=0, inboxLag=2 (v5 always enforces the timetable). + */ + +/** + * Drives an equivocation scenario to the point where at least `waitUntilOffenseCount` offenses are + * detected on the first honest node, and returns the actors + collected offenses for the caller to + * assert on. Creates two malicious nodes sharing validator index 0 (distinct coinbases) plus two honest + * nodes, finds the malicious proposer's slot, starts sequencers, and warps to it. + */ +async function runEquivocationScenario( + test: MultiNodeTestContext, + { + attestToEquivocatedProposals, + waitUntilOffenseCount, + }: { attestToEquivocatedProposals: boolean; waitUntilOffenseCount: number }, +): Promise<{ + nodes: AztecNodeService[]; + epochCache: EpochCacheInterface; + maliciousAddress: EthAddress; + honestNode: AztecNodeService; +}> { + const cheatCodes = test.context.cheatCodes.rollup; + const { rollup } = await test.getSlashingContracts(); + + // Jump forward to an epoch in the future such that the validator set is not empty + await cheatCodes.advanceToEpoch(EpochNumber(4)); + await cheatCodes.debugRollup(); + + test.logger.warn('Creating nodes'); + + // Use validator index 0 for the "malicious" proposer validator key + const maliciousProposerIndex = 0; + const maliciousAddress = test.addressAt(maliciousProposerIndex); + test.logger.warn(`Malicious proposer address: ${maliciousAddress.toString()}`); + + // Create two nodes with the SAME validator key but DIFFERENT coinbase addresses so their proposals + // have different content for the same slot. With `attestToEquivocatedProposals` they also attest to + // both proposals, producing duplicate attestations. + const maliciousConfig = (coinbase: EthAddress) => ({ + coinbase, + broadcastEquivocatedProposals: true, // Don't abort checkpoint building on duplicate block proposals + dontStartSequencer: true, + // Prevent HA peer proposals from being added to the archiver, so both malicious nodes build their + // own blocks instead of one yielding to the other. + skipPushProposedBlocksToArchiver: true, + ...(attestToEquivocatedProposals ? { attestToEquivocatedProposals: true } : {}), + }); + + const maliciousNode1 = await test.createValidatorNodeAt(maliciousProposerIndex, maliciousConfig(EthAddress.random())); + const maliciousNode2 = await test.createValidatorNodeAt(maliciousProposerIndex, maliciousConfig(EthAddress.random())); + + // Create honest nodes with unique validator keys (indices 1 and 2) + test.logger.warn('Creating honest nodes'); + const honestNode1 = await test.createValidatorNodeAt(1, { dontStartSequencer: true }); + const honestNode2 = await test.createValidatorNodeAt(2, { dontStartSequencer: true }); + + const nodes = [maliciousNode1, maliciousNode2, honestNode1, honestNode2]; + + await awaitCommitteeExists({ rollup, logger: test.logger }); + + // Find an epoch where the malicious proposer is selected, stopping one epoch before so we have time + // to start sequencers before the target epoch arrives. + const epochCache = (honestNode1 as TestAztecNodeService).epochCache; + const { targetEpoch, targetSlot } = await advanceToEpochBeforeProposer({ + epochCache, + cheatCodes, + targetProposer: maliciousAddress, + logger: test.logger, + }); + + // Start all sequencers while still one epoch before the target + test.logger.warn('Starting all sequencers'); + await Promise.all(nodes.map(n => n.getSequencer()!.start())); + + // Now warp to one slot before the target epoch — sequencers are already running. The helper picks a + // target slot at least one slot into the epoch, so warping here (rather than to the epoch start) + // leaves the freshly-started sequencers a full warm-up slot before the pipelined build for the + // malicious slot begins. Without that margin the duplicate proposals serialize past the slot + // boundary and receivers reject them as late, so no equivocation is produced. + test.logger.warn(`Advancing to one slot before target epoch ${targetEpoch} (target slot ${targetSlot})`); + await cheatCodes.advanceToEpoch(targetEpoch, { offset: -AZTEC_SLOT_DURATION }); + + test.logger.warn('Waiting for offenses to be detected...'); + await awaitOffenseDetected({ + epochDuration: aztecEpochDuration, + logger: test.logger, + nodeAdmin: honestNode1, + slashingRoundSize, + waitUntilOffenseCount, + timeoutSeconds: AZTEC_SLOT_DURATION * 16, + }); + + return { nodes, epochCache, maliciousAddress, honestNode: honestNode1 }; +} + +describe('multi-node/slashing/duplicate_attestation', () => { + let test: MultiNodeTestContext; + + beforeEach(async () => { + test = await MultiNodeTestContext.setup({ + ...SLASHER_ENABLED_MULTI_VALIDATOR_OPTS, + ...baseSlashingOpts, + slashDuplicateAttestationPenalty: slashingUnit, + initialValidators: buildMockGossipValidators(NUM_VALIDATORS), + }); + }); + + afterEach(async () => { + await test.teardown(); + }); + + // Two malicious nodes share a validator key and both attest to each other's proposals + // (attestToEquivocatedProposals). Honest nodes detect the DUPLICATE_ATTESTATION offense and verify the + // offending attester is the shared key's address, and that the address is in that slot's committee. + // Also exercises DUPLICATE_PROPOSAL as a side effect but asserts specifically on DUPLICATE_ATTESTATION. + it('slashes validator who sends duplicate attestations', async () => { + // Wait for both the duplicate proposal and the duplicate attestation offense. + const { epochCache, maliciousAddress, honestNode } = await runEquivocationScenario(test, { + attestToEquivocatedProposals: true, + waitUntilOffenseCount: 2, + }); + + const offenses = await honestNode.getSlashOffenses('all'); + test.logger.warn(`Collected offenses`, { offenses }); + + const duplicateAttestationOffenses = offenses.filter( + offense => offense.offenseType === OffenseType.DUPLICATE_ATTESTATION, + ); + test.logger.info(`Found ${duplicateAttestationOffenses.length} duplicate attestation offenses`); + + // We should have at least one duplicate attestation offense, all from the malicious proposer address + // (they are the ones with attestToEquivocatedProposals enabled). + expect(duplicateAttestationOffenses.length).toBeGreaterThan(0); + for (const offense of duplicateAttestationOffenses) { + expect(offense.offenseType).toEqual(OffenseType.DUPLICATE_ATTESTATION); + expect(offense.validator.toString()).toEqual(maliciousAddress.toString()); + } + + // For each duplicate attestation offense, the attester for that slot is the malicious validator. + for (const offense of duplicateAttestationOffenses) { + const offenseSlot = SlotNumber(Number(offense.epochOrSlot)); + const committeeInfo = await epochCache.getCommittee(offenseSlot); + test.logger.info(`Offense slot ${offenseSlot}: committee includes attester ${maliciousAddress.toString()}`); + expect(committeeInfo.committee?.map(addr => addr.toString())).toContain(maliciousAddress.toString()); + } + + test.logger.warn('Duplicate attestation offense correctly detected and recorded'); + }); +}); + +describe('multi-node/slashing/duplicate_proposal', () => { + let test: MultiNodeTestContext; + + beforeEach(async () => { + test = await MultiNodeTestContext.setup({ + ...SLASHER_ENABLED_MULTI_VALIDATOR_OPTS, + ...baseSlashingOpts, + initialValidators: buildMockGossipValidators(NUM_VALIDATORS), + }); + }); + + afterEach(async () => { + await test.teardown(); + }); + + // Two malicious nodes share a validator key but have different coinbase addresses so their proposals + // differ. Honest nodes receive both proposals via mock gossip, detect the equivocation, and record a + // DUPLICATE_PROPOSAL offense. The test collects offenses from all nodes (equivocation may only be + // observed by whichever node processed both proposals before the slot closed) and asserts the offense + // is attributed to the shared key's address and that the address is the slot's proposer. + it('slashes validator who sends duplicate proposals', async () => { + const { nodes, epochCache, maliciousAddress } = await runEquivocationScenario(test, { + attestToEquivocatedProposals: false, + waitUntilOffenseCount: 1, + }); + + // Poll every node for DUPLICATE_PROPOSAL offenses, retrying briefly so any node that detected the + // duplicate after the initial offense was collected has time to flush it through the slasher's + // offenses-collector. Under proposer pipelining, checkpoint proposals are broadcast at the slot + // boundary while receivers' wall clocks may have advanced past the build slot — when that happens, + // honest nodes reject the gossip with "invalid slot number" before duplicate detection runs, so + // DUPLICATE_PROPOSAL is only observed by whichever node processed both proposals in time. + const proposalOffenses = await test.waitForOffenseOnNodes( + nodes, + o => o.offenseType === OffenseType.DUPLICATE_PROPOSAL, + { mode: 'any', timeout: AZTEC_SLOT_DURATION * 4 }, + ); + + test.logger.warn(`Collected duplicate proposal offenses`, { proposalOffenses }); + expect(proposalOffenses.length).toBeGreaterThan(0); + for (const offense of proposalOffenses) { + expect(offense.validator.toString()).toEqual(maliciousAddress.toString()); + } + + // Verify that for each offense, the proposer for that slot is the malicious validator. + for (const offense of proposalOffenses) { + const offenseSlot = SlotNumber(Number(offense.epochOrSlot)); + const proposerForSlot = await epochCache.getProposerAttesterAddressInSlot(offenseSlot); + test.logger.info(`Offense slot ${offenseSlot}: proposer is ${proposerForSlot?.toString()}`); + expect(proposerForSlot?.toString()).toEqual(maliciousAddress.toString()); + } + + test.logger.warn('Duplicate proposal offense correctly detected and recorded'); + }); +}); diff --git a/yarn-project/end-to-end/src/multi-node/slashing/inactivity_slash.test.ts b/yarn-project/end-to-end/src/multi-node/slashing/inactivity_slash.test.ts index 8cd4b4ea0ba9..50399e3a8521 100644 --- a/yarn-project/end-to-end/src/multi-node/slashing/inactivity_slash.test.ts +++ b/yarn-project/end-to-end/src/multi-node/slashing/inactivity_slash.test.ts @@ -1,4 +1,4 @@ -import { EthAddress } from '@aztec/aztec.js/addresses'; +import type { EthAddress } from '@aztec/aztec.js/addresses'; import { promiseWithResolvers } from '@aztec/foundation/promise'; import { jest } from '@jest/globals'; @@ -8,18 +8,14 @@ import { InactivityTest } from './inactivity_setup.js'; jest.setTimeout(1000 * 60 * 10); -const SLASH_INACTIVITY_CONSECUTIVE_EPOCH_THRESHOLD = 1; - -// Verifies the basic inactivity slash path: one of 6 validators has its sequencer stopped; after -// slashInactivityConsecutiveEpochThreshold=1 epoch of inactivity the sentinel detects the offense and -// the validator is slashed on L1. Uses MultiNodeTestContext on the mock-gossip bus (6 nodes, fake -// prover, ethSlot varies by CI env, epoch=2, proofSubEpochs=1024, sentinelEnabled). +// Inactivity slashing on the shared `InactivityTest` fixture (mock-gossip bus, 6 nodes, fake prover, +// epoch=2, proofSubEpochs=1024, sentinelEnabled). describe('multi-node/slashing/inactivity_slash', () => { let test: InactivityTest; beforeAll(async () => { test = await InactivityTest.setup({ - slashInactivityConsecutiveEpochThreshold: SLASH_INACTIVITY_CONSECUTIVE_EPOCH_THRESHOLD, + slashInactivityConsecutiveEpochThreshold: 1, inactiveNodeCount: 1, }); }); @@ -28,8 +24,9 @@ describe('multi-node/slashing/inactivity_slash', () => { await test?.teardown(); }); - // Waits for a Slash event on the rollup contract and asserts it targets the offline validator with - // the expected slashing amount. Simple event-driven assertion; no polling inside the test body. + // Basic inactivity slash path: one of 6 validators has its sequencer stopped; after one epoch of + // inactivity (threshold=1) the sentinel detects the offense and the validator is slashed on L1. + // Simple event-driven assertion; no polling inside the test body. it('slashes inactive validator', async () => { const slashPromise = promiseWithResolvers<{ amount: bigint; attester: EthAddress }>(); test.rollup.listenToSlash(args => { diff --git a/yarn-project/end-to-end/src/multi-node/slashing/inactivity_slash_with_consecutive_epochs.test.ts b/yarn-project/end-to-end/src/multi-node/slashing/inactivity_slash_with_consecutive_epochs.test.ts index 6055411da53e..1d70fbd846a8 100644 --- a/yarn-project/end-to-end/src/multi-node/slashing/inactivity_slash_with_consecutive_epochs.test.ts +++ b/yarn-project/end-to-end/src/multi-node/slashing/inactivity_slash_with_consecutive_epochs.test.ts @@ -12,9 +12,8 @@ import { InactivityTest } from './inactivity_setup.js'; jest.setTimeout(1000 * 60 * 10); -// Verifies that consecutive-epoch threshold is respected: 2 validators are offline, but one is re-enabled -// after the first epoch. Only the permanently-offline validator should be slashed. Uses MultiNodeTestContext -// on the mock-gossip bus (6 nodes, fake prover, epoch=2, proofSubEpochs=1024, threshold=3 consecutive epochs). +// Inactivity slashing on the shared `InactivityTest` fixture (mock-gossip bus, 6 nodes, fake prover, +// epoch=2, proofSubEpochs=1024, sentinelEnabled). describe('multi-node/slashing/inactivity_slash_with_consecutive_epochs', () => { let test: InactivityTest; @@ -31,9 +30,9 @@ describe('multi-node/slashing/inactivity_slash_with_consecutive_epochs', () => { await test?.teardown(); }); - // Re-enables one of the two offline validators after the first epoch, then waits for INACTIVITY - // offenses to appear. Asserts that offenses are only emitted for the permanently-offline validator - // and that the re-enabled validator is never included in the slash. + // Consecutive-epoch threshold is respected: 2 validators start offline, but one is re-enabled after + // the first epoch. Asserts that offenses and slash events target only the permanently-offline + // validator (never the re-enabled one). it('only slashes validator inactive for N consecutive epochs', async () => { const [offlineValidator, reenabledValidator] = test.offlineValidators; diff --git a/yarn-project/end-to-end/src/multi-node/slashing/multiple_validators_sentinel.parallel.test.ts b/yarn-project/end-to-end/src/multi-node/slashing/multiple_validators_sentinel.parallel.test.ts index 94d673e4750e..8bfb047e9d5a 100644 --- a/yarn-project/end-to-end/src/multi-node/slashing/multiple_validators_sentinel.parallel.test.ts +++ b/yarn-project/end-to-end/src/multi-node/slashing/multiple_validators_sentinel.parallel.test.ts @@ -12,18 +12,17 @@ import { SLASHER_ENABLED_MULTI_VALIDATOR_OPTS, buildMockGossipValidators, } from '../multi_node_test_context.js'; +import { SENTINEL_TIMING } from './setup.js'; const NUM_NODES = 2; const VALIDATORS_PER_NODE = 3; const NUM_VALIDATORS = NUM_NODES * VALIDATORS_PER_NODE; const SLOT_COUNT = 3; -const EPOCH_DURATION = 2; // The body advances through SLOT_COUNT real L2 slots at wall-clock pace, so the slot duration directly -// sets body time. At eth<8 the sequencer uses the fast (mocked-p2p) operational budgets, which fit a -// checkpoint comfortably in an 8s slot even with six co-hosted validators; larger durations only add +// sets body time. SENTINEL_TIMING's 8s slot (eth 4s) uses the fast (mocked-p2p) operational budgets, +// which fit a checkpoint comfortably even with six co-hosted validators; larger durations only add // dead wall-clock without exercising new behavior. -const ETHEREUM_SLOT_DURATION = 4; -const AZTEC_SLOT_DURATION = 8; +const AZTEC_SLOT_DURATION = SENTINEL_TIMING.aztecSlotDuration; jest.setTimeout(1000 * 60 * 10); @@ -41,17 +40,12 @@ describe('multi-node/slashing/multiple_validators_sentinel', () => { beforeAll(async () => { test = await MultiNodeTestContext.setup({ ...SLASHER_ENABLED_MULTI_VALIDATOR_OPTS, - anvilSlotsInAnEpoch: 4, + ...SENTINEL_TIMING, aztecTargetCommitteeSize: NUM_VALIDATORS, - aztecSlotDuration: AZTEC_SLOT_DURATION, - ethereumSlotDuration: ETHEREUM_SLOT_DURATION, blockDurationMs: 2000, aztecProofSubmissionEpochs: 1024, // effectively do not reorg - listenAddress: '127.0.0.1', minTxsPerBlock: 0, - aztecEpochDuration: EPOCH_DURATION, slashingRoundSizeInEpochs: 2, - sentinelEnabled: true, slashInactivityPenalty: 0n, // Set to 0 to disable inboxLag: 2, initialValidators: buildMockGossipValidators(NUM_VALIDATORS), diff --git a/yarn-project/end-to-end/src/multi-node/slashing/sentinel_status_slash.parallel.test.ts b/yarn-project/end-to-end/src/multi-node/slashing/sentinel_status_slash.parallel.test.ts index 30dd83c1de96..47b64bf660f9 100644 --- a/yarn-project/end-to-end/src/multi-node/slashing/sentinel_status_slash.parallel.test.ts +++ b/yarn-project/end-to-end/src/multi-node/slashing/sentinel_status_slash.parallel.test.ts @@ -17,7 +17,7 @@ import { SLASHER_ENABLED_MULTI_VALIDATOR_OPTS, buildMockGossipValidators, } from '../multi_node_test_context.js'; -import { awaitCommitteeExists, findUpcomingProposerSlot } from './setup.js'; +import { SENTINEL_TIMING, awaitCommitteeExists, findUpcomingProposerSlot } from './setup.js'; /** * Exercises the sentinel's six-case proposer-status taxonomy end-to-end by driving each of the @@ -50,14 +50,11 @@ jest.setTimeout(TEST_TIMEOUT); const NUM_VALIDATORS = 6; const COMMITTEE_SIZE = NUM_VALIDATORS; // The body advances through real L2 slots at wall-clock pace, so the slot duration directly sets body -// time. At eth<8 the sequencer uses the fast (mocked-p2p) operational budgets, which fit a checkpoint -// comfortably in an 8s slot. proofSubEpochs=1024 disables reorg/proving deadlines and the only assertions +// time. SENTINEL_TIMING's 8s slot (eth 4s) uses the fast (mocked-p2p) operational budgets, which fit a +// checkpoint comfortably. proofSubEpochs=1024 disables reorg/proving deadlines and the only assertions // are sentinel attestation/status records (no proving-window timing), so the fast profile is safe here; // larger durations only add dead wall-clock without exercising new behavior. -const ETHEREUM_SLOT_DURATION = 4; -const AZTEC_SLOT_DURATION = ETHEREUM_SLOT_DURATION * 2; -const BLOCK_DURATION_MS = ETHEREUM_SLOT_DURATION * 500; -const AZTEC_EPOCH_DURATION = 2; +const AZTEC_SLOT_DURATION = SENTINEL_TIMING.aztecSlotDuration; const SLASHING_UNIT = BigInt(1e18); const SLASHING_AMOUNT = SLASHING_UNIT * 3n; const SLASHING_QUORUM = 3; @@ -71,17 +68,12 @@ describe('multi-node/slashing/sentinel_status_slash', () => { beforeEach(async () => { test = await MultiNodeTestContext.setup({ ...SLASHER_ENABLED_MULTI_VALIDATOR_OPTS, - anvilSlotsInAnEpoch: 4, - listenAddress: '127.0.0.1', + ...SENTINEL_TIMING, aztecTargetCommitteeSize: COMMITTEE_SIZE, - aztecSlotDuration: AZTEC_SLOT_DURATION, - ethereumSlotDuration: ETHEREUM_SLOT_DURATION, - blockDurationMs: BLOCK_DURATION_MS, - aztecEpochDuration: AZTEC_EPOCH_DURATION, + blockDurationMs: 2000, aztecProofSubmissionEpochs: 1024, minTxsPerBlock: 0, inboxLag: 2, - sentinelEnabled: true, // A single proposer-fault slot in an epoch gives missed/total = 1/6 ≈ 0.167; threshold // 0.1 lets that single fault trip inactivity. slashInactivityTargetPercentage: 0.1, @@ -126,49 +118,27 @@ describe('multi-node/slashing/sentinel_status_slash', () => { await test.teardown(); }); - // Spawns one malicious node with broadcastInvalidBlockProposal:true; honest observers reject via - // re-execution state_mismatch and record `checkpoint-unvalidated` for that proposer slot. The sentinel - // then emits an INACTIVITY offense. Asserts all honest observers agree on the fault slot and status. - it('slashes the proposer with INACTIVITY when checkpoint validation records unvalidated', async () => { - // One malicious node broadcasts invalid block proposals; honest observers reject them via - // re-execution state_mismatch and therefore never push to their archivers, so the malicious - // node's checkpoint proposals can't find their last block and observers record `unvalidated`. - const targetAddress = await spawnMaliciousAndHonestNodes({ broadcastInvalidBlockProposal: true }); - // Warp near the malicious node's proposer slot to keep wall-clock down. We discover the slot at - // which the fault is actually recorded rather than assuming it is the warped block-proposer - // slot: the re-execution outcome is keyed by the checkpoint proposal's slot, and a proposer - // only emits a checkpoint proposal when its slot closes a checkpoint, which does not always - // coincide with the block-proposer slot we warp to. - await warpToSlotBeforeTargetProposer(targetAddress); - // nodes[0] is the malicious node; honest observers are nodes[1..]. - const honestObservers = nodes.slice(1); - const faultSlot = await findObservedStatusSlot(honestObservers, targetAddress, 'checkpoint-unvalidated'); - await assertAllObserversSentinelStatus(honestObservers, targetAddress, faultSlot, 'checkpoint-unvalidated'); - // The malicious node self-records `checkpoint-valid` for that slot using the locally computed - // archive (broadcastInvalidBlockProposal only corrupts the broadcast archive, not the - // proposer's local state). - await assertAllObserversSentinelStatus([nodes[0]], targetAddress, faultSlot, 'checkpoint-valid'); - await assertInactivityOffenseFor(targetAddress, nodes[1]); - }); + // Cases 1 and 2 share the same body (spawn 1 malicious + 5 honest, warp near the malicious proposer + // slot, assert observers agree on the fault status, malicious self-records `checkpoint-valid`, and an + // INACTIVITY offense follows). They differ only in the malicious-node config flag and the fault status + // observers record. Kept as unrolled its (no it.each) so each stays its own CI job under the parallel + // convention. See {@link runProposerFaultScenario}. - // Spawns one malicious node with broadcastInvalidCheckpointProposalOnly:true; block proposals are - // valid (land in archivers) but checkpoint proposals carry a random archive. Observers detect - // header_mismatch and record `checkpoint-invalid`. The sentinel emits INACTIVITY. Asserts all - // observers agree and the malicious node self-records `checkpoint-valid`. - it('slashes the proposer with INACTIVITY when checkpoint validation records invalid', async () => { - // One malicious node broadcasts invalid CHECKPOINT proposals while keeping the underlying - // block proposals valid; observers accept the blocks (so they land in the archiver) but - // reject the checkpoint via header_mismatch, recording `invalid`. - const targetAddress = await spawnMaliciousAndHonestNodes({ broadcastInvalidCheckpointProposalOnly: true }); - await warpToSlotBeforeTargetProposer(targetAddress); - const honestObservers = nodes.slice(1); - const faultSlot = await findObservedStatusSlot(honestObservers, targetAddress, 'checkpoint-invalid'); - await assertAllObserversSentinelStatus(honestObservers, targetAddress, faultSlot, 'checkpoint-invalid'); - // Malicious self-records `checkpoint-valid` for that slot — proposers always consider their - // own freshly-built proposal valid from their local-state perspective. - await assertAllObserversSentinelStatus([nodes[0]], targetAddress, faultSlot, 'checkpoint-valid'); - await assertInactivityOffenseFor(targetAddress, nodes[1]); - }); + // broadcastInvalidBlockProposal: observers reject the block via re-execution state_mismatch and never + // push it to their archivers, so the checkpoint proposal can't find its last block → `unvalidated`. + it('slashes the proposer with INACTIVITY when checkpoint validation records unvalidated', () => + runProposerFaultScenario({ + maliciousOverride: { broadcastInvalidBlockProposal: true }, + expectedStatus: 'checkpoint-unvalidated', + })); + + // broadcastInvalidCheckpointProposalOnly: block proposals stay valid (land in archivers) but the + // checkpoint proposal carries a random archive → observers detect header_mismatch and record `invalid`. + it('slashes the proposer with INACTIVITY when checkpoint validation records invalid', () => + runProposerFaultScenario({ + maliciousOverride: { broadcastInvalidCheckpointProposalOnly: true }, + expectedStatus: 'checkpoint-invalid', + })); // Starts 6 honest validators, waits for the committee, then stops the last validator. Asserts that // all remaining observers record `attestation-missed` for the stopped node and that an INACTIVITY @@ -193,6 +163,33 @@ describe('multi-node/slashing/sentinel_status_slash', () => { // -- helpers ------------------------------------------------------------------------------ + /** + * Runs a single-malicious-proposer fault scenario: spawns the malicious node (with the given config + * override) and 5 honest observers, warps near the malicious proposer's slot, and asserts every honest + * observer records `expectedStatus` at the discovered fault slot while the malicious node self-records + * `checkpoint-valid`, then that an INACTIVITY offense follows. We discover the fault slot rather than + * assuming it is the warped block-proposer slot: the re-execution outcome is keyed by the checkpoint + * proposal's slot, which only closes a checkpoint (and so records the fault) on some proposer slots. + */ + async function runProposerFaultScenario({ + maliciousOverride, + expectedStatus, + }: { + maliciousOverride: Partial; + expectedStatus: ValidatorStatusInSlot; + }): Promise { + const targetAddress = await spawnMaliciousAndHonestNodes(maliciousOverride); + await warpToSlotBeforeTargetProposer(targetAddress); + // nodes[0] is the malicious node; honest observers are nodes[1..]. + const honestObservers = nodes.slice(1); + const faultSlot = await findObservedStatusSlot(honestObservers, targetAddress, expectedStatus); + await assertAllObserversSentinelStatus(honestObservers, targetAddress, faultSlot, expectedStatus); + // The malicious node self-records `checkpoint-valid` for that slot using its locally computed + // archive (the invalid-broadcast flags corrupt only the broadcast, not the proposer's local state). + await assertAllObserversSentinelStatus([nodes[0]], targetAddress, faultSlot, 'checkpoint-valid'); + await assertInactivityOffenseFor(targetAddress, nodes[1]); + } + /** * Spawns 1 malicious node at index 0 with the given config override, then `NUM_VALIDATORS - 1` * honest nodes at indices 1..N-1. Returns the malicious node's validator address. diff --git a/yarn-project/end-to-end/src/multi-node/slashing/setup.ts b/yarn-project/end-to-end/src/multi-node/slashing/setup.ts index 46661b510961..00f2c9dc4522 100644 --- a/yarn-project/end-to-end/src/multi-node/slashing/setup.ts +++ b/yarn-project/end-to-end/src/multi-node/slashing/setup.ts @@ -8,7 +8,7 @@ import { unique } from '@aztec/foundation/collection'; import { EthAddress } from '@aztec/foundation/eth-address'; import { retryUntil } from '@aztec/foundation/retry'; import { pluralize } from '@aztec/foundation/string'; -import { getRoundForOffense } from '@aztec/slasher'; +import { type OffenseType, getRoundForOffense } from '@aztec/slasher'; import type { AztecNodeAdmin } from '@aztec/stdlib/interfaces/client'; import type { TxHash } from '@aztec/stdlib/tx'; @@ -60,6 +60,42 @@ export const baseSlashingOpts = { slashingOffsetInRounds: 1, }; +/** + * The fast slot timing shared by the sentinel-observation suites (`sentinel_status_slash`, + * `validators_sentinel`, `multiple_validators_sentinel`, `slash_veto_demo`). Slots are 8s (eth 4s × + * epoch 2), which fit a checkpoint comfortably at the fast mocked-p2p operational budgets used at + * eth<8, keeping wall-clock down for tests whose bodies advance through real L2 slots. Spread into a + * {@link MultiNodeTestContext.setup} call alongside {@link SLASHER_ENABLED_MULTI_VALIDATOR_OPTS}, + * `initialValidators`, and the per-test slash/penalty config. Non-sentinel offense-detection files + * that only need the same fast timing spread this and override `sentinelEnabled: false`. + */ +export const SENTINEL_TIMING = { + anvilSlotsInAnEpoch: 4, + listenAddress: '127.0.0.1', + ethereumSlotDuration: 4, + aztecSlotDuration: 8, + aztecEpochDuration: 2, + sentinelEnabled: true, +} as const; + +/** A single detected slash offense as returned by {@link AztecNodeService.getSlashOffenses}. */ +export type SlashOffense = Awaited>[number]; + +/** Looks up the offense recorded for `validator` of `offenseType` at `slot`, if any. */ +export function findSlashOffense( + offenses: SlashOffense[], + validator: EthAddress, + offenseType: OffenseType, + slot: SlotNumber, +): SlashOffense | undefined { + return offenses.find( + offense => + offense.validator.equals(validator) && + offense.offenseType === offenseType && + offense.epochOrSlot === BigInt(slot), + ); +} + export function awaitProposalExecution( slashingProposer: SlashingProposerContract, timeoutSeconds: number, diff --git a/yarn-project/end-to-end/src/multi-node/slashing/slash_veto_demo.test.ts b/yarn-project/end-to-end/src/multi-node/slashing/slash_veto_demo.test.ts index 62cc053cdcbf..6ab8d440b5cf 100644 --- a/yarn-project/end-to-end/src/multi-node/slashing/slash_veto_demo.test.ts +++ b/yarn-project/end-to-end/src/multi-node/slashing/slash_veto_demo.test.ts @@ -21,16 +21,13 @@ import { SLASHER_ENABLED_MULTI_VALIDATOR_OPTS, buildMockGossipValidators, } from '../multi_node_test_context.js'; +import { SENTINEL_TIMING } from './setup.js'; const debugLogger = createLogger('e2e:multi-node:slash-veto-demo'); const VETOER_PRIVATE_KEY_INDEX = 18; // This should be after all keys used by validators const NUM_NODES = 3; const NUM_VALIDATORS = NUM_NODES + 1; // We create an extra validator, who will not have a running node -const ETHEREUM_SLOT_DURATION = 4; -const AZTEC_SLOT_DURATION = 8; -const BLOCK_DURATION_MS = 2000; -const EPOCH_DURATION = 2; // how many l2 slots make up a slashing round const SLASHING_ROUND_SIZE = 4; // how many block builders must signal for a single payload in a single round for it to be executable @@ -70,23 +67,18 @@ describe('veto slash', () => { beforeEach(async () => { test = await MultiNodeTestContext.setup({ ...SLASHER_ENABLED_MULTI_VALIDATOR_OPTS, - anvilSlotsInAnEpoch: 4, - aztecSlotDuration: AZTEC_SLOT_DURATION, - ethereumSlotDuration: ETHEREUM_SLOT_DURATION, - blockDurationMs: BLOCK_DURATION_MS, + ...SENTINEL_TIMING, + blockDurationMs: 2000, aztecProofSubmissionEpochs: 1024, // effectively do not reorg - listenAddress: '127.0.0.1', minTxsPerBlock: 0, inboxLag: 2, aztecTargetCommitteeSize: NUM_VALIDATORS, - aztecEpochDuration: EPOCH_DURATION, - sentinelEnabled: true, slashSelfAllowed: true, slashingOffsetInRounds: SLASH_OFFSET_IN_ROUNDS, slashAmountSmall: SLASHING_UNIT, slashAmountMedium: SLASHING_UNIT * 2n, slashAmountLarge: SLASHING_UNIT * 3n, - slashingRoundSizeInEpochs: SLASHING_ROUND_SIZE / EPOCH_DURATION, + slashingRoundSizeInEpochs: SLASHING_ROUND_SIZE / SENTINEL_TIMING.aztecEpochDuration, slashingQuorum: SLASHING_QUORUM, slashingLifetimeInRounds: LIFETIME_IN_ROUNDS, slashingExecutionDelayInRounds: EXECUTION_DELAY_IN_ROUNDS, @@ -152,12 +144,11 @@ describe('veto slash', () => { } // Waits for the inactive validator to accumulate inactivity offenses reaching quorum, then the vetoer - // calls vetoPayload on the Slasher contract. Asserts the payload either expires (lifetime exceeded) - // or a later round is executed, and that the inactive validator's GSE balance is unchanged. - // Currently parameterised as shouldVeto=true only (the non-veto branch is present but not exercised). - it.each([[true]] as const)( - 'vetoes %s a slashing payload', - async (shouldVeto: boolean) => { + // calls vetoPayload on the Slasher contract. Asserts the vetoed payload never executes — its lifetime + // expires or a later (non-vetoed) round executes instead. + it( + 'vetoes a slashing payload', + async () => { //#####################################// // // // Verify the initial slasher's vetoer // @@ -230,18 +221,15 @@ describe('veto slash', () => { // // //##############################// - if (shouldVeto) { - const slasherAddress = await rollup.getSlasherAddress(); - const { receipt } = await vetoerL1TxUtils.sendAndMonitorTransaction({ - to: slasherAddress.toString() as `0x${string}`, - data: encodeFunctionData({ - abi: SlasherAbi, - functionName: 'vetoPayload', - args: [submittableRound.payload], - }), - }); - debugLogger.info(`\n\nvetoPayload tx receipt: ${receipt.status}\n\n`); - } + const { receipt } = await vetoerL1TxUtils.sendAndMonitorTransaction({ + to: slasherAddress.toString() as `0x${string}`, + data: encodeFunctionData({ + abi: SlasherAbi, + functionName: 'vetoPayload', + args: [submittableRound.payload], + }), + }); + debugLogger.info(`\n\nvetoPayload tx receipt: ${receipt.status}\n\n`); //###################################// // // @@ -266,18 +254,12 @@ describe('veto slash', () => { }); const payloadExecutedOrExpired = await Promise.race([awaitPayloadSubmitted.promise, awaitPayloadExpiredPromise]); - const badAttesterFinalBalance = await gse.read.effectiveBalanceOf([rollup.address, attester.address]); - if (shouldVeto) { - // If we vetoed, then either the payload expired, or another more recent payload was executed - if (typeof payloadExecutedOrExpired === 'boolean') { - expect(payloadExecutedOrExpired).toBe(true); - } else { - expect(payloadExecutedOrExpired.round).toBeGreaterThan(submittableRound.round); - } + // The vetoed payload must never execute: either its lifetime expired, or a later (non-vetoed) + // round executed instead. + if (typeof payloadExecutedOrExpired === 'boolean') { + expect(payloadExecutedOrExpired).toBe(true); } else { - // If we didn't veto, the attester should have their balance decreased by the slashing amount. - expect((payloadExecutedOrExpired as { round: bigint }).round).toBe(submittableRound.round); - expect(badAttesterFinalBalance).toBe(badAttesterInitialBalance - slashingAmount); + expect(payloadExecutedOrExpired.round).toBeGreaterThan(submittableRound.round); } }, 1000 * 60 * 10, diff --git a/yarn-project/end-to-end/src/multi-node/slashing/validators_sentinel.parallel.test.ts b/yarn-project/end-to-end/src/multi-node/slashing/validators_sentinel.parallel.test.ts index a6e0732c5bc5..09c9a6b1870e 100644 --- a/yarn-project/end-to-end/src/multi-node/slashing/validators_sentinel.parallel.test.ts +++ b/yarn-project/end-to-end/src/multi-node/slashing/validators_sentinel.parallel.test.ts @@ -12,14 +12,12 @@ import { SLASHER_ENABLED_MULTI_VALIDATOR_OPTS, buildMockGossipValidators, } from '../multi_node_test_context.js'; +import { SENTINEL_TIMING } from './setup.js'; const NUM_NODES = 5; const NUM_VALIDATORS = NUM_NODES + 1; // We create an extra validator, who will not have a running node const BLOCK_COUNT = 3; -const EPOCH_DURATION = 2; -const ETHEREUM_SLOT_DURATION = 4; -const AZTEC_SLOT_DURATION = 8; -const BLOCK_DURATION_MS = 2000; +const AZTEC_SLOT_DURATION = SENTINEL_TIMING.aztecSlotDuration; jest.setTimeout(1000 * 60 * 10); @@ -37,17 +35,12 @@ describe('multi-node/slashing/validators_sentinel', () => { beforeAll(async () => { test = await MultiNodeTestContext.setup({ ...SLASHER_ENABLED_MULTI_VALIDATOR_OPTS, - anvilSlotsInAnEpoch: 4, + ...SENTINEL_TIMING, aztecTargetCommitteeSize: NUM_VALIDATORS, - aztecSlotDuration: AZTEC_SLOT_DURATION, - ethereumSlotDuration: ETHEREUM_SLOT_DURATION, - blockDurationMs: BLOCK_DURATION_MS, + blockDurationMs: 2000, aztecProofSubmissionEpochs: 1024, // effectively do not reorg - listenAddress: '127.0.0.1', minTxsPerBlock: 0, - aztecEpochDuration: EPOCH_DURATION, slashingRoundSizeInEpochs: 2, - sentinelEnabled: true, slashInactivityPenalty: 0n, // Set to 0 to disable inboxLag: 2, initialValidators: buildMockGossipValidators(NUM_VALIDATORS), diff --git a/yarn-project/end-to-end/src/p2p/README.md b/yarn-project/end-to-end/src/p2p/README.md new file mode 100644 index 000000000000..a4228d8f4523 --- /dev/null +++ b/yarn-project/end-to-end/src/p2p/README.md @@ -0,0 +1,78 @@ +# `p2p` e2e test category + +P2P tests run a network of validator nodes over **real libp2p** — a real bootstrap node, discv5 +discovery, the GossipSub mesh, the req/resp protocol, and real peer authentication and transport. This +is the category for any test whose subject is the networking layer itself: peer discovery and +rediscovery, gossip mesh formation and propagation, req/resp fetching, preferred-peer / supernode +topologies, and peer auth gating. + +Tests whose subject — proposals, attestations, checkpointing, pruning/recovery, offense detection, +sentinel observability — is faithfully reproduced by the in-memory `MockGossipSubNetwork` bus belong in +the `multi-node` category instead, which is far cheaper to run. Only reach for `p2p` when the behavior +under test genuinely cannot be reproduced without real networking. + +## Entrypoint + +`P2PNetworkTest` (`p2p_network.ts`) is the env-builder for this category. Unlike `MultiNodeTestContext`, +it does **not** extend `SingleNodeTestContext`; it is a parallel, self-contained builder that calls the +root `setup()` directly, then spawns real-libp2p nodes via the factories in +`../fixtures/setup_p2p_test.ts`. Its distinguishing traits: + +- A real bootstrap node (`addBootstrapNode`) with a fixed private key, so nodes discover each other via + discv5 and data directories can be reused across a restart. +- Two validator-registration paths: `applyBaseSetup()` registers the committee post-genesis via the + on-chain `MultiAdder` cheat (validators are added at `aztecTargetCommitteeSize: 0`, then activated by + warping past the validator-set lag), while tests that exercise the real onboarding flow call + `addBootstrapNode()` and register each validator through the CLI `addL1Validator` path instead. +- `waitForP2PMeshConnectivity(nodes, …)` — waits for peer connections and for the GossipSub mesh to + form on the requested topics; callers that need a proposal to reach the whole committee within a slot + raise `minMeshPeerCount` for a full mesh. +- Data-directory management: `setup()` creates one root temp dir; `dataDirFor(label)` hands out a nested + per-role path (`createNodes` appends `-`, single-node factories use it verbatim); `teardown()` + removes the whole tree in one recursive delete. Test files never `mkdtemp`/`rmSync` themselves. +- One named timing preset: `SHORTENED_BLOCK_TIME_CONFIG_NO_PRUNES` (ethSlot 4s, aztecSlot 12s, + `aztecProofSubmissionEpochs: 640` so the chain effectively never prunes), used by every file with + per-file slot/epoch overrides. `WAIT_FOR_TX_TIMEOUT` derives from the env slot duration. + +## Shared skeleton and helpers + +`shared.ts` holds the common scenario skeleton and the reusable building blocks, so test bodies read +declaratively rather than re-deriving the same ~40 lines of node-creation / mesh-wait / signer-check +boilerplate: + +- `runGossipScenario(opts)` — the shared bootstrap→createNodes→mesh→account→(submit)→verify skeleton. + Each varying part is a callback/flag: validator registration (`beforeCreateNodes`), extra prover / + monitor nodes (`createExtraNodes`), pre-submit waits (`beforeSubmit`), mesh parameters (`mesh`), + whether/how many txs to submit (`txsPerNode`, `submitSequentially`), which published checkpoint to + read signers from (`checkpointSource`), and scenario-specific verification (`afterVerify`). +- `verifyAttestationSigners(t, nodes, checkpoint)` — recovers the attestation signers from a published + checkpoint and asserts each belongs to the validator set; returns the signers for extra assertions. +- `getPublishedCheckpointForTx(node, txHash)` / `waitForFirstPublishedCheckpoint(t, nodes)` — resolve + the published checkpoint to read signers from, either from a mined tx's block or by polling for the + first one indexed by the archiver. +- `waitForNodesToSync(t, nodes)` — wait for every node to catch up to the initial node's tip. +- `maybeCheckQosAlerts(logger)` — run the QoS Grafana alert check when `CHECK_ALERTS=true`, else a no-op. +- `submitTransactions` / `submitComplexTxsTo` / `prepareTransactions` — build a throwaway wallet on a + node and submit (or pre-prove) N txs through it. + +Node factories live in `../fixtures/setup_p2p_test.ts` (`createNodes`, `createNode`, +`createNonValidatorNode`, `createProverNode`, `createValidatorConfig`, `generatePrivateKeys`), shared +with `P2PNetworkTest`. + +## Organizing principle + +Each file is one real-libp2p scenario the mock-gossip bus cannot reproduce, with a single top-level +`describe` named `e2e_p2p_*`. Files are plain `*.test.ts` (no `.parallel` suffix): each is one CI +container running the whole file. Because the nodes bind **fixed UDP/TCP ports**, two p2p test files +must never run at the same time — run them one at a time locally. + +## Files + +| File | Scenario | +|---|---| +| `gossip_network.test.ts` | The baseline gossip test, two describes on the shared skeleton: `cheat-registered validators` (4 validators + a p2p-only prover + a re-execution monitor; txs mine from every node, attestation signers match the committee, and the prover produces a proven block by collecting txs over p2p) and `on-chain-registered validators (no cheats)` (validators registered via the real `addL1Validator` CLI path with a ZkPassport mock proof, then the same propagation/signer checks). | +| `fee_asset_price_oracle_gossip.test.ts` | A fee-asset price set on a mock L1 `StateView` contract gossips through the validator network and the rollup's on-chain price converges on it across two adjustments. Runs on the shared skeleton with `txsPerNode: 0` — no txs are submitted; empty blocks carry the price. | +| `late_prover_tx_collection.test.ts` | A prover that joins after a block was already mined learns the block from its archiver (via L1 sync) but is missing the txs, and must fetch them from peers over the req/resp `BLOCK_TXS` path — the same path `ProverNode.gatherTxs` takes when preparing an epoch proof. | +| `preferred_gossip_network.test.ts` | A preferred-node (supernode) topology: preferred nodes accept only validator connections, a no-discovery validator reaches the network exclusively through them, and gossip monitors assert traffic flows only through the expected peers. Verifies exact per-role peer counts, tx mining, and attestation signers. | +| `rediscovery.test.ts` | Nodes form a mesh, then the bootstrap node and all validators are stopped and restarted from their data directories with no bootstrap ENR — they rejoin purely from the discv5 peer tables they persisted to disk. | +| `reqresp/reqresp.test.ts` | Tx gossip is disabled on the upcoming proposer nodes, so they must request the missing tx data over req/resp before they can attest; also asserts a multiple-blocks-per-slot checkpoint is produced with correctly ordered blocks. The scenario body lives in `reqresp/utils.ts`. | diff --git a/yarn-project/end-to-end/src/p2p/fee_asset_price_oracle_gossip.test.ts b/yarn-project/end-to-end/src/p2p/fee_asset_price_oracle_gossip.test.ts index a543b15893a4..21c010085ac3 100644 --- a/yarn-project/end-to-end/src/p2p/fee_asset_price_oracle_gossip.test.ts +++ b/yarn-project/end-to-end/src/p2p/fee_asset_price_oracle_gossip.test.ts @@ -1,49 +1,29 @@ -import type { Archiver } from '@aztec/archiver'; import type { AztecNodeService } from '@aztec/aztec-node'; import { createExtendedL1Client } from '@aztec/ethereum/client'; import { RollupContract, STATE_VIEW_ADDRESS } from '@aztec/ethereum/contracts'; -import { CheckpointNumber } from '@aztec/foundation/branded-types'; -import { Signature } from '@aztec/foundation/eth-signature'; import { retryUntil } from '@aztec/foundation/retry'; -import type { SequencerClient } from '@aztec/sequencer-client'; import { tryStop } from '@aztec/stdlib/interfaces/server'; -import { CheckpointAttestation, ConsensusPayload } from '@aztec/stdlib/p2p'; import { jest } from '@jest/globals'; -import fs from 'fs'; -import os from 'os'; -import path from 'path'; import { mnemonicToAccount } from 'viem/accounts'; import { foundry } from 'viem/chains'; import { MNEMONIC, shouldCollectMetrics } from '../fixtures/fixtures.js'; -import { ATTESTER_PRIVATE_KEYS_START_INDEX, createNodes, createProverNode } from '../fixtures/setup_p2p_test.js'; -import { type AlertConfig, GrafanaClient } from '../quality_of_service/grafana_client.js'; +import { ATTESTER_PRIVATE_KEYS_START_INDEX, createProverNode } from '../fixtures/setup_p2p_test.js'; import { MockStateView, diffInBps } from '../shared/mock_state_view.js'; import { P2PNetworkTest, SHORTENED_BLOCK_TIME_CONFIG_NO_PRUNES } from './p2p_network.js'; +import { maybeCheckQosAlerts, runGossipScenario, waitForNodesToSync } from './shared.js'; -const CHECK_ALERTS = process.env.CHECK_ALERTS === 'true'; - -// Don't set this to a higher value than 9 because each node will use a different L1 publisher account and anvil seeds +// Don't set NUM_VALIDATORS higher than 9 because each node uses a different L1 publisher account and anvil seeds. const NUM_VALIDATORS = 4; const BOOT_NODE_UDP_PORT = 4500; -const DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'gossip-')); - jest.setTimeout(1000 * 60 * 10); -const qosAlerts: AlertConfig[] = [ - { - alert: 'SequencerTimeToCollectAttestations', - expr: 'aztec_sequencer_time_to_collect_attestations > 3500', - labels: { severity: 'error' }, - for: '10m', - annotations: {}, - }, -]; - // Tests that the fee-asset price oracle value set on a mock L1 StateView contract gossips through the -// real libp2p validator network and converges on the rollup's on-chain price. Uses P2PNetworkTest with +// real libp2p validator network and converges on the rollup's on-chain price. Runs on the shared gossip +// skeleton (runGossipScenario) with txsPerNode:0 — no txs are submitted; instead the oracle price is +// adjusted twice and the on-chain price is asserted to converge. Uses P2PNetworkTest with // SHORTENED_BLOCK_TIME_CONFIG_NO_PRUNES (ethSlot=4s, aztecSlot=12s, epoch=4, proofSubEpochs=640) plus a // real prover node. CHECK_ALERTS env var gates optional Grafana alert validation. describe('e2e_p2p_network', () => { @@ -80,172 +60,106 @@ describe('e2e_p2p_network', () => { await tryStop(proverNode); await t.stopNodes(nodes); await t.teardown(); - for (let i = 0; i < NUM_VALIDATORS; i++) { - fs.rmSync(`${DATA_DIR}-${i}`, { recursive: true, force: true, maxRetries: 3 }); - } }); afterAll(async () => { - if (CHECK_ALERTS) { - const checker = new GrafanaClient(t.logger); - await checker.runAlertCheck(qosAlerts); - } + await maybeCheckQosAlerts(t.logger); }); // Deploys a MockStateView L1 contract, sets an initial oracle price, then starts 4 validator nodes // and a prover. Adjusts the oracle price twice and uses retryUntil to confirm the rollup's on-chain // price converges to each target within the gossip propagation window. it('should rollup txs from all peers', async () => { - // create the bootstrap node for the network - if (!t.bootstrapNodeEnr) { - throw new Error('Bootstrap node ENR is not available'); - } - const rollup = RollupContract.getFromL1ContractsValues(t.ctx.deployL1ContractsValues); const account = mnemonicToAccount(MNEMONIC, { addressIndex: 999 }); const walletClient = createExtendedL1Client(t.ctx.aztecNodeConfig.l1RpcUrls, account, foundry); - await t.ctx.ethCheatCodes.setBalance(account.address, 100n * 10n ** 18n); - - const mockStateView = await MockStateView.deploy(t.ctx.ethCheatCodes, walletClient, STATE_VIEW_ADDRESS); - - // The initial oracle price (default value) is 1e7 - await mockStateView.setEthPerFeeAsset(10n ** 7n); - - await t.ctx.ethCheatCodes.mineEmptyBlock(); - await t.ctx.ethCheatCodes.mine(10); - await t.ctx.ethCheatCodes.mineEmptyBlock(); - - t.logger.info('Creating validator nodes'); - nodes = await createNodes( - t.ctx.aztecNodeConfig, - t.ctx.dateProvider!, - t.bootstrapNodeEnr, - NUM_VALIDATORS, - BOOT_NODE_UDP_PORT, - t.genesis, - DATA_DIR, - // To collect metrics - run in aztec-packages `docker compose --profile metrics up` and set COLLECT_METRICS=true - shouldCollectMetrics(), - ); - - t.logger.warn(`Creating prover node`); - ({ proverNode } = await createProverNode( - { ...t.ctx.aztecNodeConfig, minTxsPerBlock: 0 }, - BOOT_NODE_UDP_PORT + NUM_VALIDATORS + 1, - t.bootstrapNodeEnr, - ATTESTER_PRIVATE_KEYS_START_INDEX + NUM_VALIDATORS + 1, - { dateProvider: t.ctx.dateProvider! }, - t.genesis, - `${DATA_DIR}-prover`, - shouldCollectMetrics(), - )); - - // Wait for the validators to discover each other and form a gossip mesh before proceeding. - await t.waitForP2PMeshConnectivity(nodes); - - // We need to `createNodes` before we setup account, because - // those nodes actually form the committee, and so we cannot build - // blocks without them (since targetCommitteeSize is set to the number of nodes) - await t.setupAccount(); - - // Wait until the other nodes sync to the block from which we sent the tx - const targetBlock = await t.ctx.aztecNode.getBlockNumber(); - t.logger.warn(`Waiting for all nodes to sync to block number ${targetBlock}`); - await retryUntil( - async () => { - const blockNumbers = await Promise.all(nodes.map(node => node.getBlockNumber())); - const checkpointNumber = (await t.monitor.run()).checkpointNumber; - t.logger.info(`Current block numbers ${blockNumbers} (checkpoint number on L1 is ${checkpointNumber})`); - return blockNumbers.every(bn => bn >= targetBlock); - }, - `nodes to sync to block number ${targetBlock}`, - 30, - 0.5, - ); - - for (const node of nodes) { - await node.setConfig({ minTxsPerBlock: 0 }); - } - - const targetOraclePrice = (BigInt(10n ** 7n) * 1025n) / 1000n; - await mockStateView.setEthPerFeeAsset(targetOraclePrice); - t.logger.info(`Set uniswap price to ${targetOraclePrice}`); - - // Get initial on-chain price - const initialOnChainPrice = await rollup.getEthPerFeeAsset(); - t.logger.info(`Initial on-chain price: ${initialOnChainPrice}, target oracle price: ${targetOraclePrice}`); - - // Gather signers from attestations downloaded from L1. setupAccount() no longer sends a tx, - // so when the test reaches here no checkpoint may have been published yet; wait for the - // archiver to index the first published checkpoint before reading attestations. - const dataStore = (nodes[0] as AztecNodeService).getBlockSource() as Archiver; - t.logger.warn('Waiting for first checkpoint to be published and indexed by the archiver'); - const publishedCheckpoint = await retryUntil( - async () => { - const blockNumbers = await Promise.all(nodes.map(node => node.getBlockNumber())); - const checkpointNumber = (await t.monitor.run()).checkpointNumber; - t.logger.info(`Current block numbers ${blockNumbers} (checkpoint number on L1 is ${checkpointNumber})`); - const [checkpoint] = await dataStore.getCheckpoints({ from: CheckpointNumber(1), limit: 1 }); - return checkpoint; - }, - 'published checkpoint to be indexed', - 120, - 1, - ); - const signatureContext = { - chainId: t.ctx.aztecNodeConfig.l1ChainId, - rollupAddress: t.ctx.deployL1ContractsValues.l1ContractAddresses.rollupAddress, - }; - const payload = ConsensusPayload.fromCheckpoint(publishedCheckpoint.checkpoint, signatureContext); - const attestations = publishedCheckpoint.attestations - .filter(a => !a.signature.isEmpty()) - .map(a => new CheckpointAttestation(payload, a.signature, Signature.empty())); - const signers = await Promise.all(attestations.map(att => att.getSender()!.toString())); - t.logger.info(`Attestation signers`, { signers }); - - // Check that the signers found are part of the proposer nodes to ensure the archiver fetched them right - const validatorAddresses = nodes.flatMap(node => - ((node as AztecNodeService).getSequencer() as SequencerClient).validatorAddresses?.map(a => a.toString()), - ); - t.logger.info(`Validator addresses`, { addresses: validatorAddresses }); - for (const signer of signers) { - expect(validatorAddresses).toContain(signer); - } - - await retryUntil( - async () => { - const currentPrice = await rollup.getEthPerFeeAsset(); - t.logger.info(`Current on-chain price: ${currentPrice}, waiting for: ${targetOraclePrice}`); - return diffInBps(currentPrice, targetOraclePrice) == 0n; + let mockStateView!: MockStateView; + let initialOnChainPrice!: bigint; + let targetOraclePrice!: bigint; + + nodes = await runGossipScenario({ + t, + numValidators: NUM_VALIDATORS, + bootNodePort: BOOT_NODE_UDP_PORT, + txsPerNode: 0, + checkpointSource: 'first-published', + beforeCreateNodes: async () => { + await t.ctx.ethCheatCodes.setBalance(account.address, 100n * 10n ** 18n); + + mockStateView = await MockStateView.deploy(t.ctx.ethCheatCodes, walletClient, STATE_VIEW_ADDRESS); + + // The initial oracle price (default value) is 1e7 + await mockStateView.setEthPerFeeAsset(10n ** 7n); + + await t.ctx.ethCheatCodes.mineEmptyBlock(); + await t.ctx.ethCheatCodes.mine(10); + await t.ctx.ethCheatCodes.mineEmptyBlock(); }, - 'price convergence toward oracle', - 120, // timeout in seconds - 5, // check interval in seconds - ); - - const priceAfterFirstAlignment = await rollup.getEthPerFeeAsset(); - const targetOraclePrice2 = (BigInt(priceAfterFirstAlignment) * 995n) / 1000n; - await mockStateView.setEthPerFeeAsset(targetOraclePrice2); - t.logger.info(`Set uniswap price to ${targetOraclePrice}`); - - await retryUntil( - async () => { - const currentPrice = await rollup.getEthPerFeeAsset(); - t.logger.info(`Current on-chain price: ${currentPrice}, waiting for: ${targetOraclePrice2}`); - return diffInBps(currentPrice, targetOraclePrice2) == 0n; + createExtraNodes: async () => { + t.logger.warn(`Creating prover node`); + ({ proverNode } = await createProverNode( + { ...t.ctx.aztecNodeConfig, minTxsPerBlock: 0 }, + BOOT_NODE_UDP_PORT + NUM_VALIDATORS + 1, + t.bootstrapNodeEnr, + ATTESTER_PRIVATE_KEYS_START_INDEX + NUM_VALIDATORS + 1, + { dateProvider: t.ctx.dateProvider! }, + t.genesis, + t.dataDirFor('prover'), + shouldCollectMetrics(), + )); }, - 'price convergence toward oracle', - 120, // timeout in seconds - 5, // check interval in seconds - ); + beforeSubmit: async nodes => { + await waitForNodesToSync(t, nodes); + + for (const node of nodes) { + await node.setConfig({ minTxsPerBlock: 0 }); + } - const finalPrice = await rollup.getEthPerFeeAsset(); - t.logger.info(`Final on-chain price: ${finalPrice}`); + targetOraclePrice = (BigInt(10n ** 7n) * 1025n) / 1000n; + await mockStateView.setEthPerFeeAsset(targetOraclePrice); + t.logger.info(`Set uniswap price to ${targetOraclePrice}`); - // Verify the price moved toward the oracle price - expect(finalPrice).toBeGreaterThan(initialOnChainPrice); - expect(diffInBps(finalPrice, targetOraclePrice2)).toBe(0n); + // Get initial on-chain price + initialOnChainPrice = await rollup.getEthPerFeeAsset(); + t.logger.info(`Initial on-chain price: ${initialOnChainPrice}, target oracle price: ${targetOraclePrice}`); + }, + afterVerify: async () => { + await retryUntil( + async () => { + const currentPrice = await rollup.getEthPerFeeAsset(); + t.logger.info(`Current on-chain price: ${currentPrice}, waiting for: ${targetOraclePrice}`); + return diffInBps(currentPrice, targetOraclePrice) == 0n; + }, + 'price convergence toward oracle', + 120, // timeout in seconds + 5, // check interval in seconds + ); + + const priceAfterFirstAlignment = await rollup.getEthPerFeeAsset(); + const targetOraclePrice2 = (BigInt(priceAfterFirstAlignment) * 995n) / 1000n; + await mockStateView.setEthPerFeeAsset(targetOraclePrice2); + t.logger.info(`Set uniswap price to ${targetOraclePrice}`); + + await retryUntil( + async () => { + const currentPrice = await rollup.getEthPerFeeAsset(); + t.logger.info(`Current on-chain price: ${currentPrice}, waiting for: ${targetOraclePrice2}`); + return diffInBps(currentPrice, targetOraclePrice2) == 0n; + }, + 'price convergence toward oracle', + 120, // timeout in seconds + 5, // check interval in seconds + ); + + const finalPrice = await rollup.getEthPerFeeAsset(); + t.logger.info(`Final on-chain price: ${finalPrice}`); + + // Verify the price moved toward the oracle price + expect(finalPrice).toBeGreaterThan(initialOnChainPrice); + expect(diffInBps(finalPrice, targetOraclePrice2)).toBe(0n); + }, + }); }); }); diff --git a/yarn-project/end-to-end/src/p2p/gossip_network.test.ts b/yarn-project/end-to-end/src/p2p/gossip_network.test.ts index 1edd171f69eb..a1df69dce8cd 100644 --- a/yarn-project/end-to-end/src/p2p/gossip_network.test.ts +++ b/yarn-project/end-to-end/src/p2p/gossip_network.test.ts @@ -1,33 +1,30 @@ -import type { Archiver } from '@aztec/archiver'; import type { AztecNodeConfig, AztecNodeService } from '@aztec/aztec-node'; -import { TxHash } from '@aztec/aztec.js/tx'; -import { BlockNumber } from '@aztec/foundation/branded-types'; -import { Signature } from '@aztec/foundation/eth-signature'; +import { EthAddress } from '@aztec/aztec.js/addresses'; +import { Fr } from '@aztec/aztec.js/fields'; +import { addL1Validator } from '@aztec/cli/l1/validators'; +import { RollupContract } from '@aztec/ethereum/contracts'; +import { EpochNumber } from '@aztec/foundation/branded-types'; import { retryUntil } from '@aztec/foundation/retry'; -import type { SequencerClient } from '@aztec/sequencer-client'; +import { sleep } from '@aztec/foundation/sleep'; +import { MockZKPassportVerifierAbi } from '@aztec/l1-artifacts/MockZKPassportVerifierAbi'; +import { RollupAbi } from '@aztec/l1-artifacts/RollupAbi'; import { tryStop } from '@aztec/stdlib/interfaces/server'; -import { CheckpointAttestation, ConsensusPayload } from '@aztec/stdlib/p2p'; +import { TopicType } from '@aztec/stdlib/p2p'; +import { ZkPassportProofParams } from '@aztec/stdlib/zkpassport'; import { jest } from '@jest/globals'; -import fs from 'fs'; -import os from 'os'; -import path from 'path'; +import { getContract } from 'viem'; import { shouldCollectMetrics } from '../fixtures/fixtures.js'; import { ATTESTER_PRIVATE_KEYS_START_INDEX, - createNodes, createNonValidatorNode, createProverNode, } from '../fixtures/setup_p2p_test.js'; -import { waitForTxs } from '../fixtures/wait_helpers.js'; -import { type AlertConfig, GrafanaClient } from '../quality_of_service/grafana_client.js'; -import { P2PNetworkTest, SHORTENED_BLOCK_TIME_CONFIG_NO_PRUNES, WAIT_FOR_TX_TIMEOUT } from './p2p_network.js'; -import { submitTransactions } from './shared.js'; +import { P2PNetworkTest, SHORTENED_BLOCK_TIME_CONFIG_NO_PRUNES } from './p2p_network.js'; +import { maybeCheckQosAlerts, runGossipScenario, waitForNodesToSync } from './shared.js'; -const CHECK_ALERTS = process.env.CHECK_ALERTS === 'true'; - -// Don't set this to a higher value than 9 because each node will use a different L1 publisher account and anvil seeds +// Don't set NUM_VALIDATORS higher than 9 because each node uses a different L1 publisher account and anvil seeds. const NUM_VALIDATORS = 4; const NUM_TXS_PER_NODE = 2; const BOOT_NODE_UDP_PORT = process.env.BOOT_NODE_UDP_PORT ? parseInt(process.env.BOOT_NODE_UDP_PORT) : 4500; @@ -35,195 +32,277 @@ const AZTEC_SLOT_DURATION = 24; const AZTEC_EPOCH_DURATION = 4; const BLOCK_DURATION_MS = 10_000; -const DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'gossip-')); - jest.setTimeout(1000 * 60 * 10); -const qosAlerts: AlertConfig[] = [ - { - alert: 'SequencerTimeToCollectAttestations', - expr: 'aztec_sequencer_time_to_collect_attestations > 3500', - labels: { severity: 'error' }, - for: '10m', - annotations: {}, - }, -]; - -// Tests end-to-end gossip propagation with 4 validators, a fake prover node, and a non-validator -// monitoring node (alwaysReexecuteBlockProposals:true). Uses P2PNetworkTest with real libp2p, -// SHORTENED_BLOCK_TIME_CONFIG_NO_PRUNES (ethSlot=4s, aztecSlot=24s, epoch=4, proofSubEpochs=640), -// inboxLag=2. Asserts txs are mined from all nodes, attestation signers match the validator set, -// and the prover node produces a proven block by collecting txs from p2p. +// End-to-end gossip propagation over real libp2p with 4 validators, using the shared +// bootstrap->createNodes->mesh->account->submit->verify skeleton (runGossipScenario). Both describes use +// P2PNetworkTest with SHORTENED_BLOCK_TIME_CONFIG_NO_PRUNES (ethSlot=4s, proofSubEpochs=640), aztecSlot=24s, +// blockDurationMs=10000, inboxLag=2, and differ only in how the validator set is registered. describe('e2e_p2p_network', () => { - let t: P2PNetworkTest; - let nodes: AztecNodeService[]; - let proverAztecNode: AztecNodeService; - let monitoringNode: AztecNodeService; - - beforeEach(async () => { - t = await P2PNetworkTest.create({ - testName: 'e2e_p2p_network', - numberOfNodes: 0, - numberOfValidators: NUM_VALIDATORS, - basePort: BOOT_NODE_UDP_PORT, - metricsPort: shouldCollectMetrics(), - startProverNode: false, // we'll start our own using p2p - initialConfig: { - ...SHORTENED_BLOCK_TIME_CONFIG_NO_PRUNES, - aztecSlotDuration: AZTEC_SLOT_DURATION, - aztecEpochDuration: AZTEC_EPOCH_DURATION, - blockDurationMs: BLOCK_DURATION_MS, - slashingRoundSizeInEpochs: 2, - slashingQuorum: 5, - listenAddress: '127.0.0.1', - inboxLag: 2, - }, + // Registers validators via applyBaseSetup()'s MultiAdder cheat shortcut, then stands up 4 validators + + // a fake prover node (p2p-only tx collection) + a non-validator monitoring node + // (alwaysReexecuteBlockProposals:true). Asserts txs mine from every node, attestation signers match the + // validator set, and the prover eventually produces a proven block by collecting txs from p2p. + describe('cheat-registered validators', () => { + let t: P2PNetworkTest; + let nodes: AztecNodeService[]; + let proverAztecNode: AztecNodeService; + let monitoringNode: AztecNodeService; + + beforeEach(async () => { + t = await P2PNetworkTest.create({ + testName: 'e2e_p2p_network', + numberOfNodes: 0, + numberOfValidators: NUM_VALIDATORS, + basePort: BOOT_NODE_UDP_PORT, + metricsPort: shouldCollectMetrics(), + startProverNode: false, // we'll start our own using p2p + initialConfig: { + ...SHORTENED_BLOCK_TIME_CONFIG_NO_PRUNES, + aztecSlotDuration: AZTEC_SLOT_DURATION, + aztecEpochDuration: AZTEC_EPOCH_DURATION, + blockDurationMs: BLOCK_DURATION_MS, + slashingRoundSizeInEpochs: 2, + slashingQuorum: 5, + listenAddress: '127.0.0.1', + inboxLag: 2, + }, + }); + + await t.setup(); + await t.applyBaseSetup(); }); - await t.setup(); - await t.applyBaseSetup(); - }); + afterEach(async () => { + await tryStop(proverAztecNode); + await tryStop(monitoringNode); + await t.stopNodes(nodes); + await t.teardown(); + }); - afterEach(async () => { - await tryStop(proverAztecNode); - await tryStop(monitoringNode); - await t.stopNodes(nodes); - await t.teardown(); - for (let i = 0; i < NUM_VALIDATORS; i++) { - fs.rmSync(`${DATA_DIR}-${i}`, { recursive: true, force: true, maxRetries: 3 }); - } - }); + afterAll(async () => { + await maybeCheckQosAlerts(t.logger); + }); + + it('should rollup txs from all peers', async () => { + nodes = await runGossipScenario({ + t, + numValidators: NUM_VALIDATORS, + bootNodePort: BOOT_NODE_UDP_PORT, + txsPerNode: NUM_TXS_PER_NODE, + createExtraNodes: async () => { + // A prover node that uses p2p only (not rpc) to gather txs, to test prover tx collection. + t.logger.warn(`Creating prover node`); + ({ proverNode: proverAztecNode } = await createProverNode( + t.ctx.aztecNodeConfig, + BOOT_NODE_UDP_PORT + NUM_VALIDATORS + 1, + t.bootstrapNodeEnr, + ATTESTER_PRIVATE_KEYS_START_INDEX + NUM_VALIDATORS + 1, + { dateProvider: t.ctx.dateProvider }, + t.genesis, + t.dataDirFor('prover'), + shouldCollectMetrics(), + )); - afterAll(async () => { - if (CHECK_ALERTS) { - const checker = new GrafanaClient(t.logger); - await checker.runAlertCheck(qosAlerts); - } + t.logger.warn(`Creating non validator node`); + const monitoringNodeConfig: AztecNodeConfig = { + ...t.ctx.aztecNodeConfig, + alwaysReexecuteBlockProposals: true, + }; + monitoringNode = await createNonValidatorNode( + monitoringNodeConfig, + t.ctx.dateProvider, + BOOT_NODE_UDP_PORT + NUM_VALIDATORS + 2, + t.bootstrapNodeEnr, + t.genesis, + t.dataDirFor('monitor'), + shouldCollectMetrics(), + ); + }, + beforeSubmit: nodes => waitForNodesToSync(t, nodes), + afterVerify: async nodes => { + // Ensure prover node did its job and collected txs from p2p + await retryUntil( + async () => { + const provenBlock = await nodes[0].getBlockNumber('proven'); + return provenBlock > 0; + }, + 'proven block', + SHORTENED_BLOCK_TIME_CONFIG_NO_PRUNES.aztecProofSubmissionEpochs * + AZTEC_EPOCH_DURATION * + AZTEC_SLOT_DURATION, + ); + }, + }); + }); }); - // Stands up 4 validators + 1 prover + 1 re-execution monitor, submits 2 txs per node, and waits - // for all txs to mine. Checks attestation signers match the validator set and confirms the prover - // eventually produces a proven block (collecting txs from p2p rather than RPC). - it('should rollup txs from all peers', async () => { - // create the bootstrap node for the network - if (!t.bootstrapNodeEnr) { - throw new Error('Bootstrap node ENR is not available'); - } - - // create our network of nodes and submit txs into each of them - // the number of txs per node and the number of txs per rollup - // should be set so that the only way for rollups to be built - // is if the txs are successfully gossiped around the nodes. - const txsSentViaDifferentNodes: TxHash[][] = []; - t.logger.info('Creating validator nodes'); - nodes = await createNodes( - t.ctx.aztecNodeConfig, - t.ctx.dateProvider, - t.bootstrapNodeEnr, - NUM_VALIDATORS, - BOOT_NODE_UDP_PORT, - t.genesis, - DATA_DIR, - // To collect metrics - run in aztec-packages `docker compose --profile metrics up` and set COLLECT_METRICS=true - shouldCollectMetrics(), - ); - - // create a prover node that uses p2p only (not rpc) to gather txs to test prover tx collection - t.logger.warn(`Creating prover node`); - ({ proverNode: proverAztecNode } = await createProverNode( - t.ctx.aztecNodeConfig, - BOOT_NODE_UDP_PORT + NUM_VALIDATORS + 1, - t.bootstrapNodeEnr, - ATTESTER_PRIVATE_KEYS_START_INDEX + NUM_VALIDATORS + 1, - { dateProvider: t.ctx.dateProvider }, - t.genesis, - `${DATA_DIR}-prover`, - shouldCollectMetrics(), - )); - - t.logger.warn(`Creating non validator node`); - const monitoringNodeConfig: AztecNodeConfig = { ...t.ctx.aztecNodeConfig, alwaysReexecuteBlockProposals: true }; - monitoringNode = await createNonValidatorNode( - monitoringNodeConfig, - t.ctx.dateProvider, - BOOT_NODE_UDP_PORT + NUM_VALIDATORS + 2, - t.bootstrapNodeEnr, - t.genesis, - `${DATA_DIR}-monitor`, - shouldCollectMetrics(), - ); - - t.logger.info('Waiting for nodes to connect'); - await t.waitForP2PMeshConnectivity(nodes, NUM_VALIDATORS); - - // We need to `createNodes` before we setup account, because - // those nodes actually form the committee, and so we cannot build - // blocks without them (since targetCommitteeSize is set to the number of nodes) - await t.setupAccount(); - - // Wait until the other nodes sync to the block from which we sent the tx - const targetBlock = await t.ctx.aztecNode.getBlockNumber(); - t.logger.warn(`Waiting for all nodes to sync to block number ${targetBlock}`); - await retryUntil( - async () => { - const blockNumbers = await Promise.all(nodes.map(node => node.getBlockNumber())); - const checkpointNumber = (await t.monitor.run()).checkpointNumber; - t.logger.info(`Current block numbers ${blockNumbers} (checkpoint number on L1 is ${checkpointNumber})`); - return blockNumbers.every(bn => bn >= targetBlock); - }, - `nodes to sync to block number ${targetBlock}`, - 30, - 0.5, - ); - - t.logger.info('Submitting transactions'); - // Each submitTransactions call builds its own wallet/PXE, so submissions are independent and can run - // concurrently. Promise.all preserves node order, keeping txsSentViaDifferentNodes[i] aligned with nodes[i]. - const submitted = await Promise.all( - nodes.map(node => submitTransactions(t.logger, node, NUM_TXS_PER_NODE, t.fundedAccount)), - ); - txsSentViaDifferentNodes.push(...submitted); - - t.logger.info('Waiting for transactions to be mined'); - // now ensure that all txs were successfully mined - const allTxHashes = txsSentViaDifferentNodes.flat(); - await waitForTxs(nodes[0], allTxHashes, { timeout: WAIT_FOR_TX_TIMEOUT }); - t.logger.info('All transactions mined'); - - // Gather signers from attestations downloaded from L1 - const receipt = await nodes[0].getTxReceipt(txsSentViaDifferentNodes[0][0]); - const blockNumber = receipt.blockNumber!; - const dataStore = (nodes[0] as AztecNodeService).getBlockSource() as Archiver; - const blockData = await dataStore.getBlockData({ number: BlockNumber(blockNumber) }); - const [publishedCheckpoint] = await dataStore.getCheckpoints({ from: blockData!.checkpointNumber, limit: 1 }); - const signatureContext = { - chainId: t.ctx.aztecNodeConfig.l1ChainId, - rollupAddress: t.ctx.deployL1ContractsValues.l1ContractAddresses.rollupAddress, - }; - const payload = ConsensusPayload.fromCheckpoint(publishedCheckpoint.checkpoint, signatureContext); - const attestations = publishedCheckpoint.attestations - .filter(a => !a.signature.isEmpty()) - .map(a => new CheckpointAttestation(payload, a.signature, Signature.empty())); - const signers = await Promise.all(attestations.map(att => att.getSender()!.toString())); - t.logger.info(`Attestation signers`, { signers }); - - // Check that the signers found are part of the proposer nodes to ensure the archiver fetched them right - const validatorAddresses = nodes.flatMap(node => - ((node as AztecNodeService).getSequencer() as SequencerClient).validatorAddresses?.map(a => a.toString()), - ); - t.logger.info(`Validator addresses`, { addresses: validatorAddresses }); - for (const signer of signers) { - expect(validatorAddresses).toContain(signer); - } - - // Ensure prover node did its job and collected txs from p2p - await retryUntil( - async () => { - const provenBlock = await nodes[0].getBlockNumber('proven'); - return provenBlock > 0; - }, - 'proven block', - SHORTENED_BLOCK_TIME_CONFIG_NO_PRUNES.aztecProofSubmissionEpochs * AZTEC_EPOCH_DURATION * AZTEC_SLOT_DURATION, - ); + // Registers validators via the real addL1Validator CLI path (with a ZkPassport mock proof) instead of + // the MultiAdder cheat shortcut, then submits txs to each node. Asserts the registration took effect + // on-chain, all txs mine, and attestation signers match the registered validator set. + describe('on-chain-registered validators (no cheats)', () => { + let t: P2PNetworkTest; + let nodes: AztecNodeService[]; + + beforeEach(async () => { + t = await P2PNetworkTest.create({ + testName: 'e2e_p2p_network', + numberOfNodes: 0, + numberOfValidators: NUM_VALIDATORS, + basePort: BOOT_NODE_UDP_PORT, + metricsPort: shouldCollectMetrics(), + initialConfig: { + ...SHORTENED_BLOCK_TIME_CONFIG_NO_PRUNES, + aztecSlotDuration: AZTEC_SLOT_DURATION, + blockDurationMs: BLOCK_DURATION_MS, + listenAddress: '127.0.0.1', + // Allow empty blocks so the first checkpoint can be published before any txs are submitted. + // Without this, no blocks are built until txs arrive, and a failed checkpoint during tx + // submission causes block pruning that invalidates tx references. + minTxsPerBlock: 0, + inboxLag: 2, + }, + }); + + await t.setup(); + await t.addBootstrapNode(); + }); + + afterEach(async () => { + await t.stopNodes(nodes); + await t.teardown(); + }); + + afterAll(async () => { + await maybeCheckQosAlerts(t.logger); + }); + + it('should rollup txs from all peers (and add the validators without cheating)', async () => { + nodes = await runGossipScenario({ + t, + numValidators: NUM_VALIDATORS, + bootNodePort: BOOT_NODE_UDP_PORT, + txsPerNode: NUM_TXS_PER_NODE, + submitSequentially: true, + // A full mesh (N-1 peers per node) on the proposal/checkpoint topics is required: with + // skipInitialSequencer the first blocks are built by this committee, and the first checkpoint must + // reach quorum (all 4 validators) to land on L1. If those meshes are only partly formed, some + // committee members miss the first proposal, the checkpoint stalls at 2/3, and every later slot + // rebuilds a competing un-checkpointed block 1 that peers reject as `block_number_already_exists` + // — a permanent 2/3 deadlock. + mesh: { + expectedNodeCount: NUM_VALIDATORS, + timeoutSeconds: 60, + checkIntervalSeconds: 0.5, + topics: [TopicType.block_proposal, TopicType.checkpoint_proposal, TopicType.checkpoint_attestation], + minMeshPeerCount: NUM_VALIDATORS - 1, + }, + beforeCreateNodes: async () => { + expect(t.ctx.deployL1ContractsValues.l1ContractAddresses.stakingAssetHandlerAddress).toBeDefined(); + + const { validators } = t.getValidators(); + + const rollupWrapper = RollupContract.getFromL1ContractsValues(t.ctx.deployL1ContractsValues); + + const rollup = getContract({ + address: t.ctx.deployL1ContractsValues.l1ContractAddresses.rollupAddress.toString(), + abi: RollupAbi, + client: t.ctx.deployL1ContractsValues.l1Client, + }); + + const zkPassportVerifier = getContract({ + address: t.ctx.deployL1ContractsValues.l1ContractAddresses.zkPassportVerifierAddress!.toString(), + abi: MockZKPassportVerifierAbi, + client: t.ctx.deployL1ContractsValues.l1Client, + }); + + expect((await rollupWrapper.getAttesters()).length).toBe(0); + + // Use the base account as the withdrawer for all validators in this test + const withdrawerAddress = EthAddress.fromString(t.baseAccount.address); + + // Add the validators to the rollup using the same function as the CLI + for (let i = 0; i < validators.length; i++) { + const validator = validators[i]; + const mockPassportProof = ZkPassportProofParams.random().toBuffer(); + await addL1Validator({ + rpcUrls: t.ctx.aztecNodeConfig.l1RpcUrls, + chainId: t.ctx.aztecNodeConfig.l1ChainId, + privateKey: t.baseAccountPrivateKey, + mnemonic: undefined, + attesterAddress: EthAddress.fromString(validator.attester.toString()), + withdrawerAddress, + stakingAssetHandlerAddress: t.ctx.deployL1ContractsValues.l1ContractAddresses.stakingAssetHandlerAddress!, + proofParams: mockPassportProof, + blsSecretKey: Fr.random().toBigInt(), + log: t.logger.info, + debugLogger: t.logger, + }); + + // mock nullifiers - increment the id in the mock zk passport verifier + t.logger.info('Incrementing unique identifier in mock zk passport verifier'); + await t.ctx.deployL1ContractsValues.l1Client.waitForTransactionReceipt({ + hash: await zkPassportVerifier.write.incrementUniqueIdentifier(), + }); + } + + await t.ctx.deployL1ContractsValues.l1Client.waitForTransactionReceipt({ + hash: await rollup.write.flushEntryQueue(), + }); + + const attestersImmedatelyAfterAdding = await rollupWrapper.getAttesters(); + expect(attestersImmedatelyAfterAdding.length).toBe(validators.length); + + // Check that the validators are added correctly + for (const validator of validators) { + const info = await rollupWrapper.getAttesterView(validator.attester.toString()); + expect(info.config.withdrawer.toChecksumString()).toBe(withdrawerAddress.toChecksumString()); + } + + // Wait for the validators to be added to the rollup + const timestamp = await t.ctx.cheatCodes.rollup.advanceToEpoch( + EpochNumber(t.ctx.aztecNodeConfig.lagInEpochsForValidatorSet + 1), + ); + + // Changes have now taken effect + const attesters = await rollupWrapper.getAttesters(); + expect(attesters.length).toBe(validators.length); + expect(attesters.length).toBe(NUM_VALIDATORS); + + // Send and await a tx to make sure we mine a block for the warp to correctly progress. + await t.ctx.deployL1ContractsValues.l1Client.waitForTransactionReceipt({ + hash: await t.ctx.deployL1ContractsValues.l1Client.sendTransaction({ + to: t.baseAccount.address, + value: 1n, + account: t.baseAccount, + }), + }); + + // Set the system time in the node, only after we have warped the time and waited for a block + // Time is only set in the NEXT block + t.ctx.dateProvider.setTime(Number(timestamp) * 1000); + }, + beforeSubmit: async nodes => { + // Wait for the first checkpoint to be published to L1 before submitting transactions. + // With skipInitialSequencer, no blocks exist from setup, so the first blocks are built by the + // validator committee. If we submit txs before a checkpoint lands on L1, a failed checkpoint + // publish can prune locally-proposed blocks, causing txs to reference pruned block headers. + t.logger.info('Waiting for first checkpoint to be published'); + await retryUntil( + async () => (await nodes[0].getBlockNumber('checkpointed')) > 0, + 'first checkpoint published', + 120, + ); + t.logger.info('First checkpoint published'); + + // Wait for the next L1 block so that all nodes' getCurrentMinFees() caches are + // refreshed after the first L2 checkpoint is published. Without this, some wallets + // may estimate fees based on pre-checkpoint values (very low due to fee decay), + // while receiving nodes already see the post-checkpoint fees (much higher). + const ethereumSlotDuration = t.ctx.aztecNodeConfig.ethereumSlotDuration ?? 4; + await sleep((ethereumSlotDuration + 1) * 1000); + }, + }); + }); }); }); diff --git a/yarn-project/end-to-end/src/p2p/gossip_network_no_cheat.test.ts b/yarn-project/end-to-end/src/p2p/gossip_network_no_cheat.test.ts deleted file mode 100644 index f78bcccfa651..000000000000 --- a/yarn-project/end-to-end/src/p2p/gossip_network_no_cheat.test.ts +++ /dev/null @@ -1,294 +0,0 @@ -import type { Archiver } from '@aztec/archiver'; -import type { AztecNodeService } from '@aztec/aztec-node'; -import { EthAddress } from '@aztec/aztec.js/addresses'; -import { Fr } from '@aztec/aztec.js/fields'; -import { waitForTx } from '@aztec/aztec.js/node'; -import { TxHash } from '@aztec/aztec.js/tx'; -import { addL1Validator } from '@aztec/cli/l1/validators'; -import { RollupContract } from '@aztec/ethereum/contracts'; -import { BlockNumber, EpochNumber } from '@aztec/foundation/branded-types'; -import { Signature } from '@aztec/foundation/eth-signature'; -import { retryUntil } from '@aztec/foundation/retry'; -import { sleep } from '@aztec/foundation/sleep'; -import { MockZKPassportVerifierAbi } from '@aztec/l1-artifacts/MockZKPassportVerifierAbi'; -import { RollupAbi } from '@aztec/l1-artifacts/RollupAbi'; -import type { SequencerClient } from '@aztec/sequencer-client'; -import { CheckpointAttestation, ConsensusPayload, TopicType } from '@aztec/stdlib/p2p'; -import { ZkPassportProofParams } from '@aztec/stdlib/zkpassport'; - -import { jest } from '@jest/globals'; -import fs from 'fs'; -import os from 'os'; -import path from 'path'; -import { getContract } from 'viem'; - -import { shouldCollectMetrics } from '../fixtures/fixtures.js'; -import { createNodes } from '../fixtures/setup_p2p_test.js'; -import { type AlertConfig, GrafanaClient } from '../quality_of_service/grafana_client.js'; -import { P2PNetworkTest, SHORTENED_BLOCK_TIME_CONFIG_NO_PRUNES, WAIT_FOR_TX_TIMEOUT } from './p2p_network.js'; -import { submitTransactions } from './shared.js'; - -const CHECK_ALERTS = process.env.CHECK_ALERTS === 'true'; - -// Don't set this to a higher value than 9 because each node will use a different L1 publisher account and anvil seeds -const NUM_VALIDATORS = 4; -const NUM_TXS_PER_NODE = 2; -const BOOT_NODE_UDP_PORT = 4500; -const BLOCK_DURATION_MS = 10_000; - -const DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'gossip-')); - -jest.setTimeout(1000 * 60 * 10); - -const qosAlerts: AlertConfig[] = [ - { - alert: 'SequencerTimeToCollectAttestations', - expr: 'aztec_sequencer_time_to_collect_attestations > 3500', - labels: { severity: 'error' }, - for: '10m', - annotations: {}, - }, -]; - -// Tests gossip propagation using the real CLI validator-registration path (addL1Validator + ZkPassport mock) -// instead of the MultiAdder cheat shortcut used by applyBaseSetup. Uses P2PNetworkTest with -// SHORTENED_BLOCK_TIME_CONFIG_NO_PRUNES (ethSlot=4s, aztecSlot=24s, proofSubEpochs=640) and real libp2p. -// Distinct from gossip_network.test.ts specifically because of the validator-registration flow. -describe('e2e_p2p_network', () => { - let t: P2PNetworkTest; - let nodes: AztecNodeService[]; - - beforeEach(async () => { - t = await P2PNetworkTest.create({ - testName: 'e2e_p2p_network', - numberOfNodes: 0, - numberOfValidators: NUM_VALIDATORS, - basePort: BOOT_NODE_UDP_PORT, - metricsPort: shouldCollectMetrics(), - initialConfig: { - ...SHORTENED_BLOCK_TIME_CONFIG_NO_PRUNES, - aztecSlotDuration: 24, - blockDurationMs: BLOCK_DURATION_MS, - listenAddress: '127.0.0.1', - // Allow empty blocks so the first checkpoint can be published before any txs are submitted. - // Without this, no blocks are built until txs arrive, and a failed checkpoint during tx - // submission causes block pruning that invalidates tx references. - minTxsPerBlock: 0, - inboxLag: 2, - }, - }); - - await t.setup(); - await t.addBootstrapNode(); - }); - - afterEach(async () => { - await t.stopNodes(nodes); - await t.teardown(); - for (let i = 0; i < NUM_VALIDATORS; i++) { - fs.rmSync(`${DATA_DIR}-${i}`, { recursive: true, force: true, maxRetries: 3 }); - } - }); - - afterAll(async () => { - if (CHECK_ALERTS) { - const checker = new GrafanaClient(t.logger); - await checker.runAlertCheck(qosAlerts); - } - }); - - // Registers validators via the real addL1Validator CLI path (with ZkPassport mock proof), submits - // transactions to each node, and asserts all txs are mined and that attestation signers match - // the registered validator set. Validates the non-cheat registration flow end-to-end. - it('should rollup txs from all peers (and add the validators without cheating)', async () => { - // create the bootstrap node for the network - if (!t.bootstrapNodeEnr) { - throw new Error('Bootstrap node ENR is not available'); - } - - expect(t.ctx.deployL1ContractsValues.l1ContractAddresses.stakingAssetHandlerAddress).toBeDefined(); - - const { validators } = t.getValidators(); - - const rollupWrapper = RollupContract.getFromL1ContractsValues(t.ctx.deployL1ContractsValues); - - const rollup = getContract({ - address: t.ctx.deployL1ContractsValues.l1ContractAddresses.rollupAddress.toString(), - abi: RollupAbi, - client: t.ctx.deployL1ContractsValues.l1Client, - }); - - const zkPassportVerifier = getContract({ - address: t.ctx.deployL1ContractsValues.l1ContractAddresses.zkPassportVerifierAddress!.toString(), - abi: MockZKPassportVerifierAbi, - client: t.ctx.deployL1ContractsValues.l1Client, - }); - - expect((await rollupWrapper.getAttesters()).length).toBe(0); - - // Use the base account as the withdrawer for all validators in this test - const withdrawerAddress = EthAddress.fromString(t.baseAccount.address); - - // Add the validators to the rollup using the same function as the CLI - for (let i = 0; i < validators.length; i++) { - const validator = validators[i]; - const mockPassportProof = ZkPassportProofParams.random().toBuffer(); - await addL1Validator({ - rpcUrls: t.ctx.aztecNodeConfig.l1RpcUrls, - chainId: t.ctx.aztecNodeConfig.l1ChainId, - privateKey: t.baseAccountPrivateKey, - mnemonic: undefined, - attesterAddress: EthAddress.fromString(validator.attester.toString()), - withdrawerAddress, - stakingAssetHandlerAddress: t.ctx.deployL1ContractsValues.l1ContractAddresses.stakingAssetHandlerAddress!, - proofParams: mockPassportProof, - blsSecretKey: Fr.random().toBigInt(), - log: t.logger.info, - debugLogger: t.logger, - }); - - // mock nullifiers - increment the id in the mock zk passport verifier - t.logger.info('Incrementing unique identifier in mock zk passport verifier'); - await t.ctx.deployL1ContractsValues.l1Client.waitForTransactionReceipt({ - hash: await zkPassportVerifier.write.incrementUniqueIdentifier(), - }); - } - - await t.ctx.deployL1ContractsValues.l1Client.waitForTransactionReceipt({ - hash: await rollup.write.flushEntryQueue(), - }); - - const attestersImmedatelyAfterAdding = await rollupWrapper.getAttesters(); - expect(attestersImmedatelyAfterAdding.length).toBe(validators.length); - - // Check that the validators are added correctly - for (const validator of validators) { - const info = await rollupWrapper.getAttesterView(validator.attester.toString()); - expect(info.config.withdrawer.toChecksumString()).toBe(withdrawerAddress.toChecksumString()); - } - - // Wait for the validators to be added to the rollup - const timestamp = await t.ctx.cheatCodes.rollup.advanceToEpoch( - EpochNumber(t.ctx.aztecNodeConfig.lagInEpochsForValidatorSet + 1), - ); - - // Changes have now taken effect - const attesters = await rollupWrapper.getAttesters(); - expect(attesters.length).toBe(validators.length); - expect(attesters.length).toBe(NUM_VALIDATORS); - - // Send and await a tx to make sure we mine a block for the warp to correctly progress. - await t.ctx.deployL1ContractsValues.l1Client.waitForTransactionReceipt({ - hash: await t.ctx.deployL1ContractsValues.l1Client.sendTransaction({ - to: t.baseAccount.address, - value: 1n, - account: t.baseAccount, - }), - }); - - // Set the system time in the node, only after we have warped the time and waited for a block - // Time is only set in the NEXT block - t.ctx.dateProvider.setTime(Number(timestamp) * 1000); - - // create our network of nodes and submit txs into each of them - // the number of txs per node and the number of txs per rollup - // should be set so that the only way for rollups to be built - // is if the txs are successfully gossiped around the nodes. - const txsSentViaDifferentNodes: TxHash[][] = []; - t.logger.info('Creating nodes'); - nodes = await createNodes( - t.ctx.aztecNodeConfig, - t.ctx.dateProvider, - t.bootstrapNodeEnr, - NUM_VALIDATORS, - BOOT_NODE_UDP_PORT, - t.genesis, - DATA_DIR, - // To collect metrics - run in aztec-packages `docker compose --profile metrics up` and set COLLECT_METRICS=true - shouldCollectMetrics(), - ); - - // Wait for the gossipsub mesh to fully form before the committee starts producing. With - // skipInitialSequencer, the first blocks are built by this committee, and the first checkpoint - // must reach quorum (all 4 validators) to land on L1. If the proposal/checkpoint meshes are only - // partly formed, some committee members miss the first proposal, the first checkpoint stalls at - // 2/3, and every later slot rebuilds a competing un-checkpointed block 1 that peers reject as - // `block_number_already_exists` — a permanent 2/3 deadlock. Require a full mesh (N-1 peers per - // node) on the proposal/checkpoint topics so the first proposal reaches the whole committee. - await t.waitForP2PMeshConnectivity( - nodes, - NUM_VALIDATORS, - 60, - 0.5, - [TopicType.block_proposal, TopicType.checkpoint_proposal, TopicType.checkpoint_attestation], - NUM_VALIDATORS - 1, - ); - - // Wait for the first checkpoint to be published to L1 before submitting transactions. - // With skipInitialSequencer, no blocks exist from setup, so the first blocks are built by the - // validator committee. If we submit txs before a checkpoint lands on L1, a failed checkpoint - // publish can prune locally-proposed blocks, causing txs to reference pruned block headers. - t.logger.info('Waiting for first checkpoint to be published'); - await retryUntil( - async () => (await nodes[0].getBlockNumber('checkpointed')) > 0, - 'first checkpoint published', - 120, - ); - t.logger.info('First checkpoint published'); - - // We need to `createNodes` before we setup account, because - // those nodes actually form the committee, and so we cannot build - // blocks without them (since targetCommitteeSize is set to the number of nodes) - await t.setupAccount(); - - // Wait for the next L1 block so that all nodes' getCurrentMinFees() caches are - // refreshed after the first L2 checkpoint is published. Without this, some wallets - // may estimate fees based on pre-checkpoint values (very low due to fee decay), - // while receiving nodes already see the post-checkpoint fees (much higher). - const ethereumSlotDuration = t.ctx.aztecNodeConfig.ethereumSlotDuration ?? 4; - await sleep((ethereumSlotDuration + 1) * 1000); - - t.logger.info('Submitting transactions'); - for (const node of nodes) { - const txs = await submitTransactions(t.logger, node, NUM_TXS_PER_NODE, t.fundedAccount); - txsSentViaDifferentNodes.push(txs); - } - - t.logger.info('Waiting for transactions to be mined'); - // now ensure that all txs were successfully mined - await Promise.all( - txsSentViaDifferentNodes.flatMap((txs, i) => - txs.map((txHash, j) => { - t.logger.info(`Waiting for tx ${i}-${j}: ${txHash.toString()} to be mined`); - return waitForTx(nodes[0], txHash, { timeout: WAIT_FOR_TX_TIMEOUT }); - }), - ), - ); - t.logger.info('All transactions mined'); - - // Gather signers from attestations downloaded from L1 - const blockNumber = await nodes[0].getTxReceipt(txsSentViaDifferentNodes[0][0]).then(r => r.blockNumber!); - const dataStore = (nodes[0] as AztecNodeService).getBlockSource() as Archiver; - const blockData = await dataStore.getBlockData({ number: BlockNumber(blockNumber) }); - const [publishedCheckpoint] = await dataStore.getCheckpoints({ from: blockData!.checkpointNumber, limit: 1 }); - const signatureContext = { - chainId: t.ctx.aztecNodeConfig.l1ChainId, - rollupAddress: t.ctx.deployL1ContractsValues.l1ContractAddresses.rollupAddress, - }; - const payload = ConsensusPayload.fromCheckpoint(publishedCheckpoint.checkpoint, signatureContext); - const attestations = publishedCheckpoint.attestations - .filter(a => !a.signature.isEmpty()) - .map(a => new CheckpointAttestation(payload, a.signature, Signature.empty())); - const signers = await Promise.all(attestations.map(att => att.getSender()!.toString())); - t.logger.info(`Attestation signers`, { signers }); - - // Check that the signers found are part of the proposer nodes to ensure the archiver fetched them right - const validatorAddresses = nodes.flatMap(node => - ((node as AztecNodeService).getSequencer() as SequencerClient).validatorAddresses?.map(v => v.toString()), - ); - t.logger.info(`Validator addresses`, { addresses: validatorAddresses }); - for (const signer of signers) { - expect(validatorAddresses).toContain(signer); - } - }); -}); diff --git a/yarn-project/end-to-end/src/p2p/late_prover_tx_collection.test.ts b/yarn-project/end-to-end/src/p2p/late_prover_tx_collection.test.ts index 3735a18cf930..0c46f4693c49 100644 --- a/yarn-project/end-to-end/src/p2p/late_prover_tx_collection.test.ts +++ b/yarn-project/end-to-end/src/p2p/late_prover_tx_collection.test.ts @@ -6,9 +6,6 @@ import { tryStop } from '@aztec/stdlib/interfaces/server'; import type { Tx } from '@aztec/stdlib/tx'; import { jest } from '@jest/globals'; -import fs from 'fs'; -import os from 'os'; -import path from 'path'; import { shouldCollectMetrics } from '../fixtures/fixtures.js'; import { ATTESTER_PRIVATE_KEYS_START_INDEX, createNodes, createProverNode } from '../fixtures/setup_p2p_test.js'; @@ -27,8 +24,6 @@ const BOOT_NODE_UDP_PORT = process.env.BOOT_NODE_UDP_PORT ? parseInt(process.env const AZTEC_SLOT_DURATION = 12; const AZTEC_EPOCH_DURATION = 4; -const DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'late-prover-')); - jest.setTimeout(1000 * 60 * 10); // Tests the reqresp BLOCK_TXS path for a prover that joins after a block has already been mined. The @@ -69,10 +64,6 @@ describe('e2e_p2p_late_prover_tx_collection', () => { await tryStop(proverNode); await t.stopNodes(nodes); await t.teardown(); - for (let i = 0; i < NUM_VALIDATORS; i++) { - fs.rmSync(`${DATA_DIR}-${i}`, { recursive: true, force: true, maxRetries: 3 }); - } - fs.rmSync(`${DATA_DIR}-late-prover`, { recursive: true, force: true, maxRetries: 3 }); }); // Mines a block with 2 txs via 4 validators, then starts a prover node late (after gossip has already @@ -92,7 +83,7 @@ describe('e2e_p2p_late_prover_tx_collection', () => { NUM_VALIDATORS, BOOT_NODE_UDP_PORT, t.genesis, - DATA_DIR, + t.dataDirFor('validator'), shouldCollectMetrics(), ); await t.waitForP2PMeshConnectivity(nodes, NUM_VALIDATORS); @@ -128,7 +119,7 @@ describe('e2e_p2p_late_prover_tx_collection', () => { ATTESTER_PRIVATE_KEYS_START_INDEX + NUM_VALIDATORS + 1, { dateProvider: t.ctx.dateProvider }, t.genesis, - `${DATA_DIR}-late-prover`, + t.dataDirFor('late-prover'), shouldCollectMetrics(), )); diff --git a/yarn-project/end-to-end/src/p2p/p2p_network.ts b/yarn-project/end-to-end/src/p2p/p2p_network.ts index e8d95d08901e..045ca4529360 100644 --- a/yarn-project/end-to-end/src/p2p/p2p_network.ts +++ b/yarn-project/end-to-end/src/p2p/p2p_network.ts @@ -25,7 +25,10 @@ import type { GenesisData } from '@aztec/stdlib/world-state'; import { ZkPassportProofParams } from '@aztec/stdlib/zkpassport'; import { getGenesisValues } from '@aztec/world-state/testing'; +import fs from 'fs'; import getPort from 'get-port'; +import os from 'os'; +import path from 'path'; import { type GetContractReturnType, getAddress, getContract } from 'viem'; import { privateKeyToAccount } from 'viem/accounts'; @@ -84,6 +87,9 @@ export class P2PNetworkTest { public bootstrapNode?: BootstrapNode; + // Root temp directory holding every node's data dir. Created in setup(), removed in teardown(). + public dataDir!: string; + // Store setup options for use in setup() private setupOptions: SetupOptions; private deployL1ContractsArgs: any; @@ -194,6 +200,19 @@ export class P2PNetworkTest { return this.hardcodedAccountData; } + /** + * Returns a per-role data directory nested under this test's root data dir. The multi-node factories + * (`createNodes`) append `-` to this base for each node they spawn; single-node factories + * (`createProverNode`, `createNonValidatorNode`, `createNode`) use it verbatim. Everything lives under + * `this.dataDir`, so a single recursive remove in teardown() cleans up all of it. + */ + dataDirFor(label: string): string { + if (!this.dataDir) { + throw new Error('Call setup to initialize the data directory.'); + } + return path.join(this.dataDir, label); + } + async addBootstrapNode() { this.logger.info('Adding bootstrap node'); const telemetry = await getEndToEndTestTelemetryClient(this.metricsPort); @@ -356,6 +375,8 @@ export class P2PNetworkTest { async setup() { this.logger.info('Setting up subsystems from fresh'); + this.dataDir = fs.mkdtempSync(path.join(os.tmpdir(), `${this.testName}-`)); + // Pre-compute hardcoded account data so it gets funded in genesis. const contract = new SchnorrHardcodedKeyAccountContract(); const secret = Fr.random(); @@ -488,6 +509,9 @@ export class P2PNetworkTest { await this.monitor.stop(); await tryStop(this.bootstrapNode, this.logger); await teardown(this.context); + if (this.dataDir) { + fs.rmSync(this.dataDir, { recursive: true, force: true, maxRetries: 3 }); + } } async getContracts(): Promise<{ diff --git a/yarn-project/end-to-end/src/p2p/preferred_gossip_network.test.ts b/yarn-project/end-to-end/src/p2p/preferred_gossip_network.test.ts index a3da5c55ff03..b2242e916abb 100644 --- a/yarn-project/end-to-end/src/p2p/preferred_gossip_network.test.ts +++ b/yarn-project/end-to-end/src/p2p/preferred_gossip_network.test.ts @@ -1,27 +1,21 @@ -import type { Archiver } from '@aztec/archiver'; import type { AztecNodeConfig, AztecNodeService } from '@aztec/aztec-node'; import { waitForTx } from '@aztec/aztec.js/node'; import { TxHash } from '@aztec/aztec.js/tx'; -import { BlockNumber } from '@aztec/foundation/branded-types'; -import { Signature } from '@aztec/foundation/eth-signature'; import { retryUntil } from '@aztec/foundation/retry'; import { sleep } from '@aztec/foundation/sleep'; import { ENR, type P2PClient, type P2PService, type PeerId } from '@aztec/p2p'; -import type { SequencerClient } from '@aztec/sequencer-client'; -import { CheckpointAttestation, ConsensusPayload } from '@aztec/stdlib/p2p'; import { jest } from '@jest/globals'; -import fs from 'fs'; -import os from 'os'; -import path from 'path'; import { shouldCollectMetrics } from '../fixtures/fixtures.js'; import { createNodes } from '../fixtures/setup_p2p_test.js'; -import { type AlertConfig, GrafanaClient } from '../quality_of_service/grafana_client.js'; import { P2PNetworkTest, SHORTENED_BLOCK_TIME_CONFIG_NO_PRUNES, WAIT_FOR_TX_TIMEOUT } from './p2p_network.js'; -import { submitTransactions } from './shared.js'; - -const CHECK_ALERTS = process.env.CHECK_ALERTS === 'true'; +import { + getPublishedCheckpointForTx, + maybeCheckQosAlerts, + submitTransactions, + verifyAttestationSigners, +} from './shared.js'; /** * This test builds a network using preferred nodes (supernodes) @@ -48,20 +42,8 @@ const NUM_TXS_PER_NODE = 2; const BOOT_NODE_UDP_PORT = 4500; const BLOCK_DURATION_MS = 10_000; -const DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'gossip-')); - jest.setTimeout(1000 * 180 * 10); -const qosAlerts: AlertConfig[] = [ - { - alert: 'SequencerTimeToCollectAttestations', - expr: 'aztec_sequencer_time_to_collect_attestations > 3500', - labels: { severity: 'error' }, - for: '10m', - annotations: {}, - }, -]; - // Tests the preferred-node (supernode) topology: preferred nodes only accept validator connections; // a no-discovery validator connects exclusively through preferred nodes; gossip monitors assert that // traffic flows only through expected peers. Verifies txs mine and attestation signers match validators. @@ -165,16 +147,10 @@ describe('e2e_p2p_preferred_network', () => { afterEach(async () => { await t.stopNodes([t.ctx.aztecNodeService].concat(nodes).concat(validators).concat(preferredNodes)); await t.teardown(); - for (let i = 0; i < NUM_NODES + NUM_VALIDATORS + NUM_PREFERRED_NODES; i++) { - fs.rmSync(`${DATA_DIR}-${i}`, { recursive: true, force: true, maxRetries: 3 }); - } }); afterAll(async () => { - if (CHECK_ALERTS) { - const checker = new GrafanaClient(t.logger); - await checker.runAlertCheck(qosAlerts); - } + await maybeCheckQosAlerts(t.logger); }); // Creates a 7-node topology (2 regular + 2 preferred + 2 validators + 1 no-discovery validator), @@ -210,7 +186,7 @@ describe('e2e_p2p_preferred_network', () => { NUM_PREFERRED_NODES, BOOT_NODE_UDP_PORT, t.genesis, - DATA_DIR, + t.dataDirFor('node'), // To collect metrics - run in aztec-packages `docker compose --profile metrics up` and set COLLECT_METRICS=true shouldCollectMetrics(), indexOffset, @@ -244,7 +220,7 @@ describe('e2e_p2p_preferred_network', () => { NUM_NODES, BOOT_NODE_UDP_PORT, t.genesis, - DATA_DIR, + t.dataDirFor('node'), // To collect metrics - run in aztec-packages `docker compose --profile metrics up` and set COLLECT_METRICS=true shouldCollectMetrics(), indexOffset, @@ -267,7 +243,7 @@ describe('e2e_p2p_preferred_network', () => { NUM_VALIDATORS - 1, BOOT_NODE_UDP_PORT, t.genesis, - DATA_DIR, + t.dataDirFor('node'), // To collect metrics - run in aztec-packages `docker compose --profile metrics up` and set COLLECT_METRICS=true shouldCollectMetrics(), indexOffset, @@ -291,7 +267,7 @@ describe('e2e_p2p_preferred_network', () => { 1, BOOT_NODE_UDP_PORT, t.genesis, - DATA_DIR, + t.dataDirFor('node'), // To collect metrics - run in aztec-packages `docker compose --profile metrics up` and set COLLECT_METRICS=true shouldCollectMetrics(), indexOffset, @@ -372,7 +348,7 @@ describe('e2e_p2p_preferred_network', () => { t.logger.info('Waiting for transactions to be mined'); // now ensure that all txs were successfully mined - const receipts = await Promise.all( + await Promise.all( txsSentViaDifferentNodes.flatMap((txs, i) => txs.map((txHash, j) => { t.logger.info(`Waiting for tx ${i}-${j}: ${txHash.toString()} to be mined`); @@ -382,31 +358,9 @@ describe('e2e_p2p_preferred_network', () => { ); t.logger.info('All transactions mined'); - // Gather signers from attestations downloaded from L1 - const blockNumber = receipts[0].blockNumber!; - const dataStore = (nodes[0] as AztecNodeService).getBlockSource() as Archiver; - const blockData = await dataStore.getBlockData({ number: BlockNumber(blockNumber) }); - const [publishedCheckpoint] = await dataStore.getCheckpoints({ from: blockData!.checkpointNumber, limit: 1 }); - const signatureContext = { - chainId: t.ctx.aztecNodeConfig.l1ChainId, - rollupAddress: t.ctx.deployL1ContractsValues.l1ContractAddresses.rollupAddress, - }; - const payload = ConsensusPayload.fromCheckpoint(publishedCheckpoint.checkpoint, signatureContext); - const attestations = publishedCheckpoint.attestations - .filter(a => !a.signature.isEmpty()) - .map(a => new CheckpointAttestation(payload, a.signature, Signature.empty())); - const signers = await Promise.all(attestations.map(att => att.getSender()!.toString())); - t.logger.info(`Attestation signers`, { signers }); - + // Gather signers from attestations downloaded from L1 and check they belong to the validator set. + const publishedCheckpoint = await getPublishedCheckpointForTx(nodes[0], txsSentViaDifferentNodes[0][0]); + const signers = await verifyAttestationSigners(t, validators, publishedCheckpoint); expect(signers.length).toEqual(validators.length); - - // Check that the signers found are part of the proposer nodes to ensure the archiver fetched them right - const validatorAddresses = validators.flatMap(node => - ((node as AztecNodeService).getSequencer() as SequencerClient).validatorAddresses?.map(a => a.toString()), - ); - t.logger.info(`Validator addresses`, { addresses: validatorAddresses }); - for (const signer of signers) { - expect(validatorAddresses).toContain(signer); - } }); }); diff --git a/yarn-project/end-to-end/src/p2p/rediscovery.test.ts b/yarn-project/end-to-end/src/p2p/rediscovery.test.ts index 3b526dd479d0..7eed53674dd9 100644 --- a/yarn-project/end-to-end/src/p2p/rediscovery.test.ts +++ b/yarn-project/end-to-end/src/p2p/rediscovery.test.ts @@ -2,10 +2,6 @@ import type { AztecNodeService } from '@aztec/aztec-node'; import { waitForTx } from '@aztec/aztec.js/node'; import { TxHash } from '@aztec/aztec.js/tx'; -import fs from 'fs'; -import os from 'os'; -import path from 'path'; - import { shouldCollectMetrics } from '../fixtures/fixtures.js'; import { createNode, createNodes } from '../fixtures/setup_p2p_test.js'; import { P2PNetworkTest, SHORTENED_BLOCK_TIME_CONFIG_NO_PRUNES, WAIT_FOR_TX_TIMEOUT } from './p2p_network.js'; @@ -17,8 +13,6 @@ const NUM_TXS_PER_NODE = 2; const BOOT_NODE_UDP_PORT = 4500; const BLOCK_DURATION_MS = 10_000; -const DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'rediscovery-')); - // Tests that nodes can rediscover each other from their stored peer tables after a full restart, // without a bootstrap node. Uses P2PNetworkTest real libp2p, SHORTENED_BLOCK_TIME_CONFIG_NO_PRUNES // (ethSlot=4s, aztecSlot=24s, proofSubEpochs=640), 4 validators, inboxLag=2. @@ -50,9 +44,6 @@ describe('e2e_p2p_rediscovery', () => { t.logger.info('Stopping nodes and cleaning up data directories'); await t.stopNodes(nodes); await t.teardown(); - for (let i = 0; i < NUM_VALIDATORS; i++) { - fs.rmSync(`${DATA_DIR}-${i}`, { recursive: true, force: true, maxRetries: 3 }); - } }); // Forms an initial 4-node mesh, stops the bootstrap node, then restarts each validator from its data @@ -67,7 +58,7 @@ describe('e2e_p2p_rediscovery', () => { NUM_VALIDATORS, BOOT_NODE_UDP_PORT, t.genesis, - DATA_DIR, + t.dataDirFor('validator'), // To collect metrics - run in aztec-packages `docker compose --profile metrics up` shouldCollectMetrics(), ); @@ -102,7 +93,7 @@ describe('e2e_p2p_rediscovery', () => { undefined, i, t.genesis, - `${DATA_DIR}-${i}`, + `${t.dataDirFor('validator')}-${i}`, ); t.logger.info(`Node ${i} restarted`); newNodes.push(newNode); diff --git a/yarn-project/end-to-end/src/p2p/reqresp/reqresp.test.ts b/yarn-project/end-to-end/src/p2p/reqresp/reqresp.test.ts index 010ec516f263..48dfcfa092b1 100644 --- a/yarn-project/end-to-end/src/p2p/reqresp/reqresp.test.ts +++ b/yarn-project/end-to-end/src/p2p/reqresp/reqresp.test.ts @@ -3,9 +3,7 @@ import type { AztecNodeService } from '@aztec/aztec-node'; import { jest } from '@jest/globals'; import type { P2PNetworkTest } from '../p2p_network.js'; -import { cleanupReqrespTest, createReqrespDataDir, createReqrespTest, runReqrespTxTest } from './utils.js'; - -const DATA_DIR = createReqrespDataDir(); +import { cleanupReqrespTest, createReqrespTest, runReqrespTxTest } from './utils.js'; // Under pipelining a 36s aztec slot plus build-slot/target-slot round trip + L1 // publish exceeds the default 5 min jest test timeout. Allow 15 min. @@ -24,7 +22,7 @@ describe('e2e_p2p_reqresp_tx', () => { }); afterEach(async () => { - await cleanupReqrespTest({ t, nodes, dataDir: DATA_DIR }); + await cleanupReqrespTest({ t, nodes }); }); it('should produce an attestation by requesting tx data over the p2p network', async () => { @@ -42,6 +40,6 @@ describe('e2e_p2p_reqresp_tx', () => { * * Delegates to runReqrespTxTest in utils.ts; see that helper for the full flow. */ - nodes = await runReqrespTxTest({ t, dataDir: DATA_DIR }); + nodes = await runReqrespTxTest({ t }); }); }); diff --git a/yarn-project/end-to-end/src/p2p/reqresp/utils.ts b/yarn-project/end-to-end/src/p2p/reqresp/utils.ts index 19e06a4fc14d..f26d6f183a11 100644 --- a/yarn-project/end-to-end/src/p2p/reqresp/utils.ts +++ b/yarn-project/end-to-end/src/p2p/reqresp/utils.ts @@ -9,9 +9,6 @@ import { timesAsync } from '@aztec/foundation/collection'; import { retryUntil } from '@aztec/foundation/retry'; import { expect, jest } from '@jest/globals'; -import fs from 'fs'; -import os from 'os'; -import path from 'path'; import { getBootNodeUdpPort, shouldCollectMetrics } from '../../fixtures/fixtures.js'; import { createNodes } from '../../fixtures/setup_p2p_test.js'; @@ -23,8 +20,6 @@ export const NUM_VALIDATORS = 6; export const NUM_TXS_PER_NODE = 4; export const BOOT_NODE_UDP_PORT = getBootNodeUdpPort(); -export const createReqrespDataDir = () => fs.mkdtempSync(path.join(os.tmpdir(), 'reqresp-')); - type ReqrespOptions = { disableStatusHandshake?: boolean; }; @@ -58,25 +53,21 @@ export async function createReqrespTest(options: ReqrespOptions = {}): Promise

BOOT_NODE_UDP_PORT + 1 + nodeIndex; export async function runReqrespTxTest(params: { t: P2PNetworkTest; - dataDir: string; disableStatusHandshake?: boolean; }): Promise { - const { t, dataDir, disableStatusHandshake = false } = params; + const { t, disableStatusHandshake = false } = params; if (!t.bootstrapNodeEnr) { throw new Error('Bootstrap node ENR is not available'); @@ -94,7 +85,7 @@ export async function runReqrespTxTest(params: { NUM_VALIDATORS, BOOT_NODE_UDP_PORT, t.genesis, - dataDir, + t.dataDirFor('validator'), shouldCollectMetrics(), ); diff --git a/yarn-project/end-to-end/src/p2p/shared.ts b/yarn-project/end-to-end/src/p2p/shared.ts index 9f0415817753..fe17f29d6c3b 100644 --- a/yarn-project/end-to-end/src/p2p/shared.ts +++ b/yarn-project/end-to-end/src/p2p/shared.ts @@ -1,19 +1,35 @@ import type { InitialAccountData } from '@aztec/accounts/testing'; +import type { Archiver } from '@aztec/archiver'; import type { AztecNodeService } from '@aztec/aztec-node'; import type { AztecAddress } from '@aztec/aztec.js/addresses'; import { NO_WAIT, getContractInstanceFromInstantiationParams } from '@aztec/aztec.js/contracts'; import { Fr } from '@aztec/aztec.js/fields'; import type { Logger } from '@aztec/aztec.js/log'; import { TxHash } from '@aztec/aztec.js/tx'; +import { BlockNumber, CheckpointNumber } from '@aztec/foundation/branded-types'; import { timesAsync } from '@aztec/foundation/collection'; +import { Signature } from '@aztec/foundation/eth-signature'; +import { retryUntil } from '@aztec/foundation/retry'; import type { SpamContract } from '@aztec/noir-test-contracts.js/Spam'; import { TestContract, TestContractArtifact } from '@aztec/noir-test-contracts.js/Test'; import { getPXEConfig, getPXEConfig as getRpcConfig } from '@aztec/pxe/server'; +import type { SequencerClient } from '@aztec/sequencer-client'; +import { CheckpointAttestation, ConsensusPayload, type TopicType } from '@aztec/stdlib/p2p'; +import { expect } from '@jest/globals'; + +import { shouldCollectMetrics } from '../fixtures/fixtures.js'; import { SchnorrHardcodedKeyAccountContract } from '../fixtures/schnorr_hardcoded_account_contract.js'; +import { createNodes } from '../fixtures/setup_p2p_test.js'; +import { waitForTxs } from '../fixtures/wait_helpers.js'; +import { type AlertConfig, GrafanaClient } from '../quality_of_service/grafana_client.js'; import { submitTxsTo } from '../shared/submit-transactions.js'; import { TestWallet } from '../test-wallet/test_wallet.js'; import { type ProvenTx, proveInteraction } from '../test-wallet/utils.js'; +import { type P2PNetworkTest, WAIT_FOR_TX_TIMEOUT } from './p2p_network.js'; + +/** One published checkpoint as returned by the archiver, including its attestations. */ +type PublishedCheckpoint = Awaited>[number]; // submits a set of transactions to the provided Private eXecution Environment (PXE) export const submitComplexTxsTo = async ( @@ -96,3 +112,218 @@ export async function prepareTransactions( return tx; }); } + +const CHECK_ALERTS = process.env.CHECK_ALERTS === 'true'; + +const qosAlerts: AlertConfig[] = [ + { + alert: 'SequencerTimeToCollectAttestations', + expr: 'aztec_sequencer_time_to_collect_attestations > 3500', + labels: { severity: 'error' }, + for: '10m', + annotations: {}, + }, +]; + +/** Runs the shared p2p QoS Grafana alert check when CHECK_ALERTS=true; a no-op otherwise. */ +export async function maybeCheckQosAlerts(logger: Logger): Promise { + if (CHECK_ALERTS) { + const checker = new GrafanaClient(logger); + await checker.runAlertCheck(qosAlerts); + } +} + +/** Waits until every node's synced block number reaches the initial node's current tip. */ +export async function waitForNodesToSync(t: P2PNetworkTest, nodes: AztecNodeService[]): Promise { + const targetBlock = await t.ctx.aztecNode.getBlockNumber(); + t.logger.warn(`Waiting for all nodes to sync to block number ${targetBlock}`); + await retryUntil( + async () => { + const blockNumbers = await Promise.all(nodes.map(node => node.getBlockNumber())); + const checkpointNumber = (await t.monitor.run()).checkpointNumber; + t.logger.info(`Current block numbers ${blockNumbers} (checkpoint number on L1 is ${checkpointNumber})`); + return blockNumbers.every(bn => bn >= targetBlock); + }, + `nodes to sync to block number ${targetBlock}`, + 30, + 0.5, + ); +} + +/** Reads the published checkpoint containing the block that mined the given tx. */ +export async function getPublishedCheckpointForTx( + node: AztecNodeService, + txHash: TxHash, +): Promise { + const receipt = await node.getTxReceipt(txHash); + const blockNumber = receipt.blockNumber!; + const dataStore = node.getBlockSource() as Archiver; + const blockData = await dataStore.getBlockData({ number: BlockNumber(blockNumber) }); + const [publishedCheckpoint] = await dataStore.getCheckpoints({ from: blockData!.checkpointNumber, limit: 1 }); + return publishedCheckpoint; +} + +/** Polls until the archiver has indexed the first published checkpoint, then returns it. */ +export async function waitForFirstPublishedCheckpoint( + t: P2PNetworkTest, + nodes: AztecNodeService[], + timeoutSeconds = 120, +): Promise { + const dataStore = nodes[0].getBlockSource() as Archiver; + t.logger.warn('Waiting for first checkpoint to be published and indexed by the archiver'); + return await retryUntil( + async () => { + const blockNumbers = await Promise.all(nodes.map(node => node.getBlockNumber())); + const checkpointNumber = (await t.monitor.run()).checkpointNumber; + t.logger.info(`Current block numbers ${blockNumbers} (checkpoint number on L1 is ${checkpointNumber})`); + const [checkpoint] = await dataStore.getCheckpoints({ from: CheckpointNumber(1), limit: 1 }); + return checkpoint; + }, + 'published checkpoint to be indexed', + timeoutSeconds, + 1, + ); +} + +/** + * Recovers the attestation signers from a published checkpoint and asserts each one belongs to the + * validator set formed by the given nodes. Returns the recovered signer addresses so callers can add + * scenario-specific assertions (e.g. an exact signer count). + */ +export async function verifyAttestationSigners( + t: P2PNetworkTest, + nodes: AztecNodeService[], + publishedCheckpoint: PublishedCheckpoint, +): Promise { + const signatureContext = { + chainId: t.ctx.aztecNodeConfig.l1ChainId, + rollupAddress: t.ctx.deployL1ContractsValues.l1ContractAddresses.rollupAddress, + }; + const payload = ConsensusPayload.fromCheckpoint(publishedCheckpoint.checkpoint, signatureContext); + const attestations = publishedCheckpoint.attestations + .filter(a => !a.signature.isEmpty()) + .map(a => new CheckpointAttestation(payload, a.signature, Signature.empty())); + const signers = await Promise.all(attestations.map(att => att.getSender()!.toString())); + t.logger.info(`Attestation signers`, { signers }); + + // Check that the signers found are part of the proposer nodes to ensure the archiver fetched them right + const validatorAddresses = nodes.flatMap(node => + (node.getSequencer() as SequencerClient).validatorAddresses?.map(a => a.toString()), + ); + t.logger.info(`Validator addresses`, { addresses: validatorAddresses }); + for (const signer of signers) { + expect(validatorAddresses).toContain(signer); + } + return signers; +} + +/** Options that parameterize {@link runGossipScenario}. */ +export interface GossipScenarioOptions { + /** The initialized P2P network test (setup() + validator registration already done). */ + t: P2PNetworkTest; + /** Number of validator nodes forming the committee. */ + numValidators: number; + /** Base UDP port for the validator nodes. */ + bootNodePort: number; + /** Transactions submitted per validator node; 0 skips tx submission entirely (e.g. the oracle test). */ + txsPerNode: number; + /** Submit tx batches sequentially instead of concurrently. */ + submitSequentially?: boolean; + /** Overrides for the P2P mesh-connectivity wait; unset fields fall back to waitForP2PMeshConnectivity defaults. */ + mesh?: { + expectedNodeCount?: number; + timeoutSeconds?: number; + checkIntervalSeconds?: number; + topics?: TopicType[]; + minMeshPeerCount?: number; + }; + /** Which published checkpoint the attestation signers are read from (defaults to the first tx's block). */ + checkpointSource?: 'first-tx' | 'first-published'; + /** Runs after validator registration but before the validator nodes are created. */ + beforeCreateNodes?: () => Promise; + /** Creates extra non-validator nodes (prover/monitor) once the validator nodes exist. */ + createExtraNodes?: (nodes: AztecNodeService[]) => Promise; + /** Runs after the account is registered but before txs are submitted (sync waits, checkpoint waits, sleeps). */ + beforeSubmit?: (nodes: AztecNodeService[]) => Promise; + /** Scenario-specific verification run after attestation-signer verification (proven block, price convergence). */ + afterVerify?: (nodes: AztecNodeService[]) => Promise; +} + +/** + * Shared skeleton for the p2p gossip tests: create the validator nodes and any extra nodes, wait for + * the mesh to form, register the account, optionally submit and mine txs, then verify the attestation + * signers of a published checkpoint. Each varying part (validator registration, extra nodes, pre-submit + * waits, scenario-specific verification) is supplied via the callbacks in {@link GossipScenarioOptions}. + * Returns the validator nodes so the caller can track them for teardown. + */ +export async function runGossipScenario(opts: GossipScenarioOptions): Promise { + const { t, numValidators, bootNodePort, txsPerNode } = opts; + + if (!t.bootstrapNodeEnr) { + throw new Error('Bootstrap node ENR is not available'); + } + + await opts.beforeCreateNodes?.(); + + t.logger.info('Creating validator nodes'); + const nodes = await createNodes( + t.ctx.aztecNodeConfig, + t.ctx.dateProvider, + t.bootstrapNodeEnr, + numValidators, + bootNodePort, + t.genesis, + t.dataDirFor('validator'), + shouldCollectMetrics(), + ); + + await opts.createExtraNodes?.(nodes); + + t.logger.info('Waiting for nodes to connect'); + await t.waitForP2PMeshConnectivity( + nodes, + opts.mesh?.expectedNodeCount ?? numValidators, + opts.mesh?.timeoutSeconds, + opts.mesh?.checkIntervalSeconds, + opts.mesh?.topics, + opts.mesh?.minMeshPeerCount, + ); + + // We need to create the nodes before we setup the account, because those nodes form the committee + // and blocks cannot be built without them (targetCommitteeSize is set to the number of nodes). + await t.setupAccount(); + + await opts.beforeSubmit?.(nodes); + + let firstTxHash: TxHash | undefined; + if (txsPerNode > 0) { + t.logger.info('Submitting transactions'); + const submitOne = (node: AztecNodeService) => submitTransactions(t.logger, node, txsPerNode, t.fundedAccount); + // Each submitTransactions call builds its own wallet/PXE, so submissions are independent. When run + // concurrently, Promise.all preserves node order so submitted[i] stays aligned with nodes[i]. + const submitted: TxHash[][] = []; + if (opts.submitSequentially) { + for (const node of nodes) { + submitted.push(await submitOne(node)); + } + } else { + submitted.push(...(await Promise.all(nodes.map(submitOne)))); + } + firstTxHash = submitted[0][0]; + + t.logger.info('Waiting for transactions to be mined'); + await waitForTxs(nodes[0], submitted.flat(), { timeout: WAIT_FOR_TX_TIMEOUT }); + t.logger.info('All transactions mined'); + } + + const publishedCheckpoint = + opts.checkpointSource === 'first-published' + ? await waitForFirstPublishedCheckpoint(t, nodes) + : await getPublishedCheckpointForTx(nodes[0], firstTxHash!); + + await verifyAttestationSigners(t, nodes, publishedCheckpoint); + + await opts.afterVerify?.(nodes); + + return nodes; +} diff --git a/yarn-project/end-to-end/src/single-node/block-building/block_building.test.ts b/yarn-project/end-to-end/src/single-node/block-building/block_building.test.ts index 121637044773..a0078fb1739a 100644 --- a/yarn-project/end-to-end/src/single-node/block-building/block_building.test.ts +++ b/yarn-project/end-to-end/src/single-node/block-building/block_building.test.ts @@ -336,122 +336,60 @@ describe('single-node/block-building/block_building', () => { afterAll(() => test.teardown()); - // Regressions for https://github.com/AztecProtocol/aztec-packages/issues/2502 - // Note that the order in which the TX are processed is not guaranteed. - // Both txs race to the same block; exactly one succeeds and the other fails. - describe('in the same block, different tx', () => { - // Sends two private emit_nullifier txs with the same nullifier simultaneously; - // asserts one succeeds and one rejects with DUPLICATE_NULLIFIER_ERROR. - it('private <-> private', async () => { + // Regressions for https://github.com/AztecProtocol/aztec-packages/issues/2502. + // Note that the order in which the TXs are processed is not guaranteed. + type NullifierKind = 'private' | 'public'; + const emitNullifier = (kind: NullifierKind, nullifier: Fr) => + kind === 'private' + ? contract.methods.emit_nullifier(nullifier) + : contract.methods.emit_nullifier_public(nullifier); + + // Each row double-spends the same nullifier across the two named emission surfaces. Same-block races + // (both txs sent simultaneously) admit one tx and reject the loser at block building. Across-block + // cases send sequentially: a private second tx is caught at simulation (TX_ERROR_EXISTING_NULLIFIER), + // a public second tx is caught by the sequencer (DUPLICATE_NULLIFIER_ERROR). + const doubleSpendCases: { + firstKind: NullifierKind; + secondKind: NullifierKind; + sameBlock: boolean; + expectedError: string | RegExp; + }[] = [ + { firstKind: 'private', secondKind: 'private', sameBlock: true, expectedError: DUPLICATE_NULLIFIER_ERROR }, + { firstKind: 'public', secondKind: 'public', sameBlock: true, expectedError: DUPLICATE_NULLIFIER_ERROR }, + { firstKind: 'private', secondKind: 'public', sameBlock: true, expectedError: DUPLICATE_NULLIFIER_ERROR }, + { firstKind: 'public', secondKind: 'private', sameBlock: true, expectedError: DUPLICATE_NULLIFIER_ERROR }, + { firstKind: 'private', secondKind: 'private', sameBlock: false, expectedError: TX_ERROR_EXISTING_NULLIFIER }, + { firstKind: 'public', secondKind: 'public', sameBlock: false, expectedError: DUPLICATE_NULLIFIER_ERROR }, + { firstKind: 'private', secondKind: 'public', sameBlock: false, expectedError: DUPLICATE_NULLIFIER_ERROR }, + { firstKind: 'public', secondKind: 'private', sameBlock: false, expectedError: TX_ERROR_EXISTING_NULLIFIER }, + ]; + + it.each(doubleSpendCases)( + 'rejects a $firstKind then $secondKind double-spend (sameBlock=$sameBlock)', + async ({ firstKind, secondKind, sameBlock, expectedError }) => { const nullifier = Fr.random(); - const txs = await sendAndWait( - [contract.methods.emit_nullifier(nullifier), contract.methods.emit_nullifier(nullifier)], - ownerAddress, - ); - - // One transaction should succeed, the other should fail, but in any order. - expect(txs).toIncludeSameMembers([ - { status: 'fulfilled', value: expect.anything() }, - { - status: 'rejected', - reason: expect.objectContaining({ message: expect.stringMatching(DUPLICATE_NULLIFIER_ERROR) }), - }, - ]); - }); - - // Same as private<->private but both txs use public nullifier emission. - it('public -> public', async () => { - const nullifier = Fr.random(); - const txs = await sendAndWait( - [contract.methods.emit_nullifier_public(nullifier), contract.methods.emit_nullifier_public(nullifier)], - ownerAddress, - ); - - // One transaction should succeed, the other should fail, but in any order. - expect(txs).toIncludeSameMembers([ - { status: 'fulfilled', value: expect.anything() }, - { - status: 'rejected', - reason: expect.objectContaining({ message: expect.stringMatching(DUPLICATE_NULLIFIER_ERROR) }), - }, - ]); - }); - - // One private and one public tx emit the same nullifier simultaneously; one must fail. - it('private -> public', async () => { - const nullifier = Fr.random(); - const txs = await sendAndWait( - [contract.methods.emit_nullifier(nullifier), contract.methods.emit_nullifier_public(nullifier)], - ownerAddress, - ); - - // One transaction should succeed, the other should fail, but in any order. - expect(txs).toIncludeSameMembers([ - { status: 'fulfilled', value: expect.anything() }, - { - status: 'rejected', - reason: expect.objectContaining({ message: expect.stringMatching(DUPLICATE_NULLIFIER_ERROR) }), - }, - ]); - }); - - // One public and one private tx emit the same nullifier simultaneously; one must fail. - it('public -> private', async () => { - const nullifier = Fr.random(); - const txs = await sendAndWait( - [contract.methods.emit_nullifier_public(nullifier), contract.methods.emit_nullifier(nullifier)], - ownerAddress, - ); - - // One transaction should succeed, the other should fail, but in any order. - expect(txs).toIncludeSameMembers([ - { status: 'fulfilled', value: expect.anything() }, - { - status: 'rejected', - reason: expect.objectContaining({ message: expect.stringMatching(DUPLICATE_NULLIFIER_ERROR) }), - }, - ]); - }); - }); - - // Double-spend rejection when the second tx arrives in a later block (nullifier already in the tree). - describe('across blocks', () => { - // Emits a private nullifier, then tries to emit the same in a subsequent tx and expects rejection. - it('private -> private', async () => { - const nullifier = Fr.random(); - await contract.methods.emit_nullifier(nullifier).send({ from: ownerAddress }); - await expect(contract.methods.emit_nullifier(nullifier).send({ from: ownerAddress })).rejects.toThrow( - TX_ERROR_EXISTING_NULLIFIER, - ); - }); - - // Emits a public nullifier, then tries again in a subsequent tx and expects rejection. - it('public -> public', async () => { - const nullifier = Fr.random(); - await contract.methods.emit_nullifier_public(nullifier).send({ from: ownerAddress }); - await expect(contract.methods.emit_nullifier_public(nullifier).send({ from: ownerAddress })).rejects.toThrow( - DUPLICATE_NULLIFIER_ERROR, - ); - }); - - // Emits via private then tries public with the same nullifier in a later block; expects rejection. - it('private -> public', async () => { - const nullifier = Fr.random(); - await contract.methods.emit_nullifier(nullifier).send({ from: ownerAddress }); - await expect(contract.methods.emit_nullifier_public(nullifier).send({ from: ownerAddress })).rejects.toThrow( - DUPLICATE_NULLIFIER_ERROR, - ); - }); - - // Emits via public then tries private with the same nullifier in a later block; expects rejection. - it('public -> private', async () => { - const nullifier = Fr.random(); - await contract.methods.emit_nullifier_public(nullifier).send({ from: ownerAddress }); - await expect(contract.methods.emit_nullifier(nullifier).send({ from: ownerAddress })).rejects.toThrow( - TX_ERROR_EXISTING_NULLIFIER, - ); - }); - }); + if (sameBlock) { + const txs = await sendAndWait( + [emitNullifier(firstKind, nullifier), emitNullifier(secondKind, nullifier)], + ownerAddress, + ); + + // One transaction should succeed, the other should fail, but in any order. + expect(txs).toIncludeSameMembers([ + { status: 'fulfilled', value: expect.anything() }, + { + status: 'rejected', + reason: expect.objectContaining({ message: expect.stringMatching(expectedError) }), + }, + ]); + } else { + await emitNullifier(firstKind, nullifier).send({ from: ownerAddress }); + await expect(emitNullifier(secondKind, nullifier).send({ from: ownerAddress })).rejects.toThrow( + expectedError, + ); + } + }, + ); }); // Verifies that private encrypted logs and unencrypted logs emitted from nested calls are ordered diff --git a/yarn-project/end-to-end/src/single-node/block-building/debug_trace.test.ts b/yarn-project/end-to-end/src/single-node/block-building/debug_trace.test.ts index 77c5d3814af5..fcb86aab36b1 100644 --- a/yarn-project/end-to-end/src/single-node/block-building/debug_trace.test.ts +++ b/yarn-project/end-to-end/src/single-node/block-building/debug_trace.test.ts @@ -16,14 +16,14 @@ import { type Hex, decodeFunctionData, encodeFunctionData, multicall3Abi } from import { getPrivateKeyFromIndex } from '../../fixtures/utils.js'; import { waitForBlockNumber } from '../../fixtures/wait_helpers.js'; -import { setupBlockProducer } from '../setup.js'; +import { NO_REORG_SUBMISSION_EPOCHS, setupBlockProducer } from '../setup.js'; import type { SingleNodeTestContext } from '../single_node_test_context.js'; // Tests that the sequencer can successfully process blocks when L1 block proposals are forwarded // via a proxy contract (Forwarder). Also tests that a corrupted first propose call (failing with // allowFailure:true) followed by a valid second call still produces blocks. // Uses setupBlockProducer (no prover node) with numberOfAccounts:2, ethereumSlotDuration:4, -// aztecSlotDuration:12, aztecEpochDuration:32, aztecProofSubmissionEpochs:640, minTxsPerBlock:0 — +// aztecSlotDuration:12, aztecEpochDuration:32, aztecProofSubmissionEpochs:NO_REORG_SUBMISSION_EPOCHS, minTxsPerBlock:0 — // production sequencer, anvil interval mining. The L1 interaction is Forwarder/Multicall3/Rollup // contract interception for block-proposal routing, not cross-chain bridging. describe('single-node/block-building/debug_trace', () => { @@ -58,7 +58,7 @@ describe('single-node/block-building/debug_trace', () => { // boundary at slot 6; the proposer-corruption test intercepts every propose and runs long // enough to reach it, where the proposer selection changes and the propose silently reverts. aztecEpochDuration: 32, - aztecProofSubmissionEpochs: 640, + aztecProofSubmissionEpochs: NO_REORG_SUBMISSION_EPOCHS, inboxLag: 2, }); ({ aztecNode, logger, aztecNodeAdmin, config } = test.context); diff --git a/yarn-project/end-to-end/src/single-node/bot/bot.test.ts b/yarn-project/end-to-end/src/single-node/bot/bot.test.ts index d3ee2314eaf9..3a73306a5c8a 100644 --- a/yarn-project/end-to-end/src/single-node/bot/bot.test.ts +++ b/yarn-project/end-to-end/src/single-node/bot/bot.test.ts @@ -23,10 +23,11 @@ import { jest } from '@jest/globals'; import { PIPELINED_FEE_PADDING, PIPELINING_SETUP_OPTS } from '../../fixtures/fixtures.js'; import { getPrivateKeyFromIndex, setup } from '../../fixtures/utils.js'; +import { NO_REORG_SUBMISSION_EPOCHS } from '../setup.js'; // Tests the transaction bot implementations (transfer bot, AMM bot, cross-chain bot). -// Uses setup(0, PIPELINING_SETUP_OPTS + aztecProofSubmissionEpochs:640) with one node, production -// sequencer (ethereumSlotDuration=4s, aztecSlotDuration=12s, proofSubEpochs=640, minTxsPerBlock=0; +// Uses setup(0, PIPELINING_SETUP_OPTS + aztecProofSubmissionEpochs:NO_REORG_SUBMISSION_EPOCHS) with one node, production +// sequencer (ethereumSlotDuration=4s, aztecSlotDuration=12s, proofSubEpochs=NO_REORG_SUBMISSION_EPOCHS, minTxsPerBlock=0; // aztecEpochDuration is the setup() default). The bridge-resume, setup-via-bridging, and // cross-chain-bot subsuites actively drive L1 cross-chain bridging: fee-juice portal deposits, // advanceInboxInProgress, and L2→L1 messages via CrossChainBot. @@ -43,7 +44,7 @@ describe('single-node/bot/bot', () => { const [botAccount] = await getInitialTestAccountsData(); const setupResult = await setup(0, { ...PIPELINING_SETUP_OPTS, - aztecProofSubmissionEpochs: 640, + aztecProofSubmissionEpochs: NO_REORG_SUBMISSION_EPOCHS, additionallyFundedAccounts: [botAccount], }); ({ diff --git a/yarn-project/end-to-end/src/single-node/cross-chain/l1_to_l2.test.ts b/yarn-project/end-to-end/src/single-node/cross-chain/l1_to_l2.test.ts new file mode 100644 index 000000000000..958543569a5e --- /dev/null +++ b/yarn-project/end-to-end/src/single-node/cross-chain/l1_to_l2.test.ts @@ -0,0 +1,130 @@ +import { AztecAddress } from '@aztec/aztec.js/addresses'; +import { generateClaimSecret } from '@aztec/aztec.js/ethereum'; +import { Fr } from '@aztec/aztec.js/fields'; +import type { Logger } from '@aztec/aztec.js/log'; +import type { AztecNode } from '@aztec/aztec.js/node'; +import type { Wallet } from '@aztec/aztec.js/wallet'; +import { TestContract } from '@aztec/noir-test-contracts.js/Test'; + +import { jest } from '@jest/globals'; + +import { L1_DIRECT_WRITE_ACCOUNT_INDEX, PIPELINING_SETUP_OPTS } from '../../fixtures/fixtures.js'; +import { CrossChainMessagingTest } from './cross_chain_messaging_test.js'; +import { type L1ToL2MessageScope, createL1ToL2MessageHelpers } from './message_test_helpers.js'; + +jest.setTimeout(300_000); + +// L1→L2 messaging via Inbox: message readiness and duplicate messages. Uses CrossChainMessagingTest +// (prod sequencer, pipelining preset: ethSlot=4s, aztecSlot=12s, inboxLag=2, minTxsPerBlock=1, +// aztecProofSubmissionEpochs=2, aztecEpochDuration=4) with EpochTestSettler for auto-proving and +// CrossChainTestHarness for L1↔L2 token portal bridging. The duplicate-message scenario runs over +// private and public scope via it.each, all sharing one node stood up once in beforeAll. +describe('single-node/cross-chain/l1_to_l2', () => { + let t: CrossChainMessagingTest; + + let log: Logger; + let aztecNode: AztecNode; + let wallet: Wallet; + let user1Address: AztecAddress; + let testContract: TestContract; + + let sendMessageToL2: ReturnType['sendMessageToL2']; + let waitForMessageReady: ReturnType['waitForMessageReady']; + + // Marks the current pending tip proven on L1. The e2e fixture runs L1 on interval mining and nothing + // marks blocks proven automatically, so without these explicit calls L1's `aztecProofSubmissionEpochs` + // window expires mid-test and prunes in-flight wallet txs. + const markAsProven = () => t.cheatCodes.rollup.markAsProven(); + + beforeAll(async () => { + t = new CrossChainMessagingTest( + 'l1_to_l2', + // PIPELINING_SETUP_OPTS sets minTxsPerBlock=0; this test needs minTxsPerBlock=1 because it + // manually mines blocks one tx at a time and counts checkpoints, so empty pipelined checkpoints + // interleaving between txs would break the exact block-number assertions. + { ...PIPELINING_SETUP_OPTS, minTxsPerBlock: 1 }, + { aztecProofSubmissionEpochs: 2, aztecEpochDuration: 4 }, + // Anchor PXE to the checkpointed chain so that a proposed-chain invalidation cascade + // (e.g. a missed checkpoint publish that prunes the pipelined proposed chain) doesn't + // drop the wallet's in-flight tx via handlePrunedBlocks. + { syncChainTip: 'checkpointed' }, + // This suite only passes arbitrary L1→L2 messages to its own TestContract; it never bridges + // tokens, so skip the token+portal+bridge deploy and use the test's L1 handles directly. + { l1HarnessAccountIndex: L1_DIRECT_WRITE_ACCOUNT_INDEX, deployTokenBridge: false }, + ); + await t.setup(); + + ({ logger: log, wallet, user1Address, aztecNode } = t); + ({ contract: testContract } = await TestContract.deploy(wallet).send({ from: user1Address })); + + ({ sendMessageToL2, waitForMessageReady } = createL1ToL2MessageHelpers({ + t, + aztecNode, + wallet, + user1Address, + log, + markAsProven, + })); + }, 300_000); + + afterAll(async () => { + await t.teardown(); + }); + + const getConsumeMethod = (scope: L1ToL2MessageScope) => + scope === 'private' + ? testContract.methods.consume_message_from_arbitrary_sender_private + : testContract.methods.consume_message_from_arbitrary_sender_public; + + // 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. + const canSendMessageFromNonRegisteredPortal = async (scope: L1ToL2MessageScope) => { + // 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 sendMessageToL2(message); + + await waitForMessageReady(message1Hash, scope); + + const [message1Index] = (await aztecNode.getL1ToL2MessageMembershipWitness('latest', message1Hash))!; + expect(actualMessage1Index.toBigInt()).toBe(message1Index); + + const sendConsumeMsgTx = async (index: Fr) => { + const call = getConsumeMethod(scope)(message.content, secret, t.ethAccount, index); + if (scope === 'public') { + await call.simulate({ from: user1Address }); + } + await call.send({ from: user1Address }); + }; + + // We consume the L1 to L2 message using the test contract either from private or public + await sendConsumeMsgTx(actualMessage1Index); + + // We send and consume the exact same message the second time to test that oracles correctly return the new + // non-nullified message + const { msgHash: message2Hash, globalLeafIndex: actualMessage2Index } = await sendMessageToL2(message); + + // We check that the duplicate message was correctly inserted by checking that its message index is defined + await waitForMessageReady(message2Hash, scope); + + 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 sendConsumeMsgTx(actualMessage2Index); + }; + + // Sends the same L1→L2 message content twice via a non-registered portal, waits for each to be + // ready, and consumes both. Verifies duplicate messages are indexed correctly and the second + // consumption uses the non-nullified duplicate leaf, from both private and public scope. + 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 => { + await canSendMessageFromNonRegisteredPortal(scope); + }, + ); +}); diff --git a/yarn-project/end-to-end/src/single-node/cross-chain/l1_to_l2.parallel.test.ts b/yarn-project/end-to-end/src/single-node/cross-chain/l1_to_l2_inbox_drift.test.ts similarity index 54% rename from yarn-project/end-to-end/src/single-node/cross-chain/l1_to_l2.parallel.test.ts rename to yarn-project/end-to-end/src/single-node/cross-chain/l1_to_l2_inbox_drift.test.ts index d9c27d6be37c..a7164ada4d0a 100644 --- a/yarn-project/end-to-end/src/single-node/cross-chain/l1_to_l2.parallel.test.ts +++ b/yarn-project/end-to-end/src/single-node/cross-chain/l1_to_l2_inbox_drift.test.ts @@ -1,39 +1,43 @@ -import { AztecAddress } from '@aztec/aztec.js/addresses'; +import type { AztecAddress } from '@aztec/aztec.js/addresses'; import { generateClaimSecret } from '@aztec/aztec.js/ethereum'; import { Fr } from '@aztec/aztec.js/fields'; import type { Logger } from '@aztec/aztec.js/log'; -import { isL1ToL2MessageReady } from '@aztec/aztec.js/messaging'; import type { AztecNode } from '@aztec/aztec.js/node'; import { TxExecutionResult } from '@aztec/aztec.js/tx'; import type { Wallet } from '@aztec/aztec.js/wallet'; -import { BlockNumber } from '@aztec/foundation/branded-types'; +import type { BlockNumber } from '@aztec/foundation/branded-types'; import { timesAsync } from '@aztec/foundation/collection'; import { retryUntil } from '@aztec/foundation/retry'; import { TestContract } from '@aztec/noir-test-contracts.js/Test'; -import { ExecutionPayload } from '@aztec/stdlib/tx'; import { jest } from '@jest/globals'; import { L1_DIRECT_WRITE_ACCOUNT_INDEX, PIPELINING_SETUP_OPTS } from '../../fixtures/fixtures.js'; -import { sendL1ToL2Message } from '../../fixtures/l1_to_l2_messaging.js'; import { waitForBlockNumber } from '../../fixtures/wait_helpers.js'; import { CrossChainMessagingTest } from './cross_chain_messaging_test.js'; +import { type L1ToL2MessageScope, createL1ToL2MessageHelpers } from './message_test_helpers.js'; jest.setTimeout(300_000); -// L1→L2 messaging via Inbox: message readiness, duplicate messages, and inbox drift after a rollup -// reorg. Uses CrossChainMessagingTest (prod sequencer, pipelining preset: ethSlot=4s, aztecSlot=12s, -// inboxLag=2, minTxsPerBlock=1, aztecProofSubmissionEpochs=2, aztecEpochDuration=4) with -// EpochTestSettler for auto-proving and CrossChainTestHarness for L1↔L2 token portal bridging. -// Each it is run as an independent CI job (*.parallel.test.ts convention). -describe('single-node/cross-chain/l1_to_l2', () => { +// L1→L2 messaging via Inbox: inbox checkpoint drift after a rollup reorg. Uses CrossChainMessagingTest +// (prod sequencer, pipelining preset: ethSlot=4s, aztecSlot=12s, inboxLag=2, minTxsPerBlock=1, +// aztecProofSubmissionEpochs=2, aztecEpochDuration=4) with EpochTestSettler for auto-proving and +// CrossChainTestHarness for L1↔L2 token portal bridging. The drift scenario runs over private and +// public scope via it.each, all sharing one node stood up once in beforeAll. +describe('single-node/cross-chain/l1_to_l2_inbox_drift', () => { let t: CrossChainMessagingTest; + let log: Logger; let aztecNode: AztecNode; let wallet: Wallet; let user1Address: AztecAddress; let testContract: TestContract; + let sendMessageToL2: ReturnType['sendMessageToL2']; + let advanceBlock: ReturnType['advanceBlock']; + let waitForMessageFetched: ReturnType['waitForMessageFetched']; + let waitForMessageReady: ReturnType['waitForMessageReady']; + // Whether explicit mark-as-proven calls are honored. The inbox-drift scenario flips this to // false to let the proposed chain drift and prune. let markProvenEnabled = true; @@ -48,10 +52,9 @@ describe('single-node/cross-chain/l1_to_l2', () => { await t.cheatCodes.rollup.markAsProven(); }; - beforeEach(async () => { - markProvenEnabled = true; + beforeAll(async () => { t = new CrossChainMessagingTest( - 'l1_to_l2', + 'l1_to_l2_inbox_drift', // PIPELINING_SETUP_OPTS sets minTxsPerBlock=0; this test needs minTxsPerBlock=1 because it // manually mines blocks one tx at a time via advanceBlock() and counts checkpoints, so empty // pipelined checkpoints interleaving between txs would break the exact block-number assertions. @@ -69,41 +72,31 @@ describe('single-node/cross-chain/l1_to_l2', () => { ({ logger: log, wallet, user1Address, aztecNode } = t); ({ contract: testContract } = await TestContract.deploy(wallet).send({ from: user1Address })); + + ({ sendMessageToL2, advanceBlock, waitForMessageFetched, waitForMessageReady } = createL1ToL2MessageHelpers({ + t, + aztecNode, + wallet, + user1Address, + log, + markAsProven, + })); }, 300_000); - afterEach(async () => { - await t.teardown(); + // Reset the proving gate before every it so a scenario that disabled it can't leak into the next. + beforeEach(() => { + markProvenEnabled = true; }); - // Sends an L1→L2 message from the harness L1 account. This suite skips the token bridge, so the - // message context is built from the test's L1 handles rather than a CrossChainTestHarness. - const sendMessageToL2 = (message: { recipient: AztecAddress; content: Fr; secretHash: Fr }) => - sendL1ToL2Message(message, { - l1Client: t.harnessL1Client, - l1ContractAddresses: t.deployL1ContractsValues.l1ContractAddresses, - }); + afterAll(async () => { + await t.teardown(); + }); - const getConsumeMethod = (scope: 'private' | 'public') => + const getConsumeMethod = (scope: L1ToL2MessageScope) => 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 wallet.sendTx(ExecutionPayload.empty(), { from: user1Address }); - const newBlock = await aztecNode.getBlockNumber(); - log.warn(`Advanced to block ${newBlock}`); - if (newBlock === block) { - throw new Error(`Failed to advance block ${block}`); - } - // Keep the proof window from expiring mid-test (see `markAsProven` above). No-op once the drift - // scenario disables proving. - await markAsProven(); - return newBlock; - }; - const waitForBlockToCheckpoint = async (blockNumber: BlockNumber) => { await waitForBlockNumber(aztecNode, blockNumber, { tag: 'checkpointed', timeout: 60 }); const [checkpointedBlock] = await aztecNode.getBlocks(blockNumber, 1, { @@ -125,7 +118,7 @@ describe('single-node/cross-chain/l1_to_l2', () => { log.warn(`At checkpoint ${checkpoint}`); }; - // Same as above but ignores errors. Useful if we expect a prune. + // Same as advanceBlock but ignores errors. Useful if we expect a prune. const tryAdvanceBlock = async () => { try { await advanceBlock(); @@ -134,115 +127,11 @@ describe('single-node/cross-chain/l1_to_l2', () => { } }; - // Waits until the message is fetched by the archiver of the node and returns the msg target checkpoint. - // Advances a block on each retry because an L1->L2 message is only indexed once further L2 blocks build. - const waitForMessageFetched = async (msgHash: Fr) => { - log.warn(`Waiting until the message is fetched by the node`); - return await retryUntil( - async () => { - const checkpoint = await aztecNode.getL1ToL2MessageCheckpoint(msgHash); - if (checkpoint !== undefined) { - return checkpoint; - } - await advanceBlock(); - return undefined; - }, - 'get msg checkpoint', - 60, - ); - }; - - // 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: BlockNumber) => Promise, - ) => { - const msgCheckpoint = await waitForMessageFetched(msgHash); - log.warn( - `Waiting until L2 reaches the first block of msg checkpoint ${msgCheckpoint} (current is ${await aztecNode.getCheckpointNumber()})`, - ); - await retryUntil( - async () => { - const [blockNumber, checkpointNumber] = await Promise.all([ - aztecNode.getBlockNumber(), - aztecNode.getCheckpointNumber(), - ]); - const witness = await aztecNode.getL1ToL2MessageMembershipWitness('latest', msgHash); - const isReady = await isL1ToL2MessageReady(aztecNode, msgHash); - log.info( - `Block is ${blockNumber}, checkpoint is ${checkpointNumber}. Message checkpoint is ${msgCheckpoint}. Witness ${!!witness}. Ready ${isReady}.`, - ); - if (!isReady) { - await (onNotReady ? onNotReady(blockNumber) : advanceBlock()); - } - return isReady; - }, - `wait for rollup to reach msg checkpoint ${msgCheckpoint}`, - 240, - ); - }; - - // 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. - const canSendMessageFromNonRegisteredPortal = 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 sendMessageToL2(message); - - await waitForMessageReady(message1Hash, scope); - - const [message1Index] = (await aztecNode.getL1ToL2MessageMembershipWitness('latest', message1Hash))!; - expect(actualMessage1Index.toBigInt()).toBe(message1Index); - - const sendConsumeMsgTx = async (index: Fr) => { - const call = getConsumeMethod(scope)(message.content, secret, t.ethAccount, index); - if (scope === 'public') { - await call.simulate({ from: user1Address }); - } - await call.send({ from: user1Address }); - }; - - // We consume the L1 to L2 message using the test contract either from private or public - await sendConsumeMsgTx(actualMessage1Index); - - // We send and consume the exact same message the second time to test that oracles correctly return the new - // non-nullified message - const { msgHash: message2Hash, globalLeafIndex: actualMessage2Index } = await sendMessageToL2(message); - - // We check that the duplicate message was correctly inserted by checking that its message index is defined - await waitForMessageReady(message2Hash, scope); - - 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 sendConsumeMsgTx(actualMessage2Index); - }; - - // Sends the same L1→L2 message content twice via a non-registered portal, waits for each to be - // ready, and consumes both from private. Verifies duplicate messages are indexed correctly and - // the second consumption uses the non-nullified duplicate leaf. - it('can send an L1 to L2 message from a non-registered portal address consumed from private repeatedly', async () => { - await canSendMessageFromNonRegisteredPortal('private'); - }); - - // Same as above but the message is consumed from public state. Verifies the public consumption - // path handles duplicate messages and the oracle returns the correct non-nullified leaf index. - it('can send an L1 to L2 message from a non-registered portal address consumed from public repeatedly', async () => { - await canSendMessageFromNonRegisteredPortal('public'); - }); - // Inbox checkpoint number can drift on two scenarios: if the rollup reorgs and rolls back its own // checkpoint number, or if the inbox receives too many messages and they are inserted faster than // they are consumed. In this test, we mine several checkpoints without marking them as proven until // we can trigger a reorg, and then wait until the message can be processed to consume it. - const canConsumeMessageAfterInboxDrift = async (scope: 'private' | 'public') => { + const canConsumeMessageAfterInboxDrift = async (scope: L1ToL2MessageScope) => { // Stop the background epoch test settler so the drift scenario below can proceed without // an auto-prover racing it. await t.epochTestSettler?.stop(); @@ -333,15 +222,13 @@ describe('single-node/cross-chain/l1_to_l2', () => { }; // Mines four checkpoints without proving, inserting an L1→L2 message after the drift, then - // triggers a rollup prune back to the pre-drift block. Verifies the message can be consumed from - // private only after the chain re-syncs to the message's checkpoint, not before. - it('can consume L1 to L2 message in private after inbox drifts away from the rollup', async () => { - await canConsumeMessageAfterInboxDrift('private'); - }); - - // Same drift scenario but consuming from public. Uses a send+dontThrowOnRevert loop to probe when - // the message becomes consumable, then verifies the successful tx is in the message's checkpoint. - it('can consume L1 to L2 message in public after inbox drifts away from the rollup', async () => { - await canConsumeMessageAfterInboxDrift('public'); - }); + // triggers a rollup prune back to the pre-drift block. Verifies the message can be consumed only + // after the chain re-syncs to the message's checkpoint, not before, from both private and public + // scope (public uses a send+dontThrowOnRevert loop to probe when the message becomes consumable). + it.each(['private', 'public'] as const)( + 'can consume L1 to L2 message in %s after inbox drifts away from the rollup', + async scope => { + await canConsumeMessageAfterInboxDrift(scope); + }, + ); }); diff --git a/yarn-project/end-to-end/src/single-node/cross-chain/l2_to_l1.parallel.test.ts b/yarn-project/end-to-end/src/single-node/cross-chain/l2_to_l1.test.ts similarity index 79% rename from yarn-project/end-to-end/src/single-node/cross-chain/l2_to_l1.parallel.test.ts rename to yarn-project/end-to-end/src/single-node/cross-chain/l2_to_l1.test.ts index bcc4fb3c9f58..999477df5b1b 100644 --- a/yarn-project/end-to-end/src/single-node/cross-chain/l2_to_l1.parallel.test.ts +++ b/yarn-project/end-to-end/src/single-node/cross-chain/l2_to_l1.test.ts @@ -28,9 +28,7 @@ describe('single-node/cross-chain/l2_to_l1', () => { // multi-tx flows exceed the default 300s per-test budget. jest.setTimeout(15 * 60 * 1000); - // This suite only passes arbitrary L2→L1 messages from its own TestContract; it never bridges - // tokens, so skip the token+portal+bridge deploy and use the test's L1 handles directly. - const t = new CrossChainMessagingTest('l2_to_l1', { startProverNode: true }, {}, {}, { deployTokenBridge: false }); + let t: CrossChainMessagingTest; let aztecNode: AztecNode; let aztecNodeAdmin: AztecNodeAdmin; @@ -44,6 +42,9 @@ describe('single-node/cross-chain/l2_to_l1', () => { let contract: TestContract; beforeAll(async () => { + // This suite only passes arbitrary L2→L1 messages from its own TestContract; it never bridges + // tokens, so skip the token+portal+bridge deploy and use the test's L1 handles directly. + t = new CrossChainMessagingTest('l2_to_l1', { startProverNode: true }, {}, {}, { deployTokenBridge: false }); await t.setup({ ...PIPELINING_SETUP_OPTS }, { syncChainTip: 'checkpointed' }); ({ aztecNode, aztecNodeAdmin, wallet, user1Address, rollup, outbox } = t); @@ -195,114 +196,67 @@ describe('single-node/cross-chain/l2_to_l1', () => { await expectConsumeMessageToSucceed(message, withMessageReceipt.txHash); }); - // Two txs with 3 and 4 messages respectively. Verifies the mixed-height subtree structure is - // built correctly and representative messages from each tx are consumable after epoch proving. - it('2 txs (balanced), one with 3 messages (unbalanced), one with 4 messages (balanced)', async () => { - // Force txs to be in the same block. - await aztecNodeAdmin!.setConfig({ minTxsPerBlock: 2 }); + // Multiple txs of differing message counts packed into a single block, exercising the balanced and + // unbalanced L2→L1 message subtree shapes. Verifies all txs land in the same block, the block + // contents match the recomputed leaves (for the two-tx case), and representative messages from each + // tx — chosen to span the different subtree heights — are consumable after epoch proving. The + // `consume` tuples are `[txIndex, messageIndex]` pairs. + it.each([ + { + name: '2 txs (balanced), one with 3 messages (unbalanced), one with 4 messages (balanced)', + messageCounts: [3, 4], + consume: [ + [0, 0], + [0, 2], + [1, 0], + ], + checkBlockContents: true, + }, + { + name: '3 txs (unbalanced), one with 3 messages (unbalanced), one with 1 message (the subtree root), one with 2 messages (balanced)', + messageCounts: [3, 1, 2], + consume: [ + [0, 0], + [0, 2], + [1, 0], + [2, 1], + ], + checkBlockContents: false, + }, + ])('$name', async ({ messageCounts, consume, checkBlockContents }) => { + // Force all txs into the same block. + await aztecNodeAdmin.setConfig({ minTxsPerBlock: messageCounts.length }); await waitForSequencerState(t.context.sequencer!.getSequencer(), SequencerState.IDLE); - const tx0 = generateMessages(3); - const tx1 = generateMessages(4); + const txs = messageCounts.map(count => generateMessages(count)); + const calls = txs.map(tx => createBatchCall(wallet, tx.recipients, tx.contents)); - const call0 = createBatchCall(wallet, tx0.recipients, tx0.contents); - const call1 = createBatchCall(wallet, tx1.recipients, tx1.contents); + const receipts = await Promise.all(calls.map(async call => (await call.send({ from: user1Address })).receipt)); - const [{ receipt: l2TxReceipt0 }, { receipt: l2TxReceipt1 }] = await Promise.all([ - call0.send({ from: user1Address }), - call1.send({ from: user1Address }), - ]); - - // Check that the 2 txs are in the same block. - const blockNumber = l2TxReceipt0.blockNumber!; - expect(l2TxReceipt1.blockNumber).toEqual(blockNumber); + // Check that all txs are in the same block. + const blockNumber = receipts[0].blockNumber!; + for (const receipt of receipts.slice(1)) { + expect(receipt.blockNumber).toEqual(blockNumber); + } - // Check that the block contains all the messages. - { + if (checkBlockContents) { + // Check that the block contains all the messages. const block = (await aztecNode.getBlock(blockNumber, { includeTransactions: true }))!; const messagesForAllTxs = block.body.txEffects.map(txEffect => txEffect.l2ToL1Msgs); - // We cannot guarantee the order of txs in a block, so we rearrange the leaves if call1 was rolled up first. - const [firstTx, secondTx] = messagesForAllTxs[0].length === 3 ? [tx0, tx1] : [tx1, tx0]; + // We cannot guarantee the order of txs in a block, so we rearrange the leaves if the second tx was rolled up first. + const [firstTx, secondTx] = + messagesForAllTxs[0].length === txs[0].messages.length ? [txs[0], txs[1]] : [txs[1], txs[0]]; const expectedLeaves = firstTx.messages.concat(secondTx.messages).map(msg => computeMessageLeaf(msg)); expect(messagesForAllTxs.flat()).toEqual(expectedLeaves); } // Advance the epoch until the tx is proven since the messages are inserted to the outbox when the epoch is proven. - await t.advanceToEpochProven(l2TxReceipt1); - - // Consume messages in tx0. - { - // Consume messages[0], which is in the subtree of height 2. - const msg = tx0.messages[0]; - await expectConsumeMessageToSucceed(msg, l2TxReceipt0.txHash); - } - { - // Consume messages[2], which is in the subtree of height 1. - const msg = tx0.messages[2]; - await expectConsumeMessageToSucceed(msg, l2TxReceipt0.txHash); - } - - // Consume messages in tx1. - { - // Consume messages[2], which is in the subtree of height 2. - const msg = tx1.messages[0]; - await expectConsumeMessageToSucceed(msg, l2TxReceipt1.txHash); - } - }); - - // Three txs with 3, 1, and 2 messages. The 1-message tx's subtree root is the leaf itself; the - // 3-message tx is unbalanced. Verifies representative messages from each tx are consumable. - it('3 txs (unbalanced), one with 3 messages (unbalanced), one with 1 message (the subtree root), one with 2 messages (balanced)', async () => { - // Force txs to be in the same block. - await aztecNodeAdmin!.setConfig({ minTxsPerBlock: 3 }); - await waitForSequencerState(t.context.sequencer!.getSequencer(), SequencerState.IDLE); - - const tx0 = generateMessages(3); - const tx1 = generateMessages(1); - const tx2 = generateMessages(2); - - const call0 = createBatchCall(wallet, tx0.recipients, tx0.contents); - const call1 = createBatchCall(wallet, tx1.recipients, tx1.contents); - const call2 = createBatchCall(wallet, tx2.recipients, tx2.contents); - - const [{ receipt: l2TxReceipt0 }, { receipt: l2TxReceipt1 }, { receipt: l2TxReceipt2 }] = await Promise.all([ - call0.send({ from: user1Address }), - call1.send({ from: user1Address }), - call2.send({ from: user1Address }), - ]); - - // Check that all txs are in the same block. - const blockNumber = l2TxReceipt0.blockNumber!; - expect(l2TxReceipt1.blockNumber).toEqual(blockNumber); - expect(l2TxReceipt2.blockNumber).toEqual(blockNumber); - - // Advance the epoch until the tx is proven since the messages are inserted to the outbox when the epoch is proven. - await t.advanceToEpochProven(l2TxReceipt2); + await t.advanceToEpochProven(receipts[receipts.length - 1]); - // Consume messages in tx0. - { - // Consume messages[0], which is in the subtree of height 2. - const msg = tx0.messages[0]; - await expectConsumeMessageToSucceed(msg, l2TxReceipt0.txHash); - } - { - // Consume messages[2], which is in the subtree of height 1. - const msg = tx0.messages[2]; - await expectConsumeMessageToSucceed(msg, l2TxReceipt0.txHash); - } - - // Consume messages in tx1. - { - // Consume messages[0], which is the tx subtree root. - const msg = tx1.messages[0]; - await expectConsumeMessageToSucceed(msg, l2TxReceipt1.txHash); - } - - // Consume messages in tx2. - { - // Consume messages[1], which is in the subtree of height 1. - const msg = tx2.messages[1]; - await expectConsumeMessageToSucceed(msg, l2TxReceipt2.txHash); + // Consume a representative message from each tx (spanning the different subtree heights) and + // assert each consume succeeds and cannot be replayed. + for (const [txIndex, messageIndex] of consume) { + await expectConsumeMessageToSucceed(txs[txIndex].messages[messageIndex], receipts[txIndex].txHash); } }); diff --git a/yarn-project/end-to-end/src/single-node/cross-chain/message_test_helpers.ts b/yarn-project/end-to-end/src/single-node/cross-chain/message_test_helpers.ts new file mode 100644 index 000000000000..4e520ed7002a --- /dev/null +++ b/yarn-project/end-to-end/src/single-node/cross-chain/message_test_helpers.ts @@ -0,0 +1,125 @@ +import type { AztecAddress } from '@aztec/aztec.js/addresses'; +import type { Fr } from '@aztec/aztec.js/fields'; +import type { Logger } from '@aztec/aztec.js/log'; +import { isL1ToL2MessageReady } from '@aztec/aztec.js/messaging'; +import type { AztecNode } from '@aztec/aztec.js/node'; +import type { Wallet } from '@aztec/aztec.js/wallet'; +import type { BlockNumber, CheckpointNumber } from '@aztec/foundation/branded-types'; +import { retryUntil } from '@aztec/foundation/retry'; +import { ExecutionPayload } from '@aztec/stdlib/tx'; + +import { sendL1ToL2Message } from '../../fixtures/l1_to_l2_messaging.js'; +import type { CrossChainMessagingTest } from './cross_chain_messaging_test.js'; + +/** Scope from which an L1→L2 message is consumed on L2. */ +export type L1ToL2MessageScope = 'private' | 'public'; + +/** Dependencies the L1→L2 message helpers close over. */ +export interface L1ToL2MessageHelperDeps { + t: CrossChainMessagingTest; + aztecNode: AztecNode; + wallet: Wallet; + user1Address: AztecAddress; + log: Logger; + /** Marks the current pending tip proven on L1, subject to the caller's proving policy. */ + markAsProven: () => Promise; +} + +/** Helpers for driving L1→L2 messages through the inbox, shared across the L1→L2 messaging suites. */ +export interface L1ToL2MessageHelpers { + sendMessageToL2(message: { + recipient: AztecAddress; + content: Fr; + secretHash: Fr; + }): ReturnType; + advanceBlock(): Promise; + waitForMessageFetched(msgHash: Fr): Promise; + waitForMessageReady( + msgHash: Fr, + scope: L1ToL2MessageScope, + onNotReady?: (blockNumber: BlockNumber) => Promise, + ): Promise; +} + +/** + * Builds the L1→L2 message helpers over a running {@link CrossChainMessagingTest}. The `markAsProven` + * dependency lets each suite plug in its own proving policy: suites that never pause proving pass an + * unconditional mark, while the inbox-drift suite gates it behind a flag it toggles mid-test. + */ +export function createL1ToL2MessageHelpers(deps: L1ToL2MessageHelperDeps): L1ToL2MessageHelpers { + const { t, aztecNode, wallet, user1Address, log, markAsProven } = deps; + + // Sends an L1→L2 message from the harness L1 account. This suite skips the token bridge, so the + // message context is built from the test's L1 handles rather than a CrossChainTestHarness. + const sendMessageToL2 = (message: { recipient: AztecAddress; content: Fr; secretHash: Fr }) => + sendL1ToL2Message(message, { + l1Client: t.harnessL1Client, + l1ContractAddresses: t.deployL1ContractsValues.l1ContractAddresses, + }); + + // 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 wallet.sendTx(ExecutionPayload.empty(), { from: user1Address }); + const newBlock = await aztecNode.getBlockNumber(); + log.warn(`Advanced to block ${newBlock}`); + if (newBlock === block) { + throw new Error(`Failed to advance block ${block}`); + } + // Keep the proof window from expiring mid-test. No-op once a drift scenario disables proving. + await markAsProven(); + return newBlock; + }; + + // Waits until the message is fetched by the archiver of the node and returns the msg target checkpoint. + // Advances a block on each retry because an L1->L2 message is only indexed once further L2 blocks build. + const waitForMessageFetched = async (msgHash: Fr) => { + log.warn(`Waiting until the message is fetched by the node`); + return await retryUntil( + async () => { + const checkpoint = await aztecNode.getL1ToL2MessageCheckpoint(msgHash); + if (checkpoint !== undefined) { + return checkpoint; + } + await advanceBlock(); + return undefined; + }, + 'get msg checkpoint', + 60, + ); + }; + + // 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: L1ToL2MessageScope, + onNotReady?: (blockNumber: BlockNumber) => Promise, + ) => { + const msgCheckpoint = await waitForMessageFetched(msgHash); + log.warn( + `Waiting until L2 reaches the first block of msg checkpoint ${msgCheckpoint} (current is ${await aztecNode.getCheckpointNumber()})`, + ); + await retryUntil( + async () => { + const [blockNumber, checkpointNumber] = await Promise.all([ + aztecNode.getBlockNumber(), + aztecNode.getCheckpointNumber(), + ]); + const witness = await aztecNode.getL1ToL2MessageMembershipWitness('latest', msgHash); + const isReady = await isL1ToL2MessageReady(aztecNode, msgHash); + log.info( + `Block is ${blockNumber}, checkpoint is ${checkpointNumber}. Message checkpoint is ${msgCheckpoint}. Witness ${!!witness}. Ready ${isReady}.`, + ); + if (!isReady) { + await (onNotReady ? onNotReady(blockNumber) : advanceBlock()); + } + return isReady; + }, + `wait for rollup to reach msg checkpoint ${msgCheckpoint}`, + 240, + ); + }; + + return { sendMessageToL2, advanceBlock, waitForMessageFetched, waitForMessageReady }; +} diff --git a/yarn-project/end-to-end/src/single-node/cross-chain/token_bridge.test.ts b/yarn-project/end-to-end/src/single-node/cross-chain/token_bridge.test.ts new file mode 100644 index 000000000000..959bd3821bc7 --- /dev/null +++ b/yarn-project/end-to-end/src/single-node/cross-chain/token_bridge.test.ts @@ -0,0 +1,354 @@ +import { EthAddress } from '@aztec/aztec.js/addresses'; +import { Fr } from '@aztec/aztec.js/fields'; +import { L1Actor, L1ToL2Message, L2Actor } from '@aztec/aztec.js/messaging'; +import { sha256ToField } from '@aztec/foundation/crypto/sha256'; + +import { jest } from '@jest/globals'; +import { toFunctionSelector } from 'viem'; + +import { + L1_DIRECT_WRITE_ACCOUNT_INDEX, + NO_L1_TO_L2_MSG_ERROR, + PIPELINING_SETUP_OPTS, +} from '../../fixtures/fixtures.js'; +import { waitForL2ToL1Witness } from '../../fixtures/wait_helpers.js'; +import { CrossChainMessagingTest } from './cross_chain_messaging_test.js'; + +// TokenBridge L1<->L2 round trips (private + public deposits/withdrawals) and their failure cases, +// merged onto a single CrossChainMessagingTest harness with startProverNode=true (prod sequencer, +// pipelining preset: ethSlot=4s, aztecSlot=12s), a fake in-proc prover node, and CrossChainTestHarness +// for the full portal/bridge lifecycle. Epoch proving via advanceToEpochProven is required before L1 +// Outbox consumption. All its share one node, so balance assertions are expressed as deltas over the +// balance captured at the start of each it rather than absolute amounts. +describe('single-node/cross-chain/token_bridge', () => { + // Pipelining slows wall-clock chain progress (12s slots); waitForProven via advanceToEpochProven + // needs more than the default 300s per-test budget. + jest.setTimeout(15 * 60 * 1000); + + let t: CrossChainMessagingTest; + + let version = 1; + + beforeAll(async () => { + t = new CrossChainMessagingTest( + 'token_bridge', + { startProverNode: true }, + {}, + {}, + { l1HarnessAccountIndex: L1_DIRECT_WRITE_ACCOUNT_INDEX }, + ); + await t.setup({ ...PIPELINING_SETUP_OPTS }); + version = Number(await t.rollup.getVersion()); + }, 300_000); + + afterAll(async () => { + await t.teardown(); + }); + + describe('private', () => { + // Full round-trip: mint tokens on L1, deposit privately via TokenPortal, wait for the message to be + // consumable, claim on L2 (minting private tokens), withdraw back to L1 with an authwit, advance the + // epoch until proven, then consume the Outbox message on L1 and verify the L1 balance is restored. + it('Privately deposit funds from L1 -> L2 and withdraw back to L1', async () => { + const { crossChainTestHarness, ethAccount, aztecNode, logger, ownerAddress, l2Bridge, l2Token, wallet } = t; + const l1TokenBalance = 1000000n; + const bridgeAmount = 100n; + + const initialL1Balance = await crossChainTestHarness.getL1BalanceOf(ethAccount); + const initialPrivateBalance = await crossChainTestHarness.getL2PrivateBalanceOf(ownerAddress); + + // 1. Mint tokens on L1 + await crossChainTestHarness.mintTokensOnL1(l1TokenBalance); + + // 2. Deposit tokens to the TokenPortal + const claim = await crossChainTestHarness.sendTokensToPortalPrivate(bridgeAmount); + expect(await crossChainTestHarness.getL1BalanceOf(ethAccount)).toBe( + initialL1Balance + l1TokenBalance - bridgeAmount, + ); + + await crossChainTestHarness.makeMessageConsumable(claim.messageHash); + + // 3. Consume L1 -> L2 message and mint private tokens on L2 + await crossChainTestHarness.consumeMessageOnAztecAndMintPrivately(claim); + await crossChainTestHarness.expectPrivateBalanceOnL2(ownerAddress, initialPrivateBalance + bridgeAmount); + + // time to withdraw the funds again! + logger.info('Withdrawing funds from L2'); + + // 4. Give approval to bridge to burn owner's funds: + const withdrawAmount = 9n; + const authwitNonce = Fr.random(); + const burnAuthwit = await wallet.createAuthWit(ownerAddress, { + caller: l2Bridge.address, + action: l2Token.methods.burn_private(ownerAddress, withdrawAmount, authwitNonce), + }); + + // 5. Withdraw owner's funds from L2 to L1 + const l2ToL1Message = await crossChainTestHarness.getL2ToL1MessageLeaf(withdrawAmount); + const l2TxReceipt = await crossChainTestHarness.withdrawPrivateFromAztecToL1( + withdrawAmount, + authwitNonce, + burnAuthwit, + ); + await crossChainTestHarness.expectPrivateBalanceOnL2( + ownerAddress, + initialPrivateBalance + bridgeAmount - withdrawAmount, + ); + + // Advance the epoch until the tx is proven since the messages are inserted to the outbox when the epoch is proven. + await t.advanceToEpochProven(l2TxReceipt); + + const l2ToL1MessageResult = await waitForL2ToL1Witness(aztecNode, l2TxReceipt.txHash, l2ToL1Message, { + timeout: 60, + }); + + // Check balance before and after exit. + expect(await crossChainTestHarness.getL1BalanceOf(ethAccount)).toBe( + initialL1Balance + l1TokenBalance - bridgeAmount, + ); + await crossChainTestHarness.withdrawFundsFromBridgeOnL1( + withdrawAmount, + l2ToL1MessageResult.epochNumber, + l2ToL1MessageResult.numCheckpointsInEpoch, + l2ToL1MessageResult.leafIndex, + l2ToL1MessageResult.siblingPath, + ); + expect(await crossChainTestHarness.getL1BalanceOf(ethAccount)).toBe( + initialL1Balance + l1TokenBalance - bridgeAmount + withdrawAmount, + ); + }); + + // This test checks that it's enough to have the claim secret to claim the funds to whoever we want. + // User2 (not the original depositor) uses the claim secret to call claim_private on behalf of owner. + // Asserts the funds land at ownerAddress (not user2), proving the secret-based authorization works. + it('Claim secret is enough to consume the message', async () => { + const { crossChainTestHarness, ethAccount, ownerAddress, l2Bridge, user2Address } = t; + const initialPublicBalance = await crossChainTestHarness.getL1BalanceOf(ethAccount); + const initialPrivateBalance = await crossChainTestHarness.getL2PrivateBalanceOf(ownerAddress); + + const bridgeAmount = 100n; + const claim = await crossChainTestHarness.sendTokensToPortalPrivate(bridgeAmount); + expect(await crossChainTestHarness.getL1BalanceOf(ethAccount)).toBe(initialPublicBalance - bridgeAmount); + + // Wait for the message to be available for consumption + await crossChainTestHarness.makeMessageConsumable(claim.messageHash); + + // send the right one - + await l2Bridge.methods + .claim_private(ownerAddress, bridgeAmount, claim.claimSecret, claim.messageLeafIndex) + .send({ from: user2Address }); + + await crossChainTestHarness.expectPrivateBalanceOnL2(ownerAddress, initialPrivateBalance + bridgeAmount); + }, 90_000); + }); + + describe('public', () => { + // Full round-trip: mint on L1, publicly deposit via TokenPortal, wait for message, claim_public on + // L2, authorize bridge to burn, withdraw to L1, advance to epoch proven, consume Outbox on L1. + // Asserts L1 balance is restored after the round-trip. + it('Publicly deposit funds from L1 -> L2 and withdraw back to L1', async () => { + const { crossChainTestHarness, ethAccount, aztecNode, logger, ownerAddress, l2Bridge, l2Token, wallet } = t; + const l1TokenBalance = 1000000n; + const bridgeAmount = 100n; + + const initialL1Balance = await crossChainTestHarness.getL1BalanceOf(ethAccount); + const initialPublicBalance = await crossChainTestHarness.getL2PublicBalanceOf(ownerAddress); + + // 1. Mint tokens on L1 + logger.verbose(`1. Mint tokens on L1`); + await crossChainTestHarness.mintTokensOnL1(l1TokenBalance); + + // 2. Deposit tokens to the TokenPortal + logger.verbose(`2. Deposit tokens to the TokenPortal`); + const claim = await crossChainTestHarness.sendTokensToPortalPublic(bridgeAmount); + const msgHash = Fr.fromHexString(claim.messageHash); + expect(await crossChainTestHarness.getL1BalanceOf(ethAccount)).toBe( + initialL1Balance + l1TokenBalance - bridgeAmount, + ); + + // Wait for the message to be available for consumption + logger.verbose(`Wait for the message to be available for consumption`); + await crossChainTestHarness.makeMessageConsumable(msgHash); + + // Check message leaf index matches + const maybeIndexAndPath = await aztecNode.getL1ToL2MessageMembershipWitness('latest', msgHash); + expect(maybeIndexAndPath).toBeDefined(); + const messageLeafIndex = maybeIndexAndPath![0]; + expect(messageLeafIndex).toEqual(claim.messageLeafIndex); + + // 3. Consume L1 -> L2 message and mint public tokens on L2 + logger.verbose('3. Consume L1 -> L2 message and mint public tokens on L2'); + await crossChainTestHarness.consumeMessageOnAztecAndMintPublicly(claim); + await crossChainTestHarness.expectPublicBalanceOnL2(ownerAddress, initialPublicBalance + bridgeAmount); + + // Time to withdraw the funds again! + logger.info('Withdrawing funds from L2'); + + // 4. Give approval to bridge to burn owner's funds: + const withdrawAmount = 9n; + const authwitNonce = Fr.random(); + const validateActionInteraction = await wallet.setPublicAuthWit( + ownerAddress, + { + caller: l2Bridge.address, + action: l2Token.methods.burn_public(ownerAddress, withdrawAmount, authwitNonce), + }, + true, + ); + await validateActionInteraction.send(); + + // 5. Withdraw owner's funds from L2 to L1 + logger.verbose('5. Withdraw owner funds from L2 to L1'); + const l2ToL1Message = await crossChainTestHarness.getL2ToL1MessageLeaf(withdrawAmount); + const l2TxReceipt = await crossChainTestHarness.withdrawPublicFromAztecToL1(withdrawAmount, authwitNonce); + await crossChainTestHarness.expectPublicBalanceOnL2( + ownerAddress, + initialPublicBalance + bridgeAmount - withdrawAmount, + ); + + // Advance the epoch until the tx is proven since the messages are inserted to the outbox when the epoch is proven. + await t.advanceToEpochProven(l2TxReceipt); + + const l2ToL1MessageResult = await waitForL2ToL1Witness(aztecNode, l2TxReceipt.txHash, l2ToL1Message, { + timeout: 60, + }); + + // Check balance before and after exit. + expect(await crossChainTestHarness.getL1BalanceOf(ethAccount)).toBe( + initialL1Balance + l1TokenBalance - bridgeAmount, + ); + await crossChainTestHarness.withdrawFundsFromBridgeOnL1( + withdrawAmount, + l2ToL1MessageResult.epochNumber, + l2ToL1MessageResult.numCheckpointsInEpoch, + l2ToL1MessageResult.leafIndex, + l2ToL1MessageResult.siblingPath, + ); + expect(await crossChainTestHarness.getL1BalanceOf(ethAccount)).toBe( + initialL1Balance + l1TokenBalance - bridgeAmount + withdrawAmount, + ); + }, 900_000); + + // User2 tries to claim to their own address (fails), then correctly claims to ownerAddress. + // Asserts only ownerAddress receives the tokens, user2 gets nothing, and the message is consumed. + it('Someone else can mint funds to me on my behalf (publicly)', async () => { + const { crossChainTestHarness, ethAccount, aztecNode, logger, ownerAddress, l2Bridge, user2Address } = t; + const l1TokenBalance = 1000000n; + const bridgeAmount = 100n; + + const initialL1Balance = await crossChainTestHarness.getL1BalanceOf(ethAccount); + const initialOwnerPublicBalance = await crossChainTestHarness.getL2PublicBalanceOf(ownerAddress); + + await crossChainTestHarness.mintTokensOnL1(l1TokenBalance); + const claim = await crossChainTestHarness.sendTokensToPortalPublic(bridgeAmount); + const msgHash = Fr.fromHexString(claim.messageHash); + expect(await crossChainTestHarness.getL1BalanceOf(ethAccount)).toBe( + initialL1Balance + l1TokenBalance - bridgeAmount, + ); + + await crossChainTestHarness.makeMessageConsumable(msgHash); + + // Check message leaf index matches + const maybeIndexAndPath = await aztecNode.getL1ToL2MessageMembershipWitness('latest', msgHash); + expect(maybeIndexAndPath).toBeDefined(); + const messageLeafIndex = maybeIndexAndPath![0]; + expect(messageLeafIndex).toEqual(claim.messageLeafIndex); + + // user2 tries to consume this message and minting to itself -> should fail since the message is intended to be consumed only by owner. + await expect( + l2Bridge.methods + .claim_public(user2Address, bridgeAmount, claim.claimSecret, messageLeafIndex) + .simulate({ from: user2Address }), + ).rejects.toThrow(NO_L1_TO_L2_MSG_ERROR); + + // user2 consumes owner's L1-> L2 message on bridge contract and mints public tokens on L2 + logger.info("user2 consumes owner's message on L2 Publicly"); + await l2Bridge.methods + .claim_public(ownerAddress, bridgeAmount, claim.claimSecret, messageLeafIndex) + .send({ from: user2Address }); + + // ensure funds are gone to owner and not user2. + await crossChainTestHarness.expectPublicBalanceOnL2(ownerAddress, initialOwnerPublicBalance + bridgeAmount); + await crossChainTestHarness.expectPublicBalanceOnL2(user2Address, 0n); + }, 90_000); + }); + + describe('failure cases', () => { + // Attempts to call exit_to_l1_public without granting an authwit to the bridge contract. + // Asserts the simulation reverts with "unauthorized". + it("Bridge can't withdraw my funds if I don't give approval", async () => { + const { crossChainTestHarness, ethAccount, l2Bridge, user1Address } = t; + const mintAmountToOwner = 100n; + await crossChainTestHarness.mintTokensPublicOnL2(mintAmountToOwner); + + const withdrawAmount = 9n; + const authwitNonce = Fr.random(); + // Should fail as owner has not given approval to bridge burn their funds. + await expect( + l2Bridge.methods + .exit_to_l1_public(ethAccount, withdrawAmount, EthAddress.ZERO, authwitNonce) + .simulate({ from: user1Address }), + ).rejects.toThrow(/unauthorized/); + }, 180_000); + + // Sends a public deposit to the portal, then tries to claim with a wrong bridge amount, producing + // a mismatched message hash. Asserts "No L1 to L2 message found" for the wrong hash. + it("Can't claim funds privately which were intended for public deposit from the token portal", async () => { + const { crossChainTestHarness, ethAccount, l2Bridge, ownerAddress, user2Address } = t; + const bridgeAmount = 100n; + + const initialL1Balance = await crossChainTestHarness.getL1BalanceOf(ethAccount); + await crossChainTestHarness.mintTokensOnL1(bridgeAmount); + const claim = await crossChainTestHarness.sendTokensToPortalPublic(bridgeAmount); + expect(await crossChainTestHarness.getL1BalanceOf(ethAccount)).toBe(initialL1Balance); + + await crossChainTestHarness.makeMessageConsumable(claim.messageHash); + + // Wrong message hash + const wrongBridgeAmount = bridgeAmount + 1n; + const wrongMessageContent = sha256ToField([ + Buffer.from(toFunctionSelector('mint_to_private(uint256)').substring(2), 'hex'), + new Fr(wrongBridgeAmount), + ]); + + const wrongMessage = new L1ToL2Message( + new L1Actor(crossChainTestHarness.tokenPortalAddress, crossChainTestHarness.l1Client.chain.id), + new L2Actor(l2Bridge.address, version), + wrongMessageContent, + claim.claimSecretHash, + new Fr(claim.messageLeafIndex), + ); + + // Sending wrong secret hashes should fail: + await expect( + l2Bridge.methods + .claim_private(ownerAddress, wrongBridgeAmount, claim.claimSecret, claim.messageLeafIndex) + .simulate({ from: user2Address }), + ).rejects.toThrow(`No L1 to L2 message found for message hash ${wrongMessage.hash().toString()}`); + }, 180_000); + + // Sends a private deposit to the portal, then tries to claim it publicly using claim_public. + // The message hash does not match the public-mint selector, so consumption fails with NO_L1_TO_L2_MSG_ERROR. + it("Can't claim funds publicly which were intended for private deposit from the token portal", async () => { + const { crossChainTestHarness, ethAccount, l2Bridge, ownerAddress } = t; + // 1. Mint tokens on L1 + const bridgeAmount = 100n; + const initialL1Balance = await crossChainTestHarness.getL1BalanceOf(ethAccount); + await crossChainTestHarness.mintTokensOnL1(bridgeAmount); + + // 2. Deposit tokens to the TokenPortal privately + const claim = await crossChainTestHarness.sendTokensToPortalPrivate(bridgeAmount); + expect(await crossChainTestHarness.getL1BalanceOf(ethAccount)).toBe(initialL1Balance); + + // Wait for the message to be available for consumption + await crossChainTestHarness.makeMessageConsumable(claim.messageHash); + + // 3. Consume L1 -> L2 message and try to mint publicly on L2 - should fail + await expect( + l2Bridge.methods + .claim_public(ownerAddress, bridgeAmount, Fr.random(), claim.messageLeafIndex) + .simulate({ from: ownerAddress }), + ).rejects.toThrow(NO_L1_TO_L2_MSG_ERROR); + }); + }); +}); diff --git a/yarn-project/end-to-end/src/single-node/cross-chain/token_bridge_failure_cases.test.ts b/yarn-project/end-to-end/src/single-node/cross-chain/token_bridge_failure_cases.test.ts deleted file mode 100644 index e26ff1757f91..000000000000 --- a/yarn-project/end-to-end/src/single-node/cross-chain/token_bridge_failure_cases.test.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { EthAddress } from '@aztec/aztec.js/addresses'; -import { Fr } from '@aztec/aztec.js/fields'; -import { L1Actor, L1ToL2Message, L2Actor } from '@aztec/aztec.js/messaging'; -import { sha256ToField } from '@aztec/foundation/crypto/sha256'; - -import { toFunctionSelector } from 'viem'; - -import { - L1_DIRECT_WRITE_ACCOUNT_INDEX, - NO_L1_TO_L2_MSG_ERROR, - PIPELINING_SETUP_OPTS, -} from '../../fixtures/fixtures.js'; -import { CrossChainMessagingTest } from './cross_chain_messaging_test.js'; - -// Token bridge failure scenarios: missing authwit, wrong secret hash, and wrong deposit direction. -// Uses CrossChainMessagingTest (prod sequencer, pipelining preset: ethSlot=4s, aztecSlot=12s, -// inboxLag=2, minTxsPerBlock=0), EpochTestSettler for auto-proving, and CrossChainTestHarness for -// L1↔L2 token portal bridging. -describe('single-node/cross-chain/token_bridge_failure_cases', () => { - const t = new CrossChainMessagingTest( - 'token_bridge_failure_cases', - {}, - {}, - {}, - { - l1HarnessAccountIndex: L1_DIRECT_WRITE_ACCOUNT_INDEX, - }, - ); - let version: number = 1; - - let { crossChainTestHarness, ethAccount, l2Bridge, ownerAddress, user1Address, user2Address, rollup } = t; - - beforeAll(async () => { - await t.setup({ ...PIPELINING_SETUP_OPTS }); - // Have to destructure again to ensure we have latest refs. - ({ crossChainTestHarness, user1Address, user2Address, ownerAddress, rollup } = t); - ethAccount = crossChainTestHarness.ethAccount; - l2Bridge = crossChainTestHarness.l2Bridge; - ownerAddress = crossChainTestHarness.ownerAddress; - version = Number(await rollup.getVersion()); - }, 300_000); - - afterAll(async () => { - await t.teardown(); - }); - - // Attempts to call exit_to_l1_public without granting an authwit to the bridge contract. - // Asserts the simulation reverts with "unauthorized". - it("Bridge can't withdraw my funds if I don't give approval", async () => { - const mintAmountToOwner = 100n; - await crossChainTestHarness.mintTokensPublicOnL2(mintAmountToOwner); - - const withdrawAmount = 9n; - const authwitNonce = Fr.random(); - // Should fail as owner has not given approval to bridge burn their funds. - await expect( - l2Bridge.methods - .exit_to_l1_public(ethAccount, withdrawAmount, EthAddress.ZERO, authwitNonce) - .simulate({ from: user1Address }), - ).rejects.toThrow(/unauthorized/); - }, 180_000); - - // Sends a public deposit to the portal, then tries to claim with a wrong bridge amount, producing - // a mismatched message hash. Asserts "No L1 to L2 message found" for the wrong hash. - it("Can't claim funds privately which were intended for public deposit from the token portal", async () => { - const bridgeAmount = 100n; - - await crossChainTestHarness.mintTokensOnL1(bridgeAmount); - const claim = await crossChainTestHarness.sendTokensToPortalPublic(bridgeAmount); - expect(await crossChainTestHarness.getL1BalanceOf(ethAccount)).toBe(0n); - - await crossChainTestHarness.makeMessageConsumable(claim.messageHash); - - // Wrong message hash - const wrongBridgeAmount = bridgeAmount + 1n; - const wrongMessageContent = sha256ToField([ - Buffer.from(toFunctionSelector('mint_to_private(uint256)').substring(2), 'hex'), - new Fr(wrongBridgeAmount), - ]); - - const wrongMessage = new L1ToL2Message( - new L1Actor(crossChainTestHarness.tokenPortalAddress, crossChainTestHarness.l1Client.chain.id), - new L2Actor(l2Bridge.address, version), - wrongMessageContent, - claim.claimSecretHash, - new Fr(claim.messageLeafIndex), - ); - - // Sending wrong secret hashes should fail: - await expect( - l2Bridge.methods - .claim_private(ownerAddress, wrongBridgeAmount, claim.claimSecret, claim.messageLeafIndex) - .simulate({ from: user2Address }), - ).rejects.toThrow(`No L1 to L2 message found for message hash ${wrongMessage.hash().toString()}`); - }, 180_000); - - // Sends a private deposit to the portal, then tries to claim it publicly using claim_public. - // The message hash does not match the public-mint selector, so consumption fails with NO_L1_TO_L2_MSG_ERROR. - it("Can't claim funds publicly which were intended for private deposit from the token portal", async () => { - // 1. Mint tokens on L1 - const bridgeAmount = 100n; - await crossChainTestHarness.mintTokensOnL1(bridgeAmount); - - // 2. Deposit tokens to the TokenPortal privately - const claim = await crossChainTestHarness.sendTokensToPortalPrivate(bridgeAmount); - expect(await crossChainTestHarness.getL1BalanceOf(ethAccount)).toBe(0n); - - // Wait for the message to be available for consumption - await crossChainTestHarness.makeMessageConsumable(claim.messageHash); - - // 3. Consume L1 -> L2 message and try to mint publicly on L2 - should fail - await expect( - l2Bridge.methods - .claim_public(ownerAddress, bridgeAmount, Fr.random(), claim.messageLeafIndex) - .simulate({ from: ownerAddress }), - ).rejects.toThrow(NO_L1_TO_L2_MSG_ERROR); - }); -}); diff --git a/yarn-project/end-to-end/src/single-node/cross-chain/token_bridge_private.test.ts b/yarn-project/end-to-end/src/single-node/cross-chain/token_bridge_private.test.ts deleted file mode 100644 index c3f82763b21d..000000000000 --- a/yarn-project/end-to-end/src/single-node/cross-chain/token_bridge_private.test.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { AztecAddress, EthAddress } from '@aztec/aztec.js/addresses'; -import { Fr } from '@aztec/aztec.js/fields'; -import type { Logger } from '@aztec/aztec.js/log'; -import type { AztecNode } from '@aztec/aztec.js/node'; -import type { TokenContract } from '@aztec/noir-contracts.js/Token'; -import type { TokenBridgeContract } from '@aztec/noir-contracts.js/TokenBridge'; - -import { jest } from '@jest/globals'; - -import { L1_DIRECT_WRITE_ACCOUNT_INDEX, PIPELINING_SETUP_OPTS } from '../../fixtures/fixtures.js'; -import { waitForL2ToL1Witness } from '../../fixtures/wait_helpers.js'; -import type { CrossChainTestHarness } from '../../shared/cross_chain_test_harness.js'; -import type { TestWallet } from '../../test-wallet/test_wallet.js'; -import { CrossChainMessagingTest } from './cross_chain_messaging_test.js'; - -// Private L1→L2 token deposit and L2→L1 withdrawal via the TokenBridge. Uses CrossChainMessagingTest -// with startProverNode=true (prod sequencer, pipelining preset: ethSlot=4s, aztecSlot=12s), fake -// in-proc prover node, and CrossChainTestHarness for full L1↔L2 portal/bridge lifecycle. -// Epoch proving via advanceToEpochProven is required before L1 Outbox consumption. -describe('single-node/cross-chain/token_bridge_private', () => { - // Pipelining slows wall-clock chain progress (12s slots); waitForProven via advanceToEpochProven - // needs more than the default 300s per-test budget. - jest.setTimeout(15 * 60 * 1000); - - const t = new CrossChainMessagingTest( - 'token_bridge_private', - { startProverNode: true }, - {}, - {}, - { l1HarnessAccountIndex: L1_DIRECT_WRITE_ACCOUNT_INDEX }, - ); - - let crossChainTestHarness: CrossChainTestHarness; - let ethAccount: EthAddress; - let aztecNode: AztecNode; - let logger: Logger; - let ownerAddress: AztecAddress; - let l2Bridge: TokenBridgeContract; - let l2Token: TokenContract; - let wallet: TestWallet; - let user2Address: AztecAddress; - - beforeAll(async () => { - await t.setup({ ...PIPELINING_SETUP_OPTS }); - // Have to destructure again to ensure we have latest refs. - ({ crossChainTestHarness, ethAccount, aztecNode, logger, ownerAddress, l2Bridge, l2Token, wallet, user2Address } = - t); - }, 300_000); - - afterAll(async () => { - await t.teardown(); - }); - - // Full round-trip: mint tokens on L1, deposit privately via TokenPortal, wait for the message to be - // consumable, claim on L2 (minting private tokens), withdraw back to L1 with an authwit, advance the - // epoch until proven, then consume the Outbox message on L1 and verify the L1 balance is restored. - it('Privately deposit funds from L1 -> L2 and withdraw back to L1', async () => { - // Generate a claim secret using pedersen - const l1TokenBalance = 1000000n; - const bridgeAmount = 100n; - - // 1. Mint tokens on L1 - await crossChainTestHarness.mintTokensOnL1(l1TokenBalance); - - // 2. Deposit tokens to the TokenPortal - const claim = await crossChainTestHarness.sendTokensToPortalPrivate(bridgeAmount); - expect(await crossChainTestHarness.getL1BalanceOf(ethAccount)).toBe(l1TokenBalance - bridgeAmount); - - await crossChainTestHarness.makeMessageConsumable(claim.messageHash); - - // 3. Consume L1 -> L2 message and mint private tokens on L2 - await crossChainTestHarness.consumeMessageOnAztecAndMintPrivately(claim); - await crossChainTestHarness.expectPrivateBalanceOnL2(ownerAddress, bridgeAmount); - - // time to withdraw the funds again! - logger.info('Withdrawing funds from L2'); - - // 4. Give approval to bridge to burn owner's funds: - const withdrawAmount = 9n; - const authwitNonce = Fr.random(); - const burnAuthwit = await wallet.createAuthWit(ownerAddress, { - caller: l2Bridge.address, - action: l2Token.methods.burn_private(ownerAddress, withdrawAmount, authwitNonce), - }); - - // 5. Withdraw owner's funds from L2 to L1 - const l2ToL1Message = await crossChainTestHarness.getL2ToL1MessageLeaf(withdrawAmount); - const l2TxReceipt = await crossChainTestHarness.withdrawPrivateFromAztecToL1( - withdrawAmount, - authwitNonce, - burnAuthwit, - ); - await crossChainTestHarness.expectPrivateBalanceOnL2(ownerAddress, bridgeAmount - withdrawAmount); - - // Advance the epoch until the tx is proven since the messages are inserted to the outbox when the epoch is proven. - await t.advanceToEpochProven(l2TxReceipt); - - const l2ToL1MessageResult = await waitForL2ToL1Witness(aztecNode, l2TxReceipt.txHash, l2ToL1Message, { - timeout: 60, - }); - - // Check balance before and after exit. - expect(await crossChainTestHarness.getL1BalanceOf(ethAccount)).toBe(l1TokenBalance - bridgeAmount); - await crossChainTestHarness.withdrawFundsFromBridgeOnL1( - withdrawAmount, - l2ToL1MessageResult.epochNumber, - l2ToL1MessageResult.numCheckpointsInEpoch, - l2ToL1MessageResult.leafIndex, - l2ToL1MessageResult.siblingPath, - ); - expect(await crossChainTestHarness.getL1BalanceOf(ethAccount)).toBe(l1TokenBalance - bridgeAmount + withdrawAmount); - }); - - // This test checks that it's enough to have the claim secret to claim the funds to whoever we want. - // User2 (not the original depositor) uses the claim secret to call claim_private on behalf of owner. - // Asserts the funds land at ownerAddress (not user2), proving the secret-based authorization works. - it('Claim secret is enough to consume the message', async () => { - const initialPublicBalance = await crossChainTestHarness.getL1BalanceOf(ethAccount); - const initialPrivateBalance = await crossChainTestHarness.getL2PrivateBalanceOf(ownerAddress); - - const bridgeAmount = 100n; - const claim = await crossChainTestHarness.sendTokensToPortalPrivate(bridgeAmount); - expect(await crossChainTestHarness.getL1BalanceOf(ethAccount)).toBe(initialPublicBalance - bridgeAmount); - - // Wait for the message to be available for consumption - await crossChainTestHarness.makeMessageConsumable(claim.messageHash); - - // send the right one - - await l2Bridge.methods - .claim_private(ownerAddress, bridgeAmount, claim.claimSecret, claim.messageLeafIndex) - .send({ from: user2Address }); - - await crossChainTestHarness.expectPrivateBalanceOnL2(ownerAddress, initialPrivateBalance + bridgeAmount); - }, 90_000); -}); diff --git a/yarn-project/end-to-end/src/single-node/cross-chain/token_bridge_public.parallel.test.ts b/yarn-project/end-to-end/src/single-node/cross-chain/token_bridge_public.parallel.test.ts deleted file mode 100644 index 666fbf96a6d3..000000000000 --- a/yarn-project/end-to-end/src/single-node/cross-chain/token_bridge_public.parallel.test.ts +++ /dev/null @@ -1,160 +0,0 @@ -import { Fr } from '@aztec/aztec.js/fields'; - -import { jest } from '@jest/globals'; - -import { - L1_DIRECT_WRITE_ACCOUNT_INDEX, - NO_L1_TO_L2_MSG_ERROR, - PIPELINING_SETUP_OPTS, -} from '../../fixtures/fixtures.js'; -import { waitForL2ToL1Witness } from '../../fixtures/wait_helpers.js'; -import { CrossChainMessagingTest } from './cross_chain_messaging_test.js'; - -// Public L1→L2 token deposit and L2→L1 withdrawal via the TokenBridge. Uses CrossChainMessagingTest -// with startProverNode=true (prod sequencer, pipelining preset: ethSlot=4s, aztecSlot=12s), fake -// in-proc prover node, and CrossChainTestHarness for full L1↔L2 portal/bridge lifecycle. Setup and -// teardown happen per-test (beforeEach/afterEach) because the test creates fresh bridge state each run. -describe('single-node/cross-chain/token_bridge_public', () => { - // Pipelining slows wall-clock chain progress (12s slots); waitForProven via advanceToEpochProven - // needs more than the default 300s per-test budget. - jest.setTimeout(15 * 60 * 1000); - - const t = new CrossChainMessagingTest( - 'token_bridge_public', - { startProverNode: true }, - {}, - {}, - { l1HarnessAccountIndex: L1_DIRECT_WRITE_ACCOUNT_INDEX }, - ); - - let { crossChainTestHarness, ethAccount, aztecNode, logger, ownerAddress, l2Bridge, l2Token, wallet, user2Address } = - t; - - beforeEach(async () => { - await t.setup({ ...PIPELINING_SETUP_OPTS }); - // Have to destructure again to ensure we have latest refs. - ({ crossChainTestHarness, wallet, user2Address } = t); - - ethAccount = crossChainTestHarness.ethAccount; - aztecNode = crossChainTestHarness.aztecNode; - logger = crossChainTestHarness.logger; - ownerAddress = crossChainTestHarness.ownerAddress; - l2Bridge = crossChainTestHarness.l2Bridge; - l2Token = crossChainTestHarness.l2Token; - }, 300_000); - - afterEach(async () => { - await t.teardown(); - }); - - // Full round-trip: mint on L1, publicly deposit via TokenPortal, wait for message, claim_public on - // L2, authorize bridge to burn, withdraw to L1, advance to epoch proven, consume Outbox on L1. - // Asserts L1 balance is restored after the round-trip. - it('Publicly deposit funds from L1 -> L2 and withdraw back to L1', async () => { - const l1TokenBalance = 1000000n; - const bridgeAmount = 100n; - - // 1. Mint tokens on L1 - logger.verbose(`1. Mint tokens on L1`); - await crossChainTestHarness.mintTokensOnL1(l1TokenBalance); - - // 2. Deposit tokens to the TokenPortal - logger.verbose(`2. Deposit tokens to the TokenPortal`); - const claim = await crossChainTestHarness.sendTokensToPortalPublic(bridgeAmount); - const msgHash = Fr.fromHexString(claim.messageHash); - expect(await crossChainTestHarness.getL1BalanceOf(ethAccount)).toBe(l1TokenBalance - bridgeAmount); - - // Wait for the message to be available for consumption - logger.verbose(`Wait for the message to be available for consumption`); - await crossChainTestHarness.makeMessageConsumable(msgHash); - - // Check message leaf index matches - const maybeIndexAndPath = await aztecNode.getL1ToL2MessageMembershipWitness('latest', msgHash); - expect(maybeIndexAndPath).toBeDefined(); - const messageLeafIndex = maybeIndexAndPath![0]; - expect(messageLeafIndex).toEqual(claim.messageLeafIndex); - - // 3. Consume L1 -> L2 message and mint public tokens on L2 - logger.verbose('3. Consume L1 -> L2 message and mint public tokens on L2'); - await crossChainTestHarness.consumeMessageOnAztecAndMintPublicly(claim); - await crossChainTestHarness.expectPublicBalanceOnL2(ownerAddress, bridgeAmount); - const afterBalance = bridgeAmount; - - // Time to withdraw the funds again! - logger.info('Withdrawing funds from L2'); - - // 4. Give approval to bridge to burn owner's funds: - const withdrawAmount = 9n; - const authwitNonce = Fr.random(); - const validateActionInteraction = await wallet.setPublicAuthWit( - ownerAddress, - { - caller: l2Bridge.address, - action: l2Token.methods.burn_public(ownerAddress, withdrawAmount, authwitNonce), - }, - true, - ); - await validateActionInteraction.send(); - - // 5. Withdraw owner's funds from L2 to L1 - logger.verbose('5. Withdraw owner funds from L2 to L1'); - const l2ToL1Message = await crossChainTestHarness.getL2ToL1MessageLeaf(withdrawAmount); - const l2TxReceipt = await crossChainTestHarness.withdrawPublicFromAztecToL1(withdrawAmount, authwitNonce); - await crossChainTestHarness.expectPublicBalanceOnL2(ownerAddress, afterBalance - withdrawAmount); - - // Advance the epoch until the tx is proven since the messages are inserted to the outbox when the epoch is proven. - await t.advanceToEpochProven(l2TxReceipt); - - const l2ToL1MessageResult = await waitForL2ToL1Witness(aztecNode, l2TxReceipt.txHash, l2ToL1Message, { - timeout: 60, - }); - - // Check balance before and after exit. - expect(await crossChainTestHarness.getL1BalanceOf(ethAccount)).toBe(l1TokenBalance - bridgeAmount); - await crossChainTestHarness.withdrawFundsFromBridgeOnL1( - withdrawAmount, - l2ToL1MessageResult.epochNumber, - l2ToL1MessageResult.numCheckpointsInEpoch, - l2ToL1MessageResult.leafIndex, - l2ToL1MessageResult.siblingPath, - ); - expect(await crossChainTestHarness.getL1BalanceOf(ethAccount)).toBe(l1TokenBalance - bridgeAmount + withdrawAmount); - }, 900_000); - - // User2 tries to claim to their own address (fails), then correctly claims to ownerAddress. - // Asserts only ownerAddress receives the tokens, user2 gets nothing, and the message is consumed. - it('Someone else can mint funds to me on my behalf (publicly)', async () => { - const l1TokenBalance = 1000000n; - const bridgeAmount = 100n; - - await crossChainTestHarness.mintTokensOnL1(l1TokenBalance); - const claim = await crossChainTestHarness.sendTokensToPortalPublic(bridgeAmount); - const msgHash = Fr.fromHexString(claim.messageHash); - expect(await crossChainTestHarness.getL1BalanceOf(ethAccount)).toBe(l1TokenBalance - bridgeAmount); - - await crossChainTestHarness.makeMessageConsumable(msgHash); - - // Check message leaf index matches - const maybeIndexAndPath = await aztecNode.getL1ToL2MessageMembershipWitness('latest', msgHash); - expect(maybeIndexAndPath).toBeDefined(); - const messageLeafIndex = maybeIndexAndPath![0]; - expect(messageLeafIndex).toEqual(claim.messageLeafIndex); - - // user2 tries to consume this message and minting to itself -> should fail since the message is intended to be consumed only by owner. - await expect( - l2Bridge.methods - .claim_public(user2Address, bridgeAmount, claim.claimSecret, messageLeafIndex) - .simulate({ from: user2Address }), - ).rejects.toThrow(NO_L1_TO_L2_MSG_ERROR); - - // user2 consumes owner's L1-> L2 message on bridge contract and mints public tokens on L2 - logger.info("user2 consumes owner's message on L2 Publicly"); - await l2Bridge.methods - .claim_public(ownerAddress, bridgeAmount, claim.claimSecret, messageLeafIndex) - .send({ from: user2Address }); - - // ensure funds are gone to owner and not user2. - await crossChainTestHarness.expectPublicBalanceOnL2(ownerAddress, bridgeAmount); - await crossChainTestHarness.expectPublicBalanceOnL2(user2Address, 0n); - }, 90_000); -}); diff --git a/yarn-project/end-to-end/src/single-node/fees/failures.test.ts b/yarn-project/end-to-end/src/single-node/fees/failures.test.ts index ef9e336ca787..234b84c54f44 100644 --- a/yarn-project/end-to-end/src/single-node/fees/failures.test.ts +++ b/yarn-project/end-to-end/src/single-node/fees/failures.test.ts @@ -19,11 +19,12 @@ import { jest } from '@jest/globals'; import { PIPELINING_SETUP_OPTS, U128_UNDERFLOW_ERROR } from '../../fixtures/fixtures.js'; import { ensureAuthRegistryPublished } from '../../fixtures/setup.js'; import { expectMapping } from '../../fixtures/utils.js'; +import { NO_REORG_SUBMISSION_EPOCHS } from '../setup.js'; import { FeesTest } from './fees_test.js'; // Fee behaviour when transactions revert. Uses FeesTest (prod sequencer, pipelining preset: // ethSlot=4s, aztecSlot=12s, inboxLag=2, minTxsPerBlock=0, aztecEpochDuration=4, -// aztecProofSubmissionEpochs=640), fake in-proc prover node, and GasBridgingTestHarness for +// aztecProofSubmissionEpochs=NO_REORG_SUBMISSION_EPOCHS), fake in-proc prover node, and GasBridgingTestHarness for // L1↔L2 fee-juice bridging. Auto-proving is disabled after setup so tests control proving themselves. describe('single-node/fees/failures', () => { // FeesTest.setup + applyFPCSetup chains many dependent txs which run at the @@ -45,7 +46,11 @@ describe('single-node/fees/failures', () => { // Shorter epochs (default 32 → 4) speed the per-test `advanceToNextEpoch + waitForProven` // cycle: the prover-node submits a proof as soon as the epoch is complete, so ~8x shorter // epochs ≈ ~8x faster proof cadence per cycle. Setup itself stays slot-bound. - await t.setup({ ...PIPELINING_SETUP_OPTS, aztecProofSubmissionEpochs: 640, aztecEpochDuration: 4 }); + await t.setup({ + ...PIPELINING_SETUP_OPTS, + aztecProofSubmissionEpochs: NO_REORG_SUBMISSION_EPOCHS, + aztecEpochDuration: 4, + }); await t.applyFPCSetup(); ({ wallet, aliceAddress, sequencerAddress, bananaCoin, bananaFPC, gasSettings } = t); await ensureAuthRegistryPublished(wallet, aliceAddress); diff --git a/yarn-project/end-to-end/src/single-node/fees/fee_settings.test.ts b/yarn-project/end-to-end/src/single-node/fees/fee_settings.test.ts index 333efc4067f3..53b062cff8ce 100644 --- a/yarn-project/end-to-end/src/single-node/fees/fee_settings.test.ts +++ b/yarn-project/end-to-end/src/single-node/fees/fee_settings.test.ts @@ -16,6 +16,7 @@ import { DEFAULT_MIN_FEE_PADDING } from '../../fixtures/fixtures.js'; import { waitForBlockNumber, waitForNodeCheckpoint } from '../../fixtures/wait_helpers.js'; import type { TestWallet } from '../../test-wallet/test_wallet.js'; import { proveInteraction } from '../../test-wallet/utils.js'; +import { NO_REORG_SUBMISSION_EPOCHS } from '../setup.js'; import { FeesTest } from './fees_test.js'; /** @@ -55,7 +56,7 @@ async function spikeL1BaseFeeUntilMinFee( // Fee oracle and wallet fee-padding behaviour under L1 base-fee spikes and governance fee-config bumps. // Uses FeesTest with a custom timing preset (ethSlot=4s, aztecSlot=12s, inboxLag=2, minTxsPerBlock=0, -// aztecProofSubmissionEpochs=640, manaTarget=4M, walletMinFeePadding=30) and fake in-proc prover node. +// aztecProofSubmissionEpochs=NO_REORG_SUBMISSION_EPOCHS, manaTarget=4M, walletMinFeePadding=30) and fake in-proc prover node. // No token bridging involved — all L1 interaction is L1 base-fee cheat codes and Rollup oracle calls. // (Category: single-node despite using FeesTest, since no cross-chain token transfer or fee-juice // portal bridging occurs in any test body — L1 is active only for oracle updates.) @@ -79,7 +80,7 @@ describe('single-node/fees/fee_settings', () => { minTxsPerBlock: 0, aztecSlotDuration: AZTEC_SLOT_DURATION, ethereumSlotDuration: 4, - aztecProofSubmissionEpochs: 640, + aztecProofSubmissionEpochs: NO_REORG_SUBMISSION_EPOCHS, walletMinFeePadding: 30, manaTarget: 4_000_000n, }); diff --git a/yarn-project/end-to-end/src/single-node/fees/private_payments.parallel.test.ts b/yarn-project/end-to-end/src/single-node/fees/private_payments.parallel.test.ts index d5773e556ee9..ad155ec4a467 100644 --- a/yarn-project/end-to-end/src/single-node/fees/private_payments.parallel.test.ts +++ b/yarn-project/end-to-end/src/single-node/fees/private_payments.parallel.test.ts @@ -1,24 +1,29 @@ import type { AztecAddress } from '@aztec/aztec.js/addresses'; import { BatchCall, waitForProven } from '@aztec/aztec.js/contracts'; -import { PrivateFeePaymentMethod } from '@aztec/aztec.js/fee'; +import { PrivateFeePaymentMethod, PublicFeePaymentMethod } from '@aztec/aztec.js/fee'; +import { SponsoredFeePaymentMethod } from '@aztec/aztec.js/fee/testing'; import type { AztecNode } from '@aztec/aztec.js/node'; import { FPCContract } from '@aztec/noir-contracts.js/FPC'; +import type { SponsoredFPCContract } from '@aztec/noir-contracts.js/SponsoredFPC'; import type { TokenContract as BananaCoin } from '@aztec/noir-contracts.js/Token'; import { GasSettings } from '@aztec/stdlib/gas'; import { TX_ERROR_INSUFFICIENT_FEE_PAYER_BALANCE } from '@aztec/stdlib/tx'; import { jest } from '@jest/globals'; -import { PIPELINING_SETUP_OPTS } from '../../fixtures/fixtures.js'; +import { PIPELINING_SETUP_OPTS, getPaddedMaxFeesPerGas } from '../../fixtures/fixtures.js'; import { expectMapping } from '../../fixtures/utils.js'; import type { TestWallet } from '../../test-wallet/test_wallet.js'; import { proveInteraction } from '../../test-wallet/utils.js'; import { FeesTest } from './fees_test.js'; -// Private fee payment via BananaCoin FPC (PrivateFeePaymentMethod). Uses FeesTest (prod sequencer, -// pipelining preset: ethSlot=4s, aztecSlot=12s, inboxLag=2, minTxsPerBlock=0, aztecEpochDuration=4, +// Fee payment via BananaCoin FPC over the shared FeesTest fixture (prod sequencer, pipelining preset: +// ethSlot=4s, aztecSlot=12s, inboxLag=2, minTxsPerBlock=0, aztecEpochDuration=4, // aztecProofSubmissionEpochs=640), fake in-proc prover node, and GasBridgingTestHarness for L1↔L2 -// fee-juice bridging. Auto-proving is disabled after setup so tests control epoch advancement. +// fee-juice bridging. Auto-proving is disabled after setup so tests control epoch advancement. Covers +// the private-FPC payment path (PrivateFeePaymentMethod) plus single-assertion public-FPC +// (PublicFeePaymentMethod) and sponsored-FPC (SponsoredFeePaymentMethod) payment checks folded in from +// the former public_payments/sponsored_payments files onto this same fixture. describe('single-node/fees/private_payments', () => { // FeesTest.setup + applyFPCSetup + applyFundAliceWithBananas chains many dependent txs which run at the // ~24s/tx pipelined cadence, exceeding the default 5 min hook window. @@ -30,6 +35,7 @@ describe('single-node/fees/private_payments', () => { let sequencerAddress: AztecAddress; let bananaCoin: BananaCoin; let bananaFPC: FPCContract; + let sponsoredFPC: SponsoredFPCContract; let gasSettings: GasSettings; let aztecNode: AztecNode; @@ -41,8 +47,21 @@ describe('single-node/fees/private_payments', () => { // epochs ≈ ~8x faster proof cadence per cycle. Setup itself stays slot-bound. await t.setup({ ...PIPELINING_SETUP_OPTS, aztecProofSubmissionEpochs: 640, aztecEpochDuration: 4 }); await t.applyFPCSetup(); + // Register the SponsoredFPC (funded at genesis via FeesTest's fundSponsoredFPC) so the folded + // sponsored-payment it can use it; this is a PXE registration, not an L2 tx. + await t.applySponsoredFPCSetup(); await t.applyFundAliceWithBananas(); - ({ wallet, aliceAddress, bobAddress, sequencerAddress, bananaCoin, bananaFPC, gasSettings, aztecNode } = t); + ({ + wallet, + aliceAddress, + bobAddress, + sequencerAddress, + bananaCoin, + bananaFPC, + sponsoredFPC, + gasSettings, + aztecNode, + } = t); // Prove up until the current state by advancing the epoch and waiting for the prover node. await t.waitForEpochProven(); @@ -56,7 +75,6 @@ describe('single-node/fees/private_payments', () => { let initialAlicePrivateBananas: bigint; let initialAliceGas: bigint; - // eslint-disable-next-line @typescript-eslint/no-unused-vars let initialBobPublicBananas: bigint; let initialBobPrivateBananas: bigint; @@ -354,4 +372,80 @@ describe('single-node/fees/private_payments', () => { }), ).rejects.toThrow('max fee not enough to cover tx fee'); }); + + // Folded from the former public_payments.test.ts. Alice sends bananas to Bob paying via the BananaCoin + // FPC with PublicFeePaymentMethod: Alice's public banana balance drops by transfer + fee, the FPC gains + // the fee in bananas and loses it in gas, and Bob receives the transfer. + it('pays fees for tx that makes a public transfer via public FPC', async () => { + // Public/sponsored FPC payment reads the padded max fee so the FPC's fixed budget survives fee + // evolution across the pipelined build window (the private path above uses the raw min fee). + const publicGasSettings = GasSettings.from({ + ...gasSettings, + maxFeesPerGas: await getPaddedMaxFeesPerGas(aztecNode), + }); + + const bananasToSendToBob = 10n; + const { receipt: tx } = await bananaCoin.methods + .transfer_in_public(aliceAddress, bobAddress, bananasToSendToBob, 0) + .send({ + from: aliceAddress, + fee: { + paymentMethod: new PublicFeePaymentMethod(bananaFPC.address, aliceAddress, wallet, publicGasSettings), + }, + }); + + const feeAmount = tx.transactionFee!; + + await expectMapping( + t.getBananaPublicBalanceFn, + [aliceAddress, bananaFPC.address, bobAddress], + [ + initialAlicePublicBananas - (feeAmount + bananasToSendToBob), + initialFPCPublicBananas + feeAmount, + initialBobPublicBananas + bananasToSendToBob, + ], + ); + + await expectMapping( + t.getGasBalanceFn, + [aliceAddress, bananaFPC.address, sequencerAddress], + [initialAliceGas, initialFPCGas - feeAmount, initialSequencerGas], + ); + }); + + // Folded from the former sponsored_payments.test.ts (also a docs snippet). Alice sends bananas to Bob + // via SponsoredFeePaymentMethod: the SponsoredFPC covers the fee from its own gas balance, so Alice's + // gas balance is untouched and only banana balances change. The bananaFPC-relative `initialFPCGas` + // snapshot from beforeEach does not apply here, so the SponsoredFPC gas is snapshotted locally. + it('pays fees for tx that makes a public transfer via sponsored FPC', async () => { + const [initialSponsoredFPCGas] = await t.getGasBalanceFn(sponsoredFPC.address); + gasSettings = GasSettings.from({ ...gasSettings, maxFeesPerGas: await getPaddedMaxFeesPerGas(aztecNode) }); + + // docs:start:sponsored_fpc_simple + const bananasToSendToBob = 10n; + const { receipt: tx } = await bananaCoin.methods + .transfer_in_public(aliceAddress, bobAddress, bananasToSendToBob, 0) + .send({ + from: aliceAddress, + fee: { + gasSettings, + paymentMethod: new SponsoredFeePaymentMethod(sponsoredFPC.address), + }, + }); + // docs:end:sponsored_fpc_simple + + const feeAmount = tx.transactionFee!; + + await expectMapping( + t.getBananaPublicBalanceFn, + [aliceAddress, bobAddress], + [initialAlicePublicBananas - bananasToSendToBob, initialBobPublicBananas + bananasToSendToBob], + ); + + await expectMapping( + t.getGasBalanceFn, + [aliceAddress, sponsoredFPC.address, sequencerAddress], + [initialAliceGas, initialSponsoredFPCGas - feeAmount, initialSequencerGas], + ); + }); }); diff --git a/yarn-project/end-to-end/src/single-node/fees/public_payments.test.ts b/yarn-project/end-to-end/src/single-node/fees/public_payments.test.ts deleted file mode 100644 index fb98e5c467cc..000000000000 --- a/yarn-project/end-to-end/src/single-node/fees/public_payments.test.ts +++ /dev/null @@ -1,101 +0,0 @@ -import type { AztecAddress } from '@aztec/aztec.js/addresses'; -import { PublicFeePaymentMethod } from '@aztec/aztec.js/fee'; -import type { AztecNode } from '@aztec/aztec.js/node'; -import type { Wallet } from '@aztec/aztec.js/wallet'; -import type { FPCContract } from '@aztec/noir-contracts.js/FPC'; -import type { TokenContract as BananaCoin } from '@aztec/noir-contracts.js/Token'; -import { GasSettings } from '@aztec/stdlib/gas'; - -import { jest } from '@jest/globals'; - -import { PIPELINING_SETUP_OPTS, getPaddedMaxFeesPerGas } from '../../fixtures/fixtures.js'; -import { expectMapping } from '../../fixtures/utils.js'; -import { FeesTest } from './fees_test.js'; - -// Public fee payment via BananaCoin FPC (PublicFeePaymentMethod). Uses FeesTest (prod sequencer, -// pipelining preset: ethSlot=4s, aztecSlot=12s, inboxLag=2, minTxsPerBlock=0), fake in-proc prover -// node, and GasBridgingTestHarness for L1↔L2 fee-juice bridging (the FPC setup bridges fee juice). -describe('single-node/fees/public_payments', () => { - // FeesTest.setup + applyFPCSetup + applyFundAliceWithBananas chains many dependent txs which run - // at the ~24s/tx pipelined cadence, exceeding the default 5 min hook window. - jest.setTimeout(15 * 60 * 1000); - - let aztecNode: AztecNode; - let wallet: Wallet; - let aliceAddress: AztecAddress; - let bobAddress: AztecAddress; - let sequencerAddress: AztecAddress; - let bananaCoin: BananaCoin; - let bananaFPC: FPCContract; - let gasSettings: GasSettings; - - const t = new FeesTest('public_payment'); - - beforeAll(async () => { - await t.setup({ ...PIPELINING_SETUP_OPTS }); - await t.applyFPCSetup(); - await t.applyFundAliceWithBananas(); - ({ wallet, aliceAddress, bobAddress, sequencerAddress, bananaCoin, bananaFPC, gasSettings, aztecNode } = t); - }); - - afterAll(async () => { - await t.teardown(); - }); - - let initialAlicePublicBananas: bigint; - let initialAliceGas: bigint; - - let initialBobPublicBananas: bigint; - - let initialFPCPublicBananas: bigint; - let initialFPCGas: bigint; - - let initialSequencerGas: bigint; - - beforeEach(async () => { - gasSettings = GasSettings.from({ - ...gasSettings, - maxFeesPerGas: await getPaddedMaxFeesPerGas(aztecNode), - }); - - [ - [initialAlicePublicBananas, initialBobPublicBananas, initialFPCPublicBananas], - [initialAliceGas, initialFPCGas, initialSequencerGas], - ] = await Promise.all([ - t.getBananaPublicBalanceFn(aliceAddress, bobAddress, bananaFPC.address), - t.getGasBalanceFn(aliceAddress, bananaFPC.address, sequencerAddress), - ]); - }); - - // Alice sends 10 bananas to Bob using PublicFeePaymentMethod. Asserts Alice's banana balance - // decreases by bananasToSendToBob + fee, FPC public balance increases by fee, and FPC gas decreases. - it('pays fees for tx that make public transfer', async () => { - const bananasToSendToBob = 10n; - const { receipt: tx } = await bananaCoin.methods - .transfer_in_public(aliceAddress, bobAddress, bananasToSendToBob, 0) - .send({ - from: aliceAddress, - fee: { - paymentMethod: new PublicFeePaymentMethod(bananaFPC.address, aliceAddress, wallet, gasSettings), - }, - }); - - const feeAmount = tx.transactionFee!; - - await expectMapping( - t.getBananaPublicBalanceFn, - [aliceAddress, bananaFPC.address, bobAddress], - [ - initialAlicePublicBananas - (feeAmount + bananasToSendToBob), - initialFPCPublicBananas + feeAmount, - initialBobPublicBananas + bananasToSendToBob, - ], - ); - - await expectMapping( - t.getGasBalanceFn, - [aliceAddress, bananaFPC.address, sequencerAddress], - [initialAliceGas, initialFPCGas - feeAmount, initialSequencerGas], - ); - }); -}); diff --git a/yarn-project/end-to-end/src/single-node/fees/sponsored_payments.test.ts b/yarn-project/end-to-end/src/single-node/fees/sponsored_payments.test.ts deleted file mode 100644 index 6aff0e355bd3..000000000000 --- a/yarn-project/end-to-end/src/single-node/fees/sponsored_payments.test.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { AztecAddress } from '@aztec/aztec.js/addresses'; -import { SponsoredFeePaymentMethod } from '@aztec/aztec.js/fee/testing'; -import type { AztecNode } from '@aztec/aztec.js/node'; -import type { SponsoredFPCContract } from '@aztec/noir-contracts.js/SponsoredFPC'; -import type { TokenContract } from '@aztec/noir-contracts.js/Token'; -import { GasSettings } from '@aztec/stdlib/gas'; - -import { jest } from '@jest/globals'; - -import { PIPELINING_SETUP_OPTS, getPaddedMaxFeesPerGas } from '../../fixtures/fixtures.js'; -import { expectMapping } from '../../fixtures/utils.js'; -import { FeesTest } from './fees_test.js'; - -// Sponsored fee payment via SponsoredFPC (SponsoredFeePaymentMethod). Uses FeesTest (prod sequencer, -// pipelining preset: ethSlot=4s, aztecSlot=12s, inboxLag=2, minTxsPerBlock=0), fake in-proc prover -// node, and GasBridgingTestHarness for L1↔L2 fee-juice bridging (the SponsoredFPC is funded at -// genesis via fundSponsoredFPC; the test exercises the sponsored path where the user pays no fee juice -// directly). Also used as a code snippet in the documentation (docs:start/end:sponsored_fpc_simple). -describe('single-node/fees/sponsored_payments', () => { - // FeesTest.setup + applySponsoredFPCSetup + applyFundAliceWithBananas chains many dependent txs which run - // at the ~24s/tx pipelined cadence, exceeding the default 5 min hook window. - jest.setTimeout(15 * 60 * 1000); - - let aztecNode: AztecNode; - let aliceAddress: AztecAddress; - let bobAddress: AztecAddress; - let sequencerAddress: AztecAddress; - let sponsoredFPC: SponsoredFPCContract; - let gasSettings: GasSettings; - let bananaCoin: TokenContract; - - const t = new FeesTest('sponsored_payment'); - - beforeAll(async () => { - await t.setup({ ...PIPELINING_SETUP_OPTS }); - await t.applySponsoredFPCSetup(); - await t.applyFundAliceWithBananas(); - ({ aztecNode, aliceAddress, bobAddress, sequencerAddress, sponsoredFPC, bananaCoin, gasSettings } = t); - }); - - afterAll(async () => { - await t.teardown(); - }); - - let initialAlicePublicBananas: bigint; - let initialAliceGas: bigint; - - // eslint-disable-next-line @typescript-eslint/no-unused-vars - let initialBobPublicBananas: bigint; - - let initialFPCGas: bigint; - - let initialSequencerGas: bigint; - - beforeEach(async () => { - gasSettings = GasSettings.from({ - ...gasSettings, - maxFeesPerGas: await getPaddedMaxFeesPerGas(aztecNode), - }); - - [[initialAlicePublicBananas, initialBobPublicBananas], [initialAliceGas, initialFPCGas, initialSequencerGas]] = - await Promise.all([ - t.getBananaPublicBalanceFn(aliceAddress, bobAddress), - t.getGasBalanceFn(aliceAddress, sponsoredFPC.address, sequencerAddress), - ]); - }); - - // Alice transfers bananas to Bob via SponsoredFeePaymentMethod. The SponsoredFPC covers the fee - // from its own gas balance; Alice's gas balance is unaffected and only her banana balance changes. - it('pays fees for tx that makes a public transfer', async () => { - // docs:start:sponsored_fpc_simple - const bananasToSendToBob = 10n; - const { receipt: tx } = await bananaCoin.methods - .transfer_in_public(aliceAddress, bobAddress, bananasToSendToBob, 0) - .send({ - from: aliceAddress, - fee: { - gasSettings, - paymentMethod: new SponsoredFeePaymentMethod(sponsoredFPC.address), - }, - }); - // docs:end:sponsored_fpc_simple - - const feeAmount = tx.transactionFee!; - - await expectMapping( - t.getBananaPublicBalanceFn, - [aliceAddress, bobAddress], - [initialAlicePublicBananas - bananasToSendToBob, bananasToSendToBob], - ); - - await expectMapping( - t.getGasBalanceFn, - [aliceAddress, sponsoredFPC.address, sequencerAddress], - [initialAliceGas, initialFPCGas - feeAmount, initialSequencerGas], - ); - }); -}); diff --git a/yarn-project/end-to-end/src/single-node/misc/missed_l1_slot.test.ts b/yarn-project/end-to-end/src/single-node/misc/missed_l1_slot.test.ts index 7500c72cb3ea..c68ff5c5e185 100644 --- a/yarn-project/end-to-end/src/single-node/misc/missed_l1_slot.test.ts +++ b/yarn-project/end-to-end/src/single-node/misc/missed_l1_slot.test.ts @@ -9,7 +9,7 @@ import { getTimestampForSlot } from '@aztec/stdlib/epoch-helpers'; import { jest } from '@jest/globals'; import { proveAndSendTxs } from '../../test-wallet/utils.js'; -import { setupWithProver } from '../setup.js'; +import { NO_REORG_SUBMISSION_EPOCHS, setupWithProver } from '../setup.js'; import { SingleNodeTestContext } from '../single_node_test_context.js'; jest.setTimeout(1000 * 60 * 10); @@ -90,7 +90,7 @@ describe('single-node/misc/missed_l1_slot', () => { ethereumSlotDuration: 6, aztecSlotDurationInL1Slots: L1_SLOTS_PER_L2_SLOT, startProverNode: false, - aztecProofSubmissionEpochs: 1024, + aztecProofSubmissionEpochs: NO_REORG_SUBMISSION_EPOCHS, // Required for the proposer's own broadcasts to route through the local // proposal handler (the dummy p2p service drops them). Without this, the // archiver's #proposedCheckpoints map stays empty and the pipelining diff --git a/yarn-project/end-to-end/src/single-node/misc/node_block_api.test.ts b/yarn-project/end-to-end/src/single-node/misc/node_block_api.test.ts deleted file mode 100644 index b4aeb4094933..000000000000 --- a/yarn-project/end-to-end/src/single-node/misc/node_block_api.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -import type { AztecNode } from '@aztec/aztec.js/node'; -import { BlockNumber } from '@aztec/foundation/branded-types'; - -import { jest } from '@jest/globals'; -import 'jest-extended'; - -import { PIPELINING_SETUP_OPTS } from '../../fixtures/fixtures.js'; -import { setup } from '../../fixtures/utils.js'; - -// Exercises the node block-data query API against block 0 (the genesis block). Checks that -// getBlockData returns a valid header, that getBlock by hash and by number round-trips correctly, -// and that block 0 contains no txEffects. Uses PIPELINING_SETUP_OPTS (prod sequencer, -// ethSlot=4s, aztecSlot=12s, minTxsPerBlock=0). -describe('single-node/misc/node_block_api', () => { - jest.setTimeout(5 * 60 * 1000); - - let aztecNode: AztecNode; - let teardown: () => Promise; - - beforeAll(async () => { - ({ teardown, aztecNode } = await setup(0, { - ...PIPELINING_SETUP_OPTS, - })); - }); - - afterAll(() => teardown()); - - // Fetches block 0 by number and by hash; asserts the returned blocks match and contain no txEffects. - it('returns initial block data', async () => { - const initialHeader = (await aztecNode.getBlockData(BlockNumber.ZERO))?.header; - expect(initialHeader).toBeDefined(); - const initialHeaderHash = await initialHeader!.hash(); - const initialBlockByHash = await aztecNode.getBlock(initialHeaderHash, { includeTransactions: true }); - expect(initialBlockByHash).toBeDefined(); - expect(initialBlockByHash!.hash.equals(initialHeaderHash)).toBeTrue(); - expect(initialBlockByHash!.body.txEffects.length).toBe(0); - const initialBlockByNumber = await aztecNode.getBlock(BlockNumber.ZERO, { includeTransactions: true }); - expect(initialBlockByNumber).toBeDefined(); - expect(initialBlockByNumber!.hash.equals(initialHeaderHash)).toBeTrue(); - expect(initialBlockByNumber!.body.txEffects.length).toBe(0); - }); -}); diff --git a/yarn-project/end-to-end/src/single-node/partial-proofs/multi_root.test.ts b/yarn-project/end-to-end/src/single-node/partial-proofs/multi_root.test.ts index 71c065886edf..8c8b2fead495 100644 --- a/yarn-project/end-to-end/src/single-node/partial-proofs/multi_root.test.ts +++ b/yarn-project/end-to-end/src/single-node/partial-proofs/multi_root.test.ts @@ -24,15 +24,15 @@ import { type TxReceipt, TxStatus } from '@aztec/stdlib/tx'; import { type Hex, decodeEventLog } from 'viem'; import { waitForL2ToL1Witness } from '../../fixtures/wait_helpers.js'; -import { SingleNodeTestContext, jest, setupWithProver } from './setup.js'; +import { NO_REORG_SUBMISSION_EPOCHS, SingleNodeTestContext, jest, setupWithProver } from './setup.js'; // Suite: verifies the AZIP-14 partial-proof multi-root Outbox design. Drives an EpochTestSettler // manually to stage progressively deeper partial-proof roots (K=1, 2, 3) for the same epoch, then // asserts: (a) the node picks the smallest covering root, (b) any covering root produces a valid // consume tx, (c) the shared bitmap blocks double-spend, and (d) K=4 can be staged later. // SingleNodeTestContext: single node, no prover, prod-seq, interval mining. Timing: ethSlot=default -// (8s/12s CI), aztecSlot=default, epoch=1000, proofSubmissionEpochs=1024 (v5: the disableAnvilTestWatcher -// override was removed and a perBlockAllocationMultiplier=1.3 was added so the first block of the +// (8s/12s CI), aztecSlot=default, epoch=32, proofSubmissionEpochs=NO_REORG_SUBMISSION_EPOCHS (v5: the +// disableAnvilTestWatcher override was removed and a perBlockAllocationMultiplier=1.3 was added so the first block of the // now-up-to-5-block checkpoint has enough DA budget for the TestContract deploy tx). The test actively // calls the Outbox L1 contract to consume L2-to-L1 messages → cross-chain. describe('single-node/partial-proofs/multi_root', () => { @@ -56,12 +56,11 @@ describe('single-node/partial-proofs/multi_root', () => { // fallback DA gas for the TestContract deploy is based on a 4-block checkpoint, so give the // first block enough of the checkpoint DA budget to include the deploy tx. perBlockAllocationMultiplier: 1.3, - // Long epoch so 4 well-spaced checkpoints comfortably fit before the boundary. - aztecEpochDuration: 1000, + // Epoch long enough that the 4 well-spaced checkpoints fit before the boundary. + aztecEpochDuration: 32, // Don't let the real prover land a partial proof under us. We drive Outbox state via the - // settler. `aztecProofSubmissionEpochs` >> test duration makes it impossible to enter the - // submission window. - aztecProofSubmissionEpochs: 1024, + // settler; a never-reorg window makes it impossible to enter the submission window. + aztecProofSubmissionEpochs: NO_REORG_SUBMISSION_EPOCHS, startProverNode: false, }); ({ logger } = test); diff --git a/yarn-project/end-to-end/src/single-node/partial-proofs/setup.ts b/yarn-project/end-to-end/src/single-node/partial-proofs/setup.ts index e2d5324a4d64..2fb92da27743 100644 --- a/yarn-project/end-to-end/src/single-node/partial-proofs/setup.ts +++ b/yarn-project/end-to-end/src/single-node/partial-proofs/setup.ts @@ -1,8 +1,8 @@ import { jest } from '@jest/globals'; -import { setupWithProver } from '../setup.js'; +import { NO_REORG_SUBMISSION_EPOCHS, PROVING_SLOT_TIMING, setupWithProver } from '../setup.js'; import { SingleNodeTestContext } from '../single_node_test_context.js'; jest.setTimeout(1000 * 60 * 10); -export { jest, setupWithProver, SingleNodeTestContext }; +export { jest, setupWithProver, SingleNodeTestContext, NO_REORG_SUBMISSION_EPOCHS, PROVING_SLOT_TIMING }; diff --git a/yarn-project/end-to-end/src/single-node/partial-proofs/single_root.test.ts b/yarn-project/end-to-end/src/single-node/partial-proofs/single_root.test.ts index 43e50f4ddb74..012effe3e02b 100644 --- a/yarn-project/end-to-end/src/single-node/partial-proofs/single_root.test.ts +++ b/yarn-project/end-to-end/src/single-node/partial-proofs/single_root.test.ts @@ -2,7 +2,7 @@ import type { Logger } from '@aztec/aztec.js/log'; import type { ChainMonitor } from '@aztec/ethereum/test'; import { CheckpointNumber, EpochNumber } from '@aztec/foundation/branded-types'; -import { SingleNodeTestContext, jest, setupWithProver } from './setup.js'; +import { PROVING_SLOT_TIMING, SingleNodeTestContext, jest, setupWithProver } from './setup.js'; // Co-located with the multi-root suite: both manually drive partial-epoch proving on a single node // with a very long epoch. This one is the only coverage of the prover-node `startProof` path (the @@ -15,10 +15,9 @@ describe('single-node/partial-proofs/single_root', () => { let test: SingleNodeTestContext; beforeEach(async () => { - // Run at the 4s/12s slot-cadence floor: the body waits in real wall-clock for the sequencer to publish - // empty checkpoints one per L2 slot, so a shorter slot shortens that wait. 12s is the floor for the - // 3s-block timing model. A clock warp here races the sequencer's building and trips EmptyEpochError. - test = await setupWithProver({ aztecEpochDuration: 1000, ethereumSlotDuration: 4, aztecSlotDurationInL1Slots: 3 }); + // A clock warp here races the sequencer's building and trips EmptyEpochError, so this runs at the + // real-time PROVING_SLOT_TIMING floor. Long epoch so the partial-proof checkpoints fit before the boundary. + test = await setupWithProver({ ...PROVING_SLOT_TIMING, aztecEpochDuration: 1000 }); ({ monitor, logger } = test); }); diff --git a/yarn-project/end-to-end/src/single-node/proving/default_node.test.ts b/yarn-project/end-to-end/src/single-node/proving/default_node.test.ts new file mode 100644 index 000000000000..7085bd9570ba --- /dev/null +++ b/yarn-project/end-to-end/src/single-node/proving/default_node.test.ts @@ -0,0 +1,120 @@ +import type { Logger } from '@aztec/aztec.js/log'; +import type { AztecNode } from '@aztec/aztec.js/node'; +import { RollupContract } from '@aztec/ethereum/contracts'; +import { BlockNumber, CheckpointNumber } from '@aztec/foundation/branded-types'; + +import type { EndToEndContext } from '../../fixtures/utils.js'; +import { SingleNodeTestContext, WORLD_STATE_CHECKPOINT_HISTORY, jest, setupWithProver } from './setup.js'; + +// Basic proving/node coverage that rides the context-default setup (fake prover node, single node, +// prod-seq, interval mining, ethSlot=8s/12s CI, aztecSlot=16s/24s, epoch=6, proofSubmissionEpochs=1). +// The former empty_blocks / world_state_pruning / node_block_api files each stood up their own such +// node for one or two assertions; they share one here. `world_state_pruning` runs first (it needs the +// default minTxsPerBlock:0 so empty checkpoints keep landing as it warps through epochs), then +// `empty_blocks` (which raises minTxsPerBlock to freeze its proof target), then the setup-agnostic +// `node_block_api` genesis-block query. +describe('single-node/proving/default_node', () => { + let context: EndToEndContext; + let rollup: RollupContract; + let logger: Logger; + + let test: SingleNodeTestContext; + + beforeAll(async () => { + test = await setupWithProver({}); + ({ context, rollup, logger } = test); + }); + + afterAll(async () => { + jest.restoreAllMocks(); + await test.teardown(); + }); + + // Verifies that multiple consecutive epochs are proven successfully and that world-state checkpoints + // are pruned after finalization. TARGET_PROVEN_EPOCHS env var controls iteration count. Assumes one + // block per checkpoint. + describe('world_state_pruning', () => { + // Loops through targetProvenEpochs epochs: waits for each epoch to end, asserts it is proven, + // then verifies the epoch-end block is accessible as a historic block and that earlier blocks + // beyond the checkpoint history window have been purged from world state. + it('successfully proves multiple epochs', async () => { + const targetProvenEpochs = process.env.TARGET_PROVEN_EPOCHS ? parseInt(process.env.TARGET_PROVEN_EPOCHS) : 3; + let epochNumber = 0; + logger.info(`Testing for ${targetProvenEpochs} epochs to be proven`); + + while (epochNumber < targetProvenEpochs) { + logger.info(`Waiting for the end of epoch ${epochNumber}`); + await test.warpToEpochStart(epochNumber + 1); + const epochEndCheckpointNumber = (await test.monitor.run()).checkpointNumber; + logger.info(`Epoch ${epochNumber} ended with pending checkpoint number ${epochEndCheckpointNumber}`); + + await test.waitUntilProvenCheckpointNumber(epochEndCheckpointNumber, 240); + expect(await rollup.getProvenCheckpointNumber()).toBeGreaterThanOrEqual(epochEndCheckpointNumber); + logger.info(`Reached proven checkpoint number ${epochEndCheckpointNumber}, epoch ${epochNumber} is now proven`); + epochNumber++; + + // Verify the state syncs. Assumes one block per checkpoint. + const epochEndBlockNumber = BlockNumber.fromCheckpointNumber(epochEndCheckpointNumber); + await test.waitForNodeToSync(epochEndBlockNumber, 'proven'); + await test.verifyHistoricBlock(epochEndBlockNumber, true); + + // Check that finalized blocks are purged from world state. + // Anvil is started with --slots-in-an-epoch 1, so 'finalized' = latest - 2. By the time + // we reach this point the proof has been on L1 for many blocks, so the finalized L1 block + // is past the proof submission block, making finalized checkpoint == proven checkpoint. + // This test is setup as 1 block per checkpoint. + const provenBlockNumber = epochEndBlockNumber; + const finalizedBlockNumber = provenBlockNumber; + const expectedOldestHistoricBlock = Math.max(finalizedBlockNumber - WORLD_STATE_CHECKPOINT_HISTORY + 1, 1); + const expectedBlockRemoved = expectedOldestHistoricBlock - 1; + await test.waitForNodeToSync(BlockNumber(expectedOldestHistoricBlock), 'historic'); + await test.verifyHistoricBlock(BlockNumber(expectedOldestHistoricBlock), true); + if (expectedBlockRemoved > 0) { + await test.verifyHistoricBlock(BlockNumber(expectedBlockRemoved), false); + } + } + logger.info('Test Succeeded'); + }); + }); + + // Starts a prover node (fake proofs) on the default setup and verifies the prover submits a proof for + // a real non-genesis checkpoint with no txs. Runs after world_state_pruning on the shared chain, which + // has already proven several empty checkpoints, so this reconfirms the property on the advanced chain. + describe('empty_blocks', () => { + // Waits for a real empty checkpoint, raises minTxsPerBlock to 1 to stop more empty checkpoints + // from being built, then waits for the prover to submit a proof covering that target. + it('submits proof even if there are no txs to build a block', async () => { + // Let the sequencer build its first empty checkpoint (at the setup default minTxsPerBlock:0) + // before raising the floor. Raising minTxsPerBlock first races the sequencer's first proposal + // loop: if the config lands before block 1 is built, the sequencer waits forever for a tx that + // never arrives, no non-genesis checkpoint is built, and there is nothing to prove. + const proofTargetCheckpoint = CheckpointNumber(1); + await test.waitUntilCheckpointNumber(proofTargetCheckpoint); + context.sequencer?.updateConfig({ minTxsPerBlock: 1 }); + + await test.waitUntilProvenCheckpointNumber(proofTargetCheckpoint, 240); + expect(await rollup.getProvenCheckpointNumber()).toBeGreaterThanOrEqual(proofTargetCheckpoint); + logger.info(`Test succeeded`); + }); + }); + + // Exercises the node block-data query API against block 0 (the genesis block). Genesis block retrieval + // is independent of setup timing/prover, so it rides the shared default node. + describe('node_block_api', () => { + // Fetches block 0 by number and by hash; asserts the returned blocks match and contain no txEffects. + it('returns initial block data', async () => { + const aztecNode: AztecNode = context.aztecNode; + const initialHeader = (await aztecNode.getBlockData(BlockNumber.ZERO))?.header; + expect(initialHeader).toBeDefined(); + const initialHeaderHash = await initialHeader!.hash(); + const initialBlockByHash = await aztecNode.getBlock(initialHeaderHash, { includeTransactions: true }); + expect(initialBlockByHash).toBeDefined(); + expect(initialBlockByHash!.hash.equals(initialHeaderHash)).toBe(true); + expect(initialBlockByHash!.body.txEffects.length).toBe(0); + const initialBlockByNumber = await aztecNode.getBlock(BlockNumber.ZERO, { includeTransactions: true }); + expect(initialBlockByNumber).toBeDefined(); + expect(initialBlockByNumber!.hash.equals(initialHeaderHash)).toBe(true); + expect(initialBlockByNumber!.body.txEffects.length).toBe(0); + }); + }); +}); diff --git a/yarn-project/end-to-end/src/single-node/proving/empty_blocks.test.ts b/yarn-project/end-to-end/src/single-node/proving/empty_blocks.test.ts deleted file mode 100644 index f62f825e16a1..000000000000 --- a/yarn-project/end-to-end/src/single-node/proving/empty_blocks.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -import type { Logger } from '@aztec/aztec.js/log'; -import { RollupContract } from '@aztec/ethereum/contracts'; -import { CheckpointNumber } from '@aztec/foundation/branded-types'; - -import type { EndToEndContext } from '../../fixtures/utils.js'; -import { SingleNodeTestContext, jest, setupWithProver } from './setup.js'; - -// Starts a prover node (fake proofs) on the default setup and verifies the prover submits a proof for -// a real non-genesis checkpoint with no txs. -describe('single-node/proving/empty_blocks', () => { - let context: EndToEndContext; - let rollup: RollupContract; - let logger: Logger; - - let test: SingleNodeTestContext; - - beforeEach(async () => { - test = await setupWithProver({}); - ({ context, rollup, logger } = test); - }); - - afterEach(async () => { - jest.restoreAllMocks(); - await test.teardown(); - }); - - // Waits for a real empty checkpoint, raises minTxsPerBlock to 1 to stop more empty checkpoints - // from being built, then waits for the prover to submit a proof covering that target. - it('submits proof even if there are no txs to build a block', async () => { - // Let the sequencer build its first empty checkpoint (at the setup default minTxsPerBlock:0) - // before raising the floor. Raising minTxsPerBlock first races the sequencer's first proposal - // loop: if the config lands before block 1 is built, the sequencer waits forever for a tx that - // never arrives, no non-genesis checkpoint is built, and there is nothing to prove. - const proofTargetCheckpoint = CheckpointNumber(1); - await test.waitUntilCheckpointNumber(proofTargetCheckpoint); - context.sequencer?.updateConfig({ minTxsPerBlock: 1 }); - - await test.waitUntilProvenCheckpointNumber(proofTargetCheckpoint, 240); - expect(await rollup.getProvenCheckpointNumber()).toBeGreaterThanOrEqual(proofTargetCheckpoint); - logger.info(`Test succeeded`); - }); -}); diff --git a/yarn-project/end-to-end/src/single-node/proving/long_proving_time.test.ts b/yarn-project/end-to-end/src/single-node/proving/long_proving_time.test.ts index 7a50e9a8f006..c8244d5a17a2 100644 --- a/yarn-project/end-to-end/src/single-node/proving/long_proving_time.test.ts +++ b/yarn-project/end-to-end/src/single-node/proving/long_proving_time.test.ts @@ -2,7 +2,13 @@ import type { Logger } from '@aztec/aztec.js/log'; import { ChainMonitor } from '@aztec/ethereum/test'; import { sleep } from '@aztec/foundation/sleep'; -import { SingleNodeTestContext, jest, setupWithProver } from './setup.js'; +import { + NO_REORG_SUBMISSION_EPOCHS, + PROVING_SLOT_TIMING, + SingleNodeTestContext, + jest, + setupWithProver, +} from './setup.js'; const MAX_JOB_COUNT = 20; @@ -25,18 +31,13 @@ describe('single-node/proving/long_proving_time', () => { // So we delay proving of each circuit such that each epoch takes 3 epochs to prove. const aztecEpochDuration = 2; // The body is bounded by the real-wall-clock prover delay (a `sleep`, not the date provider, so warping - // cannot shrink it): a single agent serially sleeps `proverTestDelayMs` per circuit. Both the - // block-build cadence and the proving delay scale with the slot duration, so a shorter L1 slot scales - // the whole timeline down while keeping the delay-to-slot ratio — and hence the "proving lags block - // production by ~3 epochs" assertion — intact. The L2 slot stays at 3 L1 slots (12s): the timing model - // needs S >= ~8.5s with the default 3s block duration to fit one block per checkpoint, so 12s is the - // floor — an 8s slot derives 0 blocks per checkpoint and trips the timing-config guard. - const ethereumSlotDuration = 4; - const aztecSlotDurationInL1Slots = 3; + // cannot shrink it): a single agent serially sleeps `proverTestDelayMs` per circuit. Both the block-build + // cadence and the proving delay scale with the slot duration, so the PROVING_SLOT_TIMING floor scales the + // whole timeline down while keeping the delay-to-slot ratio — and hence the "proving lags block production + // by ~3 epochs" assertion — intact. const { aztecSlotDuration } = SingleNodeTestContext.getSlotDurations({ aztecEpochDuration, - ethereumSlotDuration, - aztecSlotDurationInL1Slots, + ...PROVING_SLOT_TIMING, }); const epochDurationInSeconds = aztecSlotDuration * aztecEpochDuration; const proverTestDelayMs = (epochDurationInSeconds * 1000 * 3) / 4; @@ -44,9 +45,8 @@ describe('single-node/proving/long_proving_time', () => { // at least that many epochs to avoid rejecting jobs as stale. test = await setupWithProver({ aztecEpochDuration, - ethereumSlotDuration, - aztecSlotDurationInL1Slots, - aztecProofSubmissionEpochs: 1000, // Effectively don't re-org + ...PROVING_SLOT_TIMING, + aztecProofSubmissionEpochs: NO_REORG_SUBMISSION_EPOCHS, // Effectively don't re-org proverTestDelayMs, proverNodeMaxPendingJobs: MAX_JOB_COUNT, // Prove multiple epochs concurrently proverBrokerMaxEpochsToKeepResultsFor: 10, diff --git a/yarn-project/end-to-end/src/single-node/proving/multi_proof.test.ts b/yarn-project/end-to-end/src/single-node/proving/multi_proof.test.ts index 0e75122f6a9b..d2c9a17e0afa 100644 --- a/yarn-project/end-to-end/src/single-node/proving/multi_proof.test.ts +++ b/yarn-project/end-to-end/src/single-node/proving/multi_proof.test.ts @@ -7,7 +7,7 @@ import { getEpochAtSlot } from '@aztec/stdlib/epoch-helpers'; import { jest } from '@jest/globals'; import type { EndToEndContext } from '../../fixtures/utils.js'; -import { setupWithProver } from '../setup.js'; +import { PROVING_SLOT_TIMING, setupWithProver } from '../setup.js'; import { SingleNodeTestContext } from '../single_node_test_context.js'; jest.setTimeout(1000 * 60 * 10); @@ -27,17 +27,12 @@ describe('single-node/proving/multi_proof', () => { // Don't start prover node during setup - we'll create and manage all prover nodes in the test // This ensures we can apply delay patches before any prover starts proving. // - // Run at the 4s/12s slot-cadence floor: the body is bounded by the production sequencer building epoch - // 0 on the real wall-clock (one empty block per L2 slot) plus the per-prover stagger - // (`index * ethereumSlotDuration` ms). Both scale with the slot duration, so a shorter slot shortens the - // timeline while keeping the stagger >=1 L1 slot apart (so the three provers still land their proofs on - // distinct L1 blocks). 12s is the floor: the timing model needs an L2 slot >= ~8.5s with the default 3s - // block to fit one block per checkpoint, so an 8s slot derives 0 blocks per checkpoint. The 6-slot epoch - // is kept so epoch 0 still reliably lands >=1 block. + // The per-prover stagger (`index * ethereumSlotDuration` ms) scales with the slot duration, so the + // PROVING_SLOT_TIMING floor keeps the timeline short while holding the stagger >=1 L1 slot apart (the + // three provers still land their proofs on distinct L1 blocks). test = await setupWithProver({ startProverNode: false, - ethereumSlotDuration: 4, - aztecSlotDurationInL1Slots: 3, + ...PROVING_SLOT_TIMING, }); ({ context, logger } = test); }); diff --git a/yarn-project/end-to-end/src/single-node/proving/optimistic.parallel.test.ts b/yarn-project/end-to-end/src/single-node/proving/optimistic.parallel.test.ts index 98b9cffe1485..8a719cd3556c 100644 --- a/yarn-project/end-to-end/src/single-node/proving/optimistic.parallel.test.ts +++ b/yarn-project/end-to-end/src/single-node/proving/optimistic.parallel.test.ts @@ -13,7 +13,7 @@ import { expect, jest } from '@jest/globals'; import type { EndToEndContext } from '../../fixtures/utils.js'; import { waitForNodeCheckpoint } from '../../fixtures/wait_helpers.js'; import { proveInteraction } from '../../test-wallet/utils.js'; -import { setupWithProver } from '../setup.js'; +import { NO_REORG_SUBMISSION_EPOCHS, setupWithProver } from '../setup.js'; import { FAST_REORG_TIMING, SingleNodeTestContext } from '../single_node_test_context.js'; jest.setTimeout(1000 * 60 * 20); @@ -26,7 +26,7 @@ jest.setTimeout(1000 * 60 * 20); * six `describe` blocks builds a fresh context in its own `beforeEach` and tears it down in the shared `afterEach`. The * happy-path pair uses defaults (`numberOfAccounts: 1`; ethSlot=8s local/12s CI, aztecSlot=16s/24s, epoch=6, * proofSubEpochs=1); the five reorg describes use a faster cadence (ethSlot=4s, aztecSlot=36s, epoch=4 — or 8 for the - * with-replacement case so the replacement lands in-epoch — proofSubEpochs=1000, blockDurationMs=8s, minTxsPerBlock=0, + * with-replacement case so the replacement lands in-epoch — proofSubEpochs=NO_REORG_SUBMISSION_EPOCHS, blockDurationMs=8s, minTxsPerBlock=0, * anvilSlotsInAnEpoch=32, maxSpeedUpAttempts=0, cancelTxOnTimeout=false). The `prover-node starts mid-epoch` describe * sets `startProverNode: false` and spins up the prover via `test.createProverNode()` partway through the epoch. * @@ -275,7 +275,7 @@ describe('single-node/proving/optimistic', () => { // next epoch). aztecEpochDuration: 8, minTxsPerBlock: 0, - aztecProofSubmissionEpochs: 1000, + aztecProofSubmissionEpochs: NO_REORG_SUBMISSION_EPOCHS, }); ({ rollup, logger, context } = test); ({ L2_SLOT_DURATION_IN_S } = test); @@ -411,7 +411,7 @@ describe('single-node/proving/optimistic', () => { maxSpeedUpAttempts: 0, cancelTxOnTimeout: false, minTxsPerBlock: 0, - aztecProofSubmissionEpochs: 1000, + aztecProofSubmissionEpochs: NO_REORG_SUBMISSION_EPOCHS, }); ({ rollup, logger, context } = test); ({ L2_SLOT_DURATION_IN_S } = test); @@ -511,7 +511,7 @@ describe('single-node/proving/optimistic', () => { maxSpeedUpAttempts: 0, cancelTxOnTimeout: false, minTxsPerBlock: 0, - aztecProofSubmissionEpochs: 1000, + aztecProofSubmissionEpochs: NO_REORG_SUBMISSION_EPOCHS, }); ({ rollup, logger, context } = test); ({ L2_SLOT_DURATION_IN_S } = test); @@ -590,7 +590,7 @@ describe('single-node/proving/optimistic', () => { maxSpeedUpAttempts: 0, cancelTxOnTimeout: false, minTxsPerBlock: 0, - aztecProofSubmissionEpochs: 1000, + aztecProofSubmissionEpochs: NO_REORG_SUBMISSION_EPOCHS, // Apply a delay between "epoch complete on L1" and the prover-node hand-off so // the reorg below has time to be processed before finalization starts. proverNodeConfig: { proverNodeEpochProvingDelayMs: 10_000 }, @@ -675,7 +675,7 @@ describe('single-node/proving/optimistic', () => { maxSpeedUpAttempts: 0, cancelTxOnTimeout: false, minTxsPerBlock: 0, - aztecProofSubmissionEpochs: 1000, + aztecProofSubmissionEpochs: NO_REORG_SUBMISSION_EPOCHS, }); ({ rollup, logger, context } = test); ({ L2_SLOT_DURATION_IN_S } = test); @@ -812,7 +812,7 @@ describe('single-node/proving/optimistic', () => { maxSpeedUpAttempts: 0, cancelTxOnTimeout: false, minTxsPerBlock: 0, - aztecProofSubmissionEpochs: 1000, + aztecProofSubmissionEpochs: NO_REORG_SUBMISSION_EPOCHS, }); ({ rollup, logger, context } = test); ({ L2_SLOT_DURATION_IN_S } = test); diff --git a/yarn-project/end-to-end/src/single-node/proving/proof_fails.parallel.test.ts b/yarn-project/end-to-end/src/single-node/proving/proof_fails.parallel.test.ts index e1670002f7bf..1282d1771e89 100644 --- a/yarn-project/end-to-end/src/single-node/proving/proof_fails.parallel.test.ts +++ b/yarn-project/end-to-end/src/single-node/proving/proof_fails.parallel.test.ts @@ -17,14 +17,14 @@ import { RootRollupPublicInputs } from '@aztec/stdlib/rollup'; import { jest } from '@jest/globals'; import type { EndToEndContext } from '../../fixtures/utils.js'; -import { setupWithProver } from '../setup.js'; +import { PROVING_SLOT_TIMING, setupWithProver } from '../setup.js'; import { SingleNodeTestContext } from '../single_node_test_context.js'; jest.setTimeout(1000 * 60 * 10); // Suite: 2 parallel scenarios testing proof-submission failure paths. SingleNodeTestContext with single -// sequencer node, no initial prover (prover nodes created in test bodies). Timing: ethSlot=8s, -// aztecSlot=2×8=16s, epoch=8, proofSubmissionEpochs=1 (default), blockDurationMs=3s, +// sequencer node, no initial prover (prover nodes created in test bodies). Timing: PROVING_SLOT_TIMING +// (ethSlot=4s, aztecSlot=12s), epoch=8, proofSubmissionEpochs=1 (default), blockDurationMs=3s, // cancelTxOnTimeout=false, inboxLag=2 (v5 always enforces the timetable, so the former enforceTimeTable // override is gone). Prover Delayer steers proof tx timing. describe('single-node/proving/proof_fails', () => { @@ -42,11 +42,10 @@ describe('single-node/proving/proof_fails', () => { beforeEach(async () => { test = await setupWithProver({ + ...PROVING_SLOT_TIMING, maxSpeedUpAttempts: 0, // No speed ups startProverNode: false, // Avoid early proving - ethereumSlotDuration: 8, aztecEpochDuration: 8, // Bump epoch duration so we can land at least one block in epoch 0 - aztecSlotDurationInL1Slots: 2, blockDurationMs: 3000, // 3s blocks → 2 blocks per checkpoint under pipelining cancelTxOnTimeout: false, }); diff --git a/yarn-project/end-to-end/src/single-node/proving/setup.ts b/yarn-project/end-to-end/src/single-node/proving/setup.ts index 33b205e5613a..d0d6d7f7a5c1 100644 --- a/yarn-project/end-to-end/src/single-node/proving/setup.ts +++ b/yarn-project/end-to-end/src/single-node/proving/setup.ts @@ -1,8 +1,15 @@ import { jest } from '@jest/globals'; -import { setupWithProver } from '../setup.js'; +import { NO_REORG_SUBMISSION_EPOCHS, PROVING_SLOT_TIMING, setupWithProver } from '../setup.js'; import { SingleNodeTestContext, WORLD_STATE_CHECKPOINT_HISTORY } from '../single_node_test_context.js'; jest.setTimeout(1000 * 60 * 15); -export { jest, setupWithProver, SingleNodeTestContext, WORLD_STATE_CHECKPOINT_HISTORY }; +export { + jest, + setupWithProver, + SingleNodeTestContext, + WORLD_STATE_CHECKPOINT_HISTORY, + NO_REORG_SUBMISSION_EPOCHS, + PROVING_SLOT_TIMING, +}; diff --git a/yarn-project/end-to-end/src/single-node/proving/upload_failed_proof.test.ts b/yarn-project/end-to-end/src/single-node/proving/upload_failed_proof.test.ts index b19ef1ada0ff..f4832796d4f1 100644 --- a/yarn-project/end-to-end/src/single-node/proving/upload_failed_proof.test.ts +++ b/yarn-project/end-to-end/src/single-node/proving/upload_failed_proof.test.ts @@ -15,7 +15,7 @@ import { join } from 'path'; import { getACVMConfig } from '../../fixtures/get_acvm_config.js'; import { getBBConfig } from '../../fixtures/get_bb_config.js'; import type { EndToEndContext } from '../../fixtures/utils.js'; -import { setupWithProver } from '../setup.js'; +import { PROVING_SLOT_TIMING, setupWithProver } from '../setup.js'; import { SingleNodeTestContext } from '../single_node_test_context.js'; jest.setTimeout(1000 * 60 * 10); @@ -43,16 +43,11 @@ describe('single-node/proving/upload_failed_proof', () => { uploadPath = await mkdtemp(join(tmpdir(), 'failed-proofs-')); uploadUrl = `file://${uploadPath}`; - // Run at the 4s/12s slot-cadence floor: the body is bounded by the production sequencer building epoch - // 0 on the real wall-clock (one empty checkpoint per L2 slot) before the prover finalizes epoch 0 at the - // epoch-1 boundary and trips the failing top-tree-prove hook. The epoch wall-time scales with the slot - // duration, so a shorter slot shortens the timeline. 12s is the floor: the timing model needs an L2 slot - // >= ~8.5s with the default 3s block to fit one block per checkpoint. The 6-slot epoch is kept so epoch 0 - // still reliably lands its checkpoints. + // Runs at the PROVING_SLOT_TIMING floor: the body waits in real wall-clock for the sequencer to build + // epoch 0 before the prover finalizes it at the epoch-1 boundary and trips the failing top-tree-prove hook. test = await setupWithProver({ proverNodeConfig: { proverNodeFailedEpochStore: uploadUrl }, - ethereumSlotDuration: 4, - aztecSlotDurationInL1Slots: 3, + ...PROVING_SLOT_TIMING, }); ({ context, logger } = test); ({ config } = context); diff --git a/yarn-project/end-to-end/src/single-node/proving/world_state_pruning.test.ts b/yarn-project/end-to-end/src/single-node/proving/world_state_pruning.test.ts deleted file mode 100644 index 512832b74f1f..000000000000 --- a/yarn-project/end-to-end/src/single-node/proving/world_state_pruning.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -import type { Logger } from '@aztec/aztec.js/log'; -import { RollupContract } from '@aztec/ethereum/contracts'; -import { BlockNumber } from '@aztec/foundation/branded-types'; - -import { SingleNodeTestContext, WORLD_STATE_CHECKPOINT_HISTORY, jest, setupWithProver } from './setup.js'; - -// Verifies that multiple consecutive epochs are proven successfully and that world-state checkpoints -// are pruned after finalization. SingleNodeTestContext defaults: single node, prod-seq, interval -// mining, ethSlot=8s (12s CI), aztecSlot=16s (24s CI), epoch=6, proofSubmissionEpochs=1, fake prover. -// TARGET_PROVEN_EPOCHS env var controls iteration count. Assumes one block per checkpoint. -describe('single-node/proving/world_state_pruning', () => { - let rollup: RollupContract; - let logger: Logger; - - let test: SingleNodeTestContext; - - beforeEach(async () => { - test = await setupWithProver({}); - ({ rollup, logger } = test); - }); - - afterEach(async () => { - jest.restoreAllMocks(); - await test.teardown(); - }); - - // Loops through targetProvenEpochs epochs: waits for each epoch to end, asserts it is proven, - // then verifies the epoch-end block is accessible as a historic block and that earlier blocks - // beyond the checkpoint history window have been purged from world state. - it('successfully proves multiple epochs', async () => { - const targetProvenEpochs = process.env.TARGET_PROVEN_EPOCHS ? parseInt(process.env.TARGET_PROVEN_EPOCHS) : 3; - let epochNumber = 0; - logger.info(`Testing for ${targetProvenEpochs} epochs to be proven`); - - while (epochNumber < targetProvenEpochs) { - logger.info(`Waiting for the end of epoch ${epochNumber}`); - await test.warpToEpochStart(epochNumber + 1); - const epochEndCheckpointNumber = (await test.monitor.run()).checkpointNumber; - logger.info(`Epoch ${epochNumber} ended with pending checkpoint number ${epochEndCheckpointNumber}`); - - await test.waitUntilProvenCheckpointNumber(epochEndCheckpointNumber, 240); - expect(await rollup.getProvenCheckpointNumber()).toBeGreaterThanOrEqual(epochEndCheckpointNumber); - logger.info(`Reached proven checkpoint number ${epochEndCheckpointNumber}, epoch ${epochNumber} is now proven`); - epochNumber++; - - // Verify the state syncs. Assumes one block per checkpoint. - const epochEndBlockNumber = BlockNumber.fromCheckpointNumber(epochEndCheckpointNumber); - await test.waitForNodeToSync(epochEndBlockNumber, 'proven'); - await test.verifyHistoricBlock(epochEndBlockNumber, true); - - // Check that finalized blocks are purged from world state. - // Anvil is started with --slots-in-an-epoch 1, so 'finalized' = latest - 2. By the time - // we reach this point the proof has been on L1 for many blocks, so the finalized L1 block - // is past the proof submission block, making finalized checkpoint == proven checkpoint. - // This test is setup as 1 block per checkpoint. - const provenBlockNumber = epochEndBlockNumber; - const finalizedBlockNumber = provenBlockNumber; - const expectedOldestHistoricBlock = Math.max(finalizedBlockNumber - WORLD_STATE_CHECKPOINT_HISTORY + 1, 1); - const expectedBlockRemoved = expectedOldestHistoricBlock - 1; - await test.waitForNodeToSync(BlockNumber(expectedOldestHistoricBlock), 'historic'); - await test.verifyHistoricBlock(BlockNumber(expectedOldestHistoricBlock), true); - if (expectedBlockRemoved > 0) { - await test.verifyHistoricBlock(BlockNumber(expectedBlockRemoved), false); - } - } - logger.info('Test Succeeded'); - }); -}); diff --git a/yarn-project/end-to-end/src/single-node/recovery/manual_rollback.test.ts b/yarn-project/end-to-end/src/single-node/recovery/manual_rollback.test.ts index e7f4d5bc2e0a..17e8d6eb2c1f 100644 --- a/yarn-project/end-to-end/src/single-node/recovery/manual_rollback.test.ts +++ b/yarn-project/end-to-end/src/single-node/recovery/manual_rollback.test.ts @@ -5,11 +5,17 @@ import { CheckpointNumber } from '@aztec/foundation/branded-types'; import type { EndToEndContext } from '../../fixtures/utils.js'; import { waitForBlockNumber } from '../../fixtures/wait_helpers.js'; -import { SingleNodeTestContext, jest, setupWithProver } from './setup.js'; +import { + NO_REORG_SUBMISSION_EPOCHS, + PROVING_SLOT_TIMING, + SingleNodeTestContext, + jest, + setupWithProver, +} from './setup.js'; -// Exercises the aztecNodeAdmin.rollbackTo() API. Default SingleNodeTestContext with a very long epoch -// (aztecEpochDuration=100) so there are no L2 reorgs, no finalized blocks, and the full pending chain -// is prunable. Actively drives L1 via cheatcodes (reorgTo to remove blocks). +// Exercises the aztecNodeAdmin.rollbackTo() API. Runs on the PROVING_SLOT_TIMING floor with a never-reorg +// proof window (NO_REORG_SUBMISSION_EPOCHS) so there are no L2 reorgs, no finalized blocks, and the full +// pending chain is prunable. Actively drives L1 via cheatcodes (reorgTo to remove blocks). describe('single-node/recovery/manual_rollback', () => { let context: EndToEndContext; let logger: Logger; @@ -19,10 +25,13 @@ describe('single-node/recovery/manual_rollback', () => { let test: SingleNodeTestContext; beforeEach(async () => { - // Run at the 4s/12s slot-cadence floor: the body waits in real wall-clock for the sequencer to publish - // empty checkpoints one per L2 slot, so a shorter slot shortens that wait. A clock warp here races the - // building and times out. No L2 reorgs, no finalized blocks. - test = await setupWithProver({ aztecEpochDuration: 100, ethereumSlotDuration: 4, aztecSlotDurationInL1Slots: 3 }); + // A clock warp here races the sequencer's building and times out, so this runs at the real-time + // PROVING_SLOT_TIMING floor. NO_REORG_SUBMISSION_EPOCHS keeps the pending chain unproven and prunable. + test = await setupWithProver({ + ...PROVING_SLOT_TIMING, + aztecEpochDuration: 32, + aztecProofSubmissionEpochs: NO_REORG_SUBMISSION_EPOCHS, + }); ({ context, logger, rollup } = test); ({ aztecNode: node } = context); }); diff --git a/yarn-project/end-to-end/src/single-node/recovery/prune_when_cannot_build.test.ts b/yarn-project/end-to-end/src/single-node/recovery/prune_when_cannot_build.test.ts index 5dbc2a23e21a..f4a7f136dc47 100644 --- a/yarn-project/end-to-end/src/single-node/recovery/prune_when_cannot_build.test.ts +++ b/yarn-project/end-to-end/src/single-node/recovery/prune_when_cannot_build.test.ts @@ -7,7 +7,7 @@ import { retryUntil } from '@aztec/foundation/retry'; import { jest } from '@jest/globals'; import type { EndToEndContext } from '../../fixtures/utils.js'; -import { setupWithProver } from '../setup.js'; +import { PROVING_SLOT_TIMING, setupWithProver } from '../setup.js'; import { SingleNodeTestContext } from '../single_node_test_context.js'; jest.setTimeout(1000 * 60 * 10); @@ -18,7 +18,7 @@ jest.setTimeout(1000 * 60 * 10); // its proof-submission window closes the chain becomes prunable and the proposer's fallback must call // `prune()` despite being unable to propose. // -// Timing: ethSlot=8s, aztecSlot=2×8=16s, epoch=8, proofSubmissionEpochs=1. +// Timing: PROVING_SLOT_TIMING (ethSlot=4s, aztecSlot=12s), epoch=8, proofSubmissionEpochs=1. describe('single-node/recovery/prune_when_cannot_build', () => { let context: EndToEndContext; let logger: Logger; @@ -31,10 +31,9 @@ describe('single-node/recovery/prune_when_cannot_build', () => { beforeEach(async () => { test = await setupWithProver({ + ...PROVING_SLOT_TIMING, startProverNode: false, // Nothing ever proves epoch 0, so its pending chain stays unproven and becomes prunable. - ethereumSlotDuration: 8, aztecEpochDuration: 8, // Long enough to land a few checkpoints in epoch 0. - aztecSlotDurationInL1Slots: 2, aztecProofSubmissionEpochs: 1, // Pending chain becomes prunable one proof window after epoch 0. minTxsPerBlock: 0, // Solo proposer advances the pending chain on empty checkpoints. }); diff --git a/yarn-project/end-to-end/src/single-node/recovery/setup.ts b/yarn-project/end-to-end/src/single-node/recovery/setup.ts index e2d5324a4d64..2fb92da27743 100644 --- a/yarn-project/end-to-end/src/single-node/recovery/setup.ts +++ b/yarn-project/end-to-end/src/single-node/recovery/setup.ts @@ -1,8 +1,8 @@ import { jest } from '@jest/globals'; -import { setupWithProver } from '../setup.js'; +import { NO_REORG_SUBMISSION_EPOCHS, PROVING_SLOT_TIMING, setupWithProver } from '../setup.js'; import { SingleNodeTestContext } from '../single_node_test_context.js'; jest.setTimeout(1000 * 60 * 10); -export { jest, setupWithProver, SingleNodeTestContext }; +export { jest, setupWithProver, SingleNodeTestContext, NO_REORG_SUBMISSION_EPOCHS, PROVING_SLOT_TIMING }; diff --git a/yarn-project/end-to-end/src/single-node/sequencer/gov_proposal.parallel.test.ts b/yarn-project/end-to-end/src/single-node/sequencer/gov_proposal.parallel.test.ts index 07c5306c4deb..a33e80929f1f 100644 --- a/yarn-project/end-to-end/src/single-node/sequencer/gov_proposal.parallel.test.ts +++ b/yarn-project/end-to-end/src/single-node/sequencer/gov_proposal.parallel.test.ts @@ -29,7 +29,7 @@ import { privateKeyToAccount } from 'viem/accounts'; import { PIPELINING_SETUP_OPTS } from '../../fixtures/fixtures.js'; import { getPrivateKeyFromIndex } from '../../fixtures/utils.js'; -import { setupBlockProducer } from '../setup.js'; +import { NO_REORG_SUBMISSION_EPOCHS, setupBlockProducer } from '../setup.js'; import type { SingleNodeTestContext } from '../single_node_test_context.js'; const ETHEREUM_SLOT_DURATION = 8; @@ -45,8 +45,8 @@ jest.setTimeout(1000 * 60 * 5); // Tests that a single sequencer node running a 16-validator committee can propose blocks while // simultaneously casting governance votes, and can cast votes even when block building is disabled. // Setup: setupBlockProducer (no prover node) with { ...PIPELINING_SETUP_OPTS, ethSlot=8s, -// aztecSlot=16s, committee=16, aztecProofSubmissionEpochs=128 } — the high proof-submission window -// pins blocks against pruning (v5 always enforces the timetable, so the former enforceTimeTable +// aztecSlot=16s, committee=16, aztecProofSubmissionEpochs=NO_REORG_SUBMISSION_EPOCHS } — the high +// proof-submission window pins blocks against pruning (v5 always enforces the timetable, so the former enforceTimeTable // override is gone). Uses cheatCodes.eth.warp + retryUntil for timing. describe('single-node/sequencer/gov_proposal', () => { let logger: Logger; @@ -83,7 +83,7 @@ describe('single-node/sequencer/gov_proposal', () => { governanceProposerQuorum: QUORUM_SIZE, ethereumSlotDuration: ETHEREUM_SLOT_DURATION, aztecSlotDuration: AZTEC_SLOT_DURATION, - aztecProofSubmissionEpochs: 128, // no pruning + aztecProofSubmissionEpochs: NO_REORG_SUBMISSION_EPOCHS, // no pruning minTxsPerBlock: TXS_PER_BLOCK, automineL1Setup: true, // speed up setup // Force the L1 sync to fetch blobs rather than promote the locally-proposed checkpoint. diff --git a/yarn-project/end-to-end/src/single-node/sequencer/runtime_config.test.ts b/yarn-project/end-to-end/src/single-node/sequencer/runtime_config.test.ts new file mode 100644 index 000000000000..014977befeff --- /dev/null +++ b/yarn-project/end-to-end/src/single-node/sequencer/runtime_config.test.ts @@ -0,0 +1,162 @@ +import { getInitialTestAccountsData } from '@aztec/accounts/testing'; +import type { TestAztecNodeService } from '@aztec/aztec-node/test'; +import type { AztecNode } from '@aztec/aztec.js/node'; +import type { TxReceipt } from '@aztec/aztec.js/tx'; +import { Bot, type BotConfig, BotStore, getBotDefaultConfig } from '@aztec/bot'; +import { MAX_TX_DA_GAS } from '@aztec/constants'; +import type { Logger } from '@aztec/foundation/log'; +import { retryUntil } from '@aztec/foundation/retry'; +import { openTmpStore } from '@aztec/kv-store/lmdb-v2'; +import type { SequencerClient } from '@aztec/sequencer-client'; +import type { SlasherClientInterface } from '@aztec/slasher'; +import type { AztecNodeAdmin } from '@aztec/stdlib/interfaces/client'; +import { EmbeddedWallet } from '@aztec/wallets/embedded'; + +import { jest } from '@jest/globals'; + +import { PIPELINED_FEE_PADDING, PIPELINING_SETUP_OPTS } from '../../fixtures/fixtures.js'; +import { setupBlockProducer } from '../setup.js'; +import type { SingleNodeTestContext } from '../single_node_test_context.js'; + +// Merges the former slasher_config and sequencer_config admin-API checks onto one production-sequencer +// node (setupBlockProducer, PIPELINING_SETUP_OPTS timing). The single setup carries both suites' config +// knobs: the slasher inactivity config (whose getters slasher config asserts) and the max L2 block gas / +// mana target (which sequencer config asserts and then lowers at runtime). No block building is needed +// for the slasher check; the sequencer check drives a live Bot to measure mana and enforce the limit. +describe('single-node/sequencer/runtime_config', () => { + jest.setTimeout(20 * 60 * 1000); // 20 minutes + + // Sane targets < 64 bits. + const manaTarget = 200e6; + + let test: SingleNodeTestContext; + let aztecNode: AztecNode; + let aztecNodeAdmin: AztecNodeAdmin | undefined; + let sequencer: SequencerClient | undefined; + let logger: Logger; + + let config: BotConfig; + let bot: Bot; + let wallet: EmbeddedWallet; + + beforeAll(async () => { + const [botAccount] = await getInitialTestAccountsData(); + test = await setupBlockProducer({ + ...PIPELINING_SETUP_OPTS, + // slasher config knobs: seed the inactivity slashing config so its getters have known values. + anvilSlotsInAnEpoch: 4, + slashInactivityTargetPercentage: 1, + slashInactivityPenalty: 42n, + // sequencer config knobs: cap block gas so the getter/enforcement it can measure and lower it. + maxL2BlockGas: manaTarget * 2, + manaTarget: BigInt(manaTarget), + additionallyFundedAccounts: [botAccount], + // The bot follows the checkpointed tip; keep the PXE on the same tip (setupBlockProducer would + // otherwise default to 'proposed'). + pxeOpts: { syncChainTip: 'checkpointed' }, + }); + ({ aztecNode, aztecNodeAdmin, sequencer, logger } = test.context); + + if (!aztecNodeAdmin) { + throw new Error('Aztec node admin API must be available for this test'); + } + + config = { + ...getBotDefaultConfig(), + followChain: 'CHECKPOINTED', + botMode: 'transfer', + txMinedWaitSeconds: 60, + // Match pipelining fee padding so the bot's maxFeesPerGas keeps up with + // fee-asset price evolution between PXE snapshot and inclusion. + minFeePadding: PIPELINED_FEE_PADDING, + }; + wallet = await EmbeddedWallet.create(aztecNode, { ephemeral: true }); + await wallet.createSchnorrInitializerlessAccount(botAccount.secret, botAccount.salt, botAccount.signingKey); + bot = await Bot.create(config, wallet, aztecNode, undefined, new BotStore(await openTmpStore('bot'))); + }); + + afterAll(() => test.teardown()); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + // Tests that slasher configuration can be updated at runtime via the node admin API. + describe('slasher config', () => { + // Reads the initial slasher config from the running node's slasher client, calls setConfig() via + // the admin API to update slashInactivityTargetPercentage, and asserts the new value is reflected + // while slashInactivityPenalty remains unchanged. + it('should update slasher config', async () => { + const slasherClient = (aztecNode as TestAztecNodeService).slasherClient as SlasherClientInterface; + expect(slasherClient).toBeDefined(); + const currentConfig = slasherClient.getConfig(); + expect(currentConfig.slashInactivityTargetPercentage).toBe(1); + expect(currentConfig.slashInactivityPenalty).toBe(42n); + await aztecNodeAdmin!.setConfig({ slashInactivityTargetPercentage: 0.9 }); + const updatedConfig = slasherClient.getConfig(); + expect(updatedConfig.slashInactivityTargetPercentage).toBe(0.9); + expect(updatedConfig.slashInactivityPenalty).toBe(42n); + }); + }); + + // Suite exercising sequencer.updateConfig() at runtime to assert mana/gas limits are respected. + describe('sequencer config', () => { + // Asserts that the sequencer client's maxL2BlockGas property reflects the value passed to setup(). + it('properly sets config', () => { + if (!sequencer) { + throw new Error('Sequencer not found'); + } + expect(sequencer.maxL2BlockGas).toBe(manaTarget * 2); + }); + + // Runs a bot tx to measure actual mana used, then sets maxL2BlockGas to exactly that value + // (success expected), then to that value minus one (Timeout awaiting isMined expected). + it('respects maxL2BlockGas', async () => { + sequencer!.updateConfig({ + maxTxsPerBlock: 1, + minTxsPerBlock: 0, + }); + + // Run a tx to get the total mana used + const receipt: TxReceipt = (await bot.run()) as TxReceipt; + expect(receipt).toBeDefined(); + expect(receipt.hasExecutionSucceeded()).toBe(true); + const block = await aztecNode.getBlock(receipt.blockNumber!); + expect(block).toBeDefined(); + const totalManaUsed = block?.header.totalManaUsed!.toBigInt(); + + logger.info(`Total mana used: ${totalManaUsed}`); + expect(totalManaUsed).toBeGreaterThan(0n); + bot.updateConfig({ + l2GasLimit: Number(totalManaUsed), + daGasLimit: MAX_TX_DA_GAS, + }); + + // Set the maxL2BlockGas to the total mana used + sequencer!.updateConfig({ + maxL2BlockGas: Number(totalManaUsed), + }); + + // Run a tx and expect it to succeed + const receipt2: TxReceipt = (await bot.run()) as TxReceipt; + expect(receipt2).toBeDefined(); + expect(receipt2.hasExecutionSucceeded()).toBe(true); + + const checkpointedBeforeLimitReduction = await aztecNode.getBlockNumber('checkpointed'); + + // Set the maxL2BlockGas to the total mana used - 1 + sequencer!.updateConfig({ + maxL2BlockGas: Number(totalManaUsed) - 1, + }); + + await retryUntil( + async () => (await aztecNode.getBlockNumber('checkpointed')) > checkpointedBeforeLimitReduction, + 'checkpoint after lowering maxL2BlockGas', + PIPELINING_SETUP_OPTS.aztecSlotDuration * 4, + ); + + // Try to run a tx and expect it to fail + await expect(bot.run()).rejects.toThrow(/Timeout awaiting isMined/); + }); + }); +}); diff --git a/yarn-project/end-to-end/src/single-node/sequencer/sequencer_config.test.ts b/yarn-project/end-to-end/src/single-node/sequencer/sequencer_config.test.ts deleted file mode 100644 index 8f94715bac66..000000000000 --- a/yarn-project/end-to-end/src/single-node/sequencer/sequencer_config.test.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { getInitialTestAccountsData } from '@aztec/accounts/testing'; -import type { AztecNode } from '@aztec/aztec.js/node'; -import type { TxReceipt } from '@aztec/aztec.js/tx'; -import { Bot, type BotConfig, BotStore, getBotDefaultConfig } from '@aztec/bot'; -import { MAX_TX_DA_GAS } from '@aztec/constants'; -import type { Logger } from '@aztec/foundation/log'; -import { retryUntil } from '@aztec/foundation/retry'; -import { openTmpStore } from '@aztec/kv-store/lmdb-v2'; -import type { SequencerClient } from '@aztec/sequencer-client'; -import { EmbeddedWallet } from '@aztec/wallets/embedded'; - -import { jest } from '@jest/globals'; -import 'jest-extended'; - -import { PIPELINED_FEE_PADDING, PIPELINING_SETUP_OPTS } from '../../fixtures/fixtures.js'; -import { setup } from '../../fixtures/utils.js'; - -// Verifies sequencer runtime configuration (maxL2BlockGas / manaTarget) via a live Bot. Uses -// PIPELINING_SETUP_OPTS (prod sequencer, ethSlot=4s, aztecSlot=12s) with no accounts pre-deployed; -// the bot creates its own account inline. -describe('single-node/sequencer/sequencer_config', () => { - jest.setTimeout(20 * 60 * 1000); // 20 minutes - - let teardown: () => Promise; - let sequencer: SequencerClient | undefined; - let config: BotConfig; - let bot: Bot; - let wallet: EmbeddedWallet; - let aztecNode: AztecNode; - let logger: Logger; - - afterEach(() => { - jest.restoreAllMocks(); - }); - - // Suite exercising sequencer.updateConfig() at runtime to assert mana/gas limits are respected. - describe('Sequencer config', () => { - // Sane targets < 64 bits. - const manaTarget = 200e6; - beforeAll(async () => { - const [botAccount] = await getInitialTestAccountsData(); - ({ teardown, sequencer, aztecNode, logger } = await setup(0, { - ...PIPELINING_SETUP_OPTS, - maxL2BlockGas: manaTarget * 2, - manaTarget: BigInt(manaTarget), - additionallyFundedAccounts: [botAccount], - })); - config = { - ...getBotDefaultConfig(), - followChain: 'CHECKPOINTED', - botMode: 'transfer', - txMinedWaitSeconds: 60, - // Match pipelining fee padding so the bot's maxFeesPerGas keeps up with - // fee-asset price evolution between PXE snapshot and inclusion. - minFeePadding: PIPELINED_FEE_PADDING, - }; - wallet = await EmbeddedWallet.create(aztecNode, { ephemeral: true }); - await wallet.createSchnorrInitializerlessAccount(botAccount.secret, botAccount.salt, botAccount.signingKey); - bot = await Bot.create(config, wallet, aztecNode, undefined, new BotStore(await openTmpStore('bot'))); - }); - - afterAll(() => teardown()); - - // Asserts that the sequencer client's maxL2BlockGas property reflects the value passed to setup(). - it('properly sets config', () => { - if (!sequencer) { - throw new Error('Sequencer not found'); - } - expect(sequencer.maxL2BlockGas).toBe(manaTarget * 2); - }); - - // Runs a bot tx to measure actual mana used, then sets maxL2BlockGas to exactly that value - // (success expected), then to that value minus one (Timeout awaiting isMined expected). - it('respects maxL2BlockGas', async () => { - sequencer!.updateConfig({ - maxTxsPerBlock: 1, - minTxsPerBlock: 0, - }); - - // Run a tx to get the total mana used - const receipt: TxReceipt = (await bot.run()) as TxReceipt; - expect(receipt).toBeDefined(); - expect(receipt.hasExecutionSucceeded()).toBe(true); - const block = await aztecNode.getBlock(receipt.blockNumber!); - expect(block).toBeDefined(); - const totalManaUsed = block?.header.totalManaUsed!.toBigInt(); - - logger.info(`Total mana used: ${totalManaUsed}`); - expect(totalManaUsed).toBeGreaterThan(0n); - bot.updateConfig({ - l2GasLimit: Number(totalManaUsed), - daGasLimit: MAX_TX_DA_GAS, - }); - - // Set the maxL2BlockGas to the total mana used - sequencer!.updateConfig({ - maxL2BlockGas: Number(totalManaUsed), - }); - - // Run a tx and expect it to succeed - const receipt2: TxReceipt = (await bot.run()) as TxReceipt; - expect(receipt2).toBeDefined(); - expect(receipt2.hasExecutionSucceeded()).toBe(true); - - const checkpointedBeforeLimitReduction = await aztecNode.getBlockNumber('checkpointed'); - - // Set the maxL2BlockGas to the total mana used - 1 - sequencer!.updateConfig({ - maxL2BlockGas: Number(totalManaUsed) - 1, - }); - - await retryUntil( - async () => (await aztecNode.getBlockNumber('checkpointed')) > checkpointedBeforeLimitReduction, - 'checkpoint after lowering maxL2BlockGas', - PIPELINING_SETUP_OPTS.aztecSlotDuration * 4, - ); - - // Try to run a tx and expect it to fail - await expect(bot.run()).rejects.toThrow(/Timeout awaiting isMined/); - }); - }); -}); diff --git a/yarn-project/end-to-end/src/single-node/sequencer/slasher_config.test.ts b/yarn-project/end-to-end/src/single-node/sequencer/slasher_config.test.ts deleted file mode 100644 index 55c6e8b01e24..000000000000 --- a/yarn-project/end-to-end/src/single-node/sequencer/slasher_config.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -import type { TestAztecNodeService } from '@aztec/aztec-node/test'; -import type { SlasherClientInterface } from '@aztec/slasher'; -import type { AztecNode, AztecNodeAdmin } from '@aztec/stdlib/interfaces/client'; - -import { PIPELINING_SETUP_OPTS } from '../../fixtures/fixtures.js'; -import { setupBlockProducer } from '../setup.js'; -import type { SingleNodeTestContext } from '../single_node_test_context.js'; - -// Tests that slasher configuration can be updated at runtime via the node admin API. -// Single node with no accounts, PIPELINING_SETUP_OPTS (ethSlot=4s, aztecSlot=12s), -// slasher enabled with custom inactivity config. No block building exercised. -describe('single-node/sequencer/slasher_config', () => { - let aztecNodeAdmin: AztecNodeAdmin | undefined; - let aztecNode: AztecNode; - let test: SingleNodeTestContext; - - beforeAll(async () => { - test = await setupBlockProducer({ - ...PIPELINING_SETUP_OPTS, - anvilSlotsInAnEpoch: 4, - slashInactivityTargetPercentage: 1, - slashInactivityPenalty: 42n, - }); - ({ aztecNodeAdmin, aztecNode } = test.context); - - if (!aztecNodeAdmin) { - throw new Error('Aztec node admin API must be available for this test'); - } - }); - - afterAll(() => test.teardown()); - - // Reads the initial slasher config from the running node's slasher client, calls setConfig() via - // the admin API to update slashInactivityTargetPercentage, and asserts the new value is reflected - // while slashInactivityPenalty remains unchanged. - it('should update slasher config', async () => { - const slasherClient = (aztecNode as TestAztecNodeService).slasherClient as SlasherClientInterface; - expect(slasherClient).toBeDefined(); - const currentConfig = slasherClient.getConfig(); - expect(currentConfig.slashInactivityTargetPercentage).toBe(1); - expect(currentConfig.slashInactivityPenalty).toBe(42n); - await aztecNodeAdmin!.setConfig({ slashInactivityTargetPercentage: 0.9 }); - const updatedConfig = slasherClient.getConfig(); - expect(updatedConfig.slashInactivityTargetPercentage).toBe(0.9); - expect(updatedConfig.slashInactivityPenalty).toBe(42n); - }); -}); diff --git a/yarn-project/end-to-end/src/single-node/setup.ts b/yarn-project/end-to-end/src/single-node/setup.ts index 8687cf925af0..0bcd59af5e73 100644 --- a/yarn-project/end-to-end/src/single-node/setup.ts +++ b/yarn-project/end-to-end/src/single-node/setup.ts @@ -6,6 +6,26 @@ import { SingleNodeTestContext, type SingleNodeTestOpts } from './single_node_te * with it; tests pick the factory matching their topology and vary it through `opts`. */ +/** + * Proof-submission window (in epochs) so large the chain never prunes/reorgs in the test's lifetime. + * Tests that must keep unproven blocks alive for their whole run (no prover, or a hand-driven settler) + * set `aztecProofSubmissionEpochs` to this rather than picking an arbitrary large number. + */ +export const NO_REORG_SUBMISSION_EPOCHS = 1024; + +/** + * The "12s floor" slot cadence: a 4s L1 slot with 3 L1 slots per L2 slot, i.e. a 12s L2 slot. Shared by + * the proving / partial-proof suites whose bodies wait in real wall-clock for the sequencer to build + * empty checkpoints (one per L2 slot) and so cannot warp the clock forward. 12s is the shortest L2 slot + * that still fits one block per checkpoint under the default 3s block-duration timing model (which needs + * S >= ~8.5s); an 8s slot derives 0 blocks per checkpoint and trips the timing-config guard. Running at + * this floor keeps those real-time waits as short as possible. + */ +export const PROVING_SLOT_TIMING = { + ethereumSlotDuration: 4, + aztecSlotDurationInL1Slots: 3, +} as const; + /** * Single sequencer plus the context's fake in-process prover node (`realProofs: false`, * `aztecProofSubmissionEpochs: 1`, `syncChainTip: 'checkpointed'`). This is exactly today's @@ -18,8 +38,8 @@ export function setupWithProver(opts: SingleNodeTestOpts = {}): Promise { return SingleNodeTestContext.setup({ startProverNode: false, - aztecProofSubmissionEpochs: 1024, + aztecProofSubmissionEpochs: NO_REORG_SUBMISSION_EPOCHS, aztecEpochDuration: 32, ...opts, pxeOpts: { syncChainTip: 'proposed', ...opts.pxeOpts }, diff --git a/yarn-project/end-to-end/src/single-node/single_node_test_context.ts b/yarn-project/end-to-end/src/single-node/single_node_test_context.ts index ac2eabc8443f..8a119b75d856 100644 --- a/yarn-project/end-to-end/src/single-node/single_node_test_context.ts +++ b/yarn-project/end-to-end/src/single-node/single_node_test_context.ts @@ -9,6 +9,7 @@ import { Fr } from '@aztec/aztec.js/fields'; import type { Logger } from '@aztec/aztec.js/log'; import { MerkleTreeId } from '@aztec/aztec.js/trees'; import type { Wallet } from '@aztec/aztec.js/wallet'; +import type { CheatCodes } from '@aztec/aztec/testing'; import { EpochCache } from '@aztec/epoch-cache'; import { createExtendedL1Client } from '@aztec/ethereum/client'; import { DefaultL1ContractsConfig } from '@aztec/ethereum/config'; @@ -991,4 +992,50 @@ export class SingleNodeTestContext { } expect(failEvents).toEqual([]); } + + /** + * Warps the L1 clock to `target` with every given node's sequencer paused, then resumes them + * (pass `restart: false` to leave them paused, e.g. until some clock-driven effect is confirmed). + * + * Warping the shared date provider under live sequencers interrupts whatever iteration is mid-build, + * producing spurious `block-build-failed` / `checkpoint-error` events and dropped checkpoints. The + * sequencers are therefore paused first: the poll loop halts and the in-flight iteration, its pending L1 + * submission, and any pending fallback vote all finish untouched before the warp, so nothing fires with a + * stale slot afterwards. Pausing leaves the validator clients (and their slashing-protection stores) and + * publishers running, so a later {@link SequencerClient.start} cleanly resumes; archivers, provers, and + * the chain monitor keep running throughout, so clock-driven effects of the warp (e.g. an orphan-block + * prune) still fire. + * + * The warp is performed here (rather than via a caller-supplied callback) so it happens only after the + * pause has drained. Draining can take several slots, so a `target` computed before the pause may already + * lie in the past by the time the sequencers are down. The warp is therefore skipped when the L1 clock has + * already reached or passed `target` — `evm_setNextBlockTimestamp` rejects a non-advancing timestamp, so + * warping there would throw "timestamp in the past". + */ + public async warpWithSequencersPaused( + nodes: AztecNodeService[], + cheatCodes: CheatCodes, + target: bigint, + opts: { restart?: boolean } = {}, + ): Promise { + const sequencers = this.getSequencers(nodes); + await testSpan('warp:sequencers-paused', async () => { + this.logger.warn(`Pausing ${sequencers.length} sequencers before warp`); + await Promise.all(sequencers.map(sequencer => sequencer.pause())); + const currentTs = BigInt(await cheatCodes.eth.lastBlockTimestamp()); + if (currentTs < target) { + this.logger.warn(`Warping L1 to ${target} with all sequencers paused`, { currentTs, target }); + await cheatCodes.eth.warp(Number(target), { resetBlockInterval: true }); + } else { + this.logger.verbose(`Skipping warp: L1 clock ${currentTs} already at or past target ${target}`, { + currentTs, + target, + }); + } + if (opts.restart ?? true) { + this.logger.warn(`Resuming ${sequencers.length} sequencers after warp`); + await Promise.all(sequencers.map(sequencer => sequencer.start())); + } + }); + } } diff --git a/yarn-project/ethereum/src/publisher_manager.test.ts b/yarn-project/ethereum/src/publisher_manager.test.ts index 5eca8915bce2..f5bd2149bf45 100644 --- a/yarn-project/ethereum/src/publisher_manager.test.ts +++ b/yarn-project/ethereum/src/publisher_manager.test.ts @@ -418,6 +418,105 @@ describe('PublisherManager', () => { }); }); + describe('lifecycle', () => { + let funder: TestL1TxUtils & L1TxUtils; + + beforeEach(() => { + funder = new TestL1TxUtils(EthAddress.random()) as TestL1TxUtils & L1TxUtils; + funder.balance = 5000n; + }); + + it('stop interrupts all publishers and the funder', async () => { + mockPublishers = createMockPublishers(3); + publisherManager = new PublisherManager(mockPublishers, {}, { funder }); + + await publisherManager.start(); + await publisherManager.stop(); + + expect(mockPublishers.every(p => p.interrupted)).toBe(true); + expect(funder.interrupted).toBe(true); + }); + + it('start after stop clears the interrupted flag so publishing works again', async () => { + mockPublishers = createMockPublishers(3); + publisherManager = new PublisherManager(mockPublishers, {}, { funder }); + + await publisherManager.start(); + await publisherManager.stop(); + expect(mockPublishers.every(p => p.interrupted)).toBe(true); + + // Restart: interrupted must be cleared, otherwise sendTransaction would throw InterruptError. + await publisherManager.start(); + + expect(mockPublishers.every(p => !p.interrupted)).toBe(true); + expect(funder.interrupted).toBe(false); + }); + + it('a second start does not reload state, which would duplicate background monitors', async () => { + mockPublishers = createMockPublishers(1); + publisherManager = new PublisherManager(mockPublishers, {}); + + await publisherManager.start(); + await publisherManager.start(); + + expect(mockPublishers[0].loadCount).toBe(1); + }); + + it('a start after a stop reloads state so in-flight txs resume monitoring', async () => { + mockPublishers = createMockPublishers(1); + publisherManager = new PublisherManager(mockPublishers, {}); + + await publisherManager.start(); + await publisherManager.stop(); + await publisherManager.start(); + + expect(mockPublishers[0].loadCount).toBe(2); + }); + + it('a failed start can be retried', async () => { + mockPublishers = createMockPublishers(1); + publisherManager = new PublisherManager(mockPublishers, {}); + + mockPublishers[0].failNextLoad = true; + await expect(publisherManager.start()).rejects.toThrow('load failed'); + + await publisherManager.start(); + expect(mockPublishers[0].loadCount).toBe(1); + }); + + it('is idempotent on double stop', async () => { + mockPublishers = createMockPublishers(2); + publisherManager = new PublisherManager(mockPublishers, {}); + + await publisherManager.start(); + await publisherManager.stop(); + await expect(publisherManager.stop()).resolves.not.toThrow(); + + expect(mockPublishers.every(p => p.interrupted)).toBe(true); + }); + + it('resumes periodic funding checks after a restart', async () => { + mockPublishers = createMockPublishers(1); + mockPublishers[0].balance = 50n; // stays below threshold, so every funding check funds it + publisherManager = new PublisherManager( + mockPublishers, + { publisherFundingThreshold: 100n, publisherFundingAmount: 50n }, + { funder }, + ); + + await publisherManager.start(); + await new Promise(resolve => setTimeout(resolve, 10)); + await publisherManager.stop(); + expect(funder.sendAndMonitorTransaction).toHaveBeenCalledTimes(1); + + // The funding loop must be re-armed by the restart, triggering another immediate check. + await publisherManager.start(); + await new Promise(resolve => setTimeout(resolve, 10)); + await publisherManager.stop(); + expect(funder.sendAndMonitorTransaction).toHaveBeenCalledTimes(2); + }); + }); + function createMockPublishers(count: number, addresses: EthAddress[] = []): (TestL1TxUtils & L1TxUtils)[] { const tempAddress = [...addresses]; return times( @@ -431,6 +530,10 @@ class TestL1TxUtils { public state: TxUtilsState = TxUtilsState.IDLE; public lastMinedAtBlockNumber: bigint | undefined = undefined; public balance: bigint = 1000n; + /** Mirrors the real ReadOnlyL1TxUtils.interrupted flag so tests can assert publishing is re-enabled. */ + public interrupted = false; + public loadCount = 0; + public failNextLoad = false; public sendAndMonitorTransaction = jest.fn<() => Promise>().mockResolvedValue({ receipt: { transactionHash: '0xabc', status: 'success' }, state: {}, @@ -446,7 +549,20 @@ class TestL1TxUtils { return this.senderAddress; } - public async loadStateAndResumeMonitoring() {} + public loadStateAndResumeMonitoring() { + if (this.failNextLoad) { + this.failNextLoad = false; + return Promise.reject(new Error('load failed')); + } + this.loadCount++; + return Promise.resolve(); + } - public interrupt() {} + public interrupt() { + this.interrupted = true; + } + + public restart() { + this.interrupted = false; + } } diff --git a/yarn-project/ethereum/src/publisher_manager.ts b/yarn-project/ethereum/src/publisher_manager.ts index 5f45d722800f..d01f92df1033 100644 --- a/yarn-project/ethereum/src/publisher_manager.ts +++ b/yarn-project/ethereum/src/publisher_manager.ts @@ -41,7 +41,8 @@ export class PublisherManager { private config: PublisherManagerConfig; private static readonly FUNDING_CHECK_INTERVAL_MS = 2 * 60 * 1000; protected funder?: UtilsType; - protected fundingPromise?: RunningPromise; + protected readonly fundingPromise?: RunningPromise; + private started = false; constructor( protected publishers: UtilsType[], @@ -67,31 +68,50 @@ export class PublisherManager { this.funder = undefined; } } + + if (this.funder && hasThreshold && hasAmount) { + this.fundingPromise = new RunningPromise( + () => this.triggerFundingIfNeeded(), + this.log, + PublisherManager.FUNDING_CHECK_INTERVAL_MS, + ); + } } - /** Loads the state of all publishers and the funder, and starts periodic funding checks. */ + /** + * Clears any interrupted flag left by a previous {@link stop} so publishing works again after a restart, + * loads the state of all publishers and the funder, and starts periodic funding checks. Idempotent: a + * start while already started is a no-op, so it never re-runs `loadStateAndResumeMonitoring` (which + * would spawn a duplicate background monitor per pending nonce). Lifecycle calls are expected to be + * serialized by the caller. + */ public async start(): Promise { + if (this.started) { + this.log.debug('PublisherManager already started, ignoring start'); + return; + } + + // Clear the interrupted flag set by a previous stop() so a restarted manager can publish again. + // On a first start this is a no-op (the flag is already clear). + this.publishers.forEach(pub => pub.restart()); + this.funder?.restart(); + await Promise.all([ ...this.publishers.map(pub => pub.loadStateAndResumeMonitoring()), this.funder?.loadStateAndResumeMonitoring(), ]); - if ( - this.funder && - this.config.publisherFundingThreshold !== undefined && - this.config.publisherFundingAmount !== undefined - ) { - this.fundingPromise = new RunningPromise( - () => this.triggerFundingIfNeeded(), - this.log, - PublisherManager.FUNDING_CHECK_INTERVAL_MS, - ); - this.fundingPromise.start(); - } + this.fundingPromise?.start(); + // Marked started only once fully up, so a start that failed to load state can be retried. + this.started = true; } - /** Stops the funding loop and interrupts all publishers. */ + /** + * Stops the funding loop and interrupts all publishers so no further L1 txs are sent. Idempotent, and + * the manager may be restarted afterwards via {@link start}, which clears the interrupted flag. + */ public async stop(): Promise { + this.started = false; await this.fundingPromise?.stop(); this.publishers.forEach(pub => pub.interrupt()); this.funder?.interrupt(); diff --git a/yarn-project/ethereum/src/test/eth_cheat_codes.test.ts b/yarn-project/ethereum/src/test/eth_cheat_codes.test.ts index a4aeaffaa36b..6234180ae681 100644 --- a/yarn-project/ethereum/src/test/eth_cheat_codes.test.ts +++ b/yarn-project/ethereum/src/test/eth_cheat_codes.test.ts @@ -3,7 +3,7 @@ import { times, timesAsync } from '@aztec/foundation/collection'; import { EthAddress } from '@aztec/foundation/eth-address'; import { type Logger, createLogger } from '@aztec/foundation/log'; import { sleep } from '@aztec/foundation/sleep'; -import { DateProvider } from '@aztec/foundation/timer'; +import { DateProvider, TestDateProvider } from '@aztec/foundation/timer'; import { getErrorCause } from '@aztec/foundation/types'; import { TestERC20Abi, TestERC20Bytecode } from '@aztec/l1-artifacts'; @@ -278,6 +278,40 @@ describe('EthCheatCodes', () => { }); }); + describe('startIntervalMiningWithFreshBlock', () => { + const expectAutoBlockAfter = async (blockNumber: number) => { + for (let i = 0; i < 20; i++) { + await sleep(100); + if ((await cheatCodes.blockNumber()) > blockNumber) { + return; + } + } + expect(await cheatCodes.blockNumber()).toBeGreaterThan(blockNumber); + }; + + it('starts interval mining from a freshly mined block and syncs the date provider', async () => { + const interval = 1; + const dateProvider = new TestDateProvider(); + cheatCodes = new EthCheatCodes([rpcUrl], dateProvider); + + const blockNumber = await cheatCodes.blockNumber(); + const timestamp = await cheatCodes.lastBlockTimestamp(); + + await sleep((interval + 1) * 1000); + await cheatCodes.startIntervalMiningWithFreshBlock(interval); + + const minedBlockNumber = await cheatCodes.blockNumber(); + const minedTimestamp = await cheatCodes.lastBlockTimestamp(); + + expect(minedBlockNumber).toBe(blockNumber + 1); + expect(minedTimestamp).toBeGreaterThan(timestamp); + expect(await cheatCodes.isAutoMining()).toBe(false); + expect(await cheatCodes.getIntervalMining()).toBe(interval); + expect(Math.abs(dateProvider.now() - minedTimestamp * 1000)).toBeLessThan(1_000); + await expectAutoBlockAfter(minedBlockNumber); + }); + }); + it.each([100, 42, 99])(`setNextBlockTimestamp by %i`, async increment => { const blockNumber = await cheatCodes.blockNumber(); const timestamp = await cheatCodes.lastBlockTimestamp(); diff --git a/yarn-project/ethereum/src/test/eth_cheat_codes.ts b/yarn-project/ethereum/src/test/eth_cheat_codes.ts index fa6067683084..f45ca46aeb78 100644 --- a/yarn-project/ethereum/src/test/eth_cheat_codes.ts +++ b/yarn-project/ethereum/src/test/eth_cheat_codes.ts @@ -206,6 +206,22 @@ export class EthCheatCodes { } } + /** + * Starts interval mining from a freshly mined block and syncs the date provider to the block timestamp. + */ + public async startIntervalMiningWithFreshBlock(seconds: number): Promise { + if (seconds <= 0) { + throw new Error(`Interval mining requires a positive interval, got ${seconds}`); + } + + await this.setAutomine(false); + await this.setIntervalMining(0, { silent: true }); + await this.evmMine(); + await this.syncDateProvider(); + await this.setIntervalMining(seconds); + this.logger.warn(`Started L1 interval mining at ${seconds} seconds from a fresh block`); + } + /** * Set the automine status of the underlying anvil chain * @param automine - The automine status to set diff --git a/yarn-project/foundation/src/promise/running-promise.test.ts b/yarn-project/foundation/src/promise/running-promise.test.ts index fee3dfe68c54..1e654191c713 100644 --- a/yarn-project/foundation/src/promise/running-promise.test.ts +++ b/yarn-project/foundation/src/promise/running-promise.test.ts @@ -52,6 +52,44 @@ describe('RunningPromise', () => { }); }); + describe('lifecycle', () => { + it('a second start does not spawn a second poll loop', async () => { + runningPromise.start(); + expect(counter).toEqual(1); + + // Starting again while already running must be a no-op, not a second concurrent loop. + runningPromise.start(); + expect(counter).toEqual(1); + + await jest.advanceTimersToNextTimerAsync(); + // Exactly one loop advanced the counter, not two. + expect(counter).toEqual(2); + }); + + it('stop is safe to call when never started and when already stopped', async () => { + await expect(runningPromise.stop()).resolves.toBeUndefined(); + + runningPromise.start(); + await runningPromise.stop(); + expect(runningPromise.isRunning()).toBe(false); + + await expect(runningPromise.stop()).resolves.toBeUndefined(); + }); + + it('can be restarted after a stop', async () => { + runningPromise.start(); + expect(counter).toEqual(1); + await runningPromise.stop(); + + runningPromise.start(); + expect(runningPromise.isRunning()).toBe(true); + expect(counter).toEqual(2); + + await jest.advanceTimersToNextTimerAsync(); + expect(counter).toEqual(3); + }); + }); + describe('handles errors', () => { beforeEach(() => { fn.mockImplementation(() => { diff --git a/yarn-project/p2p/src/mem_pools/attestation_pool/attestation_pool.ts b/yarn-project/p2p/src/mem_pools/attestation_pool/attestation_pool.ts index f0beddedea15..e98c4810b25b 100644 --- a/yarn-project/p2p/src/mem_pools/attestation_pool/attestation_pool.ts +++ b/yarn-project/p2p/src/mem_pools/attestation_pool/attestation_pool.ts @@ -434,7 +434,8 @@ export class AttestationPool { const ownKey = this.getSlotSignerKey(slotNumber, address); const payloadHash = attestation.getPayloadHash(); - await this.attestationPerSlotAndSigner.set(ownKey, attestation.toBuffer()); + // Store the signature in canonical (v ∈ {27, 28}) form; see tryAddCheckpointAttestation. + await this.attestationPerSlotAndSigner.set(ownKey, attestation.withNormalizedSignature().toBuffer()); this.metrics.trackMempoolItemAdded(ownKey); // Track our own payload hash so that an equivocating attestation from another @@ -614,7 +615,10 @@ export class AttestationPool { // equivocations are detected via the multimap but their bytes are not retained. const alreadyHasStored = await this.attestationPerSlotAndSigner.hasAsync(slotSignerKey); if (!alreadyHasStored) { - await this.attestationPerSlotAndSigner.set(slotSignerKey, attestation.toBuffer()); + // Store the signature in canonical (v ∈ {27, 28}) form. `sender` recovered above, so the + // signature is non-empty and safe to normalize; this keeps a yParity-encoded (v = 0/1) variant + // from a malicious peer or an in-flight byte mutation out of the L1 bundle downstream. + await this.attestationPerSlotAndSigner.set(slotSignerKey, attestation.withNormalizedSignature().toBuffer()); this.metrics.trackMempoolItemAdded(slotSignerKey); } diff --git a/yarn-project/p2p/src/mem_pools/attestation_pool/attestation_pool_test_suite.ts b/yarn-project/p2p/src/mem_pools/attestation_pool/attestation_pool_test_suite.ts index 19180d9d156d..a568f1805dbc 100644 --- a/yarn-project/p2p/src/mem_pools/attestation_pool/attestation_pool_test_suite.ts +++ b/yarn-project/p2p/src/mem_pools/attestation_pool/attestation_pool_test_suite.ts @@ -1,7 +1,9 @@ import { IndexWithinCheckpoint, SlotNumber } from '@aztec/foundation/branded-types'; import { Secp256k1Signer } from '@aztec/foundation/crypto/secp256k1-signer'; import { Fr } from '@aztec/foundation/curves/bn254'; -import type { BlockProposal, CheckpointAttestation, CheckpointProposalCore } from '@aztec/stdlib/p2p'; +import { Signature } from '@aztec/foundation/eth-signature'; +import type { BlockProposal, CheckpointProposalCore } from '@aztec/stdlib/p2p'; +import { CheckpointAttestation } from '@aztec/stdlib/p2p'; import { CheckpointHeader } from '@aztec/stdlib/rollup'; import { makeBlockHeader, @@ -62,7 +64,44 @@ export function describeAttestationPool(getAttestationPool: () => AttestationPoo expect(a1Buffer).toEqual(expect.arrayContaining(a2Buffer)); }; + /** + * Rewrites the attester signature into yParity form (27 -> 0, 28 -> 1), keeping (r, s). The recovery + * bit is unchanged so it still recovers to the same signer, mimicking a peer that gossips (or mutates + * in flight) a signature with a non-canonical recovery byte. + */ + const toYParityForm = (attestation: CheckpointAttestation): CheckpointAttestation => { + const sig = attestation.signature; + const yParityV = sig.v === 27 ? 0 : sig.v === 28 ? 1 : sig.v; + return new CheckpointAttestation( + attestation.payload, + new Signature(sig.r, sig.s, yParityV), + attestation.proposerSignature, + ); + }; + describe('CheckpointAttestation', () => { + it('normalizes yParity (v=0/v=1) attester signatures on ingress from both gossip and own keys', async () => { + const slotNumber = 421; + const canonical = createCheckpointAttestationsForSlot(slotNumber); + const payloadHash = canonical[0].getPayloadHash(); + const expectedSenders = new Set([canonical[0].getSender()!.toString(), canonical[1].getSender()!.toString()]); + + const gossiped = toYParityForm(canonical[0]); + expect([0, 1]).toContain(gossiped.signature.v); + await ap.tryAddCheckpointAttestation(gossiped); + + const own = toYParityForm(canonical[1]); + expect([0, 1]).toContain(own.signature.v); + await ap.addOwnCheckpointAttestations([own]); + + const retrieved = await ap.getCheckpointAttestationsForSlotAndProposal(SlotNumber(slotNumber), payloadHash); + expect(retrieved.length).toBe(2); + for (const attestation of retrieved) { + expect([27, 28]).toContain(attestation.signature.v); + expect(expectedSenders.has(attestation.getSender()!.toString())).toBe(true); + } + }); + it('should add attestations to pool', async () => { const slotNumber = 420; const archive = Fr.random(); diff --git a/yarn-project/p2p/src/msg_validators/proposal_validator/proposal_validator.test.ts b/yarn-project/p2p/src/msg_validators/proposal_validator/proposal_validator.test.ts index 8c053f27081b..256c0202ef00 100644 --- a/yarn-project/p2p/src/msg_validators/proposal_validator/proposal_validator.test.ts +++ b/yarn-project/p2p/src/msg_validators/proposal_validator/proposal_validator.test.ts @@ -215,12 +215,12 @@ describe('ProposalValidator', () => { expect(result).toEqual({ result: 'reject', severity: PeerErrorSeverity.MidToleranceError }); }); - it('accepts when proposer is undefined (open committee)', async () => { + it('rejects with high tolerance error when proposer is undefined (open committee)', async () => { const proposal = await factory(currentSlot, Secp256k1Signer.random()); epochCache.getProposerAttesterAddressInSlot.mockResolvedValue(undefined); const result = await validator.validate(proposal); - expect(result).toEqual({ result: 'accept' }); + expect(result).toEqual({ result: 'reject', severity: PeerErrorSeverity.HighToleranceError }); }); it('rejects with low tolerance error on NoCommitteeError', async () => { diff --git a/yarn-project/p2p/src/msg_validators/proposal_validator/proposal_validator.ts b/yarn-project/p2p/src/msg_validators/proposal_validator/proposal_validator.ts index 281565e09795..b8e1e3d17233 100644 --- a/yarn-project/p2p/src/msg_validators/proposal_validator/proposal_validator.ts +++ b/yarn-project/p2p/src/msg_validators/proposal_validator/proposal_validator.ts @@ -97,7 +97,11 @@ export class ProposalValidator { // Proposer check const expectedProposer = await this.epochCache.getProposerAttesterAddressInSlot(slotNumber); - if (expectedProposer !== undefined && !proposer.equals(expectedProposer)) { + if (expectedProposer === undefined) { + this.logger.warn(`Penalizing peer for proposal with no expected proposer for current slot ${slotNumber}`); + return { result: 'reject', severity: PeerErrorSeverity.HighToleranceError }; + } + if (!proposer.equals(expectedProposer)) { this.logger.warn(`Penalizing peer for invalid proposer for current slot ${slotNumber}`, { expectedProposer, proposer: proposer.toString(), diff --git a/yarn-project/p2p/src/services/reqresp/rate-limiter/rate_limits.ts b/yarn-project/p2p/src/services/reqresp/rate-limiter/rate_limits.ts index 99cbf24fb6cf..af4d0417ef11 100644 --- a/yarn-project/p2p/src/services/reqresp/rate-limiter/rate_limits.ts +++ b/yarn-project/p2p/src/services/reqresp/rate-limiter/rate_limits.ts @@ -9,7 +9,7 @@ export const DEFAULT_RATE_LIMITS: ReqRespSubProtocolRateLimits = { }, globalLimit: { quotaTimeMs: 1000, - quotaCount: 10, + quotaCount: 50, }, }, [ReqRespSubProtocol.STATUS]: { @@ -19,7 +19,7 @@ export const DEFAULT_RATE_LIMITS: ReqRespSubProtocolRateLimits = { }, globalLimit: { quotaTimeMs: 1000, - quotaCount: 10, + quotaCount: 50, }, }, [ReqRespSubProtocol.AUTH]: { @@ -49,7 +49,7 @@ export const DEFAULT_RATE_LIMITS: ReqRespSubProtocolRateLimits = { }, globalLimit: { quotaTimeMs: 1000, - quotaCount: 10, + quotaCount: 50, }, }, [ReqRespSubProtocol.BLOCK_TXS]: { diff --git a/yarn-project/sequencer-client/src/client/sequencer-client.ts b/yarn-project/sequencer-client/src/client/sequencer-client.ts index c4bfa30cd5dd..5568040b6f8f 100644 --- a/yarn-project/sequencer-client/src/client/sequencer-client.ts +++ b/yarn-project/sequencer-client/src/client/sequencer-client.ts @@ -180,16 +180,23 @@ export class SequencerClient { this.validatorClient?.updateConfig(config); } - /** Starts the sequencer. */ + /** + * Starts (or resumes) the sequencer, validator, publishers, and metrics. Each underlying start is + * idempotent, so this is safe to call after a previous {@link pause} to resume building and publishing. + * The publisher manager is started before the sequencer's poll loop so publishing is ready before the + * sequencer first tries to publish to L1. + */ public async start() { await this.validatorClient?.start(); + await this.publisherManager.start(); this.sequencer.start(); this.l1Metrics?.start(); - await this.publisherManager.start(); } /** - * Stops the sequencer from processing new txs. + * Stops the sequencer, validator, publishers, and metrics for good, draining in-flight work. This is the + * final teardown path: stopping the validator client closes its slashing-protection database. For a + * restartable pause (e.g. around a test clock warp) use {@link pause} instead. */ public async stop() { await this.sequencer.stop(); @@ -198,6 +205,15 @@ export class SequencerClient { this.l1Metrics?.stop(); } + /** + * Gracefully pauses block production, waiting for in-flight work to finish rather than interrupting it, + * while leaving the validator, publishers, and metrics running so the sequencer can be resumed with + * {@link start}. Used by tests that pause sequencers around an L1 clock warp. + */ + public async pause() { + await this.sequencer.pause(); + } + /** Triggers an immediate run of the sequencer, bypassing the polling interval. */ public trigger() { return this.sequencer.trigger(); diff --git a/yarn-project/sequencer-client/src/config.ts b/yarn-project/sequencer-client/src/config.ts index 3e88d931f4e3..2dd82b2b0d1a 100644 --- a/yarn-project/sequencer-client/src/config.ts +++ b/yarn-project/sequencer-client/src/config.ts @@ -242,7 +242,7 @@ export const sequencerConfigMappings: ConfigMappingsType = { ...booleanConfigHelper(DefaultSequencerConfig.buildCheckpointIfEmpty), }, skipPushProposedBlocksToArchiver: { - description: 'Skip pushing proposed blocks to archiver (default: true)', + description: 'Skip pushing proposed blocks to archiver (test only)', ...booleanConfigHelper(DefaultSequencerConfig.skipPushProposedBlocksToArchiver), }, minBlocksForCheckpoint: { diff --git a/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.test.ts b/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.test.ts index c11ec0d1a150..fd0c3090a76f 100644 --- a/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.test.ts +++ b/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.test.ts @@ -75,6 +75,7 @@ import { CheckpointProposalJob } from './checkpoint_proposal_job.js'; import type { CheckpointProposalJobMetricsRecorder } from './checkpoint_proposal_job_metrics.js'; import type { SequencerEvents } from './events.js'; import type { SequencerMetrics } from './metrics.js'; +import { RequestsTracker } from './requests_tracker.js'; describe('CheckpointProposalJob', () => { let publisher: MockProxy; @@ -807,6 +808,7 @@ describe('CheckpointProposalJob', () => { metrics, checkpointMetrics, eventEmitter, + new RequestsTracker(), setStateFn, getTelemetryClient().getTracer('test'), { actor: 'test' }, // bindings @@ -1765,6 +1767,11 @@ class TestCheckpointProposalJob extends CheckpointProposalJob { return Promise.resolve(); } + /** Awaits the sequencer's shared tracker so tests observe the backgrounded L1 submission completing. */ + public async awaitPendingSubmission(): Promise { + await this.pendingRequests.awaitRequests(); + } + /** Wraps execute + awaitPendingSubmission so tests see the full pipeline complete. */ public async executeAndAwait(): Promise { const result = await this.execute(); diff --git a/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.timing.test.ts b/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.timing.test.ts index b508f317b88d..6088fb1579e6 100644 --- a/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.timing.test.ts +++ b/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.timing.test.ts @@ -49,6 +49,7 @@ import { CheckpointProposalJob } from './checkpoint_proposal_job.js'; import type { CheckpointProposalJobMetricsRecorder } from './checkpoint_proposal_job_metrics.js'; import type { SequencerEvents } from './events.js'; import type { SequencerMetrics } from './metrics.js'; +import { RequestsTracker } from './requests_tracker.js'; import { SequencerState } from './utils.js'; /** @@ -156,6 +157,11 @@ class TimingTestCheckpointProposalJob extends CheckpointProposalJob { return this.getSecondsIntoSlotFn(); } + /** Awaits the sequencer's shared tracker so tests observe the backgrounded L1 submission completing. */ + public async awaitPendingSubmission(): Promise { + await this.pendingRequests.awaitRequests(); + } + /** Update config for testing */ public updateConfig(partialConfig: Partial): void { this.config = { ...this.config, ...partialConfig }; @@ -337,6 +343,7 @@ describe('CheckpointProposalJob Timing Tests', () => { metrics, checkpointMetrics, eventEmitter, + new RequestsTracker(), setStateFn, getTelemetryClient().getTracer('timing-test'), { actor: 'timing-test' }, diff --git a/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.ts b/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.ts index 6269d9b1efd7..a5517823c817 100644 --- a/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.ts +++ b/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.ts @@ -74,6 +74,7 @@ import { CheckpointVoter } from './checkpoint_voter.js'; import { SequencerInterruptedError } from './errors.js'; import type { SequencerEvents } from './events.js'; import type { SequencerMetrics } from './metrics.js'; +import type { RequestsTracker } from './requests_tracker.js'; import type { SequencerRollupConstants } from './types.js'; import { SequencerState } from './utils.js'; @@ -105,8 +106,6 @@ export class CheckpointProposalJob implements Traceable { protected readonly log: Logger; private readonly checkpointEventLog: Logger; - /** Tracks the fire-and-forget L1 submission promise so it can be awaited during shutdown. */ - private pendingL1Submission: Promise | undefined; private readonly interruptibleSleep = new InterruptibleSleep(); private interrupted = false; @@ -151,6 +150,9 @@ export class CheckpointProposalJob implements Traceable { private readonly metrics: SequencerMetrics, private readonly checkpointMetrics: CheckpointProposalJobMetricsRecorder, protected readonly eventEmitter: TypedEventEmitter, + // Shared with the owning sequencer, which drains it during shutdown; the fire-and-forget L1 + // submission this job backgrounds is tracked here rather than in a job-local tracker. + protected readonly pendingRequests: RequestsTracker, private readonly setStateFn: (state: SequencerState, slot: SlotNumber) => void, public readonly tracer: Tracer, bindings?: LoggerBindings, @@ -183,12 +185,6 @@ export class CheckpointProposalJob implements Traceable { this.setStateFn(state, this.targetSlot); } - /** Awaits the pending L1 submission if one is in progress. Call during shutdown. */ - public async awaitPendingSubmission(): Promise { - this.log.info('Awaiting pending L1 payload submission'); - await this.pendingL1Submission; - } - /** Interrupts job-owned waits, including the publisher's send-at-slot sleep, so shutdown can finish. */ public interrupt(): void { this.interrupted = true; @@ -257,7 +253,7 @@ export class CheckpointProposalJob implements Traceable { // signature verification to fail silently inside Multicall3. Delay submission to the // start of `targetSlot` so the tx mines in the slot the vote was signed for. if (!this.config.fishermanMode) { - this.pendingL1Submission = this.publisher.sendRequestsAt(this.targetSlot).then(() => {}); + this.pendingRequests.trackRequest(this.publisher.sendRequestsAt(this.targetSlot), () => this.interrupt()); } return undefined; } @@ -272,7 +268,9 @@ export class CheckpointProposalJob implements Traceable { } // Background the attestation → signing → L1 pipeline so the work loop is unblocked - this.pendingL1Submission = this.waitForAttestationsAndEnqueueSubmissionAsync(broadcast, votesPromises); + this.pendingRequests.trackRequest(this.waitForAttestationsAndEnqueueSubmissionAsync(broadcast, votesPromises), () => + this.interrupt(), + ); // Return the built checkpoint immediately — the work loop is now unblocked return checkpoint; @@ -280,7 +278,7 @@ export class CheckpointProposalJob implements Traceable { /** * Background pipeline: collects attestations, signs them, enqueues the checkpoint, and submits to L1. - * Runs as a fire-and-forget task stored in `pendingL1Submission` so the work loop is unblocked. + * Runs as a fire-and-forget task tracked in the sequencer's shared tracker so the work loop is unblocked. */ private async waitForAttestationsAndEnqueueSubmissionAsync( broadcast: CheckpointProposalBroadcast, diff --git a/yarn-project/sequencer-client/src/sequencer/requests_tracker.test.ts b/yarn-project/sequencer-client/src/sequencer/requests_tracker.test.ts new file mode 100644 index 000000000000..8c2b31b05148 --- /dev/null +++ b/yarn-project/sequencer-client/src/sequencer/requests_tracker.test.ts @@ -0,0 +1,97 @@ +import { promiseWithResolvers } from '@aztec/foundation/promise'; + +import { describe, expect, it, jest } from '@jest/globals'; + +import { RequestsTracker } from './requests_tracker.js'; + +describe('RequestsTracker', () => { + it('starts empty', () => { + expect(new RequestsTracker().size).toBe(0); + }); + + it('tracks an in-flight request and drops it once it settles', async () => { + const tracker = new RequestsTracker(); + const { promise, resolve } = promiseWithResolvers(); + + tracker.trackRequest(promise); + expect(tracker.size).toBe(1); + + resolve(); + await tracker.awaitRequests(); + expect(tracker.size).toBe(0); + }); + + it('drops a rejected request without awaitRequests throwing', async () => { + const tracker = new RequestsTracker(); + const { promise, reject } = promiseWithResolvers(); + + tracker.trackRequest(promise); + reject(new Error('boom')); + + await expect(tracker.awaitRequests()).resolves.toBeUndefined(); + expect(tracker.size).toBe(0); + }); + + it('awaitRequests waits for every in-flight request', async () => { + const tracker = new RequestsTracker(); + const first = promiseWithResolvers(); + const second = promiseWithResolvers(); + tracker.trackRequest(first.promise); + tracker.trackRequest(second.promise); + + let settled = false; + const awaiting = tracker.awaitRequests().then(() => (settled = true)); + + first.resolve(); + await Promise.resolve(); + expect(settled).toBe(false); + + second.resolve(); + await awaiting; + expect(settled).toBe(true); + expect(tracker.size).toBe(0); + }); + + it('interruptRequests invokes the interrupt callback of each in-flight request', async () => { + const tracker = new RequestsTracker(); + const first = promiseWithResolvers(); + const second = promiseWithResolvers(); + const interruptFirst = jest.fn(); + const interruptSecond = jest.fn(); + tracker.trackRequest(first.promise, interruptFirst); + tracker.trackRequest(second.promise, interruptSecond); + + tracker.interruptRequests(); + + expect(interruptFirst).toHaveBeenCalledTimes(1); + expect(interruptSecond).toHaveBeenCalledTimes(1); + + first.resolve(); + second.resolve(); + await tracker.awaitRequests(); + }); + + it('does not interrupt requests that already settled', async () => { + const tracker = new RequestsTracker(); + const { promise, resolve } = promiseWithResolvers(); + const interrupt = jest.fn(); + tracker.trackRequest(promise, interrupt); + + resolve(); + await tracker.awaitRequests(); + + tracker.interruptRequests(); + expect(interrupt).not.toHaveBeenCalled(); + }); + + it('tolerates a request tracked without an interrupt callback', async () => { + const tracker = new RequestsTracker(); + const { promise, resolve } = promiseWithResolvers(); + tracker.trackRequest(promise); + + expect(() => tracker.interruptRequests()).not.toThrow(); + + resolve(); + await tracker.awaitRequests(); + }); +}); diff --git a/yarn-project/sequencer-client/src/sequencer/requests_tracker.ts b/yarn-project/sequencer-client/src/sequencer/requests_tracker.ts new file mode 100644 index 000000000000..30b9f51cf6a7 --- /dev/null +++ b/yarn-project/sequencer-client/src/sequencer/requests_tracker.ts @@ -0,0 +1,43 @@ +/** A tracked request: the awaitable to wait on, plus an optional interrupt to cancel it early. */ +type TrackedRequest = { promise: Promise; interrupt?: () => void }; + +/** + * Tracks fire-and-forget requests so a shutdown path can interrupt any in-flight work and await it to + * settle. Each tracked request is a promise plus an optional interrupt callback; the entry removes itself + * from the bag once its promise settles, so the tracker only ever holds requests that are still in flight. + */ +export class RequestsTracker { + private readonly requests = new Map(); + private nextId = 0; + + /** Number of in-flight requests currently tracked. */ + public get size(): number { + return this.requests.size; + } + + /** + * Tracks a fire-and-forget request. `interrupt`, when provided, is invoked by {@link interruptRequests} + * to cancel the in-flight work so its promise settles promptly. The entry auto-removes once `promise` + * settles, whether it resolves or rejects. + */ + public trackRequest(promise: Promise, interrupt?: () => void): void { + const id = this.nextId++; + const settled = promise.then( + () => this.requests.delete(id), + () => this.requests.delete(id), + ); + this.requests.set(id, { promise: settled, interrupt }); + } + + /** Interrupts every in-flight request that was tracked with an interrupt callback. */ + public interruptRequests(): void { + for (const { interrupt } of this.requests.values()) { + interrupt?.(); + } + } + + /** Awaits every currently in-flight request to settle. */ + public async awaitRequests(): Promise { + await Promise.all([...this.requests.values()].map(request => request.promise)); + } +} diff --git a/yarn-project/sequencer-client/src/sequencer/sequencer.test.ts b/yarn-project/sequencer-client/src/sequencer/sequencer.test.ts index ffd7a9fc9ea1..05894143248f 100644 --- a/yarn-project/sequencer-client/src/sequencer/sequencer.test.ts +++ b/yarn-project/sequencer-client/src/sequencer/sequencer.test.ts @@ -13,6 +13,7 @@ import { Secp256k1Signer } from '@aztec/foundation/crypto/secp256k1-signer'; import { Fr } from '@aztec/foundation/curves/bn254'; import { EthAddress } from '@aztec/foundation/eth-address'; import { Signature } from '@aztec/foundation/eth-signature'; +import { promiseWithResolvers } from '@aztec/foundation/promise'; import { TestDateProvider } from '@aztec/foundation/timer'; import type { P2P } from '@aztec/p2p'; import type { SlasherClientInterface } from '@aztec/slasher'; @@ -408,6 +409,143 @@ describe('sequencer', () => { }); }); + describe('lifecycle', () => { + afterEach(async () => { + await sequencer.stop(); + }); + + it('start is idempotent: a second start does not replace the poll loop', () => { + sequencer.start(); + const firstLoop = sequencer.getRunningPromise(); + expect(sequencer.isRunning()).toBe(true); + + sequencer.start(); + + // The second start must be a no-op reusing the same loop, not a fresh RunningPromise that + // leaves the first loop running with no handle to stop it. + expect(sequencer.getRunningPromise()).toBe(firstLoop); + expect(sequencer.isRunning()).toBe(true); + }); + + it('stop halts the poll loop, moves to STOPPED, and is idempotent', async () => { + sequencer.start(); + expect(sequencer.isRunning()).toBe(true); + + await sequencer.stop(); + + expect(sequencer.isRunning()).toBe(false); + expect(sequencer.status().state).toBe(SequencerState.STOPPED); + + await expect(sequencer.stop()).resolves.not.toThrow(); + expect(sequencer.status().state).toBe(SequencerState.STOPPED); + }); + + it('can be restarted after a stop and resumes the poll loop', async () => { + sequencer.start(); + await sequencer.stop(); + expect(sequencer.isRunning()).toBe(false); + + sequencer.start(); + + expect(sequencer.isRunning()).toBe(true); + // The loop is live again (start runs work() immediately, so the exact state may already have + // advanced past IDLE); the point is it is no longer STOPPED/STOPPING. + expect([SequencerState.STOPPED, SequencerState.STOPPING]).not.toContain(sequencer.status().state); + }); + + it('refuses to start while stopping, so no fresh poll loop is orphaned mid-stop', async () => { + sequencer.start(); + const loopBeforeStop = sequencer.getRunningPromise(); + + // Park stop() in the STOPPING state by hanging stopAll until we release it. + const { promise: stopAllHang, resolve: releaseStopAll } = promiseWithResolvers(); + publisherFactory.stopAll.mockReturnValueOnce(stopAllHang); + + const stopPromise = sequencer.stop(); + expect(sequencer.status().state).toBe(SequencerState.STOPPING); + + // A start() landing mid-stop must throw rather than silently allocate a new loop the stop would + // orphan while leaving the caller believing the sequencer is running. + expect(() => sequencer.start()).toThrow('Cannot start sequencer while it is stopping'); + expect(sequencer.getRunningPromise()).toBe(loopBeforeStop); + + releaseStopAll(); + await stopPromise; + expect(sequencer.status().state).toBe(SequencerState.STOPPED); + }); + + it('pause lets the in-flight iteration finish untouched and leaves the sequencer resumable', async () => { + const checkpointErrors: Error[] = []; + sequencer.on('checkpoint-error', ({ error }) => checkpointErrors.push(error)); + + // Park the in-flight work() at its proposer lookup, so pause finds a live iteration. Once released, + // we are not the proposer, so the iteration finishes on the cheap non-proposer path. + const { promise: proposerHang, resolve: releaseProposer } = promiseWithResolvers(); + epochCache.getProposerAttesterAddressInSlot.mockReturnValueOnce(proposerHang); + validatorClient.getValidatorAddresses.mockReturnValue([]); + + sequencer.start(); + const pausePromise = sequencer.pause(); + await new Promise(resolve => setTimeout(resolve, 0)); + + // While the iteration is parked, nothing may be interrupted and STOPPING may not be entered: entering + // it would make the iteration's own setState calls throw SequencerInterruptedError. pause also leaves + // the publishers running (no stopAll), unlike stop(). + expect(publisherFactory.stopAll).not.toHaveBeenCalled(); + expect(sequencer.status().state).not.toBe(SequencerState.STOPPING); + + releaseProposer(signer.address); + await pausePromise; + + // A clean pause emits no spurious checkpoint-error and, unlike stop(), leaves the sequencer resumable: + // the poll loop is halted but the state is neither STOPPED nor STOPPING. + expect(checkpointErrors).toEqual([]); + expect(sequencer.isRunning()).toBe(false); + expect([SequencerState.STOPPED, SequencerState.STOPPING]).not.toContain(sequencer.status().state); + + // And a subsequent start() resumes the poll loop. + sequencer.start(); + expect(sequencer.isRunning()).toBe(true); + }); + + it('drains an in-flight fallback send on stop, leaving nothing pending across a restart', async () => { + // Drive the fire-and-forget fallback vote path: past the build-start deadline with a governance + // payload to vote for, and us as the proposer (mirrors 'votes without building' above). + const startDeadline = sequencer.getTimeTable().getBuildStartDeadline(SlotNumber(newSlotNumber)); + dateProvider.setTime((startDeadline + 1) * 1000); + sequencer.updateConfig({ governanceProposerPayload: EthAddress.random() }); + validatorClient.getValidatorAddresses.mockReturnValue([signer.address]); + epochCache.getProposerAttesterAddressInSlot.mockResolvedValue(signer.address); + publisher.enqueueGovernanceCastSignal.mockResolvedValue(true); + + // The fallback send resolves only when released, and only after the sequencer interrupts it, + // mimicking a wrapper publisher sleeping in waitForTargetSlot. + const { promise: sendHang, resolve: releaseSend } = promiseWithResolvers(); + publisher.sendRequestsAt.mockReturnValueOnce( + sendHang.then(() => { + if (publisher.interrupt.mock.calls.length === 0) { + throw new Error('fallback send completed without being interrupted by stop()'); + } + return undefined; + }), + ); + + await sequencer.work(); + expect(publisher.sendRequestsAt).toHaveBeenCalled(); + expect(sequencer.getPendingRequestCount()).toBe(1); + + // stop() must interrupt the fallback wrapper (waking its sleep so it short-circuits without + // publishing) and await it, so nothing pending survives into a later restart. + const stopPromise = sequencer.stop(); + releaseSend(undefined); + await stopPromise; + + expect(publisher.interrupt).toHaveBeenCalled(); + expect(sequencer.getPendingRequestCount()).toBe(0); + expect(sequencer.status().state).toBe(SequencerState.STOPPED); + }); + }); + describe('block building', () => { it('builds a block out of a single tx', async () => { await setupSingleTxBlock(); @@ -863,7 +1001,10 @@ describe('sequencer', () => { expect(publisher.sendRequestsAt).toHaveBeenCalled(); }); - it('should vote when sync fails even within the build time limit', async () => { + it('does not run fallback actions when sync fails before the build start deadline', async () => { + // A transient sync miss with time still left to build must not trigger fallback actions: the + // work loop should retry on a later tick once sync recovers. In particular it must not send a + // standalone prune, which would give up the slot prematurely. const startDeadline = sequencer.getTimeTable().getBuildStartDeadline(SlotNumber(newSlotNumber)); dateProvider.setTime((startDeadline - 1) * 1000); @@ -876,12 +1017,11 @@ describe('sequencer', () => { await sequencer.work(); - expect(publisher.enqueueSlashingActions).toHaveBeenCalledWith( - mockSlashActions, - SlotNumber(newSlotNumber), - expect.any(EthAddress), - expect.any(Function), - ); + expect(publisher.enqueueSlashingActions).not.toHaveBeenCalled(); + expect(publisher.enqueuePruneIfPrunable).not.toHaveBeenCalled(); + expect(publisher.sendRequestsAt).not.toHaveBeenCalled(); + // The slot is left unmarked so a later work-loop tick can retry once sync recovers. + expect(sequencer.getLastSlotForCheckpointProposalJob()).toBeUndefined(); }); it('should not vote when sync fails but not a proposer', async () => { @@ -980,6 +1120,26 @@ describe('sequencer', () => { expect(publisher.sendRequestsAt).not.toHaveBeenCalled(); }); + it('does not enqueue a standalone prune before the build deadline even when the rollup is prunable', async () => { + // Standalone prune is reserved for when we can no longer build the slot (past the build start + // deadline). Before the deadline, a transient sync miss must retry rather than prune the pending + // chain, even if the rollup happens to be prunable at the target slot. + const startDeadline = sequencer.getTimeTable().getBuildStartDeadline(SlotNumber(newSlotNumber)); + dateProvider.setTime((startDeadline - 1) * 1000); + + // Set us as the proposer + validatorClient.getValidatorAddresses.mockReturnValue([signer.address]); + epochCache.getProposerAttesterAddressInSlot.mockResolvedValue(signer.address); + + // The rollup is prunable, but we are still within the build window. + publisher.enqueuePruneIfPrunable.mockResolvedValue(true); + + await sequencer.work(); + + expect(publisher.enqueuePruneIfPrunable).not.toHaveBeenCalled(); + expect(publisher.sendRequestsAt).not.toHaveBeenCalled(); + }); + it('should enqueue prune alongside votes and send a single request', async () => { const startDeadline = sequencer.getTimeTable().getBuildStartDeadline(SlotNumber(newSlotNumber)); dateProvider.setTime((startDeadline + 1) * 1000); @@ -1702,7 +1862,19 @@ class TestSequencer extends Sequencer { } public async awaitLastProposalSubmission() { - await this.lastCheckpointProposalJob?.awaitPendingSubmission(); + await this.pendingRequests.awaitRequests(); + } + + public getRunningPromise() { + return this.runningPromise; + } + + public isRunning() { + return this.runningPromise?.isRunning() ?? false; + } + + public getPendingRequestCount() { + return this.pendingRequests.size; } public checkCanProposeForTest(slot: SlotNumber) { diff --git a/yarn-project/sequencer-client/src/sequencer/sequencer.ts b/yarn-project/sequencer-client/src/sequencer/sequencer.ts index 19451a6f475d..dec1d10d0206 100644 --- a/yarn-project/sequencer-client/src/sequencer/sequencer.ts +++ b/yarn-project/sequencer-client/src/sequencer/sequencer.ts @@ -55,6 +55,7 @@ import { CheckpointVoter } from './checkpoint_voter.js'; import { SequencerInterruptedError } from './errors.js'; import type { SequencerEvents } from './events.js'; import { SequencerMetrics } from './metrics.js'; +import { RequestsTracker } from './requests_tracker.js'; import type { SequencerRollupConstants } from './types.js'; import { SequencerState } from './utils.js'; @@ -79,7 +80,7 @@ type SequencerSlotContext = { * - Votes for proposals and slashes on L1 */ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter) { - private runningPromise?: RunningPromise; + protected runningPromise?: RunningPromise; private state = SequencerState.STOPPED; private stateSlotNumber: SlotNumber | undefined; /** Wall-clock time (ms, via the date provider) at which the current state was entered. */ @@ -110,6 +111,17 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter TypedEventEmitter TypedEventEmitter { + if (this.state === SequencerState.STOPPED || this.state === SequencerState.STOPPING) { + this.log.debug(`Sequencer already ${this.state.toLowerCase()}, ignoring stop`); + return; + } this.log.info(`Stopping sequencer`); this.setState(SequencerState.STOPPING, undefined, { force: true }); this.lastCheckpointProposalJob?.interrupt(); await this.publisherFactory.stopAll(); + // Stop the poll loop and await the in-flight work() iteration. work() registers its fire-and-forget + // requests synchronously before returning, so once the loop has stopped, pendingRequests holds every + // request that will ever exist. Interrupting any earlier could miss a request registered by the last + // in-flight iteration. await this.runningPromise?.stop(); - await this.lastCheckpointProposalJob?.awaitPendingSubmission(); + this.pendingRequests.interruptRequests(); + await this.pendingRequests.awaitRequests(); this.setState(SequencerState.STOPPED, undefined, { force: true }); this.log.info('Stopped sequencer'); } + /** + * Gracefully pauses block production so the sequencer can later be resumed with {@link start}: halts the + * poll loop and waits for the in-flight work() iteration and every pending L1 submission / fallback send + * to finish, without interrupting them. No interrupt lands mid-build, so no spurious checkpoint-error is + * emitted and no enqueued checkpoint is dropped. Deliberately does not stop inner services (validator/HA + * signer, publishers), so the slashing-protection store stays open and the sequencer stays restartable. + * Used by tests that pause sequencers around an L1 clock warp. Idempotent. + */ + public async pause(): Promise { + if (!this.runningPromise?.isRunning()) { + this.log.debug('Sequencer not running, ignoring pause'); + return; + } + this.log.info('Pausing sequencer'); + // Halt the poll loop and let the in-flight iteration finish naturally — no interrupt, and no STOPPING + // state, since entering STOPPING would make the iteration's own setState calls throw and fail it. + // work() registers its fire-and-forget requests synchronously before returning, so once the loop has + // stopped pendingRequests holds every request that will ever exist; awaiting it lets them all finish. + await this.runningPromise.stop(); + await this.pendingRequests.awaitRequests(); + this.log.info('Paused sequencer'); + } + /** Main sequencer loop with a try/catch */ protected async safeWork() { try { @@ -452,13 +516,6 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter TypedEventEmitter TypedEventEmitter TypedEventEmitter { + // work loop while waiting for the target slot to start, but track it so stop() can drain it. + const send = publisher.sendRequestsAt(targetSlot).catch(err => { this.log.error(`Failed to publish fallback requests despite sync failure for slot ${slot}`, err, { slot }); }); + this.pendingRequests.trackRequest(send, () => publisher.interrupt()); } private async tryEnqueuePruneIfPrunable(targetSlot: SlotNumber, publisher: SequencerPublisher): Promise { @@ -1096,10 +1162,12 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter { + // waiting for the target slot to start, mirroring tryVoteAndPruneWhenCannotBuild, but tracked so + // stop() can drain it. + const send = publisher.sendRequestsAt(targetSlot).catch(err => { this.log.error(`Failed to publish escape-hatch votes for slot ${slot}`, err, { slot, targetSlot }); }); + this.pendingRequests.trackRequest(send, () => publisher.interrupt()); } /** diff --git a/yarn-project/stdlib/src/block/proposal/attestations_and_signers.test.ts b/yarn-project/stdlib/src/block/proposal/attestations_and_signers.test.ts new file mode 100644 index 000000000000..a53d78cc23d6 --- /dev/null +++ b/yarn-project/stdlib/src/block/proposal/attestations_and_signers.test.ts @@ -0,0 +1,61 @@ +import { Buffer32 } from '@aztec/foundation/buffer'; +import { EthAddress } from '@aztec/foundation/eth-address'; +import { Signature } from '@aztec/foundation/eth-signature'; + +import { TEST_COORDINATION_SIGNATURE_CONTEXT } from '../../tests/mocks.js'; +import { CommitteeAttestationsAndSigners } from './attestations_and_signers.js'; +import { CommitteeAttestation } from './committee_attestation.js'; + +function popcount(hex: `0x${string}`): number { + let count = 0; + for (const byte of Buffer.from(hex.slice(2), 'hex')) { + let b = byte; + while (b) { + count += b & 1; + b >>= 1; + } + } + return count; +} + +/** A signing attestation with the given recovery byte and non-zero (r, s). */ +function signing(v: number): CommitteeAttestation { + return new CommitteeAttestation(EthAddress.random(), new Signature(Buffer32.random(), Buffer32.random(), v)); +} + +describe('CommitteeAttestationsAndSigners.packAttestations', () => { + it('keeps the bitmap popcount consistent with getSigners() for yParity (v=0/v=1) signatures', () => { + const attestations = [ + signing(27), + signing(0), // yParity form of a v=27 signature + CommitteeAttestation.fromAddress(EthAddress.random()), // non-signing member (empty signature) + signing(1), // yParity form of a v=28 signature + signing(28), + ]; + const bundle = new CommitteeAttestationsAndSigners(attestations, TEST_COORDINATION_SIGNATURE_CONTEXT); + + const packed = bundle.getPackedAttestations(); + // Four signing members, one empty slot. + expect(bundle.getSigners().length).toEqual(4); + expect(popcount(packed.signatureIndices)).toEqual(bundle.getSigners().length); + }); + + it('packs every signature slot with a canonical v so L1 ECDSA.recover accepts it at proving time', () => { + const attestations = [signing(0), signing(1), signing(27), signing(28)]; + const bundle = new CommitteeAttestationsAndSigners(attestations, TEST_COORDINATION_SIGNATURE_CONTEXT); + + const unpacked = CommitteeAttestation.fromPacked(bundle.getPackedAttestations(), attestations.length); + for (const attestation of unpacked) { + expect([27, 28]).toContain(attestation.signature.v); + } + }); + + it('still packs a genuinely empty signature as an address-only slot', () => { + const address = EthAddress.random(); + const attestations = [signing(27), CommitteeAttestation.fromAddress(address)]; + const bundle = new CommitteeAttestationsAndSigners(attestations, TEST_COORDINATION_SIGNATURE_CONTEXT); + + expect(bundle.getSigners().map(s => s.toString())).not.toContain(address.toString()); + expect(popcount(bundle.getPackedAttestations().signatureIndices)).toEqual(1); + }); +}); diff --git a/yarn-project/stdlib/src/block/proposal/attestations_and_signers.ts b/yarn-project/stdlib/src/block/proposal/attestations_and_signers.ts index 4213fc6abfb5..c6ee4a5f8dc9 100644 --- a/yarn-project/stdlib/src/block/proposal/attestations_and_signers.ts +++ b/yarn-project/stdlib/src/block/proposal/attestations_and_signers.ts @@ -76,8 +76,10 @@ export class CommitteeAttestationsAndSigners implements Signable { let totalDataSize = 0; for (const attestation of viemAttestations) { const signature = attestation.signature; - // Check if signature is empty (v = 0) - const isEmpty = signature.v === 0; + // A slot is empty (a non-signing member, packed as its address) only when r, s and v are all zero + // — matching Signature.isEmpty() and getSigners(), so the bitmap popcount and the signers list can + // never disagree (which would revert propose() with SignersSizeMismatch). + const isEmpty = signature.v === 0 && BigInt(signature.r) === 0n && BigInt(signature.s) === 0n; if (!isEmpty) { totalDataSize += 65; // v (1) + r (32) + s (32) @@ -93,8 +95,8 @@ export class CommitteeAttestationsAndSigners implements Signable { for (const [i, attestation] of viemAttestations.entries()) { const signature = attestation.signature; - // Check if signature is empty - const isEmpty = signature.v === 0; + // Empty iff r, s and v are all zero (see the size-tally loop above). + const isEmpty = signature.v === 0 && BigInt(signature.r) === 0n && BigInt(signature.s) === 0n; if (!isEmpty) { // Set bit in bitmap (bit 7-0 in each byte, left to right) @@ -102,8 +104,10 @@ export class CommitteeAttestationsAndSigners implements Signable { const bitIndex = 7 - (i % 8); signatureIndices[byteIndex] = (signatureIndices[byteIndex] ?? 0) | (1 << bitIndex); - // Pack signature: v + r + s - signaturesOrAddresses[dataIndex] = signature.v; + // Pack signature: v + r + s. Canonicalize a yParity recovery byte (v = 0/1) to 27/28 — it + // recovers to the same signer, but L1 ECDSA.recover only accepts 27/28. Any other value is left + // as-is so a genuinely malformed signature still fails on L1 rather than being silently rewritten. + signaturesOrAddresses[dataIndex] = signature.v === 0 || signature.v === 1 ? signature.v + 27 : signature.v; dataIndex++; // Pack r (32 bytes) diff --git a/yarn-project/stdlib/src/p2p/attestation_utils.test.ts b/yarn-project/stdlib/src/p2p/attestation_utils.test.ts index 29d3dee63a95..457396baf7f5 100644 --- a/yarn-project/stdlib/src/p2p/attestation_utils.test.ts +++ b/yarn-project/stdlib/src/p2p/attestation_utils.test.ts @@ -1,12 +1,14 @@ import { SlotNumber } from '@aztec/foundation/branded-types'; import { Secp256k1Signer } from '@aztec/foundation/crypto/secp256k1-signer'; import { Fr } from '@aztec/foundation/curves/bn254'; +import { Signature } from '@aztec/foundation/eth-signature'; import { jest } from '@jest/globals'; +import { CommitteeAttestationsAndSigners } from '../block/proposal/attestations_and_signers.js'; import { CheckpointHeader } from '../rollup/index.js'; import { TEST_COORDINATION_SIGNATURE_CONTEXT } from '../tests/mocks.js'; -import { trimAttestations } from './attestation_utils.js'; +import { orderAttestations, trimAttestations } from './attestation_utils.js'; import { CheckpointAttestation } from './checkpoint_attestation.js'; import { CheckpointProposal } from './checkpoint_proposal.js'; import { ConsensusPayload } from './consensus_payload.js'; @@ -32,6 +34,86 @@ function makeSignerAndAttestation() { return { signer, attestation: makeAttestation(signer), address: signer.address }; } +/** + * Rewrites the attester signature's recovery byte into yParity form (27 -> 0, 28 -> 1) while keeping + * (r, s). Since the recovery bit is unchanged, the signature still recovers to the same signer, but the + * non-canonical `v` is what a malicious committee member (or a peer mutating the byte in flight) can + * emit to make `packAttestations` and `getSigners` disagree. + */ +function toYParityForm(attestation: CheckpointAttestation): CheckpointAttestation { + const sig = attestation.signature; + const yParityV = sig.v === 27 ? 0 : sig.v === 28 ? 1 : sig.v; + return new CheckpointAttestation( + attestation.payload, + new Signature(sig.r, sig.s, yParityV), + attestation.proposerSignature, + ); +} + +function popcount(hex: `0x${string}`): number { + let count = 0; + for (const byte of Buffer.from(hex.slice(2), 'hex')) { + let b = byte; + while (b) { + count += b & 1; + b >>= 1; + } + } + return count; +} + +describe('orderAttestations', () => { + it('normalizes yParity (v=0/v=1) attester signatures to canonical v', () => { + // Generate enough signers that both recovery bits (v=27 -> 0 and v=28 -> 1) are exercised. + const items = Array.from({ length: 8 }, () => makeSignerAndAttestation()); + const committee = items.map(i => i.address); + const yParityAttestations = items.map(i => toYParityForm(i.attestation)); + + // Sanity check that the fixture actually produced non-canonical signatures for both recovery bits. + const emittedVs = new Set(yParityAttestations.map(a => a.signature.v)); + expect([...emittedVs].every(v => v === 0 || v === 1)).toBe(true); + + const ordered = orderAttestations(yParityAttestations, committee); + + for (const [i, attestation] of ordered.entries()) { + expect([27, 28]).toContain(attestation.signature.v); + expect(attestation.address.toString()).toEqual(committee[i].toString()); + } + }); + + it('produces an L1-consistent bundle (popcount === signers.length) for yParity signatures', () => { + const items = Array.from({ length: 8 }, () => makeSignerAndAttestation()); + const committee = items.map(i => i.address); + const yParityAttestations = items.map(i => toYParityForm(i.attestation)); + + const ordered = orderAttestations(yParityAttestations, committee); + const bundle = new CommitteeAttestationsAndSigners(ordered, TEST_COORDINATION_SIGNATURE_CONTEXT); + + const packed = bundle.getPackedAttestations(); + expect(popcount(packed.signatureIndices)).toEqual(bundle.getSigners().length); + // Every signing slot must carry a canonical v so L1 ECDSA.recover (used at proving time) accepts it. + for (const attestation of ordered) { + if (!attestation.signature.isEmpty()) { + expect([27, 28]).toContain(attestation.signature.v); + } + } + }); + + it('leaves non-signing committee members as empty address-only slots', () => { + const items = Array.from({ length: 3 }, () => makeSignerAndAttestation()); + const missing = Secp256k1Signer.random().address; + const committee = [...items.map(i => i.address), missing]; + + const ordered = orderAttestations( + items.map(i => toYParityForm(i.attestation)), + committee, + ); + + expect(ordered[3].address.toString()).toEqual(missing.toString()); + expect(ordered[3].signature.isEmpty()).toBe(true); + }); +}); + describe('trimAttestations', () => { it('returns attestations unchanged when count <= required', () => { const items = Array.from({ length: 3 }, () => makeSignerAndAttestation()); diff --git a/yarn-project/stdlib/src/p2p/attestation_utils.ts b/yarn-project/stdlib/src/p2p/attestation_utils.ts index 33fb150909af..4a48964b88a5 100644 --- a/yarn-project/stdlib/src/p2p/attestation_utils.ts +++ b/yarn-project/stdlib/src/p2p/attestation_utils.ts @@ -1,3 +1,4 @@ +import { normalizeSignature } from '@aztec/foundation/crypto/secp256k1-signer'; import type { EthAddress } from '@aztec/foundation/eth-address'; import { CommitteeAttestation } from '../block/index.js'; @@ -18,9 +19,15 @@ export function orderAttestations( for (const attestation of attestations) { const sender = attestation.getSender(); if (sender) { + // Canonicalize the recovery byte to v ∈ {27, 28} before it reaches the L1 bundle. A committee + // member (or a peer mutating the byte in flight) can emit an equivalent signature in yParity form + // (v = 0/1); left as-is it makes `packAttestations` (empty iff v === 0) and `getSigners` (empty iff + // r,s,v all zero) disagree, reverting `propose()` with SignersSizeMismatch, and a v = 1 byte would + // later revert epoch proving in ECDSA.recover. `sender` recovered, so the signature is non-empty + // and low-s, and normalizeSignature preserves the recovered address. attestationMap.set( sender.toString(), - CommitteeAttestation.fromAddressAndSignature(sender, attestation.signature), + CommitteeAttestation.fromAddressAndSignature(sender, normalizeSignature(attestation.signature)), ); } } diff --git a/yarn-project/stdlib/src/p2p/checkpoint_attestation.ts b/yarn-project/stdlib/src/p2p/checkpoint_attestation.ts index c054a6c5dc40..7383baad0a52 100644 --- a/yarn-project/stdlib/src/p2p/checkpoint_attestation.ts +++ b/yarn-project/stdlib/src/p2p/checkpoint_attestation.ts @@ -1,5 +1,6 @@ import { CheckpointProposalHash, type SlotNumber } from '@aztec/foundation/branded-types'; import { type BaseBuffer32, Buffer32 } from '@aztec/foundation/buffer'; +import { normalizeSignature } from '@aztec/foundation/crypto/secp256k1-signer'; import type { Fr } from '@aztec/foundation/curves/bn254'; import type { EthAddress } from '@aztec/foundation/eth-address'; import { Signature } from '@aztec/foundation/eth-signature'; @@ -97,6 +98,17 @@ export class CheckpointAttestation extends Gossipable { return this.cachedProposer ?? undefined; } + /** + * Returns a copy with the attester signature canonicalized to v ∈ {27, 28}. Callers store attestations + * received from gossip verbatim; normalizing on ingress keeps a signature emitted in yParity form + * (v = 0/1) — whether by a malicious committee member or a peer mutating the byte in flight — from + * reaching the L1 bundle in a non-canonical form. Must only be called once the signature has recovered + * a sender (so it is non-empty and low-s); `normalizeSignature` throws on an all-zero signature. + */ + withNormalizedSignature(): CheckpointAttestation { + return new CheckpointAttestation(this.payload, normalizeSignature(this.signature), this.proposerSignature); + } + getPayload(): Buffer { return this.payload.getPayloadToSign(); } diff --git a/yarn-project/validator-client/src/proposal_handler.ts b/yarn-project/validator-client/src/proposal_handler.ts index 08941b93de57..aff904864e5e 100644 --- a/yarn-project/validator-client/src/proposal_handler.ts +++ b/yarn-project/validator-client/src/proposal_handler.ts @@ -591,7 +591,7 @@ export class ProposalHandler { } // If we succeeded, push this block into the archiver (unless disabled) - if (reexecutionResult?.block && this.config.skipPushProposedBlocksToArchiver === false) { + if (reexecutionResult?.block && !this.config.skipPushProposedBlocksToArchiver) { await this.blockSource.addBlock(reexecutionResult.block); }