From e92cc1a83f35bbfc70cf286397d092a7a290b352 Mon Sep 17 00:00:00 2001 From: Santiago Palladino Date: Sun, 5 Jul 2026 14:57:08 -0300 Subject: [PATCH 01/17] fix(sequencer): do not run fallback actions within build window (#24504) Sequencer defines a set of fallback actions (voting and pruning) which are meant to be executed if it fails to propose a checkpoint, due to sync errors. However, those actions were being triggered as soon as the sequencer sync check failed, even if in the next sequencer iteration the check succeeded and the sequencer managed to produce a block. Now, the sequencer waits until it's past its build deadline (too late within the build slot to actually build anything), and only then triggers the fallback actions. Fixes A-1362 Supersedes #24488 --- .../src/sequencer/sequencer.test.ts | 36 +++++++++++++++---- .../src/sequencer/sequencer.ts | 14 ++++---- 2 files changed, 36 insertions(+), 14 deletions(-) diff --git a/yarn-project/sequencer-client/src/sequencer/sequencer.test.ts b/yarn-project/sequencer-client/src/sequencer/sequencer.test.ts index ffd7a9fc9ea1..6214cf57e6ba 100644 --- a/yarn-project/sequencer-client/src/sequencer/sequencer.test.ts +++ b/yarn-project/sequencer-client/src/sequencer/sequencer.test.ts @@ -863,7 +863,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 +879,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 +982,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); diff --git a/yarn-project/sequencer-client/src/sequencer/sequencer.ts b/yarn-project/sequencer-client/src/sequencer/sequencer.ts index 19451a6f475d..04cb28f2bbf4 100644 --- a/yarn-project/sequencer-client/src/sequencer/sequencer.ts +++ b/yarn-project/sequencer-client/src/sequencer/sequencer.ts @@ -452,13 +452,6 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter TypedEventEmitter Date: Sun, 5 Jul 2026 14:57:32 -0300 Subject: [PATCH 02/17] fix(proposal-handler): ensure we default to pushing blocks to archiver (#24506) Fixes A-1337 --- yarn-project/sequencer-client/src/config.ts | 2 +- yarn-project/validator-client/src/proposal_handler.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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/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); } From e0859dbd88f65289dc9f776e0f696a4c541904c2 Mon Sep 17 00:00:00 2001 From: Santiago Palladino Date: Sun, 5 Jul 2026 14:57:53 -0300 Subject: [PATCH 03/17] fix(p2p): bump global reqresp limits (#24507) Otherwise very few peers can exhaust the global quota of a node. Fixes A-1335 --- .../p2p/src/services/reqresp/rate-limiter/rate_limits.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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]: { From 62ec0b49da28ac7dc5976174128db5c83c059dba Mon Sep 17 00:00:00 2001 From: Santiago Palladino Date: Sun, 5 Jul 2026 14:58:32 -0300 Subject: [PATCH 04/17] fix(p2p): reject proposal when expected proposer is undefined (#24509) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Experiment to validate [A-1326](https://linear.app/aztec-labs/issue/A-1326/proposal-accepted-when-expectedproposer-is-undefined-consensus-bypass): when `getProposerAttesterAddressInSlot` resolves to `undefined`, `ProposalValidator` currently skips the proposer-identity check entirely and accepts the proposal. This applies the audit's suggested fix — reject instead of skip — mirroring `CheckpointAttestationValidator`, which already rejects in the equivalent case. Note: `expectedProposer` resolves to `undefined` specifically when the committee is configured empty (`targetCommitteeSize == 0`, i.e. permissionless/open-committee mode where "anyone may propose") — `sequencer.ts`'s `checkCanPropose` treats that same case as `canPropose = true` for every node. This PR is intended to surface, via CI (in particular any multi-node/permissionless e2e coverage), whether rejecting proposals in that mode breaks anything that currently relies on it. ## Test plan - Updated `proposal_validator.test.ts`'s "open committee" cases to expect rejection instead of acceptance - `yarn workspace @aztec/p2p test src/msg_validators/proposal_validator/proposal_validator.test.ts` passes locally (49/49) - `yarn build` passes - Relying on CI (multi-node/e2e) to reveal any behavior that depends on the previous accept-when-undefined path --- .../proposal_validator/proposal_validator.test.ts | 4 ++-- .../msg_validators/proposal_validator/proposal_validator.ts | 6 +++++- 2 files changed, 7 insertions(+), 3 deletions(-) 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(), From 54a08fd37cb06a3f33b9df50a7bc813d4e1ee083 Mon Sep 17 00:00:00 2001 From: Santiago Palladino Date: Sun, 5 Jul 2026 15:01:01 -0300 Subject: [PATCH 05/17] fix(archiver): tolerate re-included already-stored checkpoints (#24513) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Context Reconciling checkpoints from L1 could permanently wedge the archiver. `BlockStore.addCheckpoints` accepts an already-stored checkpoint that an L1 reorg re-included in a different L1 block (same archive root, new L1 metadata), but it filtered the already-stored prefix only locally: `ArchiverDataStoreUpdater.addCheckpoints` still re-ran log and contract-data extraction for every block in the original batch. Re-presented blocks carrying a `ContractClassPublished` or `ContractInstancePublished` event then threw `already exists`, aborting the whole write transaction — the L1 sync point never advanced, so every retry re-fetched the same range and failed deterministically until manual DB cleanup. ## Approach - `BlockStore.addCheckpoints` now returns the checkpoints it actually inserted (excluding the already-stored prefix handled by `skipOrUpdateAlreadyStoredCheckpoints`) instead of a boolean, and the data store updater derives the blocks for log/contract-data extraction from that suffix. - As defense in depth, duplicate contract class/instance inserts are now a no-op when the stored insertion block number matches the incoming one; a duplicate at a different block number still throws, since that signals genuine double-processing. - Regression tests cover re-including an already-stored checkpoint alone and batched with a new one, plus store-level tests for the same-block/different-block duplicate behavior. Reported in AztecProtocol/aztec-claude#1288. Fixes A-1350 --- .../src/modules/data_store_updater.test.ts | 57 +++++++++++++++++++ .../src/modules/data_store_updater.ts | 7 ++- .../archiver/src/store/block_store.test.ts | 20 ++++--- .../archiver/src/store/block_store.ts | 16 ++++-- .../src/store/contract_class_store.test.ts | 12 +++- .../src/store/contract_class_store.ts | 8 ++- .../src/store/contract_instance_store.test.ts | 15 ++++- .../src/store/contract_instance_store.ts | 8 ++- 8 files changed, 122 insertions(+), 21 deletions(-) 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()); From c545f9473a8aa18538c4eacfa4a10dfef20118ee Mon Sep 17 00:00:00 2001 From: Santiago Palladino Date: Sun, 5 Jul 2026 15:02:50 -0300 Subject: [PATCH 06/17] fix(p2p): canonicalize yParity attestation signatures before L1 bundle (#24515) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Context A committee member — or any peer mutating the recovery byte in flight — can emit an attestation signature in yParity form (`v = 0/1`) that still recovers to the same signer, since coordination-signature recovery allows yParity as `v`. This is enough to brick an honest proposer. On the proposer side, `CommitteeAttestationsAndSigners.packAttestations` treated `v === 0` as an empty (address-only) slot, while `getSigners` treats a signature as empty only when `r`, `s`, and `v` are all zero. The two disagree for a real signature with `v = 0`: the packed bitmap popcount excludes it but the signers array keeps it, so an honest proposer's `propose()` reverts on L1 with `AttestationLib__SignersSizeMismatch`. A `v = 1` signature instead passes `propose()` but reverts epoch proving, where `verifyAttestations` runs `ECDSA.recover` and rejects `v ∉ {27,28}`. Either variant lets a single actor repeatably halt block production or proving for slots they attest to, with no slashing signal (they did attest). ## Approach Canonicalize the attester signature's recovery byte to `v ∈ {27,28}` (which keeps `(r, s)` and the recovery bit, so the recovered address and the signer's vote are preserved), at three layers: - `orderAttestations` — the honest path that assembles the L1 bundle, via the existing `normalizeSignature`. It runs before the adversarial-injection manipulation used by the invalid-attestation e2e tests, so those deliberately-malformed signatures are left untouched. - Attestation pool ingress (`tryAddCheckpointAttestation` and `addOwnCheckpointAttestations`) — defense in depth, so stored and re-gossiped bytes are always canonical. - `CommitteeAttestationsAndSigners.packAttestations` itself — its empty-slot check now matches `getSigners()` (`r`, `s`, `v` all zero, not just `v === 0`), and it canonicalizes a yParity recovery byte (`0/1 → 27/28`) when packing. This rewrites only the `v` byte — it does not touch `(r, s)` and leaves any other `v` value as-is — so the `injectHighSValueAttestation` / `injectUnrecoverableSignatureAttestation` / `MaliciousCommitteeAttestationsAndSigners` paths (which inject malformed signatures after `orderAttestations` to verify L1 *rejection*) still behave as before. A full `normalizeSignature` inside the class would break them; the `v`-byte-only fix does not. Regression tests cover both `v = 0` and `v = 1` at the `orderAttestations` layer, the pool ingress layer, and directly on `packAttestations` (each asserted red first, e.g. bitmap popcount 3/4 vs signers 4, then green). Fixes A-1351 --- .../attestation_pool/attestation_pool.ts | 8 +- .../attestation_pool_test_suite.ts | 41 ++++++++- .../proposal/attestations_and_signers.test.ts | 61 ++++++++++++++ .../proposal/attestations_and_signers.ts | 16 ++-- .../stdlib/src/p2p/attestation_utils.test.ts | 84 ++++++++++++++++++- .../stdlib/src/p2p/attestation_utils.ts | 9 +- .../stdlib/src/p2p/checkpoint_attestation.ts | 12 +++ 7 files changed, 220 insertions(+), 11 deletions(-) create mode 100644 yarn-project/stdlib/src/block/proposal/attestations_and_signers.test.ts 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/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(); } From 111790c7267381d052685c5f1224b8a77e958273 Mon Sep 17 00:00:00 2001 From: Santiago Palladino Date: Sun, 5 Jul 2026 15:03:51 -0300 Subject: [PATCH 07/17] test(e2e): sync anvil clock before sequencer start (#24474) Adds an EthCheatCodes helper that disables automine, mines a fresh block, syncs the TestDateProvider, and starts interval mining. Used when e2e setup hands off from automined L1 deployment to interval mining before sequencer startup. Should prevent a flake where anvil was left with a different date as the `TestDateProvider` after the automines triggered by the L1 deployment, and on the first L1 block mined the date provider automatically synced to the anvil date, warping time under the running node and causing a prune. --- yarn-project/end-to-end/src/fixtures/setup.ts | 3 +- .../ethereum/src/test/eth_cheat_codes.test.ts | 36 ++++++++++++++++++- .../ethereum/src/test/eth_cheat_codes.ts | 16 +++++++++ 3 files changed, 52 insertions(+), 3 deletions(-) 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/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 From 9f23247ae3cc70b52601bfa0e1a71bfcbac4c277 Mon Sep 17 00:00:00 2001 From: Santiago Palladino Date: Sun, 5 Jul 2026 15:04:32 -0300 Subject: [PATCH 08/17] test(e2e): consolidate automine token suites (#24489) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part of the e2e round-2 consolidation (PR 1 of 9). Merges the automine `token/` suites into fewer, parameterized files, with no loss of asserted behavior. Failure cases are written as explicit per-case tests (no shared dispatch DSL), and the two token harnesses share member names so the merged suites can reuse code. ## Merges (old files → new) - **Transfer quintet → two files.** `transfer.test.ts` (happy paths + the private `Transfer` event assertion, grouped by entrypoint) and new `transfer_failures.test.ts` (explicit per-case failure tests, grouped by entrypoint). Deletes `transfer_in_private`, `transfer_in_public`, `transfer_to_private`, `transfer_to_public`. - **`burn.test.ts` + `blacklist_burn.test.ts` → `burn.test.ts`** via `describe.each` over the two harnesses. Each scenario's setup returns its harness directly; only the differing private-burn method (`burn_private` vs `burn`) is mapped per scenario (`burn_public` is identical on both). The blacklist-only "sender is blacklisted" cases are an extra block. Deletes `blacklist_burn.test.ts`. - **`blacklist_transfer_private.test.ts` + `blacklist_transfer_public.test.ts` → `blacklist_transfer.test.ts`**, parameterized by authwit mechanism (private-proxy vs public) over a single shared harness (private transfers only touch private balances, public only public, so neither disturbs the other's mint). Failure cases are explicit per-case tests inside the mechanism loop. Deletes both. - **`minting.test.ts` → `describe.each(['Public','Private'])`** for the mirrored role/overflow cases; the private-only ABI-encoding overflow case stays explicit. - **`reading_constants.parallel.test.ts` → `reading_constants.test.ts`**: dropped the `.parallel` suffix, converted the 6 its to a single `it.each`, and deleted the empty no-op `beforeEach`. ## Shared helpers (`token_test_helpers.ts`) - `balanceOf` / `halfBalanceOf` / `amountAboveBalance` — the "read balance, take half or balance±1, assert > 0" idiom. - `assertPublicAuthwitReplayRejected` / `assertAuthwitProxyReplayRejected` — the grant/consume-then-replay dances for public and private-proxy authwits. - `deployAmmWithTokens` — the AMM + 3-token + authwit-minter deployment boilerplate previously duplicated in `token/amm.test.ts` and `simulation/kernelless_simulation.test.ts`; both now call it. Adopted the balance helpers (and the proxy-replay helper in unshielding) in `blacklist_shielding.test.ts` and `blacklist_unshielding.test.ts`. ## Harness name unification `TokenContractTest` and `BlacklistTokenContractTest` now share member names so the merged burn suite reads members off the harness directly: - Both expose a public `applyMint()` called after `setup()`; the Token harness's pre-setup `applyMintSnapshot()` flag is gone. - The Token harness's secondary account is renamed `account1Address` → `otherAddress` to match the blacklist harness's owner/other vocabulary. Consumers updated accordingly (`transfer`, `minting`, `access_control.parallel`, `private_transfer_recursion.parallel`). ## Quick wins - Deleted the byte-identical duplicate `it('transfer on behalf of other, wrong designated caller')` in the old `transfer_in_public`. - Collapsed the triplicate `it.skip('transfer into account to overflow')` (GH #1259) to a single copy in `transfer_failures.test.ts`. - Resolved the `new TokenContractTest('transfer_private')` harness-name collision (the two files that shared it were merged; the survivors use distinct names). - Removed the redundant inline `tokenSim.check()` calls from the old `transfer_to_private` (the harness `afterEach` already checks). ## Preserved-assertion mapping (summary) Every asserted behavior survives: each failure mode × entrypoint pair is an explicit test; happy paths and event/balance/simulator assertions stay explicit. The `CHECK_ALERTS`/Grafana `publishing_mana_per_second` QoS guard from the old `transfer_in_public` moved to `transfer.test.ts` (which now owns the public-transfer execution). ## Intentional changes (no assertion lost) - Balance-unchanged checks: where an original asserted only the owner's balance unchanged (private wrong-caller cases), the failure tests now assert both the owner's and other's balances unchanged — a strengthening, since `simulate` cannot move balances. - Dropped one redundant `createAuthWit`+`authWitnesses` in the old `transfer_in_public` over-balance-via-authwit case: the public authwit alone is the authorization mechanism (the private witness was inert on a public call); the U128-underflow revert + balances-unchanged assertions are unchanged. - For `transfer_to_public` over-balance / invalid-nonce, the recipient is now `other` rather than self; the asserted error (Balance too low / invalid nonce) is independent of the recipient. ## Verification `yarn build`, `yarn format`, `yarn lint` clean. CI is the oracle for the full matrix. --- .../simulation/kernelless_simulation.test.ts | 26 +- .../token/access_control.parallel.test.ts | 20 +- .../end-to-end/src/automine/token/amm.test.ts | 30 +- .../src/automine/token/blacklist_burn.test.ts | 318 ---------------- .../token/blacklist_shielding.test.ts | 66 +--- .../automine/token/blacklist_transfer.test.ts | 210 +++++++++++ .../token/blacklist_transfer_private.test.ts | 225 ------------ .../token/blacklist_transfer_public.test.ts | 239 ------------ .../token/blacklist_unshielding.test.ts | 86 ++--- .../src/automine/token/burn.test.ts | 347 ++++++++---------- .../src/automine/token/minting.test.ts | 141 +++---- ...rivate_transfer_recursion.parallel.test.ts | 20 +- .../token/reading_constants.parallel.test.ts | 70 ---- .../automine/token/reading_constants.test.ts | 69 ++++ .../src/automine/token/token_contract_test.ts | 26 +- .../src/automine/token/token_test_helpers.ts | 138 +++++++ .../src/automine/token/transfer.test.ts | 222 +++++++---- .../automine/token/transfer_failures.test.ts | 287 +++++++++++++++ .../token/transfer_in_private.test.ts | 201 ---------- .../automine/token/transfer_in_public.test.ts | 311 ---------------- .../token/transfer_to_private.test.ts | 68 ---- .../automine/token/transfer_to_public.test.ts | 139 ------- 22 files changed, 1137 insertions(+), 2122 deletions(-) delete mode 100644 yarn-project/end-to-end/src/automine/token/blacklist_burn.test.ts create mode 100644 yarn-project/end-to-end/src/automine/token/blacklist_transfer.test.ts delete mode 100644 yarn-project/end-to-end/src/automine/token/blacklist_transfer_private.test.ts delete mode 100644 yarn-project/end-to-end/src/automine/token/blacklist_transfer_public.test.ts delete mode 100644 yarn-project/end-to-end/src/automine/token/reading_constants.parallel.test.ts create mode 100644 yarn-project/end-to-end/src/automine/token/reading_constants.test.ts create mode 100644 yarn-project/end-to-end/src/automine/token/token_test_helpers.ts create mode 100644 yarn-project/end-to-end/src/automine/token/transfer_failures.test.ts delete mode 100644 yarn-project/end-to-end/src/automine/token/transfer_in_private.test.ts delete mode 100644 yarn-project/end-to-end/src/automine/token/transfer_in_public.test.ts delete mode 100644 yarn-project/end-to-end/src/automine/token/transfer_to_private.test.ts delete mode 100644 yarn-project/end-to-end/src/automine/token/transfer_to_public.test.ts 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()}`); - }); - }); -}); From 70ab4223cde94f6e74a88fb73a60c8e614c71575 Mon Sep 17 00:00:00 2001 From: Santiago Palladino Date: Sun, 5 Jul 2026 15:04:50 -0300 Subject: [PATCH 09/17] test(e2e): consolidate automine contracts and effects suites (#24490) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-2 e2e consolidation (PR 2 of 9), scoped to `automine/contracts/**`, `automine/effects/**`, `automine/accounts/scope_isolation.test.ts`, and `automine/mempool_limit.test.ts`. All 8 touched files pass locally. ## Merges (old → new) - `effects/{event_only.test.ts, custom_message.parallel.test.ts, large_public_event.test.ts}` → `effects/events.test.ts` — one `beforeAll` node, per-contract `describe` blocks. custom_message loses its `.parallel` suffix (bodies are tiny; one container is a net win). 3 files → 1. - `contracts/nested/{manual_private_call.test.ts, manual_public.parallel.test.ts, manual_private_enqueue.parallel.test.ts}` → `contracts/nested/manual_calls.test.ts` — single `beforeAll` deploying a shared Parent/Child; the public and enqueued-call suites redeploy a fresh Child per test because each asserts an absolute post-increment storage value that only holds from a zero start. 3 files → 1. ## Table / de-parallel conversions - `accounts/scope_isolation.test.ts` — the `external private` / `external utility` describes (structural duplicates differing only by a `_utility` method suffix) collapsed via `describe.each`. - `contracts/option_params.parallel.test.ts` → `contracts/option_params.test.ts` (plain) + `it.each` over the public/utility/private variants. - `contracts/nested_utility_calls.parallel.test.ts` → `contracts/nested_utility_calls.test.ts` (plain). See decision below. ## `nested_utility_calls` decision The file has two describes with genuinely different setups — the first uses a plain PXE, the second registers a custom `pxeCreationOptions.hooks.authorizeUtilityCall`. Per-container isolation was not the point (all bodies are fast `simulate()` calls with no cross-test shared state; the hook describe already resets its state in `beforeEach`), so I converted it to a single plain file with both describes, each keeping its own `beforeAll` (two sequential node bootstraps in one container — a CPU win over the previous 14 per-it containers). Within the hook describe the deny/allow × utility/private/view sextet is now an `it.each`; the two default-deny cases in the first describe are also tableized. The `msg_sender` and note-sync tests stay explicit. Runs in well under the container timeout locally. ## Added assertions for previously zero-assertion its - `manual_calls` "performs a nested private call": now simulates `parent.entry_point → child.value(0)` and asserts it returns the same preimage as a direct `child.value(0)` call, in addition to sending the tx for on-chain inclusion. - `manual_calls` "performs public nested calls": now routes `pub_entry_point → pub_inc_value(42)` on a fresh child and asserts child storage is 42 (was assertion-free). - `importer.parallel` (stays `.parallel`, titles unchanged): `call_no_args` asserts the imported call returns the Test contract address; `call_public_fn` and `pub_call_public_fn` assert the nullifier landed by checking that re-emitting it on the Test contract is rejected as a duplicate. ## Other - `contracts/state_vars.test.ts`: extracted a file-local `expectInitialized(interaction, expected)` helper replacing ~15 repeated `is_*_initialized` simulate+expect blocks. - `mempool_limit.test.ts`: removed the explicit no-op `proverTestVerificationDelayMs: undefined` option. ## Dropped assertions None. Every asserted behavior is preserved; the zero-assertion its gained real assertions rather than losing any. Test discovery and the compat suite are glob-based, so no `bootstrap.sh` / `.test_patterns.yml` edits were needed. --- .../automine/accounts/scope_isolation.test.ts | 70 +++---- .../nested/importer.parallel.test.ts | 23 ++- .../contracts/nested/manual_calls.test.ts | 131 +++++++++++++ .../nested/manual_private_call.test.ts | 25 --- .../manual_private_enqueue.parallel.test.ts | 67 ------- .../nested/manual_public.parallel.test.ts | 63 ------ ...l.test.ts => nested_utility_calls.test.ts} | 185 +++++++----------- .../contracts/option_params.parallel.test.ts | 99 ---------- .../automine/contracts/option_params.test.ts | 66 +++++++ .../src/automine/contracts/state_vars.test.ts | 128 +++--------- .../effects/custom_message.parallel.test.ts | 93 --------- .../src/automine/effects/event_only.test.ts | 52 ----- .../src/automine/effects/events.test.ts | 149 ++++++++++++++ .../effects/large_public_event.test.ts | 54 ----- .../src/automine/mempool_limit.test.ts | 7 +- 15 files changed, 484 insertions(+), 728 deletions(-) create mode 100644 yarn-project/end-to-end/src/automine/contracts/nested/manual_calls.test.ts delete mode 100644 yarn-project/end-to-end/src/automine/contracts/nested/manual_private_call.test.ts delete mode 100644 yarn-project/end-to-end/src/automine/contracts/nested/manual_private_enqueue.parallel.test.ts delete mode 100644 yarn-project/end-to-end/src/automine/contracts/nested/manual_public.parallel.test.ts rename yarn-project/end-to-end/src/automine/contracts/{nested_utility_calls.parallel.test.ts => nested_utility_calls.test.ts} (53%) delete mode 100644 yarn-project/end-to-end/src/automine/contracts/option_params.parallel.test.ts create mode 100644 yarn-project/end-to-end/src/automine/contracts/option_params.test.ts delete mode 100644 yarn-project/end-to-end/src/automine/effects/custom_message.parallel.test.ts delete mode 100644 yarn-project/end-to-end/src/automine/effects/event_only.test.ts create mode 100644 yarn-project/end-to-end/src/automine/effects/events.test.ts delete mode 100644 yarn-project/end-to-end/src/automine/effects/large_public_event.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'); From 863d4cc46388f6261cd8c4af88b5ce34ef5954c1 Mon Sep 17 00:00:00 2001 From: Santiago Palladino Date: Sun, 5 Jul 2026 15:05:07 -0300 Subject: [PATCH 10/17] test(e2e): consolidate cross-chain bridge and messaging suites (#24491) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part of the e2e round-2 consolidation (PR 3 of 9): fewer CI containers for `single-node/cross-chain/` with every asserted behavior preserved. ## Merges (old → new) - `token_bridge_private.test.ts` + `token_bridge_public.parallel.test.ts` + `token_bridge_failure_cases.test.ts` → **`token_bridge.test.ts`**: one `CrossChainMessagingTest` setup in `beforeAll` (with `startProverNode: true`, covering all three), with `describe('private')`, `describe('public')`, `describe('failure cases')`. The public file previously re-ran the full harness setup per it via `beforeEach`. - `l1_to_l2.parallel.test.ts` → **`l1_to_l2.test.ts`** + **`l1_to_l2_inbox_drift.test.ts`**: `beforeEach` full setup converted to a single `beforeAll` per file; the scenarios converted to `it.each(['private','public'])` tables. Split by scenario (see timings below): `l1_to_l2.test.ts` keeps the duplicate-message-from-non-registered-portal scenario, `l1_to_l2_inbox_drift.test.ts` holds the inbox-drift scenario (which resets the `markProvenEnabled` gate between its via `beforeEach`). - `l2_to_l1.parallel.test.ts` → **`l2_to_l1.test.ts`**: dropped the `.parallel` suffix; the two message-count-shape its (`[3,4]` and `[3,1,2]`) tableized via `it.each` with per-row consume tuples; the reorg-and-remine, mixed private/public, no-message-tx, and multi-block-checkpoint its kept explicit and behaviorally unchanged. ## Harness construction - The `new CrossChainMessagingTest(...)` for every suite now lives as the first statement of `beforeAll` rather than at `describe` scope, so nothing is constructed at test-registration time. Registration-time data (the `it.each` scope tables) is static, and test bodies read off `t` lazily. - The shared L1→L2 message helpers (`sendMessageToL2`, `advanceBlock`, `waitForMessageFetched`, `waitForMessageReady`) used by both `l1_to_l2` files are extracted to **`message_test_helpers.ts`**. Each suite plugs in its own proving policy via a `markAsProven` dependency (unconditional for the duplicate-message suite; gated for the inbox-drift suite). ## Container count - Before: 14 CI containers (2 plain files + 12 `.parallel` per-it containers), each paying a full node bootstrap. - After: 4 containers / 4 setups. Same 17 its in total. ## Timings (why l1_to_l2 was split) Measured from the last full CI run on this branch (commit `ba5e29b`): - `token_bridge.test.ts`: 463s (~7.7 min) - `l2_to_l1.test.ts`: 384s (~6.4 min) - `l1_to_l2.test.ts`: 738s (~12.3 min) — over the ~10 min target, so split `l1_to_l2`'s four its broke down as: duplicate-message private 149s / public 162s; inbox-drift private 194s / public 172s (setup + teardown ~59s). Splitting by scenario yields `l1_to_l2` (~6 min) and `l1_to_l2_inbox_drift` (~7 min), both comfortably under 10 min, without resorting to the `.parallel` suffix. ## Assertion mapping - No assertions dropped. All 17 its and their asserted properties survive; every `it` moved intact to exactly one file. - Because the token-bridge its now share one node, absolute balance assertions became delta-from-initial assertions (each it snapshots L1/L2 balances at its start). Same properties: deposit debits L1 by the bridge amount, claim credits L2, withdraw restores L1, wrong claims revert with the same error strings. - `.test_patterns.yml` flake-pattern regexes repointed from the three old bridge file names to `token_bridge`; the `l1_to_l2` prefix regex already covers both new `l1_to_l2*` files. - `bootstrap.sh`: 25m per-container timeout overrides for the four cross-chain files (bodies run serially in one container each). The glob picks up the new file names unchanged. ## Notes - No docs `#include_code` references any of the renamed files, so no docs changes. - `single-node/README.md` still lists the old cross-chain file names (it was already stale); left untouched to avoid conflicting with the sibling consolidation PRs — follow-up sweep will refresh it. --- .test_patterns.yml | 8 +- yarn-project/end-to-end/bootstrap.sh | 17 +- .../single-node/cross-chain/l1_to_l2.test.ts | 130 +++++++ ...l.test.ts => l1_to_l2_inbox_drift.test.ts} | 201 +++------- ...o_l1.parallel.test.ts => l2_to_l1.test.ts} | 150 +++----- .../cross-chain/message_test_helpers.ts | 125 +++++++ .../cross-chain/token_bridge.test.ts | 354 ++++++++++++++++++ .../token_bridge_failure_cases.test.ts | 118 ------ .../cross-chain/token_bridge_private.test.ts | 135 ------- .../token_bridge_public.parallel.test.ts | 160 -------- 10 files changed, 724 insertions(+), 674 deletions(-) create mode 100644 yarn-project/end-to-end/src/single-node/cross-chain/l1_to_l2.test.ts rename yarn-project/end-to-end/src/single-node/cross-chain/{l1_to_l2.parallel.test.ts => l1_to_l2_inbox_drift.test.ts} (54%) rename yarn-project/end-to-end/src/single-node/cross-chain/{l2_to_l1.parallel.test.ts => l2_to_l1.test.ts} (79%) create mode 100644 yarn-project/end-to-end/src/single-node/cross-chain/message_test_helpers.ts create mode 100644 yarn-project/end-to-end/src/single-node/cross-chain/token_bridge.test.ts delete mode 100644 yarn-project/end-to-end/src/single-node/cross-chain/token_bridge_failure_cases.test.ts delete mode 100644 yarn-project/end-to-end/src/single-node/cross-chain/token_bridge_private.test.ts delete mode 100644 yarn-project/end-to-end/src/single-node/cross-chain/token_bridge_public.parallel.test.ts diff --git a/.test_patterns.yml b/.test_patterns.yml index e4800e12b11c..d98d911ae988 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 diff --git a/yarn-project/end-to-end/bootstrap.sh b/yarn-project/end-to-end/bootstrap.sh index 428646156342..973fe6b83cf1 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 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); -}); From 7a1fe62d3a17cb7e89b910359d09ffaad84f0f6d Mon Sep 17 00:00:00 2001 From: Santiago Palladino Date: Sun, 5 Jul 2026 15:05:33 -0300 Subject: [PATCH 11/17] test(e2e): merge single-node fee, proving, and sequencer config suites (#24492) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-2 e2e consolidation, PR 4 of 9 (single-node merges). All merges preserve every asserted behavior; no assertions dropped. ## Merges (old → new) - `single-node/fees/public_payments.test.ts` + `single-node/fees/sponsored_payments.test.ts` → folded into `single-node/fees/private_payments.parallel.test.ts` as two unrolled its (plain string titles — the target is `.parallel`). All three ran the same FeesTest+FPC fixture; the shared `beforeAll` gains `applySponsoredFPCSetup()`, which is a PXE contract registration (the SponsoredFPC is already funded at genesis via `fundSponsoredFPC`), not an extra tx. The folded its compute their padded-max-fee gas settings locally, matching the originals. - `single-node/proving/empty_blocks.test.ts` + `single-node/proving/world_state_pruning.test.ts` + `single-node/misc/node_block_api.test.ts` → `single-node/proving/default_node.test.ts`: one context-default `setupWithProver({})` node, one describe per former file. Ordered so `world_state_pruning` (needs minTxsPerBlock:0 empty checkpoints) runs before `empty_blocks` (raises minTxsPerBlock). `node_block_api` queries the genesis block, which is setup-agnostic, so it drops its former bespoke PIPELINING setup. - `single-node/sequencer/slasher_config.test.ts` + `single-node/sequencer/sequencer_config.test.ts` → `single-node/sequencer/runtime_config.test.ts`: one `setupBlockProducer` carrying both suites' knobs (slasher inactivity config + maxL2BlockGas/manaTarget/bot account). The sequencer half keeps its `syncChainTip: 'checkpointed'` via explicit `pxeOpts` since `setupBlockProducer` defaults to 'proposed'. - `single-node/block-building/block_building.test.ts`: the 8-it double-spend suite is now an `it.each` table over `{firstKind, secondKind, sameBlock, expectedError}` (plain file, tables safe). Filename kept so the bootstrap.sh TIMEOUT=25m override and the `.test_patterns.yml` entry keep applying. All other its unchanged. ## Container count - Plain-file containers: −5 (public_payments, sponsored_payments, empty_blocks, world_state_pruning, node_block_api, slasher_config, sequencer_config deleted; default_node, runtime_config added). - `.parallel` per-it containers: +2 (the folded fee its), each far cheaper than the full plain-file fixture they replace. - its: 8 double-spend its → 1 `it.each` × 8 rows (same coverage, one title pattern). ## Folds skipped - `single-node/proving/cross_chain_public_message.test.ts` stays put: it is not default-compatible (`sequencerPublisherAllowInvalidStates: true`, `minTxsPerBlock: 1`, `numberOfAccounts: 1`), unlike the plan's assumption. ## Docs - `docs/docs-developers/docs/aztec-js/how_to_pay_fees.md`: `#include_code sponsored_fpc_simple` path repointed to `private_payments.parallel.test.ts`; the `docs:start/end:sponsored_fpc_simple` block moved byte-identical. ## Local runs - `runtime_config.test.ts`: 3/3 in 140s - `default_node.test.ts`: 3/3 in 183s - `block_building.test.ts`: 18 passed / 1 skipped (pre-existing manual-only skip) in 564s - `private_payments.parallel.test.ts`: 8/8 in 333s ## Deferred - `end-to-end/README.md` example command and `single-node/README.md` per-file blurbs still name the old files (README sweep is deferred per the round-2 plan; PR 5+ owns it). --- .../docs/aztec-js/how_to_pay_fees.md | 2 +- .../block-building/block_building.test.ts | 168 ++++++------------ .../fees/private_payments.parallel.test.ts | 108 ++++++++++- .../single-node/fees/public_payments.test.ts | 101 ----------- .../fees/sponsored_payments.test.ts | 98 ---------- .../single-node/misc/node_block_api.test.ts | 42 ----- .../single-node/proving/default_node.test.ts | 120 +++++++++++++ .../single-node/proving/empty_blocks.test.ts | 42 ----- .../proving/world_state_pruning.test.ts | 68 ------- .../sequencer/runtime_config.test.ts | 162 +++++++++++++++++ .../sequencer/sequencer_config.test.ts | 122 ------------- .../sequencer/slasher_config.test.ts | 47 ----- 12 files changed, 437 insertions(+), 643 deletions(-) delete mode 100644 yarn-project/end-to-end/src/single-node/fees/public_payments.test.ts delete mode 100644 yarn-project/end-to-end/src/single-node/fees/sponsored_payments.test.ts delete mode 100644 yarn-project/end-to-end/src/single-node/misc/node_block_api.test.ts create mode 100644 yarn-project/end-to-end/src/single-node/proving/default_node.test.ts delete mode 100644 yarn-project/end-to-end/src/single-node/proving/empty_blocks.test.ts delete mode 100644 yarn-project/end-to-end/src/single-node/proving/world_state_pruning.test.ts create mode 100644 yarn-project/end-to-end/src/single-node/sequencer/runtime_config.test.ts delete mode 100644 yarn-project/end-to-end/src/single-node/sequencer/sequencer_config.test.ts delete mode 100644 yarn-project/end-to-end/src/single-node/sequencer/slasher_config.test.ts 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/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/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/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/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/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/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); - }); -}); From 0829dbaebd1290198208059b70f1fb8df59c0930 Mon Sep 17 00:00:00 2001 From: Santiago Palladino Date: Sun, 5 Jul 2026 15:06:03 -0300 Subject: [PATCH 12/17] test(e2e): name shared timing presets and collapse proof-submission constants (#24494) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-2 e2e consolidation, PR 5 of 9 (workstream B: timing/config constants). Introduces two named timing constants in `single-node/setup.ts` and sweeps single-node/multi-node files onto them. ## Constants added - `NO_REORG_SUBMISSION_EPOCHS = 1024` — proof-submission window so large the chain never prunes/reorgs in the test's lifetime. Re-exported via `multi_node_test_context.ts` and the per-directory setup re-exports. - `PROVING_SLOT_TIMING = { ethereumSlotDuration: 4, aztecSlotDurationInL1Slots: 3 }` — the "12s floor" cadence previously hand-spelled (with near-verbatim derivation comments) in 5 proving/partial-proofs files. One shared doc comment now carries the derivation; the per-file copies are deleted. ## Pure renames (same effective value) - `aztecProofSubmissionEpochs: 1024` → constant: `setupBlockProducer`, `MOCK_GOSSIP_MULTI_VALIDATOR_OPTS`, `missed_l1_slot`, `multi_validator_node.parallel`. - `PROVING_SLOT_TIMING` adoption with unchanged values: `single_root`, `multi_proof`, `long_proving_time`, `upload_failed_proof`. ## Nominal value changes, same "never reorg" semantic (large window → 1024) - `640` → constant: `bot`, `fees/failures`, `fees/fee_settings`, `block-building/debug_trace`. - `1000` → constant: `proving/long_proving_time`, `proving/optimistic.parallel` (6 sites). - `128` → constant: `sequencer/gov_proposal.parallel`. These windows only exist to pin blocks against pruning; none of the tests run anywhere near the window, so 1024 is inert. Verified by build/lint plus sample runs (below). ## Effective timing changes (each run locally twice, both green) - 16s-cluster collapse onto `PROVING_SLOT_TIMING` (16s → 12s aztec slot): `proving/proof_fails.parallel` (218s/218s), `recovery/prune_when_cannot_build` (83s/83s). - Epoch one-off audit: `partial-proofs/multi_root` epoch 1000 → 32 (232s/233s), `recovery/manual_rollback` epoch 100 → 32 + `PROVING_SLOT_TIMING` + never-reorg window (58s/58s). No fallbacks were needed; all collapses held over both runs. Rename-only sample runs: `single_root` 66s, `long_proving_time` 191s, `upload_failed_proof` 51s — all green. ## Deliberately left as literals - Small windows that are production-like or test the window itself: `1` (`prune_when_cannot_build`, `l1-reorgs/setup`, `sync/synching`, `proof_boundary.parallel`), `2` (`cross-chain/l1_to_l2.parallel`), `15` (`escape_hatch_vote_only`, needed for a valid EscapeHatch config — already commented). - `single-node/l1-reorgs/**` values untouched per round-1 finding that their proof windows are sensitive to `ethereumSlotDuration`. ## Skipped (sibling-PR file ownership; literal sweeps deferred to a follow-up) - `single-node/cross-chain/**` (PR 3). - `single-node/fees/private_payments.parallel` (640), `single-node/block-building/block_building`, `single-node/proving/default_node`, `single-node/sequencer/runtime_config` (PR 4). - `multi-node/slashing/**` (PR 6): ~10 files with `1024` literals, incl. `slashing/setup.ts` and `inactivity_setup.ts`. - `multi-node/governance/setup.ts` (640), `multi-node/block-production/{setup.ts,blob_promotion}`, `multi-node/recovery/pipeline_prune`, `multi-node/invalid-attestations/**` (PR 7). - `p2p/**` (PR 8, incl. `SHORTENED_BLOCK_TIME_CONFIG_NO_PRUNES`'s 640), `composed/**` (PR 9), `automine/**` (PRs 1-2). --- .../multi_validator_node.parallel.test.ts | 5 ++-- .../src/multi-node/multi_node_test_context.ts | 4 ++- .../block-building/debug_trace.test.ts | 6 ++-- .../src/single-node/bot/bot.test.ts | 7 +++-- .../src/single-node/fees/failures.test.ts | 9 ++++-- .../src/single-node/fees/fee_settings.test.ts | 5 ++-- .../single-node/misc/missed_l1_slot.test.ts | 4 +-- .../partial-proofs/multi_root.test.ts | 15 +++++----- .../src/single-node/partial-proofs/setup.ts | 4 +-- .../partial-proofs/single_root.test.ts | 9 +++--- .../proving/long_proving_time.test.ts | 28 +++++++++---------- .../single-node/proving/multi_proof.test.ts | 15 ++++------ .../proving/optimistic.parallel.test.ts | 16 +++++------ .../proving/proof_fails.parallel.test.ts | 9 +++--- .../src/single-node/proving/setup.ts | 11 ++++++-- .../proving/upload_failed_proof.test.ts | 13 +++------ .../recovery/manual_rollback.test.ts | 25 +++++++++++------ .../recovery/prune_when_cannot_build.test.ts | 7 ++--- .../src/single-node/recovery/setup.ts | 4 +-- .../sequencer/gov_proposal.parallel.test.ts | 8 +++--- .../end-to-end/src/single-node/setup.ts | 26 +++++++++++++++-- 21 files changed, 131 insertions(+), 99 deletions(-) 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/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/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/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/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/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/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/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/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 }, From 842cb8d4fb0329dadbc6155c50120ed3bf278612 Mon Sep 17 00:00:00 2001 From: Santiago Palladino Date: Sun, 5 Jul 2026 15:06:48 -0300 Subject: [PATCH 13/17] test(e2e): unify slashing timing profiles and merge duplicate suites (#24495) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-2 e2e consolidation, PR 6 of 9 — scope: `multi-node/slashing/**`. Every asserted behavior is preserved; no assertions were dropped. ## Timing profiles (B3) The directory now uses exactly two named profiles in `slashing/setup.ts`, with zero free-floating local slot/epoch timing constants in the test files: - **`baseSlashingOpts`** (existing, eth 8s / aztec 24s) — the equivocation suites. - **`SENTINEL_TIMING`** (new: `{anvilSlotsInAnEpoch:4, listenAddress, ethereumSlotDuration:4, aztecSlotDuration:8, aztecEpochDuration:2, sentinelEnabled:true}`) — the fast 8s-slot timing. Every adoption below is a pure re-expression: the effective config values passed to `setup` are unchanged. | File | Profile | Effective value change? | |---|---|---| | `equivocation_offenses` (merged) | `baseSlashingOpts` | no (both source files already used it) | | `sentinel_status_slash.parallel` | `SENTINEL_TIMING` | no | | `validators_sentinel.parallel` | `SENTINEL_TIMING` | no | | `multiple_validators_sentinel.parallel` | `SENTINEL_TIMING` | no | | `slash_veto_demo` | `SENTINEL_TIMING` | no | | `broadcasted_invalid_block_proposal_slash` | `SENTINEL_TIMING` + `sentinelEnabled:false` | no | | `broadcasted_invalid_checkpoint_proposal_slash.parallel` | `SENTINEL_TIMING` + `sentinelEnabled:false` | no | | `data_withholding_slash` | `SENTINEL_TIMING` + `sentinelEnabled:false`, `aztecSlotDuration:12` override | no | | `attested_invalid_proposal.parallel` | `SENTINEL_TIMING` + `sentinelEnabled:false`, `aztecSlotDuration:36` + `committee:3` override | no | **Documented overrides:** `data_withholding` (12s slots for the tolerance window) and `attested_invalid_proposal` (36s slots so an AVM-heavy 3-block checkpoint fits one slot) keep their bespoke `aztecSlotDuration` as a documented one-knob override on `SENTINEL_TIMING`; their eth/epoch come from the profile. Non-sentinel offense files spread `SENTINEL_TIMING` and set `sentinelEnabled:false` (= the prior default), reusing only the fast timing. **Kept bespoke:** `inactivity_setup.ts` retains its CI-conditional timing (`eth = CI ? 8 : 4`, `aztec = eth*2`) — it is a shared fixture with a deliberate CI slot-doubling that `SENTINEL_TIMING`'s fixed 4s cannot express; the two inactivity test files themselves declare no timing constants. ## Merges - `duplicate_attestation.test.ts` + `duplicate_proposal.test.ts` → **`equivocation_offenses.test.ts`**: a shared `runEquivocationScenario` runner plus two describes (the two setups differ in `slashDuplicateAttestationPenalty` / `attestToEquivocatedProposals`, so each keeps its own `beforeEach`). - `inactivity_slash.test.ts` and `inactivity_slash_with_consecutive_epochs.test.ts` stay as separate files. They share the `InactivityTest` fixture (`inactivity_setup.ts`), but the fixture is stateful across epochs, so each file still needs its own `beforeAll` — folding them into one file with two describes would have grown the file without sharing any setup. ## Helpers / cleanups - Centralized `findSlashOffense` (and the `SlashOffense` type) in `setup.ts`, adopted by `attested_invalid_proposal` and `broadcasted_invalid_checkpoint_proposal_slash` (the latter's local offense-lookup wrappers now delegate to it, switching validator comparison from string to `EthAddress.equals`). - `slash_veto_demo`: **deleted** the dead `shouldVeto:false` branch (`it.each([[true]])` → plain `it`). No running assertion was dropped — the false branch's balance-decrease assertions were never exercised. - `sentinel_status_slash` its 1-2: hoisted onto a shared `runProposerFaultScenario` helper as thin unrolled its (stays `.parallel`, plain string titles, no `it.each`). ## Verification `yarn build`, `yarn format end-to-end`, `yarn lint end-to-end` all pass. Ran locally (green): `equivocation_offenses` (both its ×2), `slash_veto_demo`, `sentinel_status_slash` (3), `attested_invalid_proposal` (2), `broadcasted_invalid_checkpoint_proposal_slash` (3), `broadcasted_invalid_block_proposal_slash`, `data_withholding_slash`, `validators_sentinel` (4), `multiple_validators_sentinel`. `inactivity_slash` and `inactivity_slash_with_consecutive_epochs` cannot run in this local env — the `InactivityTest` fixture derives 0 blocks/checkpoint at the local `aztec=8` (default 3s blocks); confirmed the **unmodified base files fail identically**, and CI's `aztec=16` derivation is fine. Relying on CI for those files. --- .test_patterns.yml | 2 +- .../end-to-end/src/multi-node/README.md | 2 +- ...attested_invalid_proposal.parallel.test.ts | 23 +- ...asted_invalid_block_proposal_slash.test.ts | 18 +- ...checkpoint_proposal_slash.parallel.test.ts | 70 +++-- .../slashing/data_withholding_slash.test.ts | 23 +- .../slashing/duplicate_attestation.test.ts | 197 -------------- .../slashing/duplicate_proposal.test.ts | 178 ------------- .../slashing/equivocation_offenses.test.ts | 247 ++++++++++++++++++ .../slashing/inactivity_slash.test.ts | 17 +- ...vity_slash_with_consecutive_epochs.test.ts | 11 +- ...tiple_validators_sentinel.parallel.test.ts | 16 +- .../sentinel_status_slash.parallel.test.ts | 109 ++++---- .../src/multi-node/slashing/setup.ts | 38 ++- .../slashing/slash_veto_demo.test.ts | 64 ++--- .../validators_sentinel.parallel.test.ts | 15 +- 16 files changed, 441 insertions(+), 589 deletions(-) delete mode 100644 yarn-project/end-to-end/src/multi-node/slashing/duplicate_attestation.test.ts delete mode 100644 yarn-project/end-to-end/src/multi-node/slashing/duplicate_proposal.test.ts create mode 100644 yarn-project/end-to-end/src/multi-node/slashing/equivocation_offenses.test.ts diff --git a/.test_patterns.yml b/.test_patterns.yml index d98d911ae988..637f1c35533c 100644 --- a/.test_patterns.yml +++ b/.test_patterns.yml @@ -418,7 +418,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/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/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), From 78594d78b07a0e8008ed9db12f456d608ada4b23 Mon Sep 17 00:00:00 2001 From: Santiago Palladino Date: Sun, 5 Jul 2026 15:07:13 -0300 Subject: [PATCH 14/17] test(e2e): dedupe multi-node block-production and governance test setups (#24498) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-2 e2e consolidation, PR 7 of 9. Pure dedup/refactor of multi-node test setups — no timing or config values change; every asserted behavior is preserved. ## What changed - **block-production/blob_promotion + recovery/pipeline_prune → shared `setupBlockProductionWithProver`.** Both files reimplemented the same 4-validator + prover + `WIDE_SLOT_TIMING` + `mockGossipSubNetworkLatency: 500` cluster locally (`setupBlobPromotion` / `setupTest`). Those local helpers are deleted and both now call the shared helper, which gained four opt-in options: `mockGossipSubNetworkLatency`, `maxTxsPerCheckpoint`, `clearInheritedCoinbase` (per-attester coinbase), and `disableCheckpointPromotionOnFirstNode`. `buildValidatorCluster` now accepts a per-index `nodeOpts` function so node 0 can be configured distinctly. **The resolved setup options and per-node config are identical to the originals** (same keys/values; existing callers `cross_chain_messages`/`deploy_and_call_ordering`/`proposed_chain` are unaffected because both new booleans default to false). One benign side effect: pipeline_prune now also installs `watchNodeSequencerEvents` via the helper — instrumentation only, no assertions consume it. (recovery → block-production cross-directory import, as permitted by the plan.) - **governance mechanics dedup.** Extracted `createGovernanceTestDriver` + `driveGovernanceRound` into `governance/setup.ts`, wrapping the `govInfo` / round-boundary warp / quorum wait / submit-winner / vote-through-delays mechanics duplicated by `add_rollup` and `upgrade_governance_proposer`. Both files adopt it; their scenario-specific parts (payload construction, node signaling, node migration + bridging in add_rollup, pre/post-execute assertions) stay in place. Each caller passes its own quorum timeout (`upgrade`: `quorumSize * aztecSlotDuration * 3`; `add_rollup`: `600`) so no timing value changes. The vote-success assertion now lives in the driver and covers both callers. ## Dropped assertions None. The `upgrade_governance_proposer` vote-success assertion is preserved (moved into the driver's `voteToExecutable`, where it now also covers `add_rollup`). ## Local test runs (all pass) - `upgrade_governance_proposer` — 1 passed (116s) - `block-production/blob_promotion` — 1 passed (293s) - `recovery/pipeline_prune` — 1 passed (367s) - `governance/add_rollup` — 1 passed (327s locally); also green in CI. --- .../block-production/blob_promotion.test.ts | 72 ++------ .../src/multi-node/block-production/setup.ts | 41 ++++- .../multi-node/governance/add_rollup.test.ts | 120 ++------------ .../src/multi-node/governance/setup.ts | 155 ++++++++++++++++++ .../upgrade_governance_proposer.test.ts | 146 +++-------------- .../recovery/pipeline_prune.test.ts | 95 +++-------- 6 files changed, 253 insertions(+), 376 deletions(-) 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/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/recovery/pipeline_prune.test.ts b/yarn-project/end-to-end/src/multi-node/recovery/pipeline_prune.test.ts index 296aa8e06537..9ed928bbdddd 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,22 @@ -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 { 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 +34,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 +46,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); From a5b3c78cb48ba2ecaaa8447f8d734ce2165c26a5 Mon Sep 17 00:00:00 2001 From: Santiago Palladino Date: Sun, 5 Jul 2026 15:08:06 -0300 Subject: [PATCH 15/17] test(e2e): extract p2p gossip scenario skeleton and add category README (#24500) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR 8 of 9 of the e2e round-2 consolidation. Scope: `end-to-end/src/p2p/**` only. Dedup + docs; no timing/config value changes. ## DATA_DIR centralization - Every p2p file previously repeated `DATA_DIR = fs.mkdtempSync(...)` at module scope plus a per-node `fs.rmSync` cleanup loop in `afterEach`. That boilerplate now lives on `P2PNetworkTest`: `setup()` creates one root temp dir (`this.dataDir`), `dataDirFor(label)` hands out a nested per-role path (multi-node `createNodes` appends `-`; single-node factories use it verbatim), and `teardown()` removes the whole tree in one recursive delete. - Adopted in all files (the 6 test files + `reqresp/utils.ts`); deleted the per-file `fs`/`os`/`path` imports, `mkdtempSync`, and cleanup loops. This also fixes a small pre-existing leak: the old `mkdtempSync` root dir was never removed (node data went into sibling `-N` dirs), whereas nodes now live under the root that teardown deletes. ## Gossip scenario skeleton (`shared.ts`) - `runGossipScenario(opts): Promise` — the shared bootstrap→createNodes→mesh→account→(submit)→verify flow. Varying parts are options/callbacks: `numValidators`, `bootNodePort`, `txsPerNode` (0 skips submission), `submitSequentially`, `mesh` (topic/peer-count overrides), `checkpointSource` (`'first-tx'` | `'first-published'`), `beforeCreateNodes`, `createExtraNodes`, `beforeSubmit`, `afterVerify`. Returns the validator nodes for teardown tracking. - Extracted the repeated building blocks it uses: `verifyAttestationSigners(t, nodes, checkpoint)` (recovers signers, asserts each ∈ validator set, returns signers), `getPublishedCheckpointForTx(node, txHash)`, `waitForFirstPublishedCheckpoint(t, nodes)`, `waitForNodesToSync(t, nodes)`, and `maybeCheckQosAlerts(logger)` (the `CHECK_ALERTS` Grafana block that was copy-pasted in 4 files). ## Merge: `gossip_network_no_cheat.test.ts` → `gossip_network.test.ts` The two files shared the skeleton but genuinely differ in network bootstrap (cheat MultiAdder registration vs. on-chain `addL1Validator` CLI flow) and config, so they became two `describe`s in one file, each with its own `beforeEach` — the mesh is not re-spun per `it`. - `describe('cheat-registered validators')` — was `gossip_network.test.ts`. Effective node config unchanged: 4 validators, `startProverNode:false`, `SHORTENED_BLOCK_TIME_CONFIG_NO_PRUNES` + `aztecSlotDuration:24, aztecEpochDuration:4, blockDurationMs:10000, slashingRoundSizeInEpochs:2, slashingQuorum:5, listenAddress:127.0.0.1, inboxLag:2`; plus a p2p-only prover node and a re-execution monitoring node; proven-block assertion preserved. - `describe('on-chain-registered validators (no cheats)')` — was `gossip_network_no_cheat.test.ts`. Effective node config unchanged: 4 validators, `SHORTENED_BLOCK_TIME_CONFIG_NO_PRUNES` + `aztecSlotDuration:24, blockDurationMs:10000, minTxsPerBlock:0, listenAddress:127.0.0.1, inboxLag:2`; full-mesh wait on the proposal/checkpoint topics; all on-chain registration assertions preserved. `BOOT_NODE_UDP_PORT` default stays 4500 (now shared with the cheat describe, which honored the `BOOT_NODE_UDP_PORT` env override — the default is unchanged). Both describes run sequentially in one CI container reusing port 4500 (teardown fully frees ports before the next `beforeEach`). ## `fee_asset_price_oracle_gossip.test.ts` Kept as its own file, now on the shared skeleton with `txsPerNode: 0` (it submits no txs — empty blocks carry the price) and `checkpointSource: 'first-published'`. Effective node config unchanged: 4 validators, `startProverNode:false`, `SHORTENED_BLOCK_TIME_CONFIG_NO_PRUNES` + `aztecSlotDuration:12, aztecEpochDuration:4, slashingRoundSizeInEpochs:2, slashingQuorum:5, listenAddress, inboxLag:2`, plus a prover node (`minTxsPerBlock:0`). Both oracle-convergence rounds and final assertions preserved. ## Other files - `preferred_gossip_network.test.ts` adopted `verifyAttestationSigners` + `maybeCheckQosAlerts` (its signer-check block was identical, keyed off the `validators` array), plus the DATA_DIR change. Node config and the `expect(signers.length).toEqual(validators.length)` assertion unchanged. - `late_prover_tx_collection.test.ts`, `rediscovery.test.ts`, `reqresp/reqresp.test.ts` + `reqresp/utils.ts`: DATA_DIR change only; node config unchanged. - Added `p2p/README.md` (the missing category README) matching the `multi-node`/`single-node` structure: what belongs here (real-libp2p subjects — discovery, req/resp, mesh, peer auth, transport), what does not (consensus/slashing/sentinel → `multi-node`), the `P2PNetworkTest` entrypoint, the shared skeleton/helpers, and the post-merge file inventory. ## Behavior notes - No dropped assertions. - Two benign, non-asserting implementation changes, documented for reviewers: (1) in the no-cheat describe `setupAccount()` now runs before the first-checkpoint wait instead of after — both still precede tx submission, and `setupAccount()` only registers the account in the PXE (no on-chain effect), so the "checkpoint published before submit" invariant is preserved; (2) the merged submit path uses `waitForTxs(...)` for both describes rather than the no-cheat file's per-tx `Promise.all(waitForTx)` — same asserted property (all txs mined). ## Local runs (one file at a time; real-libp2p tests bind fixed ports) All green: | File / describe | wall | |---|---| | gossip_network — cheat-registered | 121s | | gossip_network — on-chain (no cheats) | 172s | | fee_asset_price_oracle_gossip | 120s | | rediscovery | 69s | | late_prover_tx_collection | 47s | | reqresp/reqresp | 121s | | preferred_gossip_network | 141s | CI selection is glob/regex-based (`src/p2p/**/*.test.ts`, `src/p2p/*.test.ts`, `.test_patterns.yml`'s `src/p2p/.*`), so deleting the merged-away file needs no CI-config edits. --- yarn-project/end-to-end/src/p2p/README.md | 78 +++ .../p2p/fee_asset_price_oracle_gossip.test.ts | 264 ++++------ .../end-to-end/src/p2p/gossip_network.test.ts | 475 ++++++++++-------- .../src/p2p/gossip_network_no_cheat.test.ts | 294 ----------- .../src/p2p/late_prover_tx_collection.test.ts | 13 +- .../end-to-end/src/p2p/p2p_network.ts | 24 + .../src/p2p/preferred_gossip_network.test.ts | 76 +-- .../end-to-end/src/p2p/rediscovery.test.ts | 13 +- .../src/p2p/reqresp/reqresp.test.ts | 8 +- .../end-to-end/src/p2p/reqresp/utils.ts | 17 +- yarn-project/end-to-end/src/p2p/shared.ts | 231 +++++++++ 11 files changed, 725 insertions(+), 768 deletions(-) create mode 100644 yarn-project/end-to-end/src/p2p/README.md delete mode 100644 yarn-project/end-to-end/src/p2p/gossip_network_no_cheat.test.ts 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; +} From 181ceed25d48affdbeb8dbb2c0426724b6333606 Mon Sep 17 00:00:00 2001 From: Santiago Palladino Date: Sun, 5 Jul 2026 15:08:35 -0300 Subject: [PATCH 16/17] test(e2e): split node-killing HA test into its own composed suite file (#24503) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-2 e2e consolidation, PR 9/9 — owns `composed/**`. ## Clock-skew suite stays on the composed HA Postgres cluster The four `Clock Skew and Timezone Safety` its in `composed/ha/e2e_ha_full.parallel.test.ts` exercise `PostgresSlashingProtectionDatabase` directly against the running 5-node HA cluster's **real dockerized PostgreSQL** slashing-protection DB: - TZ-independent duty timestamps (absolute-time storage) - `cleanupOldDuties` keeps recent duties when the node clock is 2h ahead - `cleanupOldDuties` deletes old duties by DB time even when the node clock is 1h behind - `cleanupOwnStuckDuties` keeps recent stuck duties when the node clock is 3h ahead The property under test is that these DB-clock semantics (`CURRENT_TIMESTAMP`, `timestamptz`) are immune to node clock/timezone skew — the node and its database must be able to genuinely diverge in clock and timezone environment for the assertion to mean anything. That only holds against a real, separate Postgres process. An earlier revision moved these into an in-process PGlite file; since PGlite runs inside the Node process, the DB and node can never actually diverge, so that variant is dropped. The tests keep riding the composed cluster's docker Postgres via the shared `HaFullTestContext` (`t.mainPool` / `t.dateProvider`), exactly as before; `dateProvider` simulates the skewed node clock while the DB uses its own clock. The `@electric-sql/pglite` dependency added for the PGlite variant is removed from end-to-end (other workspaces that use pglite for unit tests are unaffected). ## Order-dependent HA test → own file The `should distribute work across multiple HA nodes` test was annotated `must run last` because it permanently kills every node. Extracted verbatim into `composed/ha/e2e_ha_distribute_work.test.ts`, which shares the cluster setup with the remaining suite via a new non-test `ha_full_setup.ts` module (the ~380-line `beforeAll`/`afterAll`/helpers moved verbatim into an `HaFullTestContext` class). Result: - The killer test gets its own cluster; the ordering contract is gone (no hidden reliance on definition order). - It has a single `it`, so it is a plain `.test.ts` (not `.parallel`) and runs as one whole-file CI container. - The remaining `e2e_ha_full.parallel.test.ts` keeps its 3 non-destructive its (block production, governance voting, keystore reload) plus the clock-skew describe, all order-independent. - Zero CI runtime change: setup was moved, not rewritten, so behavior is unchanged. No assertions dropped. Added a `.test_patterns.yml` flaky entry for the new file mirroring the existing `e2e_ha_full` one (owner unchanged). ## Stranded composed tests — left in place, still excluded `composed/e2e_persistence.test.ts` and `composed/integration_proof_verification.test.ts` are excluded from every CI list and have run nowhere since April 2025. I moved each into a category dir, ran it, and reverted the move because both are broken on the current branch — root causes outside this diff's editable scope: | File | Disposition | Why | |---|---|---| | `integration_proof_verification.test.ts` | left in `composed/`, excluded, documented | Committed `fixtures/dumps/epoch_proof_result.json` is stale (last regenerated Feb 2026; circuits/VK changed since). bb and the on-chain HonkVerifier both reject the proof (`Failed to verify RootRollupArtifact proof!`). Needs the fixture regenerated; better relocated to the bb-prover circuit tests. | | `e2e_persistence.test.ts` | left in `composed/`, excluded, documented | `beforeAll` no longer completes: the single-node sequencer stalls in checkpoint proposal (`waitForAttestationsAndEnqueueSubmissionAsync`) and the 600s hook times out. The root cause is in shared setup/sequencer, not this file. | Each file's header comment and the `bootstrap.sh` exclusion now document the real reason (previously "excluded for unknown reasons"). **Decision items for follow-up:** regenerate the epoch-proof fixture and relocate `integration_proof_verification` to bb-prover; fix the single-node checkpoint stall and refile `e2e_persistence` under `single-node/`. ## Verified locally - `yarn build` (full), `yarn format end-to-end`, `yarn lint end-to-end` — all clean. - Confirmed both stranded tests fail on this branch (evidence above), which is why they stay excluded. - The compose/ha suites need docker (Postgres/Web3Signer) and were not run locally; the `e2e_ha_full` / `e2e_ha_distribute_work` split and the clock-skew describe rely on CI. Setup is moved verbatim and each `.parallel` it is already isolated per container, so runtime behavior is unchanged. --- .test_patterns.yml | 4 + yarn-project/end-to-end/bootstrap.sh | 4 + .../src/composed/e2e_persistence.test.ts | 12 +- .../ha/e2e_ha_distribute_work.test.ts | 210 ++++++ .../composed/ha/e2e_ha_full.parallel.test.ts | 636 ++---------------- .../src/composed/ha/ha_full_setup.ts | 463 +++++++++++++ .../integration_proof_verification.test.ts | 9 +- 7 files changed, 740 insertions(+), 598 deletions(-) create mode 100644 yarn-project/end-to-end/src/composed/ha/e2e_ha_distribute_work.test.ts create mode 100644 yarn-project/end-to-end/src/composed/ha/ha_full_setup.ts diff --git a/.test_patterns.yml b/.test_patterns.yml index 637f1c35533c..81ca79396330 100644 --- a/.test_patterns.yml +++ b/.test_patterns.yml @@ -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" diff --git a/yarn-project/end-to-end/bootstrap.sh b/yarn-project/end-to-end/bootstrap.sh index 973fe6b83cf1..8ea2bb11b9c7 100755 --- a/yarn-project/end-to-end/bootstrap.sh +++ b/yarn-project/end-to-end/bootstrap.sh @@ -119,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/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; From 7e7d8d0148a78f384eefff84b44970aa31daba57 Mon Sep 17 00:00:00 2001 From: Santiago Palladino Date: Sun, 5 Jul 2026 15:13:55 -0300 Subject: [PATCH 17/17] feat(sequencer): make sequencer pausable (#24475) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Supersedes #24449 and #24450, reimplementing both on a simpler design. Single PR since the lifecycle work is only exercised by the e2e primitive. ## Context Several multi-node e2e tests spend minutes of wall-clock waiting for the L1 clock to roll while live sequencers sit idle. Warping the shared clock under a running sequencer interrupts whatever iteration is mid-build, producing the reorg / "Sequencer was interrupted" failures behind earlier revert attempts. Pausing sequencers around the warp requires a lifecycle that can actually stop and resume, which it couldn't: a second `start()` orphaned the previous poll loop (two loops racing), and a restarted sequencer never cleared the publishers' `interrupted` flag, so it would build and propose blocks but silently never publish to L1. ## Approach **Idempotent, restartable lifecycle.** `start()`/`stop()` are idempotent across `SequencerClient`, `Sequencer`, and `PublisherManager`, and `start()` while `STOPPING` is refused so a mid-stop start cannot orphan a fresh loop. `PublisherManager.start()` clears the interrupted flag via the previously-unused `restart()` methods, and `SequencerClient.start()` starts the publisher manager before the sequencer loop so publishers are un-interrupted before the first post-restart publish. The funding loop is created once in the constructor and restarted across cycles, and a start that failed to load publisher state can be retried. **`pause()` for restarts.** Restartability is funneled through a dedicated `Sequencer.pause()` / `SequencerClient.pause()`: it 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. It deliberately does **not** stop inner services: the validator/HA signer and publishers keep running, so the slashing-protection store stays open and a later `start()` cleanly resumes. Draining happens without entering `STOPPING`, since in that state the iteration's own `setState` calls throw `SequencerInterruptedError`, which would fail the very iteration being drained. This replaces the wait-for-IDLE heuristic from #24450, which raced: sequencers reach IDLE at different times, and any of them could re-enter `work()` before the stop landed, flaking any test that asserts no sequencer failure events. **`stop()` stays a full teardown.** `stop()` keeps the fast interrupting shutdown used for production teardown, and stopping the validator client closes its slashing-protection database (LMDB single-node / Postgres HA), as it did before this line of work. Because restarts now go through `pause()` — which never closes the store — `stop()` no longer needs to leave the store open, so there is no DB-ownership bookkeeping to distinguish "owned" from "shared" databases. **Single shared request tracker.** The checkpoint proposal jobs' backgrounded L1 submissions and the sequencer's own fire-and-forget fallback sends (vote-and-prune when we cannot build, escape-hatch votes) are tracked in one `RequestsTracker` owned by the sequencer and handed to each job it creates. `stop()` interrupts and drains that single tracker in one place; `pause()` awaits it untouched. A sender sleeping until its target slot therefore cannot survive a `stop()` and publish a stale-slot tx after a restart clears the pooled publishers' interrupted flag: interrupting the wrapper publisher is permanent, since wrappers are never restarted. **e2e primitive + pilot.** `warpWithSequencersPaused(nodes, cheatCodes, target, opts)` on `SingleNodeTestContext` (inherited by `MultiNodeTestContext`) pauses every sequencer, runs the warp with nobody building, and resumes them (`restart: false` leaves them paused). Archivers, provers, and the chain monitor keep running, so clock-driven effects such as an orphan-block prune still fire. Applied to `pipeline_prune` to collapse its ~2-minute dead wait for the orphan slot's prune deadline: warp two L1 slots into the slot after the orphaned one, and resume the sequencers only once the prune is confirmed. `TX_COUNT` is unchanged, so `assertMultipleBlocksPerSlot` and `assertProposerPipelining` still hold. Lifecycle calls are assumed to be serialized by the caller (all current callers are); this does not add a mutex for truly concurrent start/stop/pause. The `pipeline_prune` speedup is validated by this PR's CI run — the multi-node suite can't be run locally. --- .../recovery/pipeline_prune.test.ts | 30 +++- .../single-node/single_node_test_context.ts | 47 ++++++ .../ethereum/src/publisher_manager.test.ts | 120 +++++++++++++- .../ethereum/src/publisher_manager.ts | 50 ++++-- .../src/promise/running-promise.test.ts | 38 +++++ .../src/client/sequencer-client.ts | 22 ++- .../sequencer/checkpoint_proposal_job.test.ts | 7 + .../checkpoint_proposal_job.timing.test.ts | 7 + .../src/sequencer/checkpoint_proposal_job.ts | 20 ++- .../src/sequencer/requests_tracker.test.ts | 97 +++++++++++ .../src/sequencer/requests_tracker.ts | 43 +++++ .../src/sequencer/sequencer.test.ts | 152 +++++++++++++++++- .../src/sequencer/sequencer.ts | 84 +++++++++- 13 files changed, 676 insertions(+), 41 deletions(-) create mode 100644 yarn-project/sequencer-client/src/sequencer/requests_tracker.test.ts create mode 100644 yarn-project/sequencer-client/src/sequencer/requests_tracker.ts 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 9ed928bbdddd..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 @@ -6,6 +6,7 @@ import { BlockNumber, CheckpointNumber, SlotNumber } from '@aztec/foundation/bra import { executeTimeout } from '@aztec/foundation/timer'; import type { SequencerEvents } from '@aztec/sequencer-client'; import { L2BlockSourceEvents } from '@aztec/stdlib/block'; +import { getTimestampForSlot } from '@aztec/stdlib/epoch-helpers'; import { proveAndSendTxs } from '../../test-wallet/utils.js'; import { @@ -102,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); @@ -119,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/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/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/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/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 6214cf57e6ba..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(); @@ -1724,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 04cb28f2bbf4..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 { @@ -707,6 +771,7 @@ export class Sequencer extends (EventEmitter as new () => 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()); } /**