Skip to content
Draft
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
156 changes: 156 additions & 0 deletions yarn-project/aztec/src/testing/checkpoint_auto_prover.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import type { RollupCheatCodes } from '@aztec/ethereum/test';
import { BlockNumber, CheckpointNumber } from '@aztec/foundation/branded-types';
import { createLogger } from '@aztec/foundation/log';
import type { TypedEventEmitter } from '@aztec/foundation/types';
import type { SequencerEvents } from '@aztec/sequencer-client';
import type { L2BlockSource, L2Tips } from '@aztec/stdlib/block';

import { jest } from '@jest/globals';
import EventEmitter from 'node:events';

import { CheckpointAutoProver } from './checkpoint_auto_prover.js';

/** Builds a minimal L2Tips object with the given checkpointed checkpoint number. */
function makeTips(checkpointedNumber: number): L2Tips {
const cp = CheckpointNumber(checkpointedNumber);
const blockId = { number: BlockNumber(checkpointedNumber), hash: '0x' };
return {
proposed: blockId,
checkpointed: { block: blockId, checkpoint: { number: cp, hash: '0x' } },
proposedCheckpoint: { block: blockId, checkpoint: { number: cp, hash: '0x' } },
proven: { block: blockId, checkpoint: { number: cp, hash: '0x' } },
finalized: { block: blockId, checkpoint: { number: cp, hash: '0x' } },
};
}

describe('CheckpointAutoProver', () => {
const log = createLogger('test:checkpoint-auto-prover');

let sequencer: TypedEventEmitter<SequencerEvents>;
let getL2Tips: ReturnType<typeof jest.fn<() => Promise<L2Tips>>>;
let getBlocks: ReturnType<typeof jest.fn>;
let markAsProven: ReturnType<typeof jest.fn<(n?: CheckpointNumber) => Promise<void>>>;
let prover: CheckpointAutoProver;

beforeEach(() => {
// Use a real EventEmitter cast to the typed interface so emits actually fire listeners.
sequencer = new EventEmitter() as unknown as TypedEventEmitter<SequencerEvents>;

getL2Tips = jest.fn<() => Promise<L2Tips>>();
getBlocks = jest.fn<() => Promise<unknown[]>>().mockResolvedValue([]);

markAsProven = jest.fn<(n?: CheckpointNumber) => Promise<void>>().mockResolvedValue(undefined as unknown as void);

prover = new CheckpointAutoProver(
{
sequencer,
l2BlockSource: { getL2Tips, getBlocks } as unknown as L2BlockSource,
rollupCheatCodes: { markAsProven } as unknown as RollupCheatCodes,
log,
},
/* promoteTimeoutSecs= */ 5,
);
});

afterEach(async () => {
await prover.stop();
});

it('marks checkpoint proven after archiver promotes the tip', async () => {
const checkpoint = CheckpointNumber(3);

// Archiver initially reports checkpoint 0, then 3 after one poll.
getL2Tips.mockResolvedValueOnce(makeTips(0)).mockResolvedValueOnce(makeTips(0)).mockResolvedValue(makeTips(3));

prover.start();
(sequencer as EventEmitter).emit('checkpoint-published', { checkpoint, slot: 10 });

await prover.trigger();

expect(markAsProven).toHaveBeenCalledWith(checkpoint);
});

it('does not mark as proven if archiver never promotes (timeout path)', async () => {
const checkpoint = CheckpointNumber(5);

// Archiver always returns stale tip (checkpoint 0).
getL2Tips.mockResolvedValue(makeTips(0));

// Use a very short timeout so the test is fast.
prover = new CheckpointAutoProver(
{
sequencer,
l2BlockSource: { getL2Tips, getBlocks } as unknown as L2BlockSource,
rollupCheatCodes: { markAsProven } as unknown as RollupCheatCodes,
log,
},
/* promoteTimeoutSecs= */ 1,
);

prover.start();
(sequencer as EventEmitter).emit('checkpoint-published', { checkpoint, slot: 10 });

// trigger() should return once the timed-out proveCheckpoint completes (no hang).
await prover.trigger();

// markAsProven must NOT have been called because the archiver never promoted.
expect(markAsProven).not.toHaveBeenCalled();
}, 10_000);

it('stops cleanly while a wait is in flight', async () => {
const checkpoint = CheckpointNumber(2);

// Archiver takes a while to promote; stop() is called first.
let resolvePromotion!: () => void;
getL2Tips.mockImplementation(
() =>
new Promise<L2Tips>(resolve => {
resolvePromotion = () => resolve(makeTips(2));
}),
);

prover.start();
(sequencer as EventEmitter).emit('checkpoint-published', { checkpoint, slot: 10 });

// Let the worker enter the retryUntil loop once, then stop.
await new Promise(resolve => setImmediate(resolve));

// Resolve the pending poll so retryUntil can exit cleanly when stop() drains.
resolvePromotion();

// stop() should await the in-flight worker and return without hanging.
await prover.stop();

// The wait resolved so markAsProven may or may not have been called — but stop()
// must have returned without throwing or hanging.
});

it('processes multiple checkpoint-published events in order', async () => {
const cp1 = CheckpointNumber(1);
const cp2 = CheckpointNumber(2);
const cp3 = CheckpointNumber(3);

const promotedAt: CheckpointNumber[] = [];

// The archiver tip advances to 3 immediately, so each checkpoint's wait resolves right away.
getL2Tips.mockResolvedValue(makeTips(3));
markAsProven.mockImplementation((n?: CheckpointNumber) => {
if (n !== undefined) {
promotedAt.push(n);
}
return Promise.resolve();
});

prover.start();

// Emit all three before the worker has a chance to run.
(sequencer as EventEmitter).emit('checkpoint-published', { checkpoint: cp1, slot: 1 });
(sequencer as EventEmitter).emit('checkpoint-published', { checkpoint: cp2, slot: 2 });
(sequencer as EventEmitter).emit('checkpoint-published', { checkpoint: cp3, slot: 3 });

await prover.trigger();

// All three should have been processed in emission order.
expect(promotedAt).toEqual([cp1, cp2, cp3]);
});
});
141 changes: 141 additions & 0 deletions yarn-project/aztec/src/testing/checkpoint_auto_prover.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import type { RollupCheatCodes } from '@aztec/ethereum/test';
import { BlockNumber, type CheckpointNumber } from '@aztec/foundation/branded-types';
import { TimeoutError } from '@aztec/foundation/error';
import type { Logger } from '@aztec/foundation/log';
import { retryUntil } from '@aztec/foundation/retry';
import type { TypedEventEmitter } from '@aztec/foundation/types';
import type { SequencerEvents } from '@aztec/sequencer-client';
import type { L2BlockSource } from '@aztec/stdlib/block';

