Skip to content

Commit afc238d

Browse files
authored
test(slash): attest eagerly when skipping checkpoint validation (#23722)
Slashing e2e tests that relied on `skipCheckpointProposalValidation` were flakey when a previous block in the checkpoint was invalid. Reason is that a checkpoint proposal usually carries a last block, which was validated, but required the previous invalid block to be found. Since it wasn't, the block proposal validation was timing out at the end of the slot, then yielding to the checkpoint validation, which immediately produced the attestation. This attestation only made it because of the 500ms grace period we give to them after the slot end. This PR changes the flow when `skipCheckpointProposalValidation` is set, so we attest immediately, and then we try processing the last block. ## Motivation The `e2e_slashing_attested_invalid_proposal` test flakes: a "lazy" validator (`skipCheckpointProposalValidation`) is supposed to attest to a bad checkpoint proposal so an honest node records the offense, but it sometimes attests ~24s late and peers reject the attestation as stale (`Checkpoint attestation slot N is not current or next slot`), so the offense is never recorded and the test times out. The root cause is a real liveness issue, not just a test artifact: a skip-validation node's attestation was serialized behind processing the last block embedded in the checkpoint proposal, and that block's re-execution blocks until the re-execution deadline (~a full slot) when its parent is unavailable — which is guaranteed here, since the parent is the deliberately-invalid block. Whether the resulting late attestation lands inside the slot's acceptance window then comes down to timing jitter. ## Approach When a node skips checkpoint proposal validation, its attestation needs nothing from the embedded block, so create and broadcast it before processing that block instead of after. Nodes that validate checkpoints keep the original order (block first), because their checkpoint validation depends on the last block. The decision is gated on the `skipCheckpointProposalValidation` flag, mirrored into `P2PConfig` next to its sibling `skipProposalSlotValidation`; the default is `false`, so all production nodes are byte-for-byte unchanged. ## Changes - **p2p (`libp2p_service.ts`)**: In `handleGossipedCheckpointProposal`, process the checkpoint proposal before the embedded block when `skipCheckpointProposalValidation` is set; otherwise keep the existing block-then-checkpoint order. Single shared closure, no duplication. - **p2p (`config.ts`)**: Mirror `skipCheckpointProposalValidation` into `P2PConfig` (interface + mapping, no env var, default `false`). - **validator-client (`proposal_handler.ts`)**: Remove a redundant second `skipCheckpointProposalValidation` early-return in the all-nodes checkpoint handler (dead code; the first early-return already covers it). - **p2p (tests)**: Add a test that a skip-mode node attests without waiting for stalled block processing, and a complementary test that the default node still processes the block first.
1 parent 34134bb commit afc238d

4 files changed

Lines changed: 123 additions & 14 deletions

File tree

yarn-project/p2p/src/config.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,13 @@ export interface P2PConfig
246246

247247
/** Accept proposal gossip regardless of slot timing (for testing only). */
248248
skipProposalSlotValidation?: boolean;
249+
250+
/**
251+
* Whether this node skips checkpoint proposal validation and always attests. When set, the checkpoint
252+
* attestation is created and broadcast before the embedded last block is processed, so it is not delayed
253+
* past the slot's attestation window by that block's re-execution. Mirrors the validator config flag.
254+
*/
255+
skipCheckpointProposalValidation?: boolean;
249256
}
250257

