Skip to content

Commit 269e6d0

Browse files
authored
fix(test): reliably find target proposer in sentinel_status_slash (A-1217) (#24143)
## Problem The e2e helper `warpToSlotBeforeTargetProposer` (in `sentinel_status_slash.parallel.test.ts`) intermittently threw `Target proposer ... not found with sufficient buffer within 20 epochs`, surfacing as an `e2e_p2p` flake. It's a deterministic helper bug, not network flakiness. The search window was derived as `searchStart = currentSlot + minBufferSlots`, `searchEnd = (currentEpoch + 2) * AZTEC_EPOCH_DURATION - 1`. With `AZTEC_EPOCH_DURATION = 2` and `minBufferSlots = 2`, the buffer offset consumes a full epoch, so whenever `currentSlot` is odd the window collapses to a single, always-odd slot. Because the proposer for each slot is a different RANDAO-shuffled committee member, probing only odd slots never examines the even slot of any epoch — where the 1-of-6 target can be the proposer — so the loop exhausts all attempts and throws. ## Log verification Confirmed against CI run [`cc8c935bb167ed37`](http://ci.aztec-labs.com/cc8c935bb167ed37): - All 20 attempts probed a 1-slot window, every probed slot odd: `13, 15, 17, … 51`. Zero hit lines. - The target `0x90f79b…` was on the committee every epoch (RANDAO-shuffled into different positions) and attested at slots 11–12 — reachable, just never at a probed (odd) slot. - Zero `EpochNotStable` reverts during the scan — the search ran clean, just structurally blind to even slots. ## Fix Extract the proposer search into a shared `findUpcomingProposerSlot` in `e2e_p2p/shared.ts`, parameterized by `minLeadSlots`, and have the sentinel test use it: - Scans forward **one slot at a time** from `currentSlot + minLeadSlots`, so it examines **both epoch parities** (fixing the odd-only blindness) and finds the RANDAO-shuffled target wherever it proposes. - Guarantees the returned slot is **at least `minLeadSlots` ahead**, so the sentinel can warp to `targetSlot - minLeadSlots` for its settle buffer with no risk of a backwards warp — the lead comes from the scan start, not from a per-epoch position constraint. - Handles `EpochNotStable` by warping one epoch forward and continuing, keeping the lead. The sentinel helper becomes a thin wrapper: `findUpcomingProposerSlot({ minLeadSlots: 2 })` then `advanceToSlot(targetSlot - 2)`. Kept **separate** from the existing `advanceToEpochBeforeProposer`, which serves a genuinely different pattern: its four callers stay one epoch before the target to start sequencers, then warp to the epoch boundary, and rely on `warmupSlots` for their warm-up margin (load-bearing — without it their proposals serialize past the slot boundary and are rejected as late). That helper is unchanged, so its callers are unaffected. ## Testing - Build, format, and lint clean; only `shared.ts` and the sentinel test changed. - The proposer search now examines both parities and guarantees the lead by construction. - **Not yet run:** the full e2e (`sentinel_status_slash.parallel.test.ts`, ~20 min, real-time-dependent). The real validation is running it repeatedly to confirm the proposer is found and the suite's other two tests (which share the helper) still pass. Closes A-1217.
1 parent fbfddf4 commit 269e6d0

2 files changed

Lines changed: 84 additions & 55 deletions

File tree

yarn-project/end-to-end/src/e2e_p2p/sentinel_status_slash.parallel.test.ts

Lines changed: 25 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import path from 'path';
1818
import { shouldCollectMetrics } from '../fixtures/fixtures.js';
1919
import { createNodes } from '../fixtures/setup_p2p_test.js';
2020
import { P2PNetworkTest } from './p2p_network.js';
21-
import { awaitCommitteeExists } from './shared.js';
21+
import { awaitCommitteeExists, findUpcomingProposerSlot } from './shared.js';
2222

2323
/**
2424
* Exercises the sentinel's six-case proposer-status taxonomy end-to-end by driving each of the
@@ -243,66 +243,36 @@ describe('e2e_p2p_sentinel_status_slash', () => {
243243
return targetAddress;
244244
}
245245

246+
// Land two slots before an upcoming slot in which `targetAddress` is the proposer, so the network
247+
// has a slot of real-time to settle before the malicious node (which we control) builds.
248+
const MIN_LEAD_SLOTS = 2;
249+
246250
/**
247-
* Finds the next slot at which `targetAddress` is the proposer and warps L1 time to the slot
248-
* just before it (so the proposer-pipelining build phase for the target's slot lands on the
249-
* malicious node immediately, with no need to poll for the slot to come around naturally).
251+
* Warps L1 time to {@link MIN_LEAD_SLOTS} slots before an upcoming slot in which `targetAddress` is
252+
* the proposer, so the proposer-pipelining build phase for the target's slot lands on the malicious
253+
* node with a slot of real-time for the network to settle first.
250254
*
251-
* Probes the NEXT epoch's slots only (further epochs revert with `EpochNotStable`). If the
252-
* target isn't selected next epoch, advances one epoch and tries again.
255+
* The proposer search is delegated to the shared `findUpcomingProposerSlot`, which scans forward
256+
* from {@link MIN_LEAD_SLOTS} ahead — examining both epoch parities so the RANDAO-shuffled 1-of-N
257+
* target is reliably found — and guarantees the returned slot is at least {@link MIN_LEAD_SLOTS}
258+
* ahead, so the landing warp can never go backwards.
253259
*/
254-
async function warpToSlotBeforeTargetProposer(targetAddress: EthAddress): Promise<SlotNumber> {
260+
async function warpToSlotBeforeTargetProposer(targetAddress: EthAddress): Promise<void> {
255261
const epochCache = (nodes[0] as TestAztecNodeService).epochCache;
256262
const cheatCodes = t.ctx.cheatCodes.rollup;
257-
const maxEpochAttempts = 20;
258-
// Minimum slots between the warp landing (`targetSlot - 1`) and where we currently are.
259-
// Without this, the malicious's bad broadcast lands while observers are still transitioning
260-
// across the epoch boundary and gossipsub may drop the proposal. Two slots of real-time
261-
// is enough for everyone to stabilise.
262-
const minBufferSlots = 2;
263-
264-
for (let attempt = 0; attempt < maxEpochAttempts; attempt++) {
265-
const currentSlot = Number(await cheatCodes.getSlot());
266-
const currentEpoch = Math.floor(currentSlot / AZTEC_EPOCH_DURATION);
267-
// Search the remainder of the current epoch and all of the next epoch (the second-next
268-
// epoch's committee may revert with EpochNotStable). Skip slots within `minBufferSlots`
269-
// of the current slot — too close to warp into safely.
270-
const searchStart = currentSlot + minBufferSlots;
271-
const searchEnd = (currentEpoch + 2) * AZTEC_EPOCH_DURATION - 1;
272-
273-
let targetSlot: number | undefined;
274-
for (let s = searchStart; s <= searchEnd; s++) {
275-
const proposer = await epochCache.getProposerAttesterAddressInSlot(SlotNumber(s));
276-
if (proposer && targetAddress.equals(proposer)) {
277-
targetSlot = s;
278-
break;
279-
}
280-
}
281-
282-
if (targetSlot === undefined) {
283-
t.logger.info(`Target not selected as proposer in slots ${searchStart}..${searchEnd}; advancing one epoch`);
284-
await cheatCodes.advanceToNextEpoch();
285-
continue;
286-
}
287-
288-
// Land 2 slots before the target. The malicious's sequencer pipelines for slot N during
289-
// slot N-1, so landing at N-2 gives the network one full slot (N-1) of real-time to
290-
// settle after the warp before the malicious starts building. Use the absolute-slot
291-
// helper rather than `advanceSlots(N)` so any real-time elapsed between the slot search
292-
// above and this call doesn't push us past the intended landing slot.
293-
const landingSlot = SlotNumber(targetSlot - 2);
294-
t.logger.warn(
295-
`Target proposes at slot ${targetSlot}; warping to slot ${landingSlot} (target is 2 slots ahead to let gossipsub stabilise before the malicious broadcasts)`,
296-
);
297-
if (landingSlot > currentSlot) {
298-
await cheatCodes.advanceToSlot(landingSlot);
299-
}
300-
return SlotNumber(targetSlot);
263+
const targetSlot = await findUpcomingProposerSlot({
264+
epochCache,
265+
cheatCodes,
266+
targetProposer: targetAddress,
267+
logger: t.logger,
268+
minLeadSlots: MIN_LEAD_SLOTS,
269+
});
270+
// The malicious sequencer pipelines for slot N during N-1, so landing at N - MIN_LEAD_SLOTS leaves
271+
// slot N-1 of real-time for the network to settle before it broadcasts.
272+
const landingSlot = SlotNumber(targetSlot - MIN_LEAD_SLOTS);
273+
if (landingSlot > Number(await cheatCodes.getSlot())) {
274+
await cheatCodes.advanceToSlot(landingSlot);
301275
}
302-
303-
throw new Error(
304-
`Target proposer ${targetAddress} not found with sufficient buffer within ${maxEpochAttempts} epochs`,
305-
);
306276
}
307277

308278
/**

yarn-project/end-to-end/src/e2e_p2p/shared.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,65 @@ export async function awaitCommitteeExists({
147147
return committee!.map(c => c.toString() as `0x${string}`);
148148
}
149149

150+
/**
151+
* Scans L2 slots forward from `minLeadSlots` ahead of the current slot, returning the first slot in
152+
* which `targetProposer` is the proposer.
153+
*
154+
* Scanning starts at `currentSlot + minLeadSlots` and only ever moves forward, so every returned slot
155+
* is at least `minLeadSlots` ahead — a caller can safely warp to `targetSlot - minLeadSlots` for a
156+
* settle buffer without risking a backwards warp. Stepping by a single slot examines both epoch
157+
* parities, which matters because the per-slot proposer is a different RANDAO-shuffled committee
158+
* member: searching only a fixed offset within each epoch can leave a 1-of-N target unexamined when
159+
* the epoch is short. A candidate in an epoch whose committee isn't sampled yet makes the proposer
160+
* lookup revert with EpochNotStable; this warps one epoch forward and continues, keeping the
161+
* candidate at least `minLeadSlots` ahead of the new current slot. Throws after `maxSlotsToScan`.
162+
*
163+
* Unlike {@link advanceToEpochBeforeProposer}, this does not stop an epoch early — callers that want
164+
* to warp close to the target (rather than stage sequencers an epoch ahead) use this and warp the
165+
* final `minLeadSlots` in themselves.
166+
*/
167+
export async function findUpcomingProposerSlot({
168+
epochCache,
169+
cheatCodes,
170+
targetProposer,
171+
logger,
172+
minLeadSlots,
173+
maxSlotsToScan = 100,
174+
}: {
175+
epochCache: EpochCacheInterface;
176+
cheatCodes: RollupCheatCodes;
177+
targetProposer: EthAddress;
178+
logger: Logger;
179+
minLeadSlots: number;
180+
maxSlotsToScan?: number;
181+
}): Promise<SlotNumber> {
182+
let candidate = Number(await cheatCodes.getSlot()) + minLeadSlots;
183+
184+
for (let scanned = 0; scanned < maxSlotsToScan; scanned++) {
185+
let proposer: EthAddress | undefined;
186+
try {
187+
proposer = await epochCache.getProposerAttesterAddressInSlot(SlotNumber(candidate));
188+
} catch (err) {
189+
if (!(err instanceof Error) || !err.message.includes('EpochNotStable')) {
190+
throw err;
191+
}
192+
await cheatCodes.advanceToNextEpoch();
193+
const newCurrentSlot = Number(await cheatCodes.getSlot());
194+
// Keep the lead after the warp: never return a slot we could no longer warp ahead of.
195+
candidate = Math.max(candidate, newCurrentSlot + minLeadSlots);
196+
continue;
197+
}
198+
199+
if (proposer && proposer.equals(targetProposer)) {
200+
logger.warn(`Found target proposer ${targetProposer} at slot ${candidate}`);
201+
return SlotNumber(candidate);
202+
}
203+
candidate++;
204+
}
205+
206+
throw new Error(`Target proposer ${targetProposer} not found within ${maxSlotsToScan} slots`);
207+
}
208+
150209
/**
151210
* Advance epochs until we find one where the target proposer is selected for a slot at least
152211
* `warmupSlots` into the epoch, then stop one epoch before it. This leaves time for the caller to

0 commit comments

Comments
 (0)