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
41 changes: 41 additions & 0 deletions yarn-project/validator-client/src/proposal_handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ export type BlockProposalValidationFailureReason =
| 'failed_txs'
| 'initial_state_mismatch'
| 'timeout'
| 'block_proposal_beyond_checkpoint'
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we add this to the list of slashable block proposal validation failures?

| 'checkpoint_proposal_equivocation'
| 'unknown_error';

type ReexecuteTransactionsResult = {
Expand Down Expand Up @@ -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;
Expand All @@ -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<P2P, 'getProposalsForSlot'>;

private checkpointProposalValidationFailureCallback?: CheckpointProposalValidationFailureCallback;

constructor(
Expand Down Expand Up @@ -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)
Expand All @@ -224,6 +234,7 @@ export class ProposalHandler {
archiver?: Pick<Archiver, 'addProposedCheckpoint' | 'getL1Constants'>,
getOwnValidatorAddresses?: () => string[],
): ProposalHandler {
this.p2pClient = p2pClient;
this.archiver = archiver;
this.getOwnValidatorAddresses = getOwnValidatorAddresses;

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -500,6 +521,26 @@ export class ProposalHandler {
return { isValid: true, blockNumber, reexecutionResult };
}

private async validateNewBlockInSlot(blockProposal: BlockProposal): Promise<BlockProposalSlotValidationResult> {
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();
Expand Down
116 changes: 116 additions & 0 deletions yarn-project/validator-client/src/validator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<FullNodeCheckpointsBuilder>();
checkpointsBuilder.getConfig.mockReturnValue({
l1GenesisTime: 1n,
Expand Down Expand Up @@ -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);
Expand Down
Loading