Skip to content

Commit 419ed31

Browse files
committed
Add e2e test
1 parent 5ecc113 commit 419ed31

File tree

6 files changed

+188
-14
lines changed

6 files changed

+188
-14
lines changed
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import type { AztecNodeService } from '@aztec/aztec-node';
2+
import { type Logger, retryUntil } from '@aztec/aztec.js';
3+
import { type ExtendedViemWalletClient, type Operator, RollupContract } from '@aztec/ethereum';
4+
import { asyncMap } from '@aztec/foundation/async-map';
5+
import { times } from '@aztec/foundation/collection';
6+
import { EthAddress } from '@aztec/foundation/eth-address';
7+
import { bufferToHex } from '@aztec/foundation/string';
8+
import { RollupAbi } from '@aztec/l1-artifacts';
9+
import type { SpamContract } from '@aztec/noir-test-contracts.js/Spam';
10+
11+
import { jest } from '@jest/globals';
12+
import { privateKeyToAccount } from 'viem/accounts';
13+
14+
import { type EndToEndContext, getPrivateKeyFromIndex } from '../fixtures/utils.js';
15+
import { EpochsTestContext } from './epochs_test.js';
16+
17+
jest.setTimeout(1000 * 60 * 10);
18+
19+
const NODE_COUNT = 3;
20+
const VALIDATOR_COUNT = 3;
21+
22+
// This test validates the scenario where:
23+
// 1. A sequencer posts a block without all necessary attestations
24+
// 2. The next proposer sees the invalid block and invalidates it as part of publishing a new block
25+
// 3. All nodes sync the block with correct attestations
26+
describe('e2e_epochs/epochs_invalidate_block', () => {
27+
let context: EndToEndContext;
28+
let logger: Logger;
29+
let l1Client: ExtendedViemWalletClient;
30+
let rollupContract: RollupContract;
31+
32+
let test: EpochsTestContext;
33+
let validators: (Operator & { privateKey: `0x${string}` })[];
34+
let nodes: AztecNodeService[];
35+
let contract: SpamContract;
36+
37+
beforeEach(async () => {
38+
validators = times(VALIDATOR_COUNT, i => {
39+
const privateKey = bufferToHex(getPrivateKeyFromIndex(i + 3)!);
40+
const attester = EthAddress.fromString(privateKeyToAccount(privateKey).address);
41+
return { attester, withdrawer: attester, privateKey };
42+
});
43+
44+
// Setup context with the given set of validators, mocked gossip sub network, and no anvil test watcher.
45+
test = await EpochsTestContext.setup({
46+
numberOfAccounts: 1,
47+
initialValidators: validators,
48+
mockGossipSubNetwork: true,
49+
disableAnvilTestWatcher: true,
50+
aztecProofSubmissionEpochs: 1024,
51+
startProverNode: false,
52+
aztecTargetCommitteeSize: VALIDATOR_COUNT,
53+
});
54+
55+
({ context, logger, l1Client } = test);
56+
rollupContract = new RollupContract(l1Client, test.rollup.address);
57+
58+
// Halt block building in initial aztec node
59+
logger.warn(`Stopping sequencer in initial aztec node.`);
60+
await context.sequencer!.stop();
61+
62+
// Start the validator nodes
63+
logger.warn(`Initial setup complete. Starting ${NODE_COUNT} validator nodes.`);
64+
const validatorNodes = validators.slice(0, NODE_COUNT);
65+
nodes = await asyncMap(validatorNodes, ({ privateKey }) =>
66+
test.createValidatorNode([privateKey], { dontStartSequencer: true, minTxsPerBlock: 1, maxTxsPerBlock: 1 }),
67+
);
68+
logger.warn(`Started ${NODE_COUNT} validator nodes.`, { validators: validatorNodes.map(v => v.attester) });
69+
70+
// Register spam contract for sending txs.
71+
contract = await test.registerSpamContract(context.wallet);
72+
logger.warn(`Test setup completed.`, { validators: validators.map(v => v.attester.toString()) });
73+
});
74+
75+
afterEach(async () => {
76+
jest.restoreAllMocks();
77+
await test.teardown();
78+
});
79+
80+
it('invalidates a block published without sufficient attestations', async () => {
81+
const sequencers = nodes.map(node => node.getSequencer()!);
82+
const initialBlockNumber = await nodes[0].getBlockNumber();
83+
84+
// Configure all sequencers to skip collecting attestations before starting
85+
logger.warn('Configuring all sequencers to skip attestation collection');
86+
sequencers.forEach(sequencer => {
87+
sequencer.updateSequencerConfig({ skipCollectingAttestations: true });
88+
});
89+
90+
// Send a transaction so the sequencer builds a block
91+
logger.warn('Sending transaction to trigger block building');
92+
const sentTx = contract.methods.spam(1, 1n, false).send();
93+
94+
// Disable skipCollectingAttestations after the first block is mined
95+
test.monitor.once('l2-block', ({ l2BlockNumber }) => {
96+
logger.warn(`Disabling skipCollectingAttestations after L2 block ${l2BlockNumber} has been mined`);
97+
sequencers.forEach(sequencer => {
98+
sequencer.updateSequencerConfig({ skipCollectingAttestations: false });
99+
});
100+
});
101+
102+
// Start all sequencers
103+
await Promise.all(sequencers.map(s => s.start()));
104+
logger.warn(`Started all sequencers with skipCollectingAttestations=true`);
105+
106+
// Create a filter for BlockInvalidated events
107+
const blockInvalidatedFilter = await l1Client.createContractEventFilter({
108+
address: rollupContract.address,
109+
abi: RollupAbi,
110+
eventName: 'BlockInvalidated',
111+
fromBlock: 1n,
112+
toBlock: 'latest',
113+
});
114+
115+
// The next proposer should invalidate the previous block and publish a new one
116+
logger.warn('Waiting for next proposer to invalidate the previous block');
117+
118+
// Wait for the BlockInvalidated event
119+
const blockInvalidatedEvents = await retryUntil(
120+
async () => {
121+
const events = await l1Client.getFilterLogs({ filter: blockInvalidatedFilter });
122+
return events.length > 0 ? events : undefined;
123+
},
124+
'BlockInvalidated event',
125+
test.L2_SLOT_DURATION_IN_S * 5,
126+
0.1,
127+
);
128+
129+
// Verify the BlockInvalidated event was emitted
130+
const [event] = blockInvalidatedEvents;
131+
logger.warn(`BlockInvalidated event emitted`, { event });
132+
expect(event.args.blockNumber).toBeGreaterThan(initialBlockNumber);
133+
134+
// Wait for all nodes to sync the new block
135+
logger.warn('Waiting for all nodes to sync');
136+
await retryUntil(
137+
async () => {
138+
const blockNumbers = await Promise.all(nodes.map(node => node.getBlockNumber()));
139+
logger.info(`Node synced block numbers: ${blockNumbers.join(', ')}`);
140+
return blockNumbers.every(bn => bn > initialBlockNumber);
141+
},
142+
'Node sync check',
143+
test.L2_SLOT_DURATION_IN_S * 5,
144+
0.5,
145+
);
146+
147+
// Verify the transaction was eventually included
148+
const receipt = await sentTx.wait({ timeout: 30 });
149+
expect(receipt.status).toBe('success');
150+
logger.warn(`Transaction included in block ${receipt.blockNumber}`);
151+
});
152+
});

