diff --git a/yarn-project/validator-client/src/proposal_handler.ts b/yarn-project/validator-client/src/proposal_handler.ts index 5c8ba9d8e202..5eb403170670 100644 --- a/yarn-project/validator-client/src/proposal_handler.ts +++ b/yarn-project/validator-client/src/proposal_handler.ts @@ -59,6 +59,8 @@ export type BlockProposalValidationFailureReason = | 'failed_txs' | 'initial_state_mismatch' | 'timeout' + | 'block_proposal_beyond_checkpoint' + | 'checkpoint_proposal_equivocation' | 'unknown_error'; type ReexecuteTransactionsResult = { @@ -146,6 +148,10 @@ type CheckpointComputationResult = | { checkpointNumber: CheckpointNumber; reason?: undefined } | { checkpointNumber?: undefined; reason: 'invalid_proposal' | 'global_variables_mismatch' }; +type BlockProposalSlotValidationResult = + | { isValid: true } + | { isValid: false; reason: 'block_proposal_beyond_checkpoint' | 'checkpoint_proposal_equivocation' }; + /** Handles block and checkpoint proposals for both validator and non-validator nodes. */ export class ProposalHandler { public readonly tracer: Tracer; @@ -164,6 +170,9 @@ export class ProposalHandler { /** Returns current validator addresses for own-proposal detection. Set via register(). */ private getOwnValidatorAddresses?: () => string[]; + /** P2P proposal pool access for deciding when retained proposals should block archiver processing. */ + private p2pClient?: Pick; + private checkpointProposalValidationFailureCallback?: CheckpointProposalValidationFailureCallback; constructor( @@ -213,6 +222,7 @@ export class ProposalHandler { /** * Registers handlers for block and checkpoint proposals on the p2p client. + * Records the p2p client so validation can inspect retained proposals. * Block proposals are registered for non-validator nodes (validators register their own enhanced handler). * The all-nodes checkpoint proposal handler is always registered for validation, caching, and pipelining. * @param archiver - Archiver reference for setting proposed checkpoints (pipelining) @@ -224,6 +234,7 @@ export class ProposalHandler { archiver?: Pick, getOwnValidatorAddresses?: () => string[], ): ProposalHandler { + this.p2pClient = p2pClient; this.archiver = archiver; this.getOwnValidatorAddresses = getOwnValidatorAddresses; @@ -362,6 +373,16 @@ export class ProposalHandler { return { isValid: false, reason: 'invalid_proposal' }; } + const retainedSlotValidation = await this.validateNewBlockInSlot(proposal); + if (!retainedSlotValidation.isValid) { + this.log.info(`Block proposal conflicts with retained proposals, skipping archiver processing`, { + ...proposalInfo, + indexWithinCheckpoint: proposal.indexWithinCheckpoint, + reason: retainedSlotValidation.reason, + }); + return { isValid: false, blockNumber: proposal.blockNumber, reason: retainedSlotValidation.reason }; + } + // Ensure the block source is synced before checking for existing blocks, // since a proposed checkpoint prune may remove blocks we'd otherwise find. // This affects mostly the block_number_already_exists check, since a pending @@ -500,6 +521,26 @@ export class ProposalHandler { return { isValid: true, blockNumber, reexecutionResult }; } + private async validateNewBlockInSlot(blockProposal: BlockProposal): Promise { + if (!this.p2pClient) { + return { isValid: true }; + } + + const { blockProposals, checkpointProposals } = await this.p2pClient.getProposalsForSlot(blockProposal.slotNumber); + + if (checkpointProposals.length === 0) { + return { isValid: true }; + } else if (checkpointProposals.length > 1) { + return { isValid: false, reason: 'checkpoint_proposal_equivocation' }; + } else { + const checkpointProposal = checkpointProposals[0]; + const terminalBlock = blockProposals.find(block => block.archive.equals(checkpointProposal.archive)); + return terminalBlock !== undefined && blockProposal.indexWithinCheckpoint > terminalBlock.indexWithinCheckpoint + ? { isValid: false, reason: 'block_proposal_beyond_checkpoint' } + : { isValid: true }; + } + } + private async getParentBlock(proposal: BlockProposal): Promise<'genesis' | BlockData | undefined> { const parentArchive = proposal.blockHeader.lastArchive.root; const config = this.checkpointsBuilder.getConfig(); diff --git a/yarn-project/validator-client/src/validator.test.ts b/yarn-project/validator-client/src/validator.test.ts index 63c0d5394359..d582a73c3575 100644 --- a/yarn-project/validator-client/src/validator.test.ts +++ b/yarn-project/validator-client/src/validator.test.ts @@ -117,6 +117,7 @@ describe('ValidatorClient', () => { p2pClient.getCheckpointAttestationsForSlot.mockImplementation(() => Promise.resolve([])); p2pClient.handleAuthRequestFromPeer.mockResolvedValue(StatusMessage.random()); p2pClient.broadcastCheckpointAttestations.mockResolvedValue(); + p2pClient.getProposalsForSlot.mockResolvedValue({ blockProposals: [], checkpointProposals: [] }); checkpointsBuilder = mock(); checkpointsBuilder.getConfig.mockReturnValue({ l1GenesisTime: 1n, @@ -520,6 +521,121 @@ describe('ValidatorClient', () => { expect(isValid).toBe(true); }); + it('does not push a block proposal beyond a retained checkpoint terminal block to the archiver', async () => { + validatorClient.updateConfig({ skipPushProposedBlocksToArchiver: false }); + validatorClient.getProposalHandler().register(p2pClient, true); + + const signer = Secp256k1Signer.random(); + const emptyInHash = computeInHashFromL1ToL2Messages([]); + const checkpointProposal = await makeCheckpointProposal({ + signer, + checkpointHeader: makeCheckpointHeader(1, { slotNumber: proposal.slotNumber, inHash: emptyInHash }), + archiveRoot: Fr.random(), + lastBlock: { + blockHeader: makeBlockHeader(1, { blockNumber, slotNumber: proposal.slotNumber }), + indexWithinCheckpoint: IndexWithinCheckpoint(0), + txHashes: proposal.txHashes, + }, + }); + const terminalBlock = checkpointProposal.getBlockProposal()!; + + const terminalGlobals = terminalBlock.blockHeader.globalVariables; + const laterBlockHeader = makeBlockHeader(2, { + lastArchive: new AppendOnlyTreeSnapshot(terminalBlock.archive, terminalBlock.blockNumber), + blockNumber: BlockNumber(terminalBlock.blockNumber + 1), + slotNumber: proposal.slotNumber, + chainId: terminalGlobals.chainId, + version: terminalGlobals.version, + timestamp: terminalGlobals.timestamp, + coinbase: terminalGlobals.coinbase, + feeRecipient: terminalGlobals.feeRecipient, + gasFees: terminalGlobals.gasFees, + }); + const laterBlock = await makeBlockProposal({ + signer, + blockHeader: laterBlockHeader, + indexWithinCheckpoint: IndexWithinCheckpoint(1), + inHash: emptyInHash, + archiveRoot: Fr.random(), + }); + + epochCache.getProposerAttesterAddressInSlot.mockResolvedValue(signer.address); + p2pClient.getProposalsForSlot.mockResolvedValue({ + blockProposals: [terminalBlock, laterBlock], + checkpointProposals: [checkpointProposal.toCore()], + }); + + const terminalBlockData = { + header: terminalBlock.blockHeader, + archive: new AppendOnlyTreeSnapshot(terminalBlock.archive, terminalBlock.blockNumber), + blockHash: BlockHash.random(), + checkpointNumber: CheckpointNumber(1), + indexWithinCheckpoint: terminalBlock.indexWithinCheckpoint, + } as unknown as BlockData; + blockSource.getBlockData.mockImplementation(query => + Promise.resolve('number' in query ? undefined : terminalBlockData), + ); + + const blockAddedIfProcessed = { + ...blockBuildResult.block, + header: laterBlock.blockHeader, + body: { txEffects: times(laterBlock.txHashes.length, () => TxEffect.empty()) }, + archive: new AppendOnlyTreeSnapshot(laterBlock.archive, laterBlock.blockNumber), + checkpointNumber: CheckpointNumber(1), + indexWithinCheckpoint: laterBlock.indexWithinCheckpoint, + } as unknown as L2Block; + mockCheckpointBuilder.buildBlock.mockResolvedValue({ + ...blockBuildResult, + block: blockAddedIfProcessed, + numTxs: laterBlock.txHashes.length, + }); + worldState.fork.mockResolvedValue({ + close: () => Promise.resolve(), + [Symbol.asyncDispose]: () => Promise.resolve(), + getTreeInfo: () => Promise.resolve({ root: laterBlock.blockHeader.lastArchive.root.toBuffer() }), + } as never); + + const result = await validatorClient.getProposalHandler().handleBlockProposal(laterBlock, sender, true); + + expect(result).toMatchObject({ isValid: false, reason: 'block_proposal_beyond_checkpoint' }); + expect(blockSource.addBlock).not.toHaveBeenCalled(); + }); + + it('does not push a block proposal to the archiver when retained checkpoint proposals equivocate', async () => { + validatorClient.updateConfig({ skipPushProposedBlocksToArchiver: false }); + validatorClient.getProposalHandler().register(p2pClient, true); + + const emptyInHash = computeInHashFromL1ToL2Messages([]); + const checkpointProposal = await makeCheckpointProposal({ + checkpointHeader: makeCheckpointHeader(1, { slotNumber: proposal.slotNumber, inHash: emptyInHash }), + archiveRoot: Fr.random(), + lastBlock: { + blockHeader: makeBlockHeader(1, { blockNumber, slotNumber: proposal.slotNumber }), + indexWithinCheckpoint: IndexWithinCheckpoint(0), + txHashes: proposal.txHashes, + }, + }); + const equivocatedCheckpointProposal = await makeCheckpointProposal({ + checkpointHeader: makeCheckpointHeader(1, { slotNumber: proposal.slotNumber, inHash: emptyInHash }), + archiveRoot: Fr.random(), + lastBlock: { + blockHeader: makeBlockHeader(1, { blockNumber, slotNumber: proposal.slotNumber }), + indexWithinCheckpoint: IndexWithinCheckpoint(0), + txHashes: proposal.txHashes, + }, + }); + + p2pClient.getProposalsForSlot.mockResolvedValue({ + blockProposals: [proposal], + checkpointProposals: [checkpointProposal.toCore(), equivocatedCheckpointProposal.toCore()], + }); + + const result = await validatorClient.getProposalHandler().handleBlockProposal(proposal, sender, true); + + expect(result).toMatchObject({ isValid: false, reason: 'checkpoint_proposal_equivocation' }); + expect(blockSource.addBlock).not.toHaveBeenCalled(); + }); + it('uses the next wall-clock slot as the tx collection deadline for pipelined proposals', async () => { const pipelineOffsetInSlots = 1; epochCache.isProposerPipeliningEnabled.mockReturnValue(true);