/** Default timeout in seconds to wait for the archiver to promote a checkpoint. */
const DEFAULT_PROMOTE_TIMEOUT_SECS = 30;

/** Dependencies injected into CheckpointAutoProver. */
export type CheckpointAutoProverDeps = {
sequencer: TypedEventEmitter<SequencerEvents>;
l2BlockSource: L2BlockSource;
rollupCheatCodes: RollupCheatCodes;
log: Logger;
};

/**
* Test helper that replaces the `markAsProven` polling loop in `AnvilTestWatcher`.
*
* Subscribes to the sequencer's `checkpoint-published` event. When fired, waits for the
* local archiver to have promoted the checkpoint (i.e. `getL2Tips().checkpointed.checkpoint.number
* >= checkpointNumber` and the checkpoint's blocks are locally readable), then calls
* `rollupCheatCodes.markAsProven(checkpointNumber)`.
*/
export class CheckpointAutoProver {
private readonly sequencer: TypedEventEmitter<SequencerEvents>;
private readonly l2BlockSource: L2BlockSource;
private readonly rollupCheatCodes: RollupCheatCodes;
private readonly log: Logger;
private readonly promoteTimeoutSecs: number;

/** Queue of checkpoints to prove, processed in order by the worker. */
private readonly queue: CheckpointNumber[] = [];
/** Promise tracking the currently-running worker so stop() can await it. */
private workerPromise: Promise<void> | undefined;
/** Set to true by stop() to signal the worker to exit after its current item. */
private stopped = false;

private readonly listener: (args: { checkpoint: CheckpointNumber; slot: unknown }) => void;

constructor(deps: CheckpointAutoProverDeps, promoteTimeoutSecs = DEFAULT_PROMOTE_TIMEOUT_SECS) {
this.sequencer = deps.sequencer;
this.l2BlockSource = deps.l2BlockSource;
this.rollupCheatCodes = deps.rollupCheatCodes;
this.log = deps.log;
this.promoteTimeoutSecs = promoteTimeoutSecs;

this.listener = ({ checkpoint }) => this.enqueue(checkpoint);
}

/** Subscribes to checkpoint-published events and starts the background worker. */
start() {
this.stopped = false;
this.sequencer.on('checkpoint-published', this.listener);
this.log.debug('CheckpointAutoProver started');
}

/**
* Unsubscribes from checkpoint-published events and waits for any in-flight prove to finish.
*/
async stop() {
this.stopped = true;
this.sequencer.off('checkpoint-published', this.listener);
await this.workerPromise;
this.log.debug('CheckpointAutoProver stopped');
}

/**
* Forces a synchronous wait: polls until the archiver's checkpointed tip is at least as high
* as the latest item in the queue (or the queue is empty) and all pending proves have finished.
* Useful in tests that want to assert state after a checkpoint is proven.
*/
async trigger() {
await this.workerPromise;
}

private enqueue(checkpointNumber: CheckpointNumber) {
this.log.debug(`Queuing checkpoint ${checkpointNumber} for proving`);
this.queue.push(checkpointNumber);
// Only one worker at a time; start it if it isn't already running.
if (!this.workerPromise) {
this.workerPromise = this.runWorker().finally(() => {
this.workerPromise = undefined;
});
}
}

private async runWorker() {
while (this.queue.length > 0 && !this.stopped) {
const checkpointNumber = this.queue.shift()!;
await this.proveCheckpoint(checkpointNumber);
}
}

private async proveCheckpoint(checkpointNumber: CheckpointNumber) {
this.log.verbose(`Waiting for archiver to promote checkpoint ${checkpointNumber}`);
try {
// Step 1: wait for the archiver's checkpointed tip to reach checkpointNumber.
await retryUntil(
async () => {
const tips = await this.l2BlockSource.getL2Tips();
return tips.checkpointed.checkpoint.number >= checkpointNumber || undefined;
},
`checkpoint ${checkpointNumber} to be promoted`,
this.promoteTimeoutSecs,
/* interval= */ 0.5,
);
} catch (e) {
if (e instanceof TimeoutError) {
this.log.warn(
`Timed out waiting for archiver to promote checkpoint ${checkpointNumber} after ${this.promoteTimeoutSecs}s; skipping markAsProven`,
{ checkpointNumber, timeoutSecs: this.promoteTimeoutSecs },
);
return;
}
throw e;
}

// Step 2: verify the checkpoint's blocks are locally readable.
try {
const blocks = await this.l2BlockSource.getBlocks({ from: BlockNumber(1), limit: 1, onlyCheckpointed: true });
this.log.debug(`Archiver has ${blocks.length} checkpointed block(s); proceeding to markAsProven`, {
checkpointNumber,
});
} catch {
this.log.warn(`Could not read checkpointed blocks for checkpoint ${checkpointNumber}; skipping markAsProven`, {
checkpointNumber,
});
return;
}

// Step 3: mark checkpoint as proven on the rollup contract.
this.log.verbose(`Marking checkpoint ${checkpointNumber} as proven`);
await this.rollupCheatCodes.markAsProven(checkpointNumber);
this.log.info(`Marked checkpoint ${checkpointNumber} as proven`, { checkpointNumber });
}
}
1 change: 1 addition & 0 deletions yarn-project/aztec/src/testing/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export { AnvilTestWatcher, type AnvilTestWatcherOpts } from './anvil_test_watcher.js';
export { CheckpointAutoProver, type CheckpointAutoProverDeps } from './checkpoint_auto_prover.js';
export { EthCheatCodes, RollupCheatCodes } from '@aztec/ethereum/test';
export { CheatCodes } from './cheat_codes.js';
export { EpochTestSettler } from './epoch_test_settler.js';
Expand Down
4 changes: 2 additions & 2 deletions yarn-project/end-to-end/src/e2e_2_pxes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { ChildContract } from '@aztec/noir-test-contracts.js/Child';

