diff --git a/yarn-project/.claude/rules/typescript-style.md b/yarn-project/.claude/rules/typescript-style.md index 5ea500e2e736..0990cc1f0c00 100644 --- a/yarn-project/.claude/rules/typescript-style.md +++ b/yarn-project/.claude/rules/typescript-style.md @@ -330,4 +330,20 @@ mock.getData.mockImplementation((id: string) => { } return Promise.resolve(undefined); }); -``` \ No newline at end of file +``` + +## Arrow Function Bodies + +Use expression bodies instead of block bodies when the block only contains a `return`: + +```typescript +// Good: Expression body +items.map(item => item.value * 2) +fn(arg => expression(arg, foo)) + +// Bad: Block body with just a return +items.map(item => { return item.value * 2; }) +fn(arg => { return expression(arg, foo); }) +``` + +Block bodies are appropriate when the callback has multiple statements or side effects beyond the return. \ No newline at end of file diff --git a/yarn-project/archiver/src/archiver.ts b/yarn-project/archiver/src/archiver.ts index 3955cfc83c22..f77681a00824 100644 --- a/yarn-project/archiver/src/archiver.ts +++ b/yarn-project/archiver/src/archiver.ts @@ -342,19 +342,33 @@ export class Archiver extends ArchiverDataSourceBase implements L2BlockSink, Tra return Promise.resolve(this.synchronizer.getL1Timestamp()); } - public getSyncedL2SlotNumber(): Promise { + public async getSyncedL2SlotNumber(): Promise { + // The synced L2 slot is the latest slot for which we have all L1 data, + // either because we have seen all L1 blocks for that slot, or because + // we have seen the corresponding checkpoint. + + let slotFromL1Sync: SlotNumber | undefined; const l1Timestamp = this.synchronizer.getL1Timestamp(); - if (l1Timestamp === undefined) { - return Promise.resolve(undefined); + if (l1Timestamp !== undefined) { + const nextL1BlockSlot = getSlotAtNextL1Block(l1Timestamp, this.l1Constants); + if (Number(nextL1BlockSlot) > 0) { + slotFromL1Sync = SlotNumber.add(nextL1BlockSlot, -1); + } + } + + let slotFromCheckpoint: SlotNumber | undefined; + const latestCheckpointNumber = await this.store.getSynchedCheckpointNumber(); + if (latestCheckpointNumber > 0) { + const checkpointData = await this.store.getCheckpointData(latestCheckpointNumber); + if (checkpointData) { + slotFromCheckpoint = checkpointData.header.slotNumber; + } } - // The synced slot is the last L2 slot whose all L1 blocks have been processed. - // If the next L1 block (at l1Timestamp + ethereumSlotDuration) falls in slot N, - // then we've fully synced slot N-1. - const nextL1BlockSlot = getSlotAtNextL1Block(l1Timestamp, this.l1Constants); - if (Number(nextL1BlockSlot) === 0) { - return Promise.resolve(undefined); + + if (slotFromL1Sync === undefined && slotFromCheckpoint === undefined) { + return undefined; } - return Promise.resolve(SlotNumber(nextL1BlockSlot - 1)); + return SlotNumber(Math.max(slotFromL1Sync ?? 0, slotFromCheckpoint ?? 0)); } public async getSyncedL2EpochNumber(): Promise { diff --git a/yarn-project/aztec-node/src/aztec-node/server.ts b/yarn-project/aztec-node/src/aztec-node/server.ts index 2a2355ff28f3..b2c3bdd99598 100644 --- a/yarn-project/aztec-node/src/aztec-node/server.ts +++ b/yarn-project/aztec-node/src/aztec-node/server.ts @@ -458,6 +458,14 @@ export class AztecNodeService implements AztecNode, AztecNodeAdmin, Traceable { }) .catch(err => log.error('Failed to start p2p services after archiver sync', err)); + const globalVariableBuilder = new GlobalVariableBuilder(dateProvider, publicClient, { + l1Contracts: config.l1Contracts, + ethereumSlotDuration: config.ethereumSlotDuration, + rollupVersion: BigInt(config.rollupVersion), + l1GenesisTime, + slotDuration: Number(slotDuration), + }); + // Validator enabled, create/start relevant service let sequencer: SequencerClient | undefined; let slasherClient: SlasherClientInterface | undefined; @@ -520,6 +528,7 @@ export class AztecNodeService implements AztecNode, AztecNodeAdmin, Traceable { dateProvider, blobClient, nodeKeyStore: keyStoreManager!, + globalVariableBuilder, }); } @@ -553,13 +562,6 @@ export class AztecNodeService implements AztecNode, AztecNodeAdmin, Traceable { } } - const globalVariableBuilder = new GlobalVariableBuilder({ - ...config, - rollupVersion: BigInt(config.rollupVersion), - l1GenesisTime, - slotDuration: Number(slotDuration), - }); - const node = new AztecNodeService( config, p2pClient, diff --git a/yarn-project/end-to-end/src/e2e_epochs/epochs_missed_l1_slot.test.ts b/yarn-project/end-to-end/src/e2e_epochs/epochs_missed_l1_slot.test.ts new file mode 100644 index 000000000000..1fe3b1a9a47f --- /dev/null +++ b/yarn-project/end-to-end/src/e2e_epochs/epochs_missed_l1_slot.test.ts @@ -0,0 +1,152 @@ +import type { ChainMonitorEventMap } from '@aztec/ethereum/test'; +import { CheckpointNumber, SlotNumber } from '@aztec/foundation/branded-types'; +import { AbortError } from '@aztec/foundation/error'; +import { sleep } from '@aztec/foundation/sleep'; +import { executeTimeout } from '@aztec/foundation/timer'; +import { SequencerState } from '@aztec/sequencer-client'; +import { getTimestampForSlot } from '@aztec/stdlib/epoch-helpers'; + +import { jest } from '@jest/globals'; + +import { EpochsTestContext } from './epochs_test.js'; + +jest.setTimeout(1000 * 60 * 10); + +// Validates that the sequencer can build a block in an L2 slot even when the archiver hasn't synced +// all L1 blocks of the previous slot. This happens when an L1 slot is missed (no block produced). +// The fix relies on getSyncedL2SlotNumber using the latest synced checkpoint slot as a signal, +// bypassing the stale L1 timestamp when L1 blocks are missing. +// Regression test for https://github.com/AztecProtocol/aztec-packages/issues/14766 +describe('e2e_epochs/epochs_missed_l1_slot', () => { + let test: EpochsTestContext; + + // Use enough L1 slots per L2 slot to have room for pausing mining mid-slot. + // With 6 L1 slots per L2 slot (L1=8s, L2=48s), we have plenty of time to + // publish a checkpoint and pause mining without accidentally skipping a slot. + const L1_SLOTS_PER_L2_SLOT = 6; + + beforeEach(async () => { + test = await EpochsTestContext.setup({ + numberOfAccounts: 0, + minTxsPerBlock: 0, + aztecSlotDurationInL1Slots: L1_SLOTS_PER_L2_SLOT, + startProverNode: false, + aztecProofSubmissionEpochs: 1024, + disableAnvilTestWatcher: true, + enforceTimeTable: true, + }); + }); + + afterEach(async () => { + jest.restoreAllMocks(); + await test.teardown(); + }); + + it('builds a block after missed L1 slots when previous checkpoint is synced', async () => { + const { logger, constants, monitor, context } = test; + const eth = context.cheatCodes.eth; + const L1_BLOCK_TIME = test.L1_BLOCK_TIME_IN_S; + const L2_SLOT_DURATION = test.L2_SLOT_DURATION_IN_S; + + // Step 1: Wait for a checkpoint that's published NOT in the last L1 slot of its L2 slot. + // We need the checkpoint to land early enough that when we pause mining, the archiver's + // L1 timestamp is still in the middle of the slot (not at the end). + logger.info('Waiting for a checkpoint published early in its L2 slot...'); + const checkpointEvent = await executeTimeout( + signal => + new Promise((res, rej) => { + const handleCheckpoint = (...[ev]: ChainMonitorEventMap['checkpoint']) => { + // Skip the initial checkpoint (genesis state). + if (ev.checkpointNumber === 0) { + return; + } + const slotStart = getTimestampForSlot(ev.l2SlotNumber, constants); + const lastL1SlotStart = slotStart + BigInt(L2_SLOT_DURATION - L1_BLOCK_TIME); + if (ev.timestamp < lastL1SlotStart) { + logger.info( + `Checkpoint ${ev.checkpointNumber} in slot ${ev.l2SlotNumber} at L1 timestamp ${ev.timestamp}`, + { slotStart, lastL1SlotStart }, + ); + res(ev); + monitor.off('checkpoint', handleCheckpoint); + } else { + logger.info( + `Skipping checkpoint ${ev.checkpointNumber}: published at ${ev.timestamp} (last L1 slot starts at ${lastL1SlotStart})`, + ); + } + }; + signal.onabort = () => { + monitor.off('checkpoint', handleCheckpoint); + rej(new AbortError()); + }; + monitor.on('checkpoint', handleCheckpoint); + }), + 60_000, + 'Wait for early checkpoint', + ); + + const checkpointSlotNumber = checkpointEvent.l2SlotNumber; + const nextSlotNumber = SlotNumber(checkpointSlotNumber + 1); + const nextSlotTimestamp = Number(getTimestampForSlot(nextSlotNumber, constants)); + + logger.info(`Using checkpoint ${checkpointEvent.checkpointNumber} in L2 slot ${checkpointSlotNumber}`, { + nextSlotNumber, + nextSlotTimestamp, + }); + + // Step 2: Wait briefly for the sequencer to finish its current work cycle, then pause mining. + await sleep(1500); + + logger.info('Pausing L1 block production (simulating missed L1 slots)...'); + await eth.setAutomine(false); + await eth.setIntervalMining(0, { silent: true }); + + const frozenL1Timestamp = await eth.timestamp(); + logger.info(`L1 mining paused at L1 timestamp ${frozenL1Timestamp}`); + + // Step 3: Wait until the sequencer reaches PUBLISHING_CHECKPOINT during the mining pause. + // With the fix: the sequencer sees the checkpoint for slot N, so getSyncedL2SlotNumber + // returns N, checkSync passes for slot N+1, and it advances all the way to publishing. + // Without the fix: getSyncedL2SlotNumber is stuck at N-1, checkSync fails, sequencer + // stays in IDLE/SYNCHRONIZING and never reaches PUBLISHING_CHECKPOINT. + const sequencer = context.sequencer!.getSequencer(); + + logger.info('Waiting for sequencer to reach PUBLISHING_CHECKPOINT during mining pause...'); + await executeTimeout( + signal => + new Promise((res, rej) => { + const stateListener = ({ newState }: { newState: SequencerState }) => { + if (newState === SequencerState.PUBLISHING_CHECKPOINT) { + sequencer.off('state-changed', stateListener); + res(); + } + }; + signal.onabort = () => { + sequencer.off('state-changed', stateListener); + rej(new AbortError()); + }; + sequencer.on('state-changed', stateListener); + }), + L2_SLOT_DURATION * 2 * 1000, + 'Wait for sequencer to reach PUBLISHING_CHECKPOINT', + ); + + logger.info('Sequencer reached PUBLISHING_CHECKPOINT during mining pause'); + + // Step 4: Resume mining so the pending L1 tx lands and the test can clean up. + logger.info('Resuming L1 block production...'); + const resumeTimestamp = Math.floor(context.dateProvider.now() / 1000); + await eth.setNextBlockTimestamp(resumeTimestamp); + await eth.mine(); + await eth.setIntervalMining(L1_BLOCK_TIME); + + // Step 5: Wait for the next checkpoint to confirm the block was actually published. + const finalCheckpoint = CheckpointNumber(checkpointEvent.checkpointNumber + 1); + logger.info(`Waiting for checkpoint ${finalCheckpoint}...`); + await test.waitUntilCheckpointNumber(finalCheckpoint, 60); + await monitor.run(); + logger.info(`Checkpoint ${finalCheckpoint} published in slot ${monitor.l2SlotNumber}`); + + expect(monitor.checkpointNumber).toBeGreaterThanOrEqual(finalCheckpoint); + }); +}); diff --git a/yarn-project/end-to-end/src/e2e_l1_publisher/e2e_l1_publisher.test.ts b/yarn-project/end-to-end/src/e2e_l1_publisher/e2e_l1_publisher.test.ts index 394f86adaa7e..2dafcdf0e69b 100644 --- a/yarn-project/end-to-end/src/e2e_l1_publisher/e2e_l1_publisher.test.ts +++ b/yarn-project/end-to-end/src/e2e_l1_publisher/e2e_l1_publisher.test.ts @@ -606,7 +606,7 @@ describe('L1Publisher integration', () => { const checkpointAttestations = validators.map(v => makeCheckpointAttestationFromCheckpoint(checkpoint, v)); const attestations = orderAttestations(checkpointAttestations, committee!); - const canPropose = await publisher.canProposeAtNextEthBlock(new Fr(GENESIS_ARCHIVE_ROOT), proposer!); + const canPropose = await publisher.canProposeAt(new Fr(GENESIS_ARCHIVE_ROOT), proposer!); expect(canPropose?.slot).toEqual(block.header.getSlot()); await publisher.validateBlockHeader(checkpoint.header); @@ -630,7 +630,7 @@ describe('L1Publisher integration', () => { const attestations = orderAttestations(checkpointAttestations, committee!).reverse(); const attestationsAndSigners = new CommitteeAttestationsAndSigners(attestations); - const canPropose = await publisher.canProposeAtNextEthBlock(new Fr(GENESIS_ARCHIVE_ROOT), proposer!); + const canPropose = await publisher.canProposeAt(new Fr(GENESIS_ARCHIVE_ROOT), proposer!); expect(canPropose?.slot).toEqual(block.header.getSlot()); await publisher.validateBlockHeader(checkpoint.header); @@ -645,7 +645,7 @@ describe('L1Publisher integration', () => { const checkpointAttestations = validators.map(v => makeCheckpointAttestationFromCheckpoint(checkpoint, v)); const attestations = orderAttestations(checkpointAttestations, committee!); - const canPropose = await publisher.canProposeAtNextEthBlock(new Fr(GENESIS_ARCHIVE_ROOT), proposer!); + const canPropose = await publisher.canProposeAt(new Fr(GENESIS_ARCHIVE_ROOT), proposer!); expect(canPropose?.slot).toEqual(block.header.getSlot()); await publisher.validateBlockHeader(checkpoint.header); @@ -670,7 +670,7 @@ describe('L1Publisher integration', () => { const checkpointAttestations = validators.map(v => makeCheckpointAttestationFromCheckpoint(checkpoint, v)); const attestations = orderAttestations(checkpointAttestations, committee!); - const canPropose = await publisher.canProposeAtNextEthBlock(new Fr(GENESIS_ARCHIVE_ROOT), proposer!); + const canPropose = await publisher.canProposeAt(new Fr(GENESIS_ARCHIVE_ROOT), proposer!); expect(canPropose?.slot).toEqual(block.header.getSlot()); await publisher.validateBlockHeader(checkpoint.header); @@ -742,8 +742,8 @@ describe('L1Publisher integration', () => { // We cannot propose directly, we need to assume the previous checkpoint is invalidated const genesis = new Fr(GENESIS_ARCHIVE_ROOT); logger.warn(`Checking can propose at next eth block on top of genesis ${genesis}`); - expect(await publisher.canProposeAtNextEthBlock(genesis, proposer!)).toBeUndefined(); - const canPropose = await publisher.canProposeAtNextEthBlock(genesis, proposer!, { forcePendingCheckpointNumber }); + expect(await publisher.canProposeAt(genesis, proposer!)).toBeUndefined(); + const canPropose = await publisher.canProposeAt(genesis, proposer!, { forcePendingCheckpointNumber }); expect(canPropose?.slot).toEqual(block.header.getSlot()); // Same for validation diff --git a/yarn-project/epoch-cache/src/epoch_cache.ts b/yarn-project/epoch-cache/src/epoch_cache.ts index e961706d815c..59271c34e3a6 100644 --- a/yarn-project/epoch-cache/src/epoch_cache.ts +++ b/yarn-project/epoch-cache/src/epoch_cache.ts @@ -9,6 +9,7 @@ import { type L1RollupConstants, getEpochAtSlot, getEpochNumberAtTimestamp, + getNextL1SlotTimestamp, getSlotAtTimestamp, getSlotRangeForEpoch, getTimestampForSlot, @@ -148,10 +149,6 @@ export class EpochCache implements EpochCacheInterface { return { ...this.getEpochAndSlotAtTimestamp(nowSeconds), nowMs }; } - public nowInSeconds(): bigint { - return BigInt(Math.floor(this.dateProvider.now() / 1000)); - } - private getEpochAndSlotAtSlot(slot: SlotNumber): EpochAndSlot { const epoch = getEpochAtSlot(slot, this.l1constants); const ts = getTimestampRangeForEpoch(epoch, this.l1constants)[0]; @@ -159,8 +156,8 @@ export class EpochCache implements EpochCacheInterface { } public getEpochAndSlotInNextL1Slot(): EpochAndSlot & { now: bigint } { - const now = this.nowInSeconds(); - const nextSlotTs = now + BigInt(this.l1constants.ethereumSlotDuration); + const now = BigInt(this.dateProvider.nowInSeconds()); + const nextSlotTs = getNextL1SlotTimestamp(Number(now), this.l1constants); return { ...this.getEpochAndSlotAtTimestamp(nextSlotTs), now }; } @@ -376,10 +373,11 @@ export class EpochCache implements EpochCacheInterface { async getRegisteredValidators(): Promise { const validatorRefreshIntervalMs = this.config.validatorRefreshIntervalSeconds * 1000; const validatorRefreshTime = this.lastValidatorRefresh + validatorRefreshIntervalMs; - if (validatorRefreshTime < this.dateProvider.now()) { - const currentSet = await this.rollup.getAttesters(); + const now = this.dateProvider.now(); + if (validatorRefreshTime < now) { + const currentSet = await this.rollup.getAttesters(BigInt(Math.floor(now / 1000))); this.allValidators = new Set(currentSet.map(v => v.toString())); - this.lastValidatorRefresh = this.dateProvider.now(); + this.lastValidatorRefresh = now; } return Array.from(this.allValidators.keys()).map(v => EthAddress.fromString(v)); } diff --git a/yarn-project/ethereum/src/contracts/rollup.ts b/yarn-project/ethereum/src/contracts/rollup.ts index 980ff98ed12c..538b04d6aa82 100644 --- a/yarn-project/ethereum/src/contracts/rollup.ts +++ b/yarn-project/ethereum/src/contracts/rollup.ts @@ -777,14 +777,13 @@ export class RollupContract { * timestamp of the next L1 block * @throws otherwise */ - public async canProposeAtNextEthBlock( + public async canProposeAt( archive: Buffer, account: `0x${string}` | Account, - slotDuration: number, + timestamp: bigint, opts: { forcePendingCheckpointNumber?: CheckpointNumber } = {}, ): Promise<{ slot: SlotNumber; checkpointNumber: CheckpointNumber; timeOfNextL1Slot: bigint }> { - const latestBlock = await this.client.getBlock(); - const timeOfNextL1Slot = latestBlock.timestamp + BigInt(slotDuration); + const timeOfNextL1Slot = timestamp; const who = typeof account === 'string' ? account : account.address; try { @@ -937,11 +936,10 @@ export class RollupContract { return this.rollup.read.getSpecificProverRewardsForEpoch([epoch, prover]); } - async getAttesters(): Promise { + async getAttesters(timestamp?: bigint): Promise { const attesterSize = await this.getActiveAttesterCount(); const gse = new GSEContract(this.client, await this.getGSE()); - const ts = (await this.client.getBlock()).timestamp; - + const ts = timestamp ?? (await this.client.getBlock()).timestamp; const indices = Array.from({ length: attesterSize }, (_, i) => BigInt(i)); const chunks = chunk(indices, 1000); diff --git a/yarn-project/foundation/src/branded-types/slot.ts b/yarn-project/foundation/src/branded-types/slot.ts index 069104a88fe0..2657cd622036 100644 --- a/yarn-project/foundation/src/branded-types/slot.ts +++ b/yarn-project/foundation/src/branded-types/slot.ts @@ -73,6 +73,11 @@ SlotNumber.isValid = function (value: unknown): value is SlotNumber { return typeof value === 'number' && Number.isInteger(value) && value >= 0; }; +/** Increments a SlotNumber by a given value. */ +SlotNumber.add = function (sn: SlotNumber, increment: number): SlotNumber { + return SlotNumber(sn + increment); +}; + /** * The zero slot value. */ diff --git a/yarn-project/sequencer-client/src/client/sequencer-client.ts b/yarn-project/sequencer-client/src/client/sequencer-client.ts index 6c00c41db92c..6afbb525df8a 100644 --- a/yarn-project/sequencer-client/src/client/sequencer-client.ts +++ b/yarn-project/sequencer-client/src/client/sequencer-client.ts @@ -20,7 +20,7 @@ import { L1Metrics, type TelemetryClient } from '@aztec/telemetry-client'; import { FullNodeCheckpointsBuilder, NodeKeystoreAdapter, type ValidatorClient } from '@aztec/validator-client'; import { type SequencerClientConfig, getPublisherConfigFromSequencerConfig } from '../config.js'; -import { GlobalVariableBuilder } from '../global_variable_builder/index.js'; +import type { GlobalVariableBuilder } from '../global_variable_builder/index.js'; import { SequencerPublisherFactory } from '../publisher/sequencer-publisher-factory.js'; import { Sequencer, type SequencerConfig } from '../sequencer/index.js'; @@ -66,6 +66,7 @@ export class SequencerClient { epochCache?: EpochCache; l1TxUtils: L1TxUtils[]; nodeKeyStore: KeystoreManager; + globalVariableBuilder: GlobalVariableBuilder; }, ) { const { @@ -93,10 +94,9 @@ export class SequencerClient { log.getBindings(), ); const rollupContract = new RollupContract(publicClient, config.l1Contracts.rollupAddress.toString()); - const [l1GenesisTime, slotDuration, rollupVersion, rollupManaLimit] = await Promise.all([ + const [l1GenesisTime, slotDuration, rollupManaLimit] = await Promise.all([ rollupContract.getL1GenesisTime(), rollupContract.getSlotDuration(), - rollupContract.getVersion(), rollupContract.getManaLimit().then(Number), ] as const); @@ -139,13 +139,7 @@ export class SequencerClient { const ethereumSlotDuration = config.ethereumSlotDuration; - const globalsBuilder = new GlobalVariableBuilder({ - ...config, - l1GenesisTime, - slotDuration: Number(slotDuration), - ethereumSlotDuration, - rollupVersion, - }); + const globalsBuilder = deps.globalVariableBuilder; // When running in anvil, assume we can post a tx up until one second before the end of an L1 slot. // Otherwise, we need the full L1 slot duration for publishing to ensure inclusion. diff --git a/yarn-project/sequencer-client/src/global_variable_builder/global_builder.ts b/yarn-project/sequencer-client/src/global_variable_builder/global_builder.ts index 20c0f236292d..3043f9a56eed 100644 --- a/yarn-project/sequencer-client/src/global_variable_builder/global_builder.ts +++ b/yarn-project/sequencer-client/src/global_variable_builder/global_builder.ts @@ -1,15 +1,13 @@ -import { createEthereumChain } from '@aztec/ethereum/chain'; -import { makeL1HttpTransport } from '@aztec/ethereum/client'; -import type { L1ContractsConfig } from '@aztec/ethereum/config'; import { RollupContract } from '@aztec/ethereum/contracts'; -import type { L1ReaderConfig } from '@aztec/ethereum/l1-reader'; +import type { L1ContractAddresses } from '@aztec/ethereum/l1-contract-addresses'; import type { ViemPublicClient } from '@aztec/ethereum/types'; import { BlockNumber, SlotNumber } from '@aztec/foundation/branded-types'; import { Fr } from '@aztec/foundation/curves/bn254'; import type { EthAddress } from '@aztec/foundation/eth-address'; import { createLogger } from '@aztec/foundation/log'; +import type { DateProvider } from '@aztec/foundation/timer'; import type { AztecAddress } from '@aztec/stdlib/aztec-address'; -import { type L1RollupConstants, getTimestampForSlot } from '@aztec/stdlib/epoch-helpers'; +import { type L1RollupConstants, getNextL1SlotTimestamp, getTimestampForSlot } from '@aztec/stdlib/epoch-helpers'; import { GasFees } from '@aztec/stdlib/gas'; import type { CheckpointGlobalVariables, @@ -17,7 +15,12 @@ import type { } from '@aztec/stdlib/tx'; import { GlobalVariables } from '@aztec/stdlib/tx'; -import { createPublicClient } from 'viem'; +/** Configuration for the GlobalVariableBuilder (excludes L1 client config). */ +export type GlobalVariableBuilderConfig = { + l1Contracts: Pick; + ethereumSlotDuration: number; + rollupVersion: bigint; +} & Pick; /** * Simple global variables builder. @@ -28,7 +31,6 @@ export class GlobalVariableBuilder implements GlobalVariableBuilderInterface { private currentL1BlockNumber: bigint | undefined = undefined; private readonly rollupContract: RollupContract; - private readonly publicClient: ViemPublicClient; private readonly ethereumSlotDuration: number; private readonly aztecSlotDuration: number; private readonly l1GenesisTime: bigint; @@ -37,28 +39,18 @@ export class GlobalVariableBuilder implements GlobalVariableBuilderInterface { private version: Fr; constructor( - config: L1ReaderConfig & - Pick & - Pick & { rollupVersion: bigint }, + private readonly dateProvider: DateProvider, + private readonly publicClient: ViemPublicClient, + config: GlobalVariableBuilderConfig, ) { - const { l1RpcUrls, l1ChainId: chainId, l1Contracts } = config; - - const chain = createEthereumChain(l1RpcUrls, chainId); - this.version = new Fr(config.rollupVersion); - this.chainId = new Fr(chainId); + this.chainId = new Fr(this.publicClient.chain!.id); this.ethereumSlotDuration = config.ethereumSlotDuration; this.aztecSlotDuration = config.slotDuration; this.l1GenesisTime = config.l1GenesisTime; - this.publicClient = createPublicClient({ - chain: chain.chainInfo, - transport: makeL1HttpTransport(chain.rpcUrls, { timeout: config.l1HttpTimeoutMS }), - pollingInterval: config.viemPollingIntervalMS, - }); - - this.rollupContract = new RollupContract(this.publicClient, l1Contracts.rollupAddress); + this.rollupContract = new RollupContract(this.publicClient, config.l1Contracts.rollupAddress); } /** @@ -74,7 +66,10 @@ export class GlobalVariableBuilder implements GlobalVariableBuilderInterface { const earliestTimestamp = await this.rollupContract.getTimestampForSlot( SlotNumber.fromBigInt(BigInt(lastCheckpoint.slotNumber) + 1n), ); - const nextEthTimestamp = BigInt((await this.publicClient.getBlock()).timestamp + BigInt(this.ethereumSlotDuration)); + const nextEthTimestamp = getNextL1SlotTimestamp(this.dateProvider.nowInSeconds(), { + l1GenesisTime: this.l1GenesisTime, + ethereumSlotDuration: this.ethereumSlotDuration, + }); const timestamp = earliestTimestamp > nextEthTimestamp ? earliestTimestamp : nextEthTimestamp; return new GasFees(0, await this.rollupContract.getManaMinFeeAt(timestamp, true)); @@ -109,7 +104,10 @@ export class GlobalVariableBuilder implements GlobalVariableBuilderInterface { const slot: SlotNumber = maybeSlot ?? (await this.rollupContract.getSlotAt( - BigInt((await this.publicClient.getBlock()).timestamp + BigInt(this.ethereumSlotDuration)), + getNextL1SlotTimestamp(this.dateProvider.nowInSeconds(), { + l1GenesisTime: this.l1GenesisTime, + ethereumSlotDuration: this.ethereumSlotDuration, + }), )); const checkpointGlobalVariables = await this.buildCheckpointGlobalVariables(coinbase, feeRecipient, slot); diff --git a/yarn-project/sequencer-client/src/global_variable_builder/index.ts b/yarn-project/sequencer-client/src/global_variable_builder/index.ts index 5669a0412ae4..a48ed6c244eb 100644 --- a/yarn-project/sequencer-client/src/global_variable_builder/index.ts +++ b/yarn-project/sequencer-client/src/global_variable_builder/index.ts @@ -1 +1 @@ -export { GlobalVariableBuilder } from './global_builder.js'; +export { GlobalVariableBuilder, type GlobalVariableBuilderConfig } from './global_builder.js'; diff --git a/yarn-project/sequencer-client/src/publisher/sequencer-publisher.test.ts b/yarn-project/sequencer-client/src/publisher/sequencer-publisher.test.ts index bf26a3ae16e1..12592d5a1042 100644 --- a/yarn-project/sequencer-client/src/publisher/sequencer-publisher.test.ts +++ b/yarn-project/sequencer-client/src/publisher/sequencer-publisher.test.ts @@ -22,6 +22,7 @@ import { TestDateProvider } from '@aztec/foundation/timer'; import { EmpireBaseAbi, RollupAbi } from '@aztec/l1-artifacts'; import { CommitteeAttestationsAndSigners, L2Block, Signature } from '@aztec/stdlib/block'; import { Checkpoint } from '@aztec/stdlib/checkpoint'; +import { EmptyL1RollupConstants } from '@aztec/stdlib/epoch-helpers'; import type { SlashFactoryContract } from '@aztec/stdlib/l1-contracts'; import { CheckpointHeader } from '@aztec/stdlib/rollup'; @@ -138,6 +139,7 @@ describe('SequencerPublisher', () => { const epochCache = mock(); epochCache.getEpochAndSlotNow.mockReturnValue({ epoch: EpochNumber(1), slot: SlotNumber(2), ts: 3n, nowMs: 3000n }); + epochCache.getL1Constants.mockReturnValue(EmptyL1RollupConstants); epochCache.getCommittee.mockResolvedValue({ committee: [], seed: 1n, diff --git a/yarn-project/sequencer-client/src/publisher/sequencer-publisher.ts b/yarn-project/sequencer-client/src/publisher/sequencer-publisher.ts index ff1616869741..3ff0e0d87893 100644 --- a/yarn-project/sequencer-client/src/publisher/sequencer-publisher.ts +++ b/yarn-project/sequencer-client/src/publisher/sequencer-publisher.ts @@ -41,6 +41,7 @@ import { EmpireBaseAbi, ErrorsAbi, RollupAbi } from '@aztec/l1-artifacts'; import { type ProposerSlashAction, encodeSlashConsensusVotes } from '@aztec/slasher'; import { CommitteeAttestationsAndSigners, type ValidateCheckpointResult } from '@aztec/stdlib/block'; import type { Checkpoint } from '@aztec/stdlib/checkpoint'; +import { getNextL1SlotTimestamp } from '@aztec/stdlib/epoch-helpers'; import { SlashFactoryContract } from '@aztec/stdlib/l1-contracts'; import type { CheckpointHeader } from '@aztec/stdlib/rollup'; import type { L1PublishCheckpointStats } from '@aztec/stdlib/stats'; @@ -121,6 +122,7 @@ export class SequencerPublisher { protected log: Logger; protected ethereumSlotDuration: bigint; + private dateProvider: DateProvider; private blobClient: BlobClientInterface; @@ -169,6 +171,7 @@ export class SequencerPublisher { ) { this.log = deps.log ?? createLogger('sequencer:publisher'); this.ethereumSlotDuration = BigInt(config.ethereumSlotDuration); + this.dateProvider = deps.dateProvider; this.epochCache = deps.epochCache; this.lastActions = deps.lastActions; @@ -450,11 +453,11 @@ export class SequencerPublisher { } /** - * @notice Will call `canProposeAtNextEthBlock` to make sure that it is possible to propose + * @notice Will call `canProposeAt` to make sure that it is possible to propose * @param tipArchive - The archive to check * @returns The slot and block number if it is possible to propose, undefined otherwise */ - public canProposeAtNextEthBlock( + public async canProposeAt( tipArchive: Fr, msgSender: EthAddress, opts: { forcePendingCheckpointNumber?: CheckpointNumber } = {}, @@ -462,8 +465,10 @@ export class SequencerPublisher { // TODO: #14291 - should loop through multiple keys to check if any of them can propose const ignoredErrors = ['SlotAlreadyInChain', 'InvalidProposer', 'InvalidArchive']; + const nextL1SlotTs = await this.getNextL1SlotTimestampWithL1Floor(); + return this.rollupContract - .canProposeAtNextEthBlock(tipArchive.toBuffer(), msgSender.toString(), Number(this.ethereumSlotDuration), { + .canProposeAt(tipArchive.toBuffer(), msgSender.toString(), nextL1SlotTs, { forcePendingCheckpointNumber: opts.forcePendingCheckpointNumber, }) .catch(err => { @@ -500,7 +505,7 @@ export class SequencerPublisher { flags, ] as const; - const ts = BigInt((await this.l1TxUtils.getBlock()).timestamp + this.ethereumSlotDuration); + const ts = await this.getNextL1SlotTimestampWithL1Floor(); const stateOverrides = await this.rollupContract.makePendingCheckpointNumberOverride( opts?.forcePendingCheckpointNumber, ); @@ -1355,4 +1360,21 @@ export class SequencerPublisher { }, }); } + + /** + * Returns the timestamp to use when simulating L1 proposal calls. + * Uses the wall-clock-based next L1 slot boundary, but floors it with the latest L1 block timestamp + * plus one slot duration. This prevents the sequencer from targeting a future L2 slot when the L1 + * chain hasn't caught up to the wall clock yet (e.g., the dateProvider is one L1 slot ahead of the + * latest mined block), which would cause the propose tx to land in an L1 block with block.timestamp + * still in the previous L2 slot. + * TODO(palla): Properly fix by keeping dateProvider synced with anvil's chain time on every block. + */ + private async getNextL1SlotTimestampWithL1Floor(): Promise { + const l1Constants = this.epochCache.getL1Constants(); + const fromWallClock = getNextL1SlotTimestamp(this.dateProvider.nowInSeconds(), l1Constants); + const latestBlock = await this.l1TxUtils.client.getBlock(); + const fromL1Block = latestBlock.timestamp + BigInt(l1Constants.ethereumSlotDuration); + return fromWallClock > fromL1Block ? fromWallClock : fromL1Block; + } } diff --git a/yarn-project/sequencer-client/src/sequencer/sequencer.test.ts b/yarn-project/sequencer-client/src/sequencer/sequencer.test.ts index c30a93fb770e..d6d6fcc80dce 100644 --- a/yarn-project/sequencer-client/src/sequencer/sequencer.test.ts +++ b/yarn-project/sequencer-client/src/sequencer/sequencer.test.ts @@ -187,7 +187,7 @@ describe('sequencer', () => { publisher.enqueueProposeCheckpoint.mockResolvedValue(undefined); publisher.enqueueGovernanceCastSignal.mockResolvedValue(true); publisher.enqueueSlashingActions.mockResolvedValue(true); - publisher.canProposeAtNextEthBlock.mockResolvedValue({ + publisher.canProposeAt.mockResolvedValue({ slot: SlotNumber(newSlotNumber), checkpointNumber: CheckpointNumber.fromBlockNumber(newBlockNumber), timeOfNextL1Slot: 1000n, @@ -352,21 +352,21 @@ describe('sequencer', () => { expect(checkpointBuilder.buildBlockCalls).toHaveLength(0); expect(publisher.enqueueProposeCheckpoint).not.toHaveBeenCalled(); - expect(publisher.canProposeAtNextEthBlock).not.toHaveBeenCalled(); + expect(publisher.canProposeAt).not.toHaveBeenCalled(); }); it('builds a checkpoint when it is their turn', async () => { await setupSingleTxBlock(); - // Not your turn! canProposeAtNextEthBlock returns undefined - publisher.canProposeAtNextEthBlock.mockResolvedValue(undefined); + // Not your turn! canProposeAt returns undefined + publisher.canProposeAt.mockResolvedValue(undefined); await sequencer.work(); // When it's not our turn, we should not build the checkpoint expect(checkpointBuilder.buildBlockCalls).toHaveLength(0); // Now it's our turn! - publisher.canProposeAtNextEthBlock.mockResolvedValue({ + publisher.canProposeAt.mockResolvedValue({ slot: block.header.globalVariables.slotNumber, checkpointNumber: CheckpointNumber.fromBlockNumber(block.header.globalVariables.blockNumber), timeOfNextL1Slot: 1000n, @@ -474,7 +474,7 @@ describe('sequencer', () => { pub.enqueueProposeCheckpoint.mockResolvedValue(undefined); pub.enqueueGovernanceCastSignal.mockResolvedValue(true); pub.enqueueSlashingActions.mockResolvedValue(true); - pub.canProposeAtNextEthBlock.mockResolvedValue({ + pub.canProposeAt.mockResolvedValue({ slot: SlotNumber(newSlotNumber + i), checkpointNumber: CheckpointNumber.fromBlockNumber(BlockNumber(newBlockNumber)), timeOfNextL1Slot: 1000n, diff --git a/yarn-project/sequencer-client/src/sequencer/sequencer.ts b/yarn-project/sequencer-client/src/sequencer/sequencer.ts index d75788ea3cf4..3af12aec8200 100644 --- a/yarn-project/sequencer-client/src/sequencer/sequencer.ts +++ b/yarn-project/sequencer-client/src/sequencer/sequencer.ts @@ -327,7 +327,7 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter TypedEventEmitter { // Check that the archiver has fully synced the L2 slot before the one we want to propose in. - // TODO(#14766): Archiver reports L1 timestamp based on L1 blocks seen, which means that a missed L1 block will - // cause the archiver L1 timestamp to fall behind, and cause this sequencer to start processing one L1 slot later. + // The archiver reports sync progress via L1 block timestamps and synced checkpoint slots. + // See getSyncedL2SlotNumber for how missed L1 blocks are handled. const syncedL2Slot = await this.l2BlockSource.getSyncedL2SlotNumber(); const { slot } = args; if (syncedL2Slot === undefined || syncedL2Slot + 1 < slot) { diff --git a/yarn-project/stdlib/src/block/l2_block_source.ts b/yarn-project/stdlib/src/block/l2_block_source.ts index 75f181a003f2..af020893af9c 100644 --- a/yarn-project/stdlib/src/block/l2_block_source.ts +++ b/yarn-project/stdlib/src/block/l2_block_source.ts @@ -176,8 +176,10 @@ export interface L2BlockSource { getSettledTxReceipt(txHash: TxHash): Promise; /** - * Returns the last L2 slot number that has been fully synchronized from L1. - * An L2 slot is fully synced when all L1 blocks that fall within its time range have been processed. + * Returns the last L2 slot number for which we have all L1 data needed to build the next checkpoint. + * Determined by the max of two signals: L1 block sync progress and latest synced checkpoint slot. + * The checkpoint signal handles missed L1 blocks, since a published checkpoint seals the message tree + * for the next checkpoint via the inbox LAG mechanism. */ getSyncedL2SlotNumber(): Promise; diff --git a/yarn-project/stdlib/src/epoch-helpers/index.ts b/yarn-project/stdlib/src/epoch-helpers/index.ts index 637afa3caf09..0ae35f5461f4 100644 --- a/yarn-project/stdlib/src/epoch-helpers/index.ts +++ b/yarn-project/stdlib/src/epoch-helpers/index.ts @@ -57,6 +57,17 @@ export function getSlotAtTimestamp( : SlotNumber.fromBigInt((ts - constants.l1GenesisTime) / BigInt(constants.slotDuration)); } +/** Returns the timestamp of the next L1 slot boundary after the given wall-clock time. */ +export function getNextL1SlotTimestamp( + nowInSeconds: number, + constants: Pick, +): bigint { + const now = BigInt(nowInSeconds); + const elapsed = now - constants.l1GenesisTime; + const currentL1Slot = elapsed / BigInt(constants.ethereumSlotDuration); + return constants.l1GenesisTime + (currentL1Slot + 1n) * BigInt(constants.ethereumSlotDuration); +} + /** Returns the L2 slot number at the next L1 block based on the current timestamp. */ export function getSlotAtNextL1Block( currentL1Timestamp: bigint,