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
1 change: 1 addition & 0 deletions yarn-project/archiver/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,4 @@ export { ContractInstanceStore } from './store/contract_instance_store.js';
export { L2TipsCache } from './store/l2_tips_cache.js';

export { retrieveCheckpointsFromRollup, retrieveL2ProofVerifiedEvents } from './l1/data_retrieval.js';
export { CalldataRetriever } from './l1/calldata_retriever.js';

Large diffs are not rendered by default.

5 changes: 3 additions & 2 deletions yarn-project/ethereum/src/contracts/rollup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
type Account,
type GetContractReturnType,
type Hex,
type Log,
type StateOverride,
type WatchContractEventReturnType,
encodeAbiParameters,
Expand Down Expand Up @@ -1224,7 +1225,7 @@ export class RollupContract {
}

public listenToCheckpointInvalidated(
callback: (args: { checkpointNumber: CheckpointNumber }) => unknown,
callback: (args: { checkpointNumber: CheckpointNumber; event: Log }) => unknown,
): WatchContractEventReturnType {
return this.rollup.watchEvent.CheckpointInvalidated(
{},
Expand All @@ -1233,7 +1234,7 @@ export class RollupContract {
for (const log of logs) {
const args = log.args;
if (args.checkpointNumber !== undefined) {
callback({ checkpointNumber: CheckpointNumber.fromBigInt(args.checkpointNumber) });
callback({ checkpointNumber: CheckpointNumber.fromBigInt(args.checkpointNumber), event: log });
}
}
},
Expand Down
5 changes: 5 additions & 0 deletions yarn-project/ethereum/src/test/chain_monitor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,11 @@ export class ChainMonitor extends EventEmitter<ChainMonitorEventMap> {
});
}

public async waitUntilNextL2Slot(): Promise<void> {
const targetSlot = SlotNumber.add((await this.run()).l2SlotNumber, 1);
return this.waitUntilL2Slot(targetSlot);
}

