Skip to content

Commit 3ba6dbe

Browse files
authored
test(e2e): fix race in broadcasted_invalid_block_proposal_slash under pipelining (#23302)
## Summary Fixes the `e2e_p2p_broadcasted_invalid_block_proposal_slash` failure that has been blocking the `merge-train/spartan` train (run https://github.com/AztecProtocol/aztec-packages/actions/runs/25896899879, test log http://ci.aztec-labs.com/2bf4e2cd2d9e7944). The test creates the malicious proposer first (auto-starting its sequencer) and only later creates the honest nodes and waits for P2P mesh. Under `enableProposerPipelining: true` (turned on for this test by #23070), the malicious proposer is selected for the very next slot, builds + broadcasts the invalid proposal one slot ahead, and lands the broadcast before the honest validators have joined the mesh. They then reject it at the gossipsub `checkpoint_proposal_validator` with `Penalizing peer for invalid slot number` (since their target slot has already moved past), so the `state_mismatch` slashing path never runs. The malicious sequencer then gets stuck on the failed publish (`Awaiting pending L1 payload submission`) and never proposes again before the test times out on `awaitOffenseDetected`. This is the same race that #23070 fixed in `duplicate_proposal_slash.test.ts`; the same pattern is applied here: - Create both the invalid proposer and the honest nodes with `dontStartSequencer: true`. - After P2P mesh connectivity + committee formation, use `advanceToEpochBeforeProposer` to land one epoch before an epoch where the invalid proposer is scheduled. - Start all sequencers, then `advanceToEpoch(targetEpoch, { offset: -AZTEC_SLOT_DURATION })` so the malicious slot fires while every node is online and at the same wall-clock slot. - After `awaitOffenseDetected` on one node, poll `getSlashOffenses` across **all** nodes for `BROADCASTED_INVALID_BLOCK_PROPOSAL` — under pipelining a given receiver may have already advanced past the build slot when the proposal arrives, so we need to catch whichever node was still in the build slot. The on-chain slash assertion (`rollup.listenToSlash`) is preserved unchanged. Full failure analysis: https://gist.github.com/AztecBot/39b69c1117f419145938ccd2c198f8e9 ## Test plan - CI: `e2e_p2p_broadcasted_invalid_block_proposal_slash` passes on `merge-train/spartan`. - Local `./bootstrap.sh ci` / `fast` / `build` are not runnable in this container (no Docker socket and `$HOME` not writable for the container UID — `yarn install` fails on `corepack` mkdir, parallel-bootstrap can't create `~/.parallel`). Fix is a direct port of a pattern already shipping green on `next` via the sibling `duplicate_proposal_slash.test.ts`. ClaudeBox log: https://claudebox.work/s/06a4929a1971beaf?run=1
1 parent 7551378 commit 3ba6dbe

1 file changed

Lines changed: 54 additions & 39 deletions

File tree

yarn-project/end-to-end/src/e2e_p2p/broadcasted_invalid_block_proposal_slash.test.ts

Lines changed: 54 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { AztecNodeService } from '@aztec/aztec-node';
2+
import type { TestAztecNodeService } from '@aztec/aztec-node/test';
23
import { EthAddress } from '@aztec/aztec.js/addresses';
34
import { EpochNumber } from '@aztec/foundation/branded-types';
45
import { promiseWithResolvers } from '@aztec/foundation/promise';
@@ -13,7 +14,7 @@ import path from 'path';
1314
import { shouldCollectMetrics } from '../fixtures/fixtures.js';
1415
import { createNodes } from '../fixtures/setup_p2p_test.js';
1516
import { P2PNetworkTest } from './p2p_network.js';
16-
import { awaitCommitteeExists, awaitOffenseDetected } from './shared.js';
17+
import { advanceToEpochBeforeProposer, awaitCommitteeExists, awaitOffenseDetected } from './shared.js';
1718

1819
const TEST_TIMEOUT = 1_000_000;
1920

@@ -114,10 +115,14 @@ describe('e2e_p2p_broadcasted_invalid_block_proposal_slash', () => {
114115

115116
t.logger.warn('Creating nodes');
116117

117-
// Create first node that broadcasts invalid proposals
118+
// Create first node that broadcasts invalid proposals. Keep its sequencer stopped until
119+
// every node has joined the P2P mesh; otherwise (under proposer pipelining) the invalid
120+
// proposer can publish its sole bad block to slot N before the honest nodes are connected,
121+
// and they will reject the proposal as "invalid slot number" instead of slashing it.
118122
const invalidProposerConfig = {
119123
...t.ctx.aztecNodeConfig,
120124
broadcastInvalidBlockProposal: true,
125+
dontStartSequencer: true,
121126
};
122127
const invalidProposerNodes = await createNodes(
123128
invalidProposerConfig,
@@ -134,9 +139,9 @@ describe('e2e_p2p_broadcasted_invalid_block_proposal_slash', () => {
134139
const invalidProposerAddress = invalidProposerNodes[0].getSequencer()!.validatorAddresses![0];
135140
t.logger.warn(`Invalid proposer address: ${invalidProposerAddress.toString()}`);
136141

137-
// Create remaining honest nodes
142+
// Create remaining honest nodes, also with sequencers stopped, for the same reason.
138143
const honestNodes = await createNodes(
139-
t.ctx.aztecNodeConfig,
144+
{ ...t.ctx.aztecNodeConfig, dontStartSequencer: true },
140145
t.ctx.dateProvider,
141146
t.bootstrapNodeEnr,
142147
NUM_VALIDATORS - 1,
@@ -149,42 +154,39 @@ describe('e2e_p2p_broadcasted_invalid_block_proposal_slash', () => {
149154

150155
nodes = [...invalidProposerNodes, ...honestNodes];
151156

152-
// Wait for P2P mesh to be fully formed before proceeding
157+
// Wait for P2P mesh to be fully formed before starting sequencers
153158
await t.waitForP2PMeshConnectivity(nodes, NUM_VALIDATORS);
154159

155160
await awaitCommitteeExists({ rollup, logger: t.logger });
156161

157-
const startSlot = await rollup.getSlotNumber();
158-
const proposerEarliestSlot = startSlot + 1;
159-
160-
// Wait until the bad proposer has had a slot
161-
await retryUntil(
162-
async () => {
163-
const currentSlot = await rollup.getSlotNumber();
164-
return currentSlot >= proposerEarliestSlot;
165-
},
166-
'Wait for next slot...',
167-
TEST_TIMEOUT / 1000,
168-
ETHEREUM_SLOT_DURATION,
169-
);
170-
171-
await retryUntil(
172-
async () => {
173-
const currentProposer = await rollup.getCurrentProposer();
174-
if (!currentProposer.equals(invalidProposerAddress)) {
175-
t.logger.info(
176-
`Current proposer: ${currentProposer}, waiting for malicious proposer ${invalidProposerAddress} to get a slot...`,
177-
);
178-
return false;
179-
}
180-
return true;
181-
},
182-
'Wait for malicious proposer slot...',
183-
TEST_TIMEOUT / 1000,
184-
ETHEREUM_SLOT_DURATION,
185-
);
162+
// Find an epoch where the invalid proposer is selected, stopping one epoch before so
163+
// we have time to start sequencers before the target epoch arrives.
164+
const epochCache = (honestNodes[0] as TestAztecNodeService).epochCache;
165+
const { targetEpoch } = await advanceToEpochBeforeProposer({
166+
epochCache,
167+
cheatCodes: t.ctx.cheatCodes.rollup,
168+
targetProposer: invalidProposerAddress,
169+
logger: t.logger,
170+
});
186171

187-
const offenses = await awaitOffenseDetected({
172+
// Start all sequencers while still one epoch before the target
173+
t.logger.warn('Starting all sequencers');
174+
await Promise.all(nodes.map(n => n.getSequencer()!.start()));
175+
176+
// Now warp to one slot before the target epoch — sequencers are already running.
177+
// Under proposer pipelining, the invalid proposer begins building for the first slot
178+
// of the target epoch one slot earlier; warping to the start of the epoch would force
179+
// the bad proposal to serialize past the slot boundary, after which honest receivers
180+
// reject it as late.
181+
t.logger.warn(`Advancing to one slot before target epoch ${targetEpoch}`);
182+
await t.ctx.cheatCodes.rollup.advanceToEpoch(targetEpoch, { offset: -AZTEC_SLOT_DURATION });
183+
184+
// Wait for offense to be detected. Under proposer pipelining, the invalid block proposal is
185+
// broadcast at the slot boundary while a receiver's wall clock may have already advanced
186+
// past the build slot — when that happens, the honest node rejects the gossip with "invalid
187+
// slot number" before slashing logic runs. Collect offenses from every node so we catch
188+
// whichever node managed to process the proposal while still in the build slot.
189+
await awaitOffenseDetected({
188190
epochDuration: t.ctx.aztecNodeConfig.aztecEpochDuration,
189191
logger: t.logger,
190192
nodeAdmin: nodes[1], // Use honest node to check for offenses
@@ -193,10 +195,23 @@ describe('e2e_p2p_broadcasted_invalid_block_proposal_slash', () => {
193195
timeoutSeconds: AZTEC_SLOT_DURATION * 16,
194196
});
195197

196-
// Check offense is correct
197-
expect(offenses).toHaveLength(1);
198-
expect(offenses[0].offenseType).toEqual(OffenseType.BROADCASTED_INVALID_BLOCK_PROPOSAL);
199-
expect(offenses[0].validator.toString()).toEqual(t.validators[0].attester.toString());
198+
const invalidBlockOffenses = await retryUntil(
199+
async () => {
200+
const allOffenses = (await Promise.all(nodes.map(n => n.getSlashOffenses('all')))).flat();
201+
const filtered = allOffenses.filter(o => o.offenseType === OffenseType.BROADCASTED_INVALID_BLOCK_PROPOSAL);
202+
if (filtered.length > 0) {
203+
return filtered;
204+
}
205+
},
206+
'broadcasted invalid block proposal offense',
207+
AZTEC_SLOT_DURATION * 4,
208+
);
209+
210+
t.logger.warn(`Collected broadcasted invalid block proposal offenses`, { invalidBlockOffenses });
211+
expect(invalidBlockOffenses.length).toBeGreaterThan(0);
212+
for (const offense of invalidBlockOffenses) {
213+
expect(offense.validator.toString()).toEqual(invalidProposerAddress.toString());
214+
}
200215

201216
// Check slash is recorded on chain
202217
const slashPromise = promiseWithResolvers<{ amount: bigint; attester: EthAddress }>();

0 commit comments

Comments
 (0)