import { expect, jest } from '@jest/globals';

import { PIPELINING_SETUP_OPTS } from './fixtures/fixtures.js';
import { FAST_E2E_SETUP_OPTS } from './fixtures/fixtures.js';
import { deployToken, expectTokenBalance, mintTokensToPrivate } from './fixtures/token_utils.js';
import { setup, setupPXEAndGetWallet } from './fixtures/utils.js';
import { TestWallet } from './test-wallet/test_wallet.js';
Expand Down Expand Up @@ -53,7 +53,7 @@ describe('e2e_2_pxes', () => {
accounts: [accountAAddress],
logger,
teardown: teardownA,
} = await setup(1, { ...PIPELINING_SETUP_OPTS, numberOfInitialFundedAccounts: 3 }));
} = await setup(1, { ...FAST_E2E_SETUP_OPTS, numberOfInitialFundedAccounts: 3 }));

({
wallet: walletB,
Expand Down
4 changes: 2 additions & 2 deletions yarn-project/end-to-end/src/e2e_abi_types.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { AbiTypesContract } from '@aztec/noir-test-contracts.js/AbiTypes';

import { jest } from '@jest/globals';

import { PIPELINING_SETUP_OPTS } from './fixtures/fixtures.js';
import { FAST_E2E_SETUP_OPTS } from './fixtures/fixtures.js';
import { setup } from './fixtures/utils.js';

const TIMEOUT = 300_000;
Expand All @@ -31,7 +31,7 @@ describe('AbiTypes', () => {
teardown,
wallet,
accounts: [defaultAccountAddress],
} = await setup(1, { ...PIPELINING_SETUP_OPTS }));
} = await setup(1, { ...FAST_E2E_SETUP_OPTS }));
({ contract: abiTypesContract } = await AbiTypesContract.deploy(wallet).send({ from: defaultAccountAddress }));
});