public waitUntilL1Block(block: number | bigint): Promise<void> {
const targetBlock = typeof block === 'bigint' ? block.valueOf() : block;
if (this.l1BlockNumber >= targetBlock) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,13 @@ import type { TypedEventEmitter } from '@aztec/foundation/types';
import { type P2P, P2PClientState } from '@aztec/p2p';
import type { SlasherClientInterface } from '@aztec/slasher';
import { AztecAddress } from '@aztec/stdlib/aztec-address';
import { CommitteeAttestation, L2Block, type L2BlockSink, type L2BlockSource } from '@aztec/stdlib/block';
import {
CommitteeAttestation,
L2Block,
type L2BlockSink,
type L2BlockSource,
type ValidateCheckpointResult,
} from '@aztec/stdlib/block';
import {
Checkpoint,
type CheckpointData,
Expand Down Expand Up @@ -52,7 +58,7 @@ import type { TransactionReceipt } from 'viem';

import { DefaultSequencerConfig } from '../config.js';
import type { GlobalVariableBuilder } from '../global_variable_builder/global_builder.js';
import type { SequencerPublisher } from '../publisher/sequencer-publisher.js';
import type { InvalidateCheckpointRequest, SequencerPublisher } from '../publisher/sequencer-publisher.js';
import {
MockCheckpointBuilder,
MockCheckpointsBuilder,
Expand Down Expand Up @@ -743,6 +749,286 @@ describe('CheckpointProposalJob', () => {
});
});

describe('pipelining parent checkpoint validation', () => {
const parentCheckpointHeader = CheckpointHeader.empty();
const parentCheckpointHash = parentCheckpointHeader.hash().toString();

const proposedParent: ProposedCheckpointData = {
checkpointNumber: CheckpointNumber(1),
header: parentCheckpointHeader,
archive: new AppendOnlyTreeSnapshot(Fr.ZERO, 1),
checkpointOutHash: Fr.ZERO,
startBlock: BlockNumber(1),
blockCount: 1,
totalManaUsed: 5000n,
feeAssetPriceModifier: 100n,
};

let mismatchEvents: { slot: SlotNumber; checkpointNumber: CheckpointNumber; reason: string }[];

/** Creates a pipelined job for checkpoint 2, builds one block, and returns the job ready for executeAndAwait. */
async function createPipelinedJobWithBlock(
proposedCheckpointData?: ProposedCheckpointData,
): Promise<TestCheckpointProposalJob> {
checkpointNumber = CheckpointNumber(2);
epochCache.isProposerPipeliningEnabled.mockReturnValue(true);

const pipelinedJob = createCheckpointProposalJob({
targetSlot: SlotNumber(newSlotNumber + 1),
proposedCheckpointData,
});

// Listen for mismatch events on this job's emitter
mismatchEvents = [];
pipelinedJob.eventEmitter.on(
'pipelined-checkpoint-discarded',
(evt: { slot: SlotNumber; checkpointNumber: CheckpointNumber; reason: string }) => {
mismatchEvents.push(evt);
},
);

// Seed a block so the checkpoint builds successfully
const { txs, block } = await setupTxsAndBlock(p2p, globalVariables, 1, chainId);
// Re-create the checkpoint builder for checkpoint 2
const checkpointConstants = {
slotNumber: globalVariables.slotNumber,
timestamp: globalVariables.timestamp,
coinbase: globalVariables.coinbase,
feeRecipient: globalVariables.feeRecipient,
gasFees: globalVariables.gasFees,
chainId: globalVariables.chainId,
version: globalVariables.version,
};
checkpointBuilder = checkpointsBuilder.createCheckpointBuilder(checkpointConstants, checkpointNumber);
checkpointBuilder.seedBlocks([block], [txs]);
validatorClient.collectAttestations.mockResolvedValue(getAttestations(block));

return pipelinedJob;
}

/** Helper to set up l2BlockSource mocks for tips and synced slot. */
function mockL2BlockSource(opts: {
syncedSlot?: SlotNumber;
checkpointedNumber?: CheckpointNumber;
checkpointedHash?: string;
}) {
l2BlockSource.getSyncedL2SlotNumber.mockResolvedValue(opts.syncedSlot ?? SlotNumber(newSlotNumber));
l2BlockSource.getPendingChainValidationStatus.mockResolvedValue({ valid: true });
l2BlockSource.getL2Tips.mockResolvedValue({
proposed: { number: BlockNumber(1), hash: 'proposed-hash' },
checkpointed: {
block: { number: BlockNumber(1), hash: 'block-hash' },
checkpoint: {
number: opts.checkpointedNumber ?? CheckpointNumber(1),
hash: opts.checkpointedHash ?? parentCheckpointHash,
},
},
proposedCheckpoint: {
block: { number: BlockNumber(1), hash: 'block-hash' },
checkpoint: { number: CheckpointNumber(1), hash: parentCheckpointHash },
},
proven: {
block: { number: BlockNumber.ZERO, hash: 'proven-hash' },
checkpoint: { number: CheckpointNumber.ZERO, hash: 'proven-ckpt-hash' },
},
finalized: {
block: { number: BlockNumber.ZERO, hash: 'finalized-hash' },
checkpoint: { number: CheckpointNumber.ZERO, hash: 'finalized-ckpt-hash' },
},
});
}

it('proposes checkpoint when parent landed with matching hash and valid attestations', async () => {
const pipelinedJob = await createPipelinedJobWithBlock(proposedParent);
mockL2BlockSource({ checkpointedNumber: CheckpointNumber(1), checkpointedHash: parentCheckpointHash });

await pipelinedJob.executeAndAwait();

expect(publisher.enqueueProposeCheckpoint).toHaveBeenCalledTimes(1);
expect(publisher.sendRequestsAt).toHaveBeenCalled();
expect(mismatchEvents).toHaveLength(0);
});

it('proposes checkpoint when no proposed parent and none appeared on L1', async () => {
const pipelinedJob = await createPipelinedJobWithBlock(undefined);
mockL2BlockSource({ checkpointedNumber: CheckpointNumber(0) });

await pipelinedJob.executeAndAwait();

expect(publisher.enqueueProposeCheckpoint).toHaveBeenCalledTimes(1);
expect(publisher.sendRequestsAt).toHaveBeenCalled();
expect(mismatchEvents).toHaveLength(0);
});

it('skips proposal with archiver-sync-timeout when archiver does not sync in time', async () => {
const pipelinedJob = await createPipelinedJobWithBlock(proposedParent);
l2BlockSource.getSyncedL2SlotNumber.mockResolvedValue(SlotNumber(0));

await pipelinedJob.executeAndAwait();

expect(publisher.enqueueProposeCheckpoint).not.toHaveBeenCalled();
expect(publisher.sendRequestsAt).toHaveBeenCalled();
expect(mismatchEvents).toEqual([expect.objectContaining({ reason: 'archiver-sync-timeout' })]);
expect(metrics.recordPipelineParentCheckpointMismatch).toHaveBeenCalledWith('archiver-sync-timeout');
}, 120_000);

it('skips proposal with parent-not-on-l1 when parent checkpoint did not land', async () => {
const pipelinedJob = await createPipelinedJobWithBlock(proposedParent);
mockL2BlockSource({ checkpointedNumber: CheckpointNumber(0) });

await pipelinedJob.executeAndAwait();

expect(publisher.enqueueProposeCheckpoint).not.toHaveBeenCalled();
expect(publisher.sendRequestsAt).toHaveBeenCalled();
expect(mismatchEvents).toEqual([expect.objectContaining({ reason: 'parent-not-on-l1' })]);
expect(metrics.recordPipelineParentCheckpointMismatch).toHaveBeenCalledWith('parent-not-on-l1');
});

it('skips proposal with parent-hash-mismatch when parent landed with different hash', async () => {
const pipelinedJob = await createPipelinedJobWithBlock(proposedParent);
mockL2BlockSource({ checkpointedNumber: CheckpointNumber(1), checkpointedHash: 'different-hash' });

await pipelinedJob.executeAndAwait();

expect(publisher.enqueueProposeCheckpoint).not.toHaveBeenCalled();
expect(publisher.sendRequestsAt).toHaveBeenCalled();
expect(mismatchEvents).toEqual([expect.objectContaining({ reason: 'parent-hash-mismatch' })]);
expect(metrics.recordPipelineParentCheckpointMismatch).toHaveBeenCalledWith('parent-hash-mismatch');
});

it('skips proposal and enqueues invalidation with parent-invalid-attestations', async () => {
const pipelinedJob = await createPipelinedJobWithBlock(proposedParent);
mockL2BlockSource({ checkpointedNumber: CheckpointNumber(1), checkpointedHash: parentCheckpointHash });

const invalidValidation: ValidateCheckpointResult = {
valid: false,
reason: 'invalid-attestation',
checkpoint: {
archive: Fr.random(),
lastArchive: Fr.random(),
slotNumber: SlotNumber(1),
checkpointNumber: CheckpointNumber(1),
timestamp: 0n,
},
committee: [EthAddress.random()],
epoch: EpochNumber.ZERO,
seed: 0n,
attestors: [EthAddress.random()],
invalidIndex: 0,
attestations: [CommitteeAttestation.random()],
};
l2BlockSource.getPendingChainValidationStatus.mockResolvedValue(invalidValidation);

const fakeRequest = { fake: true } as unknown as InvalidateCheckpointRequest;
publisher.simulateInvalidateCheckpoint.mockResolvedValue(fakeRequest);

await pipelinedJob.executeAndAwait();

expect(publisher.enqueueProposeCheckpoint).not.toHaveBeenCalled();
expect(publisher.simulateInvalidateCheckpoint).toHaveBeenCalledWith(invalidValidation);
expect(publisher.enqueueInvalidateCheckpoint).toHaveBeenCalledWith(fakeRequest, expect.any(Object));
expect(publisher.sendRequestsAt).toHaveBeenCalled();
expect(mismatchEvents).toEqual([expect.objectContaining({ reason: 'parent-invalid-attestations' })]);
expect(metrics.recordPipelineParentCheckpointMismatch).toHaveBeenCalledWith('parent-invalid-attestations');
});

it('skips invalidation when skipInvalidateBlockAsProposer is set', async () => {
const pipelinedJob = await createPipelinedJobWithBlock(proposedParent);
pipelinedJob.updateConfig({ skipInvalidateBlockAsProposer: true });
mockL2BlockSource({ checkpointedNumber: CheckpointNumber(1), checkpointedHash: parentCheckpointHash });

l2BlockSource.getPendingChainValidationStatus.mockResolvedValue({
valid: false,
reason: 'invalid-attestation',
checkpoint: {
archive: Fr.random(),
lastArchive: Fr.random(),
slotNumber: SlotNumber(1),
checkpointNumber: CheckpointNumber(1),
timestamp: 0n,
},
committee: [EthAddress.random()],
epoch: EpochNumber.ZERO,
seed: 0n,
attestors: [EthAddress.random()],
invalidIndex: 0,
attestations: [CommitteeAttestation.random()],
});

await pipelinedJob.executeAndAwait();

expect(publisher.enqueueProposeCheckpoint).not.toHaveBeenCalled();
expect(publisher.simulateInvalidateCheckpoint).not.toHaveBeenCalled();
expect(publisher.enqueueInvalidateCheckpoint).not.toHaveBeenCalled();
expect(mismatchEvents).toEqual([expect.objectContaining({ reason: 'parent-invalid-attestations' })]);
});

it('enqueues invalidation when attestation collection fails and pending chain has invalid attestations', async () => {
const pipelinedJob = await createPipelinedJobWithBlock(proposedParent);
mockL2BlockSource({ checkpointedNumber: CheckpointNumber(1), checkpointedHash: parentCheckpointHash });

// Attestation collection fails — waitForAttestations will return undefined
validatorClient.collectAttestations.mockRejectedValue(new AttestationTimeoutError(0, 1, SlotNumber.ZERO));

const invalidValidation: ValidateCheckpointResult = {
valid: false,
reason: 'invalid-attestation',
checkpoint: {
archive: Fr.random(),
lastArchive: Fr.random(),
slotNumber: SlotNumber(1),
checkpointNumber: CheckpointNumber(1),
timestamp: 0n,
},
committee: [EthAddress.random()],
epoch: EpochNumber.ZERO,
seed: 0n,
attestors: [EthAddress.random()],
invalidIndex: 0,
attestations: [CommitteeAttestation.random()],
};
l2BlockSource.getPendingChainValidationStatus.mockResolvedValue(invalidValidation);

const fakeRequest = { fake: true } as unknown as InvalidateCheckpointRequest;
publisher.simulateInvalidateCheckpoint.mockResolvedValue(fakeRequest);

await pipelinedJob.executeAndAwait();

// No propose action since we didn't collect attestations
expect(publisher.enqueueProposeCheckpoint).not.toHaveBeenCalled();
// But we still enqueue invalidation so the chain is cleaned up for the next proposer
expect(publisher.simulateInvalidateCheckpoint).toHaveBeenCalledWith(invalidValidation);
expect(publisher.enqueueInvalidateCheckpoint).toHaveBeenCalledWith(fakeRequest, expect.any(Object));
expect(publisher.sendRequestsAt).toHaveBeenCalled();
});

it('does not enqueue invalidation when attestation collection fails but pending chain is valid', async () => {
const pipelinedJob = await createPipelinedJobWithBlock(proposedParent);
mockL2BlockSource({ checkpointedNumber: CheckpointNumber(1), checkpointedHash: parentCheckpointHash });

validatorClient.collectAttestations.mockRejectedValue(new AttestationTimeoutError(0, 1, SlotNumber.ZERO));

await pipelinedJob.executeAndAwait();

expect(publisher.enqueueProposeCheckpoint).not.toHaveBeenCalled();
expect(publisher.simulateInvalidateCheckpoint).not.toHaveBeenCalled();
expect(publisher.enqueueInvalidateCheckpoint).not.toHaveBeenCalled();
expect(publisher.sendRequestsAt).toHaveBeenCalled();
});

it('skips proposal with unexpected-parent-appeared when a new checkpoint appears without proposed parent', async () => {
const pipelinedJob = await createPipelinedJobWithBlock(undefined);
mockL2BlockSource({ checkpointedNumber: CheckpointNumber(2) });

await pipelinedJob.executeAndAwait();

expect(publisher.enqueueProposeCheckpoint).not.toHaveBeenCalled();
expect(publisher.sendRequestsAt).toHaveBeenCalled();
expect(mismatchEvents).toEqual([expect.objectContaining({ reason: 'unexpected-parent-appeared' })]);
expect(metrics.recordPipelineParentCheckpointMismatch).toHaveBeenCalledWith('unexpected-parent-appeared');
});
});

describe('multiple block mode', () => {
beforeEach(() => {
// Keep the real L1 publish budget and use the largest valid non-pipelined
Expand Down Expand Up @@ -1259,6 +1545,8 @@ describe('CheckpointProposalJob', () => {
});

class TestCheckpointProposalJob extends CheckpointProposalJob {
declare public eventEmitter: EventEmitter;

/** Override to be a no-op for testing - allows tests to run without timing delays */
public override waitUntilTimeInSlot(targetSecondsIntoSlot: number): Promise<void> {
this.log.warn(`Skipping waitUntilTimeInSlot(${targetSecondsIntoSlot}) in test`);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1072,6 +1072,28 @@ describe('CheckpointProposalJob Timing Tests', () => {

beforeEach(() => {
epochCache.isProposerPipeliningEnabled.mockReturnValue(true);

// Mock l2BlockSource methods needed by waitForValidParentCheckpointOnL1
l2BlockSource.getSyncedL2SlotNumber.mockResolvedValue(slotNumber);
l2BlockSource.getL2Tips.mockResolvedValue({
proposed: { number: BlockNumber.ZERO, hash: '' },
checkpointed: {
block: { number: BlockNumber.ZERO, hash: '' },
checkpoint: { number: CheckpointNumber(0), hash: '' },
},
proposedCheckpoint: {
block: { number: BlockNumber.ZERO, hash: '' },
checkpoint: { number: CheckpointNumber(0), hash: '' },
},
proven: {
block: { number: BlockNumber.ZERO, hash: '' },
checkpoint: { number: CheckpointNumber(0), hash: '' },
},
finalized: {
block: { number: BlockNumber.ZERO, hash: '' },
checkpoint: { number: CheckpointNumber(0), hash: '' },
},
});
});

it('sets attestation deadline to the target-slot publish cutoff when pipelining', async () => {
Expand Down
Loading
Loading