Skip to content

Commit 9c5bc9f

Browse files
committed
chore(e2e): multiple blocks per slot e2e tests
1 parent da7c717 commit 9c5bc9f

File tree

11 files changed

+292
-42
lines changed

11 files changed

+292
-42
lines changed

yarn-project/aztec.js/src/wallet/wallet.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import type { AztecAddress } from '@aztec/stdlib/aztec-address';
1515
import { type ContractInstanceWithAddress, ContractInstanceWithAddressSchema } from '@aztec/stdlib/contract';
1616
import { Gas } from '@aztec/stdlib/gas';
1717
import { AbiDecodedSchema, type ApiSchemaFor, optional, schemas, zodFor } from '@aztec/stdlib/schemas';
18+
import type { ExecutionPayload, InTx } from '@aztec/stdlib/tx';
1819
import {
1920
Capsule,
2021
HashedValues,
@@ -25,7 +26,6 @@ import {
2526
UtilitySimulationResult,
2627
inTxSchema,
2728
} from '@aztec/stdlib/tx';
28-
import type { ExecutionPayload, InTx } from '@aztec/stdlib/tx';
2929

3030
import { z } from 'zod';
3131

Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
import type { Archiver } from '@aztec/archiver';
2+
import type { AztecNodeService } from '@aztec/aztec-node';
3+
import { AztecAddress, EthAddress } from '@aztec/aztec.js/addresses';
4+
import { NO_WAIT } from '@aztec/aztec.js/contracts';
5+
import { Fr } from '@aztec/aztec.js/fields';
6+
import type { Logger } from '@aztec/aztec.js/log';
7+
import { waitForTx } from '@aztec/aztec.js/node';
8+
import { RollupContract } from '@aztec/ethereum/contracts';
9+
import type { Operator } from '@aztec/ethereum/deploy-aztec-l1-contracts';
10+
import { asyncMap } from '@aztec/foundation/async-map';
11+
import { CheckpointNumber } from '@aztec/foundation/branded-types';
12+
import { times, timesAsync } from '@aztec/foundation/collection';
13+
import { SecretValue } from '@aztec/foundation/config';
14+
import { bufferToHex } from '@aztec/foundation/string';
15+
import { executeTimeout } from '@aztec/foundation/timer';
16+
import { TestContract } from '@aztec/noir-test-contracts.js/Test';
17+
import { TxStatus } from '@aztec/stdlib/tx';
18+
import { TestWallet, proveInteraction } from '@aztec/test-wallet/server';
19+
20+
import { jest } from '@jest/globals';
21+
import { privateKeyToAccount } from 'viem/accounts';
22+
23+
import { type EndToEndContext, getPrivateKeyFromIndex } from '../fixtures/utils.js';
24+
import { EpochsTestContext } from './epochs_test.js';
25+
26+
jest.setTimeout(1000 * 60 * 15);
27+
28+
const NODE_COUNT = 4;
29+
const EXPECTED_BLOCKS_PER_CHECKPOINT = 3;
30+
31+
// Send enough transactions to trigger multiple blocks within a checkpoint assuming 2 txs per block.
32+
// If we start including txs at the 2nd block of a checkpoint, we can ensure a 3-block checkpoint
33+
// if we produce 10 txs:
34+
// - Checkpoint 1: Block 1 (0 txs), Block 2 (2 txs), Block 3 (2 txs)
35+
// - Checkpoint 2: Block 1 (2 txs), Block 2 (2 txs), Block 3 (2 txs)
36+
const TX_COUNT = 10;
37+
38+
/**
39+
* E2E tests for Multiple Blocks Per Slot (MBPS) functionality.
40+
* Tests that the system correctly builds multiple blocks within a single slot/checkpoint.
41+
*/
42+
describe('e2e_epochs/epochs_mbps', () => {
43+
let context: EndToEndContext;
44+
let logger: Logger;
45+
let rollup: RollupContract;
46+
let archiver: Archiver;
47+
48+
let test: EpochsTestContext;
49+
let validators: (Operator & { privateKey: `0x${string}` })[];
50+
let nodes: AztecNodeService[];
51+
let contract: TestContract;
52+
let wallet: TestWallet;
53+
let from: AztecAddress;
54+
55+
/**
56+
* Creates validators and sets up the test context with MBPS configuration.
57+
*/
58+
async function setupTest(opts: {
59+
syncChainTip: 'proposed' | 'checkpointed';
60+
minTxsPerBlock?: number;
61+
maxTxsPerBlock?: number;
62+
buildCheckpointIfEmpty?: boolean;
63+
}) {
64+
const { syncChainTip = 'checkpointed', ...setupOpts } = opts;
65+
66+
validators = times(NODE_COUNT, i => {
67+
const privateKey = bufferToHex(getPrivateKeyFromIndex(i + 3)!);
68+
const attester = EthAddress.fromString(privateKeyToAccount(privateKey).address);
69+
return { attester, withdrawer: attester, privateKey, bn254SecretKey: new SecretValue(Fr.random().toBigInt()) };
70+
});
71+
72+
// Setup context with the given set of validators and MBPS configuration.
73+
// Timing calculation for 3 blocks per checkpoint with 8s sub-slots:
74+
// - initializationOffset ≈ 0.5s (test mode with ethereumSlotDuration < 8)
75+
// - 3 blocks × 8s = 24s
76+
// - checkpointFinalization = 0.5s (assemble) + 0 (p2p in test) + 2s (L1 publish) = 2.5s
77+
// - finalBlockDuration = 8s
78+
// - Total: 0.5 + 24 + 8 + 2.5 = 35s → use 36s for margin
79+
test = await EpochsTestContext.setup({
80+
numberOfAccounts: 1,
81+
initialValidators: validators,
82+
mockGossipSubNetwork: true,
83+
disableAnvilTestWatcher: true,
84+
aztecProofSubmissionEpochs: 1024,
85+
startProverNode: false,
86+
enforceTimeTable: true,
87+
// L1 slot duration - using < 8 to enable test mode optimizations
88+
ethereumSlotDuration: 4,
89+
// L2 slot duration - should fit 3 blocks (8s each) + overhead
90+
aztecSlotDuration: 36,
91+
// Block duration of 8s as specified
92+
blockDurationMs: 8000,
93+
// L1 publishing time
94+
l1PublishingTime: 2,
95+
// Reduce attestation propagation time for tests
96+
attestationPropagationTime: 0.5,
97+
// Committee size of 3
98+
aztecTargetCommitteeSize: 3,
99+
// Additional options (minTxsPerBlock, maxTxsPerBlock, etc.)
100+
...setupOpts,
101+
// PXE options for chain tip syncing
102+
pxeOpts: { syncChainTip },
103+
});
104+
105+
({ context, logger, rollup } = test);
106+
wallet = context.wallet;
107+
archiver = (context.aztecNode as AztecNodeService).getBlockSource() as Archiver;
108+
from = context.accounts[0];
109+
110+
// Halt block building in initial aztec node, which was not set up as a validator.
111+
logger.warn(`Stopping sequencer in initial aztec node.`);
112+
await context.sequencer!.stop();
113+
114+
// Start the validator nodes (but don't start sequencers yet)
115+
logger.warn(`Initial setup complete. Starting ${NODE_COUNT} validator nodes.`);
116+
nodes = await asyncMap(validators, ({ privateKey }) =>
117+
test.createValidatorNode([privateKey], { dontStartSequencer: true }),
118+
);
119+
logger.warn(`Started ${NODE_COUNT} validator nodes.`, { validators: validators.map(v => v.attester.toString()) });
120+
121+
// Register contract for sending txs.
122+
contract = await test.registerTestContract(wallet);
123+
logger.warn(`Test setup completed.`, { validators: validators.map(v => v.attester.toString()) });
124+
}
125+
126+
/** Retrieves all checkpoints from the archiver and checks that one of them at least has the target block count */
127+
async function assertMultipleBlocksPerSlot(targetBlockCount: number, logger: Logger) {
128+
const checkpoints = await archiver.getCheckpoints(CheckpointNumber(1), 50);
129+
logger.warn(`Retrieved ${checkpoints.length} checkpoints from archiver`, {
130+
checkpoints: checkpoints.map(pc => pc.checkpoint.getStats()),
131+
});
132+
133+
let expectedBlockNumber = checkpoints[0].checkpoint.blocks[0].number;
134+
let targetFound = false;
135+
136+
for (const checkpoint of checkpoints) {
137+
const blockCount = checkpoint.checkpoint.blocks.length;
138+
targetFound = targetFound || blockCount >= targetBlockCount;
139+
logger.warn(`Checkpoint ${checkpoint.checkpoint.number} has ${blockCount} blocks`, {
140+
checkpoint: checkpoint.checkpoint.getStats(),
141+
});
142+
143+
for (let i = 0; i < blockCount; i++) {
144+
const block = checkpoint.checkpoint.blocks[i];
145+
expect(block.indexWithinCheckpoint).toBe(i);
146+
expect(block.checkpointNumber).toBe(checkpoint.checkpoint.number);
147+
expect(block.number).toBe(expectedBlockNumber);
148+
expectedBlockNumber++;
149+
}
150+
}
151+
152+
expect(targetFound).toBe(true);
153+
}
154+
155+
afterEach(async () => {
156+
jest.restoreAllMocks();
157+
await test?.teardown();
158+
});
159+
160+
it('builds multiple blocks per slot with transactions anchored to checkpointed block', async () => {
161+
await setupTest({ syncChainTip: 'checkpointed', minTxsPerBlock: 1, maxTxsPerBlock: 2 });
162+
163+
// Record the current checkpoint number before starting sequencers
164+
const initialCheckpointNumber = await rollup.getCheckpointNumber();
165+
logger.warn(`Initial checkpoint number: ${initialCheckpointNumber}`);
166+
167+
// Pre-prove and send transactions
168+
const txs = await timesAsync(TX_COUNT, i =>
169+
proveInteraction(context.wallet, contract.methods.emit_nullifier(new Fr(i + 1)), { from }),
170+
);
171+
const txHashes = await Promise.all(txs.map(tx => tx.send({ wait: NO_WAIT })));
172+
logger.warn(`Sent ${txHashes.length} transactions`, { txs: txHashes });
173+
174+
// Start the sequencers
175+
await Promise.all(nodes.map(n => n.getSequencer()!.start()));
176+
logger.warn(`Started all sequencers`);
177+
178+
// Wait until all txs are mined
179+
const timeout = test.L2_SLOT_DURATION_IN_S * 5;
180+
await executeTimeout(
181+
() => Promise.all(txHashes.map(txHash => waitForTx(context.aztecNode, txHash, { timeout }))),
182+
timeout * 1000,
183+
);
184+
logger.warn(`All txs have been mined`);
185+
186+
await assertMultipleBlocksPerSlot(EXPECTED_BLOCKS_PER_CHECKPOINT, logger);
187+
});
188+
189+
it('builds multiple blocks per slot with transactions anchored to proposed blocks', async () => {
190+
await setupTest({ syncChainTip: 'proposed', minTxsPerBlock: 1, maxTxsPerBlock: 1 });
191+
192+
// Record the current checkpoint number before starting sequencers
193+
const initialCheckpointNumber = await rollup.getCheckpointNumber();
194+
logger.warn(`Initial checkpoint number: ${initialCheckpointNumber}`);
195+
196+
// Start the sequencers
197+
await Promise.all(nodes.map(n => n.getSequencer()!.start()));
198+
logger.warn(`Started all sequencers`);
199+
200+
// Now send the txs and wait for them to be mined one at a time
201+
// If the pxe syncs correctly, every tx should be anchored to the block in which the previous one was mined
202+
const txReceipts = [];
203+
let expectedAnchorBlockNumber = undefined;
204+
205+
while (txReceipts.length < TX_COUNT / 2) {
206+
logger.warn(`Sending transaction ${txReceipts.length}`);
207+
const nullifier = new Fr(txReceipts.length + 1);
208+
const tx = await proveInteraction(context.wallet, contract.methods.emit_nullifier(nullifier), { from });
209+
const txAnchorBlockNumber = tx.data.constants.anchorBlockHeader.globalVariables.blockNumber;
210+
expect(txAnchorBlockNumber).toBeGreaterThanOrEqual(expectedAnchorBlockNumber ?? txAnchorBlockNumber);
211+
212+
const txReceipt = await tx.send({ wait: { waitForStatus: TxStatus.PROPOSED } });
213+
txReceipts.push(txReceipt);
214+
expectedAnchorBlockNumber = txReceipt.blockNumber;
215+
logger.warn(`Transaction ${txReceipts.length} mined on block ${txReceipt.blockNumber}`, { txReceipt });
216+
217+
await wallet.sync();
218+
expect((await wallet.getSyncedBlockHeader()).getBlockNumber()).toBeGreaterThanOrEqual(txReceipt.blockNumber!);
219+
}
220+
logger.warn(`All txs have been mined`);
221+
222+
// We are fine with at least 2 blocks per checkpoint, since we may lose one sub-slot if assembling a tx is slow
223+
await assertMultipleBlocksPerSlot(2, logger);
224+
});
225+
});

yarn-project/end-to-end/src/e2e_epochs/epochs_test.ts

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,11 @@ import { withLogNameSuffix } from '@aztec/foundation/log';
1818
import { retryUntil } from '@aztec/foundation/retry';
1919
import { sleep } from '@aztec/foundation/sleep';
2020
import { SpamContract } from '@aztec/noir-test-contracts.js/Spam';
21+
import { TestContract } from '@aztec/noir-test-contracts.js/Test';
2122
import { getMockPubSubP2PServiceFactory } from '@aztec/p2p/test-helpers';
2223
import { ProverNode, type ProverNodeConfig, ProverNodePublisher } from '@aztec/prover-node';
2324
import type { TestProverNode } from '@aztec/prover-node/test';
25+
import type { PXEConfig } from '@aztec/pxe/config';
2426
import {
2527
type SequencerClient,
2628
type SequencerEvents,
@@ -49,7 +51,7 @@ export const WORLD_STATE_BLOCK_CHECK_INTERVAL = 50;
4951
export const ARCHIVER_POLL_INTERVAL = 50;
5052
export const DEFAULT_L1_BLOCK_TIME = process.env.CI ? 12 : 8;
5153

52-
export type EpochsTestOpts = Partial<SetupOptions> & { numberOfAccounts?: number };
54+
export type EpochsTestOpts = Partial<SetupOptions> & { numberOfAccounts?: number; pxeOpts?: Partial<PXEConfig> };
5355

5456
export type TrackedSequencerEvent = {
5557
[K in keyof SequencerEvents]: Parameters<SequencerEvents[K]>[0] & {
@@ -147,8 +149,9 @@ export class EpochsTestContext {
147149
l1PublishingTime,
148150
...opts,
149151
},
150-
// Use checkpointed chain tip for PXE to avoid issues with blocks being dropped due to pruned anchor blocks.
151-
{ syncChainTip: 'checkpointed' },
152+
// Use checkpointed chain tip for PXE by default to avoid issues with blocks being dropped due to pruned anchor blocks.
153+
// Can be overridden via opts.pxeOpts.
154+
{ syncChainTip: 'checkpointed', ...opts.pxeOpts },
152155
);
153156

154157
this.context = context;
@@ -375,6 +378,19 @@ export class EpochsTestContext {
375378
return SpamContract.at(instance.address, wallet);
376379
}
377380

381+
/** Registers the TestContract on the given wallet. */
382+
public async registerTestContract(wallet: Wallet, salt = Fr.ZERO) {
383+
const instance = await getContractInstanceFromInstantiationParams(TestContract.artifact, {
384+
constructorArgs: [],
385+
constructorArtifact: undefined,
386+
salt,
387+
publicKeys: undefined,
388+
deployer: undefined,
389+
});
390+
await wallet.registerContract(instance, TestContract.artifact);
391+
return TestContract.at(instance.address, wallet);
392+
}
393+
378394
/** Creates an L1 client using a fresh account with funds from anvil, with a tx delayer already set up. */
379395
public async createL1Client() {
380396
const { client, delayer } = withDelayer(

yarn-project/end-to-end/src/fixtures/setup.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -385,7 +385,7 @@ export async function setup(
385385
const res = await startAnvil({
386386
l1BlockTime: opts.ethereumSlotDuration,
387387
accounts: opts.anvilAccounts,
388-
port: opts.anvilPort,
388+
port: opts.anvilPort ?? (process.env.ANVIL_PORT ? parseInt(process.env.ANVIL_PORT) : undefined),
389389
});
390390
anvil = res.anvil;
391391
config.l1RpcUrls = [res.rpcUrl];
Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,32 @@
11
import { randomBytes } from '@aztec/foundation/crypto/random';
22
import type { NoteDao, NotesFilter } from '@aztec/stdlib/note';
3+
import type { BlockHeader } from '@aztec/stdlib/tx';
34

5+
import type { BlockSynchronizer } from '../block_synchronizer/block_synchronizer.js';
46
import type { PXE } from '../pxe.js';
57
import type { ContractStore } from '../storage/contract_store/contract_store.js';
8+
import type { AnchorBlockStore } from '../storage/index.js';
69
import type { NoteStore } from '../storage/note_store/note_store.js';
710

811
/**
912
* Methods provided by this class might help debugging but must not be used in production.
1013
* No backwards compatibility or API stability should be expected. Use at your own risk.
1114
*/
1215
export class PXEDebugUtils {
13-
#pxe: PXE | undefined = undefined;
16+
#pxe!: PXE;
17+
#putJobInQueue!: <T>(job: (jobId: string) => Promise<T>) => Promise<T>;
1418

1519
constructor(
1620
private contractStore: ContractStore,
1721
private noteStore: NoteStore,
22+
private blockStateSynchronizer: BlockSynchronizer,
23+
private anchorBlockStore: AnchorBlockStore,
1824
) {}
1925

20-
/**
21-
* Not injected through constructor since they're are co-dependant.
22-
*/
23-
public setPXE(pxe: PXE) {
26+
/** Not injected through constructor since they're are co-dependant */
27+
public setPXE(pxe: PXE, putJobInQueue: <T>(job: (jobId: string) => Promise<T>) => Promise<T>) {
2428
this.#pxe = pxe;
29+
this.#putJobInQueue = putJobInQueue;
2530
}
2631

2732
/**
@@ -36,14 +41,23 @@ export class PXEDebugUtils {
3641
* @returns The requested notes.
3742
*/
3843
public async getNotes(filter: NotesFilter): Promise<NoteDao[]> {
39-
if (!this.#pxe) {
40-
throw new Error('Cannot getNotes because no PXE is set');
41-
}
42-
4344
// We need to manually trigger private state sync to have a guarantee that all the notes are available.
4445
const call = await this.contractStore.getFunctionCall('sync_state', [], filter.contractAddress);
4546
await this.#pxe.simulateUtility(call);
4647

4748
return this.noteStore.getNotes(filter, randomBytes(8).toString('hex'));
4849
}
50+
51+
/** Returns the block header up to which the PXE has synced. */
52+
public getSyncedBlockHeader(): Promise<BlockHeader> {
53+
return this.anchorBlockStore.getBlockHeader();
54+
}
55+
56+
/**
57+
* Triggers a sync of the PXE with the node.
58+
* Blocks until the sync is complete.
59+
*/
60+
public sync(): Promise<void> {
61+
return this.#putJobInQueue(() => this.blockStateSynchronizer.sync());
62+
}
4963
}

yarn-project/pxe/src/pxe.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -166,7 +166,7 @@ export class PXE {
166166
noteStore,
167167
]);
168168

169-
const debugUtils = new PXEDebugUtils(contractStore, noteStore);
169+
const debugUtils = new PXEDebugUtils(contractStore, noteStore, synchronizer, anchorBlockStore);
170170

171171
const jobQueue = new SerialQueue();
172172

@@ -193,7 +193,7 @@ export class PXE {
193193
debugUtils,
194194
);
195195

196-
debugUtils.setPXE(pxe);
196+
debugUtils.setPXE(pxe, pxe.#putInJobQueue.bind(pxe));
197197

198198
pxe.jobQueue.start();
199199

yarn-project/stdlib/src/interfaces/validator.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,8 +51,7 @@ export type ValidatorClientConfig = ValidatorHASignerConfig & {
5151
/** Whether to run in fisherman mode: validates all proposals and attestations but does not broadcast attestations or participate in consensus */
5252
fishermanMode?: boolean;
5353

54-
// TODO(palla/mbps): Change default to false once checkpoint validation is stable
55-
/** Skip checkpoint proposal validation and always attest (default: true) */
54+
/** Skip checkpoint proposal validation and always attest (default: false) */
5655
skipCheckpointProposalValidation?: boolean;
5756

5857
/** Skip pushing re-executed blocks to archiver (default: false) */

0 commit comments

Comments
 (0)