251258
export const DEFAULT_P2P_PORT = 40400;
@@ -580,6 +587,11 @@ export const p2pConfigMappings: ConfigMappingsType<P2PConfig> = {
580587
description: 'Accept proposal gossip regardless of slot timing (for testing only)',
581588
...booleanConfigHelper(false),
582589
},
590+
skipCheckpointProposalValidation: {
591+
description:
592+
'Skip checkpoint proposal validation and always attest, broadcasting the attestation before processing the embedded last block',
593+
...booleanConfigHelper(false),
594+
},
583595
minTxPoolAgeMs: {
584596
env: 'P2P_MIN_TX_POOL_AGE_MS',
585597
description: 'Minimum age (ms) a transaction must have been in the pool before it is eligible for block building.',

yarn-project/p2p/src/services/libp2p/libp2p_service.test.ts

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -955,6 +955,93 @@ describe('LibP2PService', () => {
955955
expect(storedBlock).toBeDefined();
956956
});
957957

958+
it('skipCheckpointProposalValidation: attests before (not gated by) slow last-block processing', async () => {
959+
// Recreate the service in skip-validation mode and re-register the checkpoint/block callbacks on it.
960+
service = createTestLibP2PServiceWithPools(
961+
mockPeerManager,
962+
reportMessageValidationResultSpy,
963+
attestationPool,
964+
mockTxPool,
965+
mockEpochCache,
966+
{ skipCheckpointProposalValidation: true },
967+
);
968+
service.registerBlockReceivedCallback(blockReceivedCallback as any);
969+
service.registerValidatorCheckpointReceivedCallback(validatorCheckpointReceivedCallback as any);
970+
service.registerAllNodesCheckpointReceivedCallback(allNodesCheckpointReceivedCallback as any);
971+
972+
const checkpointHeader = makeCheckpointHeader(1, { slotNumber: targetSlot });
973+
const blockHeader = makeBlockHeader(1, { slotNumber: targetSlot });
974+
const proposal = await makeCheckpointProposal({ signer, checkpointHeader, lastBlock: { blockHeader } });
975+
976+
// Block processing hangs until released, simulating waiting for the parent block up to the
977+
// re-execution deadline. In skip mode the attestation must not be blocked behind it.
978+
let releaseBlock!: () => void;
979+
blockReceivedCallback.mockReturnValue(
980+
new Promise<boolean>(resolve => {
981+
releaseBlock = () => resolve(true);
982+
}),
983+
);
984+
985+
// Resolves once the checkpoint attestation callback runs; if it were serialized behind the hung block
986+
// processing this would never resolve and the test would time out.
987+
let signalCheckpoint!: () => void;
988+
const checkpointInvoked = new Promise<void>(resolve => {
989+
signalCheckpoint = resolve;
990+
});
991+
validatorCheckpointReceivedCallback.mockImplementation(() => {
992+
signalCheckpoint();
993+
return Promise.resolve([]);
994+
});
995+
996+
const handled = service.handleGossipedCheckpointProposal(proposal.toBuffer(), 'msg-1', mockPeerId);
997+
998+
await checkpointInvoked;
999+
expect(validatorCheckpointReceivedCallback).toHaveBeenCalledTimes(1);
1000+
1001+
releaseBlock();
1002+
await handled;
1003+
expect(blockReceivedCallback).toHaveBeenCalledTimes(1);
1004+
});
1005+
1006+
it('default: processes the last block before the checkpoint proposal', async () => {
1007+
const checkpointHeader = makeCheckpointHeader(1, { slotNumber: targetSlot });
1008+
const blockHeader = makeBlockHeader(1, { slotNumber: targetSlot });
1009+
const proposal = await makeCheckpointProposal({ signer, checkpointHeader, lastBlock: { blockHeader } });
1010+
1011+
// Block processing hangs until released and signals when it starts; with validation enabled the
1012+
// checkpoint callback must wait for the block to finish.
1013+
let releaseBlock!: () => void;
1014+
let signalBlockStarted!: () => void;
1015+
const blockStarted = new Promise<void>(resolve => {
1016+
signalBlockStarted = resolve;
1017+
});
1018+
blockReceivedCallback.mockImplementation(() => {
1019+
signalBlockStarted();
1020+
return new Promise<boolean>(resolve => {
1021+
releaseBlock = () => resolve(true);
1022+
});
1023+
});
1024+
1025+
let checkpointInvoked = false;
1026+
validatorCheckpointReceivedCallback.mockImplementation(() => {
1027+
checkpointInvoked = true;
1028+
return Promise.resolve([]);
1029+
});
1030+
1031+
const handled = service.handleGossipedCheckpointProposal(proposal.toBuffer(), 'msg-1', mockPeerId);
1032+
1033+
// Wait until block processing is in flight (hung), then flush microtasks. The checkpoint callback
1034+
// must not have run, since it is gated behind the block.
1035+
await blockStarted;
1036+
await new Promise(resolve => setImmediate(resolve));
1037+
expect(checkpointInvoked).toBe(false);
1038+
1039+
// Once the block completes, the checkpoint proposal is processed.
1040+
releaseBlock();
1041+
await handled;
1042+
expect(checkpointInvoked).toBe(true);
1043+
});
1044+
9581045
it('lastBlock processed even when checkpoint cap exceeded', async () => {
9591046
const checkpointHeader = makeCheckpointHeader(1, { slotNumber: targetSlot });
9601047
const blockHeader = makeBlockHeader(1, { slotNumber: targetSlot });
@@ -1525,6 +1612,7 @@ function createTestLibP2PServiceWithPools(
15251612
attestationPool: AttestationPool,
15261613
mockTxPool: MockProxy<TxPoolV2>,
15271614
mockEpochCache: MockProxy<EpochCacheInterface>,
1615+
configOverrides?: Partial<P2PConfig>,
15281616
): TestLibP2PService {
15291617
const mockNode = mock<PubSubLibp2p>();
15301618
mockNode.services = {
@@ -1539,5 +1627,6 @@ function createTestLibP2PServiceWithPools(
15391627
attestationPool,
15401628
txPool: mockTxPool,
15411629
epochCache: mockEpochCache,
1630+
configOverrides,
15421631
});
15431632
}

yarn-project/p2p/src/services/libp2p/libp2p_service.ts

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1342,17 +1342,33 @@ export class LibP2PService extends WithTracer implements P2PService {
13421342
TopicType.checkpoint_proposal,
13431343
);
13441344

1345+
// Process checkpoint proposal if valid and not equivocated.
1346+
const processCheckpointFn = () =>
1347+
result === TopicValidatorResult.Accept && checkpoint && !isEquivocated
1348+
? this.processValidCheckpointProposal(checkpoint.toCore(), source)
1349+
: Promise.resolve();
1350+
13451351
// If the checkpoint contained a valid last block, we process it even if the checkpoint itself is to be rejected
13461352
// TODO(palla/mbps): Is this ok? Should we be considering a block from a checkpoint that was equivocated?
1347-
if (processBlock && checkpoint?.getBlockProposal()) {
1348-
await this.processValidBlockProposal(checkpoint.getBlockProposal()!, source);
1349-
}
1350-
1351-
if (result !== TopicValidatorResult.Accept || !checkpoint || isEquivocated) {
1353+
const processBlockFn = () =>
1354+
processBlock && checkpoint && checkpoint.getBlockProposal()
1355+
? this.processValidBlockProposal(checkpoint.getBlockProposal()!, source)
1356+
: Promise.resolve();
1357+
1358+
// A node that skips checkpoint validation attests without re-executing the embedded last block, so run
1359+
// the checkpoint callback first: this creates and broadcasts the attestation before the block is
1360+
// processed. Otherwise the block's re-execution — which can stall until the re-execution deadline
1361+
// waiting for a parent that may never arrive — would delay the attestation past the slot's attestation
1362+
// window, after which peers reject it as stale.
1363+
if (this.config.skipCheckpointProposalValidation) {
1364+
await processCheckpointFn();
1365+
await processBlockFn();
13521366
return;
13531367
}
13541368

1355-
await this.processValidCheckpointProposal(checkpoint.toCore(), source);
1369+
// Process the block first, since it's required for the checkpoint proposal validation.
1370+
await processBlockFn();
1371+
await processCheckpointFn();
13561372
}
13571373

13581374
/**

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

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -311,14 +311,6 @@ export class ProposalHandler {
311311
return undefined;
312312
}
313313

314-
if (this.config.skipCheckpointProposalValidation) {
315-
this.log.warn(
316-
`Skipping all-nodes checkpoint proposal validation for slot ${proposal.slotNumber}`,
317-
proposalInfo,
318-
);
319-
return undefined;
320-
}
321-
322314
const result = await this.handleCheckpointProposal(proposal, proposalInfo);
323315
if (!result.isValid) {
324316
await this.checkpointProposalValidationFailureCallback?.(proposal, result, proposalInfo);

0 commit comments

Comments
 (0)