yarn-project/sequencer-client/src/config.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,10 @@ export const sequencerConfigMappings: ConfigMappingsType<SequencerConfig> = {
120120
fakeProcessingDelayPerTxMs: {
121121
description: 'Used for testing to introduce a fake delay after processing each tx',
122122
},
123+
skipCollectingAttestations: {
124+
description: 'Whether to skip collecting attestations from validators and only use self-attestations.',
125+
...booleanConfigHelper(false),
126+
},
123127
...pickConfigMappings(p2pConfigMappings, ['txPublicSetupAllowList']),
124128
};
125129

yarn-project/sequencer-client/src/sequencer/sequencer.ts

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,9 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter<Sequ
144144

145145
// Register the slasher on the publisher to fetch slashing payloads
146146
this.publisher.registerSlashPayloadGetter(this.slasherClient.getSlashPayload.bind(this.slasherClient));
147+
148+
// Initialize config
149+
this.updateConfig(this.config);
147150
}
148151

149152
get tracer(): Tracer {
@@ -229,7 +232,6 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter<Sequ
229232
* Starts the sequencer and moves to IDLE state.
230233
*/
231234
public start() {
232-
this.updateConfig(this.config);
233235
this.metrics.start();
234236
this.runningPromise = new RunningPromise(this.work.bind(this), this.log, this.pollingIntervalMs);
235237
this.setState(SequencerState.IDLE, undefined, { force: true });
@@ -430,7 +432,7 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter<Sequ
430432
);
431433

432434
this.setState(SequencerState.INITIALIZING_PROPOSAL, slot);
433-
this.log.info(`Preparing proposal for block ${newBlockNumber} at slot ${slot}`, {
435+
this.log.verbose(`Preparing proposal for block ${newBlockNumber} at slot ${slot}`, {
434436
proposer: proposerInNextSlot?.toString(),
435437
globalVariables: newGlobalVariables.toInspect(),
436438
chainTipArchive,
@@ -715,9 +717,15 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter<Sequ
715717
proposerAddress,
716718
blockProposalOptions,
717719
);
720+
718721
if (!proposal) {
719-
const msg = `Failed to create block proposal`;
720-
throw new Error(msg);
722+
throw new Error(`Failed to create block proposal`);
723+
}
724+
725+
if (this.config.skipCollectingAttestations) {
726+
this.log.warn('Skipping attestation collection as per config (attesting with own keys only)');
727+
const attestations = await this.validatorClient?.collectOwnAttestations(proposal);
728+
return orderAttestations(attestations ?? [], committee);
721729
}
722730

723731
this.log.debug('Broadcasting block proposal to validators');
@@ -747,7 +755,6 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter<Sequ
747755
if (err && err instanceof AttestationTimeoutError) {
748756
collectedAttestionsCount = err.collectedCount;
749757
}
750-
751758
throw err;
752759
} finally {
753760
this.metrics.recordCollectedAttestations(collectedAttestionsCount, timer.ms());

yarn-project/sequencer-client/src/sequencer/timetable.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -78,13 +78,10 @@ export class SequencerTimetable {
7878
this.attestationPropagationTime * 2 +
7979
this.blockValidationTime +
8080
this.l1PublishingTime;
81+
8182
const initializeDeadline = this.aztecSlotDuration - allWorkToDo;
82-
if (initializeDeadline <= 0) {
83-
throw new Error(
84-
`Block proposal initialize deadline cannot be negative (got ${initializeDeadline} from total time needed ${allWorkToDo} and a slot duration of ${this.aztecSlotDuration}).`,
85-
);
86-
}
8783
this.initializeDeadline = initializeDeadline;
84+
8885
this.log.verbose(`Sequencer timetable initialized (${this.enforce ? 'enforced' : 'not enforced'})`, {
8986
ethereumSlotDuration: this.ethereumSlotDuration,
9087
aztecSlotDuration: this.aztecSlotDuration,
@@ -98,6 +95,12 @@ export class SequencerTimetable {
9895
enforce: this.enforce,
9996
allWorkToDo,
10097
});
98+
99+
if (initializeDeadline <= 0) {
100+
throw new Error(
101+
`Block proposal initialize deadline cannot be negative (got ${initializeDeadline} from total time needed ${allWorkToDo} and a slot duration of ${this.aztecSlotDuration}).`,
102+
);
103+
}
101104
}
102105

103106
private get afterBlockBuildingTimeNeededWithoutReexec() {

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ export interface SequencerConfig {
4444
fakeProcessingDelayPerTxMs?: number;
4545
/** How many seconds it takes for proposals and attestations to travel across the p2p layer (one-way) */
4646
attestationPropagationTime?: number;
47+
/** Skip collecting attestations (for testing only) */
48+
skipCollectingAttestations?: boolean;
4749
}
4850

4951
export const SequencerConfigSchema = z.object({
@@ -64,4 +66,5 @@ export const SequencerConfigSchema = z.object({
6466
enforceTimeTable: z.boolean().optional(),
6567
fakeProcessingDelayPerTxMs: z.number().optional(),
6668
attestationPropagationTime: z.number().optional(),
69+
skipCollectingAttestations: z.boolean().optional(),
6770
}) satisfies ZodFor<SequencerConfig>;

yarn-project/validator-client/src/validator.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -521,6 +521,13 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
521521
await this.p2pClient.broadcastProposal(proposal);
522522
}
523523

524+
async collectOwnAttestations(proposal: BlockProposal): Promise<BlockAttestation[]> {
525+
const slot = proposal.payload.header.slotNumber.toBigInt();
526+
const inCommittee = await this.epochCache.filterInCommittee(slot, this.keyStore.getAddresses());
527+
this.log.debug(`Collecting ${inCommittee.length} self-attestations for slot ${slot}`, { inCommittee });
528+
return this.doAttestToProposal(proposal, inCommittee);
529+
}
530+
524531
async collectAttestations(proposal: BlockProposal, required: number, deadline: Date): Promise<BlockAttestation[]> {
525532
// Wait and poll the p2pClient's attestation pool for this block until we have enough attestations
526533
const slot = proposal.payload.header.slotNumber.toBigInt();
@@ -533,11 +540,9 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter)
533540
throw new AttestationTimeoutError(0, required, slot);
534541
}
535542

536-
const proposalId = proposal.archive.toString();
537-
// adds attestations for all of my addresses locally
538-
const inCommittee = await this.epochCache.filterInCommittee(slot, this.keyStore.getAddresses());
539-
await this.doAttestToProposal(proposal, inCommittee);
543+
await this.collectOwnAttestations(proposal);
540544

545+
const proposalId = proposal.archive.toString();
541546
const myAddresses = this.keyStore.getAddresses();
542547

543548
let attestations: BlockAttestation[] = [];

0 commit comments

Comments
 (0)