Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import path from 'path';
import { shouldCollectMetrics } from '../fixtures/fixtures.js';
import { createNodes } from '../fixtures/setup_p2p_test.js';
import { P2PNetworkTest } from './p2p_network.js';
import { awaitCommitteeExists } from './shared.js';
import { awaitCommitteeExists, findUpcomingProposerSlot } from './shared.js';

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

// Land two slots before an upcoming slot in which `targetAddress` is the proposer, so the network
// has a slot of real-time to settle before the malicious node (which we control) builds.
const MIN_LEAD_SLOTS = 2;

/**
* Finds the next slot at which `targetAddress` is the proposer and warps L1 time to the slot
* just before it (so the proposer-pipelining build phase for the target's slot lands on the
* malicious node immediately, with no need to poll for the slot to come around naturally).
* Warps L1 time to {@link MIN_LEAD_SLOTS} slots before an upcoming slot in which `targetAddress` is
* the proposer, so the proposer-pipelining build phase for the target's slot lands on the malicious
* node with a slot of real-time for the network to settle first.
*
* Probes the NEXT epoch's slots only (further epochs revert with `EpochNotStable`). If the
* target isn't selected next epoch, advances one epoch and tries again.
* The proposer search is delegated to the shared `findUpcomingProposerSlot`, which scans forward
* from {@link MIN_LEAD_SLOTS} ahead — examining both epoch parities so the RANDAO-shuffled 1-of-N
* target is reliably found — and guarantees the returned slot is at least {@link MIN_LEAD_SLOTS}
* ahead, so the landing warp can never go backwards.
*/
async function warpToSlotBeforeTargetProposer(targetAddress: EthAddress): Promise<SlotNumber> {
async function warpToSlotBeforeTargetProposer(targetAddress: EthAddress): Promise<void> {
const epochCache = (nodes[0] as TestAztecNodeService).epochCache;
const cheatCodes = t.ctx.cheatCodes.rollup;
const maxEpochAttempts = 20;
// Minimum slots between the warp landing (`targetSlot - 1`) and where we currently are.
// Without this, the malicious's bad broadcast lands while observers are still transitioning
// across the epoch boundary and gossipsub may drop the proposal. Two slots of real-time
// is enough for everyone to stabilise.
const minBufferSlots = 2;

for (let attempt = 0; attempt < maxEpochAttempts; attempt++) {
const currentSlot = Number(await cheatCodes.getSlot());
const currentEpoch = Math.floor(currentSlot / AZTEC_EPOCH_DURATION);
// Search the remainder of the current epoch and all of the next epoch (the second-next
// epoch's committee may revert with EpochNotStable). Skip slots within `minBufferSlots`
// of the current slot — too close to warp into safely.
const searchStart = currentSlot + minBufferSlots;
const searchEnd = (currentEpoch + 2) * AZTEC_EPOCH_DURATION - 1;

let targetSlot: number | undefined;
for (let s = searchStart; s <= searchEnd; s++) {
const proposer = await epochCache.getProposerAttesterAddressInSlot(SlotNumber(s));
if (proposer && targetAddress.equals(proposer)) {
targetSlot = s;
break;
}
}

if (targetSlot === undefined) {
t.logger.info(`Target not selected as proposer in slots ${searchStart}..${searchEnd}; advancing one epoch`);
await cheatCodes.advanceToNextEpoch();
continue;
}

// Land 2 slots before the target. The malicious's sequencer pipelines for slot N during
// slot N-1, so landing at N-2 gives the network one full slot (N-1) of real-time to
// settle after the warp before the malicious starts building. Use the absolute-slot
// helper rather than `advanceSlots(N)` so any real-time elapsed between the slot search
// above and this call doesn't push us past the intended landing slot.
const landingSlot = SlotNumber(targetSlot - 2);
t.logger.warn(
`Target proposes at slot ${targetSlot}; warping to slot ${landingSlot} (target is 2 slots ahead to let gossipsub stabilise before the malicious broadcasts)`,
);
if (landingSlot > currentSlot) {
await cheatCodes.advanceToSlot(landingSlot);
}
return SlotNumber(targetSlot);
const targetSlot = await findUpcomingProposerSlot({
epochCache,
cheatCodes,
targetProposer: targetAddress,
logger: t.logger,
minLeadSlots: MIN_LEAD_SLOTS,
});
// The malicious sequencer pipelines for slot N during N-1, so landing at N - MIN_LEAD_SLOTS leaves
// slot N-1 of real-time for the network to settle before it broadcasts.
const landingSlot = SlotNumber(targetSlot - MIN_LEAD_SLOTS);
if (landingSlot > Number(await cheatCodes.getSlot())) {
await cheatCodes.advanceToSlot(landingSlot);
}

throw new Error(
`Target proposer ${targetAddress} not found with sufficient buffer within ${maxEpochAttempts} epochs`,
);
}

/**
Expand Down
59 changes: 59 additions & 0 deletions yarn-project/end-to-end/src/e2e_p2p/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,65 @@ export async function awaitCommitteeExists({
return committee!.map(c => c.toString() as `0x${string}`);
}

/**
* Scans L2 slots forward from `minLeadSlots` ahead of the current slot, returning the first slot in
* which `targetProposer` is the proposer.
*
* Scanning starts at `currentSlot + minLeadSlots` and only ever moves forward, so every returned slot
* is at least `minLeadSlots` ahead — a caller can safely warp to `targetSlot - minLeadSlots` for a
* settle buffer without risking a backwards warp. Stepping by a single slot examines both epoch
* parities, which matters because the per-slot proposer is a different RANDAO-shuffled committee
* member: searching only a fixed offset within each epoch can leave a 1-of-N target unexamined when
* the epoch is short. A candidate in an epoch whose committee isn't sampled yet makes the proposer
* lookup revert with EpochNotStable; this warps one epoch forward and continues, keeping the
* candidate at least `minLeadSlots` ahead of the new current slot. Throws after `maxSlotsToScan`.
*
* Unlike {@link advanceToEpochBeforeProposer}, this does not stop an epoch early — callers that want
* to warp close to the target (rather than stage sequencers an epoch ahead) use this and warp the
* final `minLeadSlots` in themselves.
*/
export async function findUpcomingProposerSlot({
epochCache,
cheatCodes,
targetProposer,
logger,
minLeadSlots,
maxSlotsToScan = 100,
}: {
epochCache: EpochCacheInterface;
cheatCodes: RollupCheatCodes;
targetProposer: EthAddress;
logger: Logger;
minLeadSlots: number;
maxSlotsToScan?: number;
}): Promise<SlotNumber> {
let candidate = Number(await cheatCodes.getSlot()) + minLeadSlots;

for (let scanned = 0; scanned < maxSlotsToScan; scanned++) {
let proposer: EthAddress | undefined;
try {
proposer = await epochCache.getProposerAttesterAddressInSlot(SlotNumber(candidate));
} catch (err) {
if (!(err instanceof Error) || !err.message.includes('EpochNotStable')) {
throw err;
}
await cheatCodes.advanceToNextEpoch();
const newCurrentSlot = Number(await cheatCodes.getSlot());
// Keep the lead after the warp: never return a slot we could no longer warp ahead of.
candidate = Math.max(candidate, newCurrentSlot + minLeadSlots);
continue;
}

if (proposer && proposer.equals(targetProposer)) {
logger.warn(`Found target proposer ${targetProposer} at slot ${candidate}`);
return SlotNumber(candidate);
}
candidate++;
}

throw new Error(`Target proposer ${targetProposer} not found within ${maxSlotsToScan} slots`);
}

/**
* Advance epochs until we find one where the target proposer is selected for a slot at least
* `warmupSlots` into the epoch, then stop one epoch before it. This leaves time for the caller to
Expand Down
Loading