Skip to content

Commit d04c126

Browse files
authored
feat: merge-train/spartan (#23748)
BEGIN_COMMIT_OVERRIDE test(slash): attest eagerly when skipping checkpoint validation (#23722) END_COMMIT_OVERRIDE
2 parents 34134bb + afc238d commit d04c126

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)