Expand Down
4 changes: 2 additions & 2 deletions yarn-project/end-to-end/src/e2e_account_contracts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import { ChildContract } from '@aztec/noir-test-contracts.js/Child';
import { createPXE, getPXEConfig } from '@aztec/pxe/server';
import { deriveSigningKey } from '@aztec/stdlib/keys';

import { PIPELINING_SETUP_OPTS } from './fixtures/fixtures.js';
import { FAST_E2E_SETUP_OPTS } from './fixtures/fixtures.js';
import { setup } from './fixtures/utils.js';
import { TestWallet } from './test-wallet/test_wallet.js';
import { AztecNodeProxy } from './test-wallet/utils.js';
Expand Down Expand Up @@ -62,7 +62,7 @@ const itShouldBehaveLikeAnAccountContract = (
};

({ logger, teardown, aztecNode } = await setup(0, {
...PIPELINING_SETUP_OPTS,
...FAST_E2E_SETUP_OPTS,
initialFundedAccounts: [accountData],
}));
wallet = await TestWalletInternals.create(aztecNode);
Expand Down
4 changes: 2 additions & 2 deletions yarn-project/end-to-end/src/e2e_amm.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import type { TokenContract } from '@aztec/noir-contracts.js/Token';

import { jest } from '@jest/globals';

import { PIPELINING_SETUP_OPTS } from './fixtures/fixtures.js';
import { FAST_E2E_SETUP_OPTS } from './fixtures/fixtures.js';
import { deployToken, mintTokensToPrivate } from './fixtures/token_utils.js';
import { setup } from './fixtures/utils.js';
import type { TestWallet } from './test-wallet/test_wallet.js';
Expand Down Expand Up @@ -51,7 +51,7 @@ describe('AMM', () => {
wallet,
accounts: [adminAddress, liquidityProviderAddress, otherLiquidityProviderAddress, swapperAddress],
logger,
} = await setup(4, { ...PIPELINING_SETUP_OPTS }, { syncChainTip: 'checkpointed' }));
} = await setup(4, { ...FAST_E2E_SETUP_OPTS }, { syncChainTip: 'checkpointed' }));

({ contract: token0 } = await deployToken(wallet, adminAddress, 0n, logger));
({ contract: token1 } = await deployToken(wallet, adminAddress, 0n, logger));
Expand Down
4 changes: 2 additions & 2 deletions yarn-project/end-to-end/src/e2e_authwit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { ProtocolContractAddress } from '@aztec/protocol-contracts';
import { jest } from '@jest/globals';

import { sendThroughAuthwitProxy } from './fixtures/authwit_proxy.js';
import { DUPLICATE_NULLIFIER_ERROR, PIPELINING_SETUP_OPTS } from './fixtures/fixtures.js';
import { DUPLICATE_NULLIFIER_ERROR, FAST_E2E_SETUP_OPTS } from './fixtures/fixtures.js';
import { type EndToEndContext, ensureAccountContractsPublished, setup } from './fixtures/utils.js';
import type { TestWallet } from './test-wallet/test_wallet.js';

Expand All @@ -31,7 +31,7 @@ describe('e2e_authwit_tests', () => {
teardown,
wallet,
accounts: [account1Address, account2Address],
} = await setup(2, { ...PIPELINING_SETUP_OPTS }));
} = await setup(2, { ...FAST_E2E_SETUP_OPTS }));
await ensureAccountContractsPublished(wallet, [account1Address, account2Address]);

({ contract: auth } = await AuthWitTestContract.deploy(wallet).send({ from: account1Address }));
Expand Down
Loading
Loading