diff --git a/.github/workflows/metrics-deploy.yml b/.github/workflows/metrics-deploy.yml index 9ccc2ad2a555..b8774a1a7e96 100644 --- a/.github/workflows/metrics-deploy.yml +++ b/.github/workflows/metrics-deploy.yml @@ -37,6 +37,11 @@ on: required: true type: string default: "grafana-dashboard-password" + slack_alert_mention_user_ids: + description: Optional Terraform list of Slack user IDs to mention on Grafana alert notifications + required: false + type: string + default: '["U0AHB6VR8N5"]' secrets: GCP_SA_KEY: required: true @@ -70,6 +75,10 @@ on: description: The name of the secret which holds the Grafana dashboard password required: true default: "grafana-dashboard-password" + slack_alert_mention_user_ids: + description: Optional Terraform list of Slack user IDs to mention on Grafana alert notifications + required: false + default: '["U0AHB6VR8N5"]' jobs: metrics_deployment: @@ -96,6 +105,7 @@ jobs: SLACK_WEBHOOK_NEXT_NET_SECRET_NAME: slack-webhook-next-net-url SLACK_WEBHOOK_TESTNET_SECRET_NAME: slack-webhook-testnet-url SLACK_WEBHOOK_MAINNET_SECRET_NAME: slack-webhook-mainnet-url + TF_VAR_SLACK_ALERT_MENTION_USER_IDS: ${{ inputs.slack_alert_mention_user_ids }} steps: - name: Checkout code diff --git a/spartan/metrics/grafana/alerts/contactpoints.yaml b/spartan/metrics/grafana/alerts/contactpoints.yaml index 03b883cb5c6c..956d94cce523 100644 --- a/spartan/metrics/grafana/alerts/contactpoints.yaml +++ b/spartan/metrics/grafana/alerts/contactpoints.yaml @@ -7,6 +7,7 @@ contactPoints: type: slack settings: url: $SLACK_WEBHOOK_URL + mentionUsers: $SLACK_ALERT_MENTION_USER_IDS text: |- {{ template "aztec.slack.by_namespace" . }} disableResolveMessage: false @@ -18,6 +19,7 @@ contactPoints: type: slack settings: url: $SLACK_WEBHOOK_NEXT_SCENARIO_URL + mentionUsers: $SLACK_ALERT_MENTION_USER_IDS text: |- {{ template "aztec.slack.by_namespace" . }} disableResolveMessage: false @@ -29,6 +31,7 @@ contactPoints: type: slack settings: url: $SLACK_WEBHOOK_NEXT_NET_URL + mentionUsers: $SLACK_ALERT_MENTION_USER_IDS text: |- {{ template "aztec.slack.by_namespace" . }} disableResolveMessage: false @@ -40,6 +43,7 @@ contactPoints: type: slack settings: url: $SLACK_WEBHOOK_TESTNET_URL + mentionUsers: $SLACK_ALERT_MENTION_USER_IDS text: |- {{ template "aztec.slack.by_namespace" . }} disableResolveMessage: false @@ -51,6 +55,7 @@ contactPoints: type: slack settings: url: $SLACK_WEBHOOK_MAINNET_URL + mentionUsers: $SLACK_ALERT_MENTION_USER_IDS text: |- {{ template "aztec.slack.by_namespace" . }} disableResolveMessage: false @@ -62,6 +67,7 @@ contactPoints: type: slack settings: url: $SLACK_WEBHOOK_TESTNET_URL + mentionUsers: $SLACK_ALERT_MENTION_USER_IDS text: |- {{ template "aztec.slack.by_network" . }} disableResolveMessage: false @@ -73,6 +79,7 @@ contactPoints: type: slack settings: url: $SLACK_WEBHOOK_MAINNET_URL + mentionUsers: $SLACK_ALERT_MENTION_USER_IDS text: |- {{ template "aztec.slack.by_network" . }} disableResolveMessage: false @@ -84,6 +91,7 @@ contactPoints: type: slack settings: url: $SLACK_WEBHOOK_DEVNET_URL + mentionUsers: $SLACK_ALERT_MENTION_USER_IDS text: |- {{ template "aztec.slack.by_namespace" . }} disableResolveMessage: false diff --git a/spartan/terraform/deploy-metrics/main.tf b/spartan/terraform/deploy-metrics/main.tf index 47a5c27cc1a0..57467ee59855 100644 --- a/spartan/terraform/deploy-metrics/main.tf +++ b/spartan/terraform/deploy-metrics/main.tf @@ -125,7 +125,8 @@ resource "helm_release" "aztec-gke-cluster" { env = { # we have to set an admin username through env vars otherwise the chart expects to find an 'admin-user' key in the admin secret - GF_SECURITY_ADMIN_USER = "admin" + GF_SECURITY_ADMIN_USER = "admin" + SLACK_ALERT_MENTION_USER_IDS = join(",", var.SLACK_ALERT_MENTION_USER_IDS) } sidecar = { diff --git a/spartan/terraform/deploy-metrics/variables.tf b/spartan/terraform/deploy-metrics/variables.tf index 88ca6e974f13..f7327e84877e 100644 --- a/spartan/terraform/deploy-metrics/variables.tf +++ b/spartan/terraform/deploy-metrics/variables.tf @@ -70,6 +70,12 @@ variable "SLACK_WEBHOOK_MAINNET_SECRET_NAME" { default = "slack-webhook-mainnet-url" } +variable "SLACK_ALERT_MENTION_USER_IDS" { + description = "Optional Slack user IDs to mention on Grafana alert notifications." + type = list(string) + default = ["U0AHB6VR8N5"] +} + variable "project" { default = "testnet-440309" type = string diff --git a/yarn-project/archiver/src/archiver-misc.test.ts b/yarn-project/archiver/src/archiver-misc.test.ts index 3b6bbd7b7e2a..433b83dc0f2e 100644 --- a/yarn-project/archiver/src/archiver-misc.test.ts +++ b/yarn-project/archiver/src/archiver-misc.test.ts @@ -10,6 +10,7 @@ import { Fr } from '@aztec/foundation/curves/bn254'; import { EthAddress } from '@aztec/foundation/eth-address'; import { openTmpStore } from '@aztec/kv-store/lmdb-v2'; import type { L1RollupConstants } from '@aztec/stdlib/epoch-helpers'; +import { BlockHeader } from '@aztec/stdlib/tx'; import { getTelemetryClient } from '@aztec/telemetry-client'; import { EventEmitter } from 'events'; @@ -57,7 +58,9 @@ describe('Archiver misc', () => { const instrumentation = mock({ isEnabled: () => true, tracer }); const archiverStore = createArchiverDataStores(await openTmpStore('archiver_misc_test'), { logsMaxPageSize: 1000 }); const events = new EventEmitter() as ArchiverEmitter; - const l2TipsCache = new L2TipsCache(archiverStore.blocks); + const initialHeader = BlockHeader.empty(); + const initialBlockHash = await initialHeader.hash(); + const l2TipsCache = new L2TipsCache(archiverStore.blocks, initialBlockHash); archiver = new Archiver( publicClient, @@ -77,6 +80,8 @@ describe('Archiver misc', () => { l1Constants, synchronizer, events, + initialHeader, + initialBlockHash, l2TipsCache, ); }); diff --git a/yarn-project/archiver/src/archiver-store.test.ts b/yarn-project/archiver/src/archiver-store.test.ts index ff03a8c4d262..2f1a0b770390 100644 --- a/yarn-project/archiver/src/archiver-store.test.ts +++ b/yarn-project/archiver/src/archiver-store.test.ts @@ -1,5 +1,4 @@ import type { BlobClientInterface } from '@aztec/blob-client/client'; -import { GENESIS_ARCHIVE_ROOT } from '@aztec/constants'; import type { EpochCache, EpochCommitteeInfo } from '@aztec/epoch-cache'; import { RollupContract } from '@aztec/ethereum/contracts'; import type { ViemPublicClient } from '@aztec/ethereum/types'; @@ -18,6 +17,7 @@ import { L2Block } from '@aztec/stdlib/block'; import type { L1RollupConstants } from '@aztec/stdlib/epoch-helpers'; import { makeStateReference } from '@aztec/stdlib/testing'; import { AppendOnlyTreeSnapshot } from '@aztec/stdlib/trees'; +import { BlockHeader } from '@aztec/stdlib/tx'; import { getTelemetryClient } from '@aztec/telemetry-client'; import { EventEmitter } from 'events'; @@ -28,6 +28,7 @@ import { BlockNumberNotSequentialError } from './errors.js'; import type { ArchiverInstrumentation } from './modules/instrumentation.js'; import { ArchiverL1Synchronizer } from './modules/l1_synchronizer.js'; import { type ArchiverDataStores, createArchiverDataStores, getArchiverSynchPoint } from './store/data_stores.js'; +import { L2TipsCache } from './store/l2_tips_cache.js'; import { makeChainedCheckpoints } from './test/mock_structs.js'; describe('Archiver Store', () => { @@ -44,10 +45,17 @@ describe('Archiver Store', () => { let epochCache: MockProxy; let archiverStore: ArchiverDataStores; let l1Constants: L1RollupConstants & { l1StartBlockHash: Buffer32; genesisArchiveRoot: Fr }; + let initialHeader: BlockHeader; + let genesisArchiveRoot: Fr; let archiver: Archiver; beforeEach(async () => { const now = +new Date(); + // Build a non-trivial initial header so we can distinguish it from BlockHeader.empty(). + initialHeader = BlockHeader.empty({ lastArchive: new AppendOnlyTreeSnapshot(Fr.fromString('0x1234'), 1) }); + // Genesis archive root is the post-block-0 archive root from L1, distinct from + // initialHeader.lastArchive.root (which is the pre-block-0 archive, always empty in practice). + genesisArchiveRoot = Fr.fromString('0xabcd'); publicClient = mock(); debugClient = publicClient; @@ -73,7 +81,7 @@ describe('Archiver Store', () => { proofSubmissionEpochs: 1, targetCommitteeSize: 48, rollupManaLimit: Number.MAX_SAFE_INTEGER, - genesisArchiveRoot: new Fr(GENESIS_ARCHIVE_ROOT), + genesisArchiveRoot, }; const contractAddresses = { @@ -94,6 +102,8 @@ describe('Archiver Store', () => { const events = new EventEmitter() as ArchiverEmitter; const synchronizer = mock(); + const initialBlockHash = await initialHeader.hash(); + const l2TipsCache = new L2TipsCache(archiverStore.blocks, initialBlockHash); archiver = new Archiver( publicClient, debugClient, @@ -106,6 +116,9 @@ describe('Archiver Store', () => { l1Constants, synchronizer, events, + initialHeader, + initialBlockHash, + l2TipsCache, ); }); @@ -115,7 +128,7 @@ describe('Archiver Store', () => { describe('getCheckpoints', () => { it('returns published checkpoints with full checkpoint data', async () => { - const genesisArchive = new AppendOnlyTreeSnapshot(new Fr(GENESIS_ARCHIVE_ROOT), 1); + const genesisArchive = new AppendOnlyTreeSnapshot(genesisArchiveRoot, 1); const testCheckpoints = await makeChainedCheckpoints(3, { previousArchive: genesisArchive }); await archiverStore.blocks.addCheckpoints(testCheckpoints); @@ -131,7 +144,7 @@ describe('Archiver Store', () => { }); it('respects the limit parameter', async () => { - const genesisArchive = new AppendOnlyTreeSnapshot(new Fr(GENESIS_ARCHIVE_ROOT), 1); + const genesisArchive = new AppendOnlyTreeSnapshot(genesisArchiveRoot, 1); const testCheckpoints = await makeChainedCheckpoints(3, { previousArchive: genesisArchive }); await archiverStore.blocks.addCheckpoints(testCheckpoints); @@ -142,7 +155,7 @@ describe('Archiver Store', () => { }); it('respects the starting checkpoint number', async () => { - const genesisArchive = new AppendOnlyTreeSnapshot(new Fr(GENESIS_ARCHIVE_ROOT), 1); + const genesisArchive = new AppendOnlyTreeSnapshot(genesisArchiveRoot, 1); const testCheckpoints = await makeChainedCheckpoints(3, { previousArchive: genesisArchive }); await archiverStore.blocks.addCheckpoints(testCheckpoints); @@ -162,7 +175,7 @@ describe('Archiver Store', () => { describe('getCheckpointsForEpoch', () => { it('returns checkpoints for a specific epoch based on slot numbers', async () => { // l1Constants has epochDuration: 4, so epoch 0 has slots 0-3, epoch 1 has slots 4-7 - const genesisArchive = new AppendOnlyTreeSnapshot(new Fr(GENESIS_ARCHIVE_ROOT), 1); + const genesisArchive = new AppendOnlyTreeSnapshot(genesisArchiveRoot, 1); const testCheckpoints = await makeChainedCheckpoints(3, { previousArchive: genesisArchive, makeCheckpointOptions: cpNumber => { @@ -183,7 +196,7 @@ describe('Archiver Store', () => { }); it('returns empty array for epoch with no checkpoints', async () => { - const genesisArchive = new AppendOnlyTreeSnapshot(new Fr(GENESIS_ARCHIVE_ROOT), 1); + const genesisArchive = new AppendOnlyTreeSnapshot(genesisArchiveRoot, 1); const testCheckpoints = await makeChainedCheckpoints(1, { previousArchive: genesisArchive, makeCheckpointOptions: () => ({ slotNumber: SlotNumber(2) }), // Epoch 0 @@ -196,7 +209,7 @@ describe('Archiver Store', () => { it('returns checkpoints in correct order (ascending by checkpoint number)', async () => { // Create multiple checkpoints all in epoch 0 - const genesisArchive = new AppendOnlyTreeSnapshot(new Fr(GENESIS_ARCHIVE_ROOT), 1); + const genesisArchive = new AppendOnlyTreeSnapshot(genesisArchiveRoot, 1); const testCheckpoints = await makeChainedCheckpoints(3, { previousArchive: genesisArchive, makeCheckpointOptions: cpNumber => { @@ -228,14 +241,17 @@ describe('Archiver Store', () => { ...(previousArchive ? { lastArchive: previousArchive } : {}), }); - // Genesis archive for the first block - const genesisArchive = new AppendOnlyTreeSnapshot(new Fr(GENESIS_ARCHIVE_ROOT), 1); + // Genesis archive for the first block — bound in beforeEach so it picks up the suite-level genesisArchiveRoot. + let genesisArchive: AppendOnlyTreeSnapshot; + beforeEach(() => { + genesisArchive = new AppendOnlyTreeSnapshot(genesisArchiveRoot, 1); + }); it('adds a block to the store', async () => { const block = await makeBlock(BlockNumber(1), IndexWithinCheckpoint(0), genesisArchive); await archiver.addBlock(block); - const retrievedBlock = await archiver.getL2Block(BlockNumber(1)); + const retrievedBlock = await archiver.getBlock({ number: BlockNumber(1) }); expect(retrievedBlock).toBeDefined(); expect(retrievedBlock!.number).toEqual(BlockNumber(1)); expect((await retrievedBlock!.header.hash()).toString()).toEqual((await block.header.hash()).toString()); @@ -250,9 +266,9 @@ describe('Archiver Store', () => { await archiver.addBlock(block2); await archiver.addBlock(block3); - const retrievedBlock1 = await archiver.getL2Block(BlockNumber(1)); - const retrievedBlock2 = await archiver.getL2Block(BlockNumber(2)); - const retrievedBlock3 = await archiver.getL2Block(BlockNumber(3)); + const retrievedBlock1 = await archiver.getBlock({ number: BlockNumber(1) }); + const retrievedBlock2 = await archiver.getBlock({ number: BlockNumber(2) }); + const retrievedBlock3 = await archiver.getBlock({ number: BlockNumber(3) }); expect(retrievedBlock1!.number).toEqual(BlockNumber(1)); expect(retrievedBlock2!.number).toEqual(BlockNumber(2)); @@ -292,7 +308,7 @@ describe('Archiver Store', () => { await archiver.addBlock(block1); - const retrievedBlock = await archiver.getL2Block(BlockNumber(1)); + const retrievedBlock = await archiver.getBlock({ number: BlockNumber(1) }); expect(retrievedBlock).toBeDefined(); expect(retrievedBlock!.number).toEqual(BlockNumber(1)); }); @@ -306,7 +322,7 @@ describe('Archiver Store', () => { await archiver.addBlock(block2); await archiver.addBlock(block3); - const blocks = await archiver.getBlocks(BlockNumber(1), 3); + const blocks = await archiver.getBlocks({ from: BlockNumber(1), limit: 3 }); expect(blocks.length).toEqual(3); expect(await blocks[0].hash()).toEqual(await block1.hash()); expect(await blocks[1].hash()).toEqual(await block2.hash()); @@ -323,7 +339,7 @@ describe('Archiver Store', () => { await archiver.addBlock(block3); // Request only 2 blocks starting from block 1 - const blocks = await archiver.getBlocks(BlockNumber(1), 2); + const blocks = await archiver.getBlocks({ from: BlockNumber(1), limit: 2 }); expect(blocks.length).toEqual(2); expect(await blocks[0].hash()).toEqual(await block1.hash()); expect(await blocks[1].hash()).toEqual(await block2.hash()); @@ -339,7 +355,7 @@ describe('Archiver Store', () => { await archiver.addBlock(block3); // Start from block 2 - const blocks = await archiver.getBlocks(BlockNumber(2), 2); + const blocks = await archiver.getBlocks({ from: BlockNumber(2), limit: 2 }); expect(blocks.length).toEqual(2); expect(await blocks[0].hash()).toEqual(await block2.hash()); expect(await blocks[1].hash()).toEqual(await block3.hash()); @@ -351,7 +367,7 @@ describe('Archiver Store', () => { await archiver.addBlock(block1); // Request blocks starting from block 5 (which doesn't exist) - const blocks = await archiver.getBlocks(BlockNumber(5), 3); + const blocks = await archiver.getBlocks({ from: BlockNumber(5), limit: 3 }); expect(blocks).toEqual([]); }); @@ -363,20 +379,20 @@ describe('Archiver Store', () => { await archiver.addBlock(block2); // Request 10 blocks but only 2 are available - const blocks = await archiver.getBlocks(BlockNumber(1), 10); + const blocks = await archiver.getBlocks({ from: BlockNumber(1), limit: 10 }); expect(blocks.length).toEqual(2); expect(await blocks[0].hash()).toEqual(await block1.hash()); expect(await blocks[1].hash()).toEqual(await block2.hash()); }); }); - describe('getCheckpointedBlocks', () => { + describe('getBlocks with onlyCheckpointed', () => { it('returns checkpointed blocks with checkpoint info', async () => { - const genesisArchive = new AppendOnlyTreeSnapshot(new Fr(GENESIS_ARCHIVE_ROOT), 1); + const genesisArchive = new AppendOnlyTreeSnapshot(genesisArchiveRoot, 1); const testCheckpoints = await makeChainedCheckpoints(3, { previousArchive: genesisArchive }); await archiverStore.blocks.addCheckpoints(testCheckpoints); - const result = await archiver.getCheckpointedBlocks(BlockNumber(1), 100); + const result = await archiver.getBlocks({ from: BlockNumber(1), limit: 100, onlyCheckpointed: true }); const expectedBlocks = testCheckpoints.flatMap(c => c.checkpoint.blocks); expect(result.length).toBe(expectedBlocks.length); @@ -389,11 +405,12 @@ describe('Archiver Store', () => { const cb = result[blockIndex]; const expectedBlock = checkpoint.checkpoint.blocks[i]; - expect(cb.block.number).toBe(expectedBlock.number); + expect(cb.number).toBe(expectedBlock.number); expect(cb.checkpointNumber).toBe(checkpoint.checkpoint.number); - expect(cb.block.archive.root.toString()).toBe(expectedBlock.archive.root.toString()); - expect(cb.l1).toBeDefined(); - expect(cb.l1.blockNumber).toBeGreaterThan(0n); + expect(cb.archive.root.toString()).toBe(expectedBlock.archive.root.toString()); + const checkpointData = await archiverStore.blocks.getCheckpointData(cb.checkpointNumber); + expect(checkpointData?.l1).toBeDefined(); + expect(checkpointData!.l1.blockNumber).toBeGreaterThan(0n); blockIndex++; } @@ -401,40 +418,141 @@ describe('Archiver Store', () => { }); it('respects the limit parameter', async () => { - const genesisArchive = new AppendOnlyTreeSnapshot(new Fr(GENESIS_ARCHIVE_ROOT), 1); + const genesisArchive = new AppendOnlyTreeSnapshot(genesisArchiveRoot, 1); const testCheckpoints = await makeChainedCheckpoints(3, { previousArchive: genesisArchive }); await archiverStore.blocks.addCheckpoints(testCheckpoints); - const result = await archiver.getCheckpointedBlocks(BlockNumber(1), 2); + const result = await archiver.getBlocks({ from: BlockNumber(1), limit: 2, onlyCheckpointed: true }); expect(result.length).toBe(2); - expect(result[0].block.number).toBe(BlockNumber(1)); - expect(result[1].block.number).toBe(BlockNumber(2)); + expect(result[0].number).toBe(BlockNumber(1)); + expect(result[1].number).toBe(BlockNumber(2)); expect(result[0].checkpointNumber).toBe(1); expect(result[1].checkpointNumber).toBe(2); }); it('returns blocks starting from specified block number', async () => { - const genesisArchive = new AppendOnlyTreeSnapshot(new Fr(GENESIS_ARCHIVE_ROOT), 1); + const genesisArchive = new AppendOnlyTreeSnapshot(genesisArchiveRoot, 1); const testCheckpoints = await makeChainedCheckpoints(3, { previousArchive: genesisArchive }); await archiverStore.blocks.addCheckpoints(testCheckpoints); - const result = await archiver.getCheckpointedBlocks(BlockNumber(2), 10); + const result = await archiver.getBlocks({ from: BlockNumber(2), limit: 10, onlyCheckpointed: true }); expect(result.length).toBe(2); - expect(result[0].block.number).toBe(BlockNumber(2)); - expect(result[1].block.number).toBe(BlockNumber(3)); + expect(result[0].number).toBe(BlockNumber(2)); + expect(result[1].number).toBe(BlockNumber(3)); expect(result[0].checkpointNumber).toBe(2); expect(result[1].checkpointNumber).toBe(3); }); it('returns empty array when no checkpointed blocks exist', async () => { - const result = await archiver.getCheckpointedBlocks(BlockNumber(1), 10); + const result = await archiver.getBlocks({ from: BlockNumber(1), limit: 10, onlyCheckpointed: true }); expect(result).toEqual([]); }); }); + describe('getBlocks / getBlocksData with epoch query', () => { + it('returns empty array for epoch with no checkpoints', async () => { + const genesisArchive = new AppendOnlyTreeSnapshot(genesisArchiveRoot, 1); + // Checkpoint 1 is in epoch 0 (slot 1, epochDuration=4) + const testCheckpoints = await makeChainedCheckpoints(1, { + previousArchive: genesisArchive, + makeCheckpointOptions: () => ({ slotNumber: SlotNumber(1) }), + }); + await archiverStore.blocks.addCheckpoints(testCheckpoints); + + // Epoch 1 has no checkpoints — both methods must return [] without throwing + await expect(archiver.getBlocks({ epoch: EpochNumber(1), onlyCheckpointed: true })).resolves.toEqual([]); + await expect(archiver.getBlocksData({ epoch: EpochNumber(1), onlyCheckpointed: true })).resolves.toEqual([]); + }); + + it('returns blocks for epoch with checkpoints', async () => { + const genesisArchive = new AppendOnlyTreeSnapshot(genesisArchiveRoot, 1); + const testCheckpoints = await makeChainedCheckpoints(2, { + previousArchive: genesisArchive, + makeCheckpointOptions: cpNumber => ({ + slotNumber: SlotNumber(Number(cpNumber) - 1), // Slots 0 and 1, both in epoch 0 + }), + }); + await archiverStore.blocks.addCheckpoints(testCheckpoints); + + const blocks = await archiver.getBlocks({ epoch: EpochNumber(0), onlyCheckpointed: true }); + expect(blocks.length).toBe(2); + expect(blocks[0].number).toBe(BlockNumber(1)); + expect(blocks[1].number).toBe(BlockNumber(2)); + + const blocksData = await archiver.getBlocksData({ epoch: EpochNumber(0), onlyCheckpointed: true }); + expect(blocksData.length).toBe(2); + }); + }); + + describe('getBlock / getBlockData with tag', () => { + it('returns the genesis block for any tag when chain is empty', async () => { + for (const tag of ['proposed', 'checkpointed', 'proven', 'finalized'] as const) { + const block = await archiver.getBlock({ tag }); + expect(block?.number).toBe(BlockNumber.ZERO); + const data = await archiver.getBlockData({ tag }); + expect(data?.header.globalVariables.blockNumber).toBe(BlockNumber.ZERO); + } + }); + + it('resolves proposed to the latest block', async () => { + const genesisArchive = new AppendOnlyTreeSnapshot(genesisArchiveRoot, 1); + const testCheckpoints = await makeChainedCheckpoints(3, { + previousArchive: genesisArchive, + blocksPerCheckpoint: 2, + }); + await archiverStore.blocks.addCheckpoints(testCheckpoints); + + const block = await archiver.getBlock({ tag: 'proposed' }); + expect(block?.number).toBe(BlockNumber(6)); + const data = await archiver.getBlockData({ tag: 'proposed' }); + expect(data?.header.globalVariables.blockNumber).toBe(BlockNumber(6)); + }); + + it('resolves checkpointed, proven, and finalized to the corresponding block', async () => { + const genesisArchive = new AppendOnlyTreeSnapshot(genesisArchiveRoot, 1); + const testCheckpoints = await makeChainedCheckpoints(3, { + previousArchive: genesisArchive, + blocksPerCheckpoint: 2, + }); + await archiverStore.blocks.addCheckpoints(testCheckpoints); + + // Checkpoint 1 = blocks 1-2, checkpoint 2 = blocks 3-4, checkpoint 3 = blocks 5-6. + // All checkpoints are added so checkpointed L2 block = 6. + await archiverStore.blocks.setProvenCheckpointNumber(CheckpointNumber(2)); + await archiverStore.blocks.setFinalizedCheckpointNumber(CheckpointNumber(1)); + + const checkpointedBlock = await archiver.getBlock({ tag: 'checkpointed' }); + expect(checkpointedBlock?.number).toBe(BlockNumber(6)); + + const provenBlock = await archiver.getBlock({ tag: 'proven' }); + expect(provenBlock?.number).toBe(BlockNumber(4)); + + const finalizedBlock = await archiver.getBlock({ tag: 'finalized' }); + expect(finalizedBlock?.number).toBe(BlockNumber(2)); + + const provenData = await archiver.getBlockData({ tag: 'proven' }); + expect(provenData?.header.globalVariables.blockNumber).toBe(BlockNumber(4)); + }); + + it('returns the genesis block when proven tag points to genesis', async () => { + const genesisArchive = new AppendOnlyTreeSnapshot(genesisArchiveRoot, 1); + const testCheckpoints = await makeChainedCheckpoints(1, { + previousArchive: genesisArchive, + blocksPerCheckpoint: 1, + }); + await archiverStore.blocks.addCheckpoints(testCheckpoints); + + // No proven checkpoint set — proven block number is 0 → genesis. + const block = await archiver.getBlock({ tag: 'proven' }); + expect(block?.number).toBe(BlockNumber.ZERO); + const data = await archiver.getBlockData({ tag: 'proven' }); + expect(data?.header.globalVariables.blockNumber).toBe(BlockNumber.ZERO); + }); + }); + describe('rollbackTo', () => { beforeEach(() => { publicClient.getBlock.mockImplementation( @@ -444,7 +562,7 @@ describe('Archiver Store', () => { }); it('rejects rollback to a block that is not at a checkpoint boundary', async () => { - const genesisArchive = new AppendOnlyTreeSnapshot(new Fr(GENESIS_ARCHIVE_ROOT), 1); + const genesisArchive = new AppendOnlyTreeSnapshot(genesisArchiveRoot, 1); // Checkpoint 1: 3 blocks (1, 2, 3). Checkpoint 2: 3 blocks (4, 5, 6). const testCheckpoints = await makeChainedCheckpoints(2, { previousArchive: genesisArchive, @@ -463,8 +581,29 @@ describe('Archiver Store', () => { ); }); + it('rejects rollback to a proposed but not yet checkpointed block', async () => { + const genesisArchive = new AppendOnlyTreeSnapshot(genesisArchiveRoot, 1); + const checkpoints1 = await makeChainedCheckpoints(1, { + previousArchive: genesisArchive, + blocksPerCheckpoint: 2, + }); + const checkpoints2 = await makeChainedCheckpoints(1, { + previousArchive: checkpoints1[0].checkpoint.blocks.at(-1)!.archive, + startCheckpointNumber: CheckpointNumber(2), + startBlockNumber: 3, + startL1BlockNumber: 20, + blocksPerCheckpoint: 2, + }); + await archiverStore.blocks.addCheckpoints(checkpoints1); + for (const block of checkpoints2[0].checkpoint.blocks) { + await archiverStore.blocks.addProposedBlock(block); + } + + await expect(archiver.rollbackTo(BlockNumber(3))).rejects.toThrow(/Target L2 block 3 is not checkpointed yet/); + }); + it('allows rollback to the last block of a checkpoint and updates sync points', async () => { - const genesisArchive = new AppendOnlyTreeSnapshot(new Fr(GENESIS_ARCHIVE_ROOT), 1); + const genesisArchive = new AppendOnlyTreeSnapshot(genesisArchiveRoot, 1); // Checkpoint 1: 3 blocks (1, 2, 3), L1 block 10. Checkpoint 2: 3 blocks (4, 5, 6), L1 block 20. const testCheckpoints = await makeChainedCheckpoints(2, { previousArchive: genesisArchive, @@ -484,7 +623,7 @@ describe('Archiver Store', () => { }); it('includes correct boundary info in error for mid-checkpoint rollback', async () => { - const genesisArchive = new AppendOnlyTreeSnapshot(new Fr(GENESIS_ARCHIVE_ROOT), 1); + const genesisArchive = new AppendOnlyTreeSnapshot(genesisArchiveRoot, 1); // Checkpoint 1: 2 blocks (1, 2). Checkpoint 2: 3 blocks (3, 4, 5). const checkpoints1 = await makeChainedCheckpoints(1, { previousArchive: genesisArchive, @@ -507,7 +646,7 @@ describe('Archiver Store', () => { }); it('rolls back proven checkpoint number when target is before proven block', async () => { - const genesisArchive = new AppendOnlyTreeSnapshot(new Fr(GENESIS_ARCHIVE_ROOT), 1); + const genesisArchive = new AppendOnlyTreeSnapshot(genesisArchiveRoot, 1); // Checkpoint 1: blocks 1-2, Checkpoint 2: blocks 3-4, Checkpoint 3: blocks 5-6 const testCheckpoints = await makeChainedCheckpoints(3, { previousArchive: genesisArchive, @@ -527,7 +666,7 @@ describe('Archiver Store', () => { }); it('preserves proven checkpoint number when target is after proven block', async () => { - const genesisArchive = new AppendOnlyTreeSnapshot(new Fr(GENESIS_ARCHIVE_ROOT), 1); + const genesisArchive = new AppendOnlyTreeSnapshot(genesisArchiveRoot, 1); // Checkpoint 1: blocks 1-2, Checkpoint 2: blocks 3-4, Checkpoint 3: blocks 5-6 const testCheckpoints = await makeChainedCheckpoints(3, { previousArchive: genesisArchive, @@ -547,7 +686,7 @@ describe('Archiver Store', () => { }); it('rolls back finalized checkpoint number when target is before finalized block', async () => { - const genesisArchive = new AppendOnlyTreeSnapshot(new Fr(GENESIS_ARCHIVE_ROOT), 1); + const genesisArchive = new AppendOnlyTreeSnapshot(genesisArchiveRoot, 1); // Checkpoint 1: blocks 1-2, Checkpoint 2: blocks 3-4, Checkpoint 3: blocks 5-6 const testCheckpoints = await makeChainedCheckpoints(3, { previousArchive: genesisArchive, @@ -568,7 +707,7 @@ describe('Archiver Store', () => { }); it('preserves finalized checkpoint number when target is after finalized block', async () => { - const genesisArchive = new AppendOnlyTreeSnapshot(new Fr(GENESIS_ARCHIVE_ROOT), 1); + const genesisArchive = new AppendOnlyTreeSnapshot(genesisArchiveRoot, 1); // Checkpoint 1: blocks 1-2, Checkpoint 2: blocks 3-4, Checkpoint 3: blocks 5-6 const testCheckpoints = await makeChainedCheckpoints(3, { previousArchive: genesisArchive, @@ -588,4 +727,62 @@ describe('Archiver Store', () => { expect(await archiver.getFinalizedL2BlockNumber()).toEqual(BlockNumber(2)); }); }); + + describe('genesis block handling', () => { + it('getBlock({number:0}) returns the synthetic genesis block', async () => { + const block = await archiver.getBlock({ number: BlockNumber.ZERO }); + expect(block).toBeDefined(); + expect(block!.header).toEqual(initialHeader); + }); + + it('getBlock({hash:initialHeaderHash}) returns the synthetic genesis block', async () => { + const initialHeaderHash = await initialHeader.hash(); + const block = await archiver.getBlock({ hash: initialHeaderHash }); + expect(block).toBeDefined(); + expect(block!.header).toEqual(initialHeader); + }); + + it('getBlock({archive:genesisArchiveRoot}) returns the synthetic genesis block', async () => { + const block = await archiver.getBlock({ archive: genesisArchiveRoot }); + expect(block).toBeDefined(); + expect(block!.header).toEqual(initialHeader); + expect(block!.archive.root).toEqual(genesisArchiveRoot); + expect(block!.archive.nextAvailableLeafIndex).toEqual(1); + }); + + it('getBlock({archive:initialHeader.lastArchive.root}) does NOT match genesis (it is the pre-block-0 archive)', async () => { + const block = await archiver.getBlock({ archive: initialHeader.lastArchive.root }); + expect(block).toBeUndefined(); + }); + + it('getBlock({tag:"finalized"}) returns the synthetic genesis block when no blocks synced', async () => { + // With an empty store the finalized tip is INITIAL_L2_BLOCK_NUM - 1 = 0 → resolves to genesis. + const block = await archiver.getBlock({ tag: 'finalized' }); + expect(block).toBeDefined(); + expect(block!.header).toEqual(initialHeader); + }); + + it('getBlockData({number:0}) returns the synthetic genesis block data', async () => { + const data = await archiver.getBlockData({ number: BlockNumber.ZERO }); + expect(data).toBeDefined(); + expect(data!.header).toEqual(initialHeader); + expect(data!.blockHash).toEqual(await initialHeader.hash()); + }); + + it('getBlockNumber({hash:initialHeaderHash}) returns 0', async () => { + const initialHeaderHash = await initialHeader.hash(); + const number = await archiver.getBlockNumber({ hash: initialHeaderHash }); + expect(number).toEqual(BlockNumber.ZERO); + }); + + it('getBlocks({from:0, limit:5}) throws — range queries do not support genesis', async () => { + await expect(archiver.getBlocks({ from: BlockNumber.ZERO, limit: 5 })).rejects.toThrow(/from/); + }); + + it('returns the same block instance on consecutive calls (caching invariant)', async () => { + const block1 = await archiver.getBlock({ number: BlockNumber.ZERO }); + const block2 = await archiver.getBlock({ number: BlockNumber.ZERO }); + expect(block1).toBe(block2); + }); + }); }); diff --git a/yarn-project/archiver/src/archiver-sync.test.ts b/yarn-project/archiver/src/archiver-sync.test.ts index 4c20a13d7052..81c820ddcb3b 100644 --- a/yarn-project/archiver/src/archiver-sync.test.ts +++ b/yarn-project/archiver/src/archiver-sync.test.ts @@ -20,6 +20,7 @@ import type { ProposedCheckpointInput } from '@aztec/stdlib/checkpoint'; import type { L1RollupConstants } from '@aztec/stdlib/epoch-helpers'; import { computeInHashFromL1ToL2Messages } from '@aztec/stdlib/messaging'; import { CheckpointHeader } from '@aztec/stdlib/rollup'; +import { BlockHeader } from '@aztec/stdlib/tx'; import { getTelemetryClient } from '@aztec/telemetry-client'; import { jest } from '@jest/globals'; @@ -123,7 +124,9 @@ describe('Archiver Sync', () => { const events = new EventEmitter() as ArchiverEmitter; // Create L2 tips cache shared by archiver and synchronizer - const l2TipsCache = new L2TipsCache(archiverStore.blocks); + const initialHeader = BlockHeader.empty(); + const initialBlockHash = await initialHeader.hash(); + const l2TipsCache = new L2TipsCache(archiverStore.blocks, initialBlockHash); // Create the L1 synchronizer synchronizer = new ArchiverL1Synchronizer( @@ -156,6 +159,8 @@ describe('Archiver Sync', () => { l1Constants, synchronizer, events, + initialHeader, + initialBlockHash, l2TipsCache, ); }); @@ -224,7 +229,7 @@ describe('Archiver Sync', () => { const expectedTotalNumLogs = (name: 'private' | 'public' | 'contractClass') => sum(block.body.txEffects.map(txEffect => txEffect[`${name}Logs`].length)); - const privateLogs = (await archiver.getBlock(blockNumber))!.getPrivateLogs(); + const privateLogs = (await archiver.getBlock({ number: blockNumber }))!.getPrivateLogs(); expect(privateLogs.length).toBe(expectedTotalNumLogs('private')); const publicLogs = (await archiver.getPublicLogs({ fromBlock: blockNumber, toBlock: blockNumber + 1 })).logs; @@ -1527,7 +1532,7 @@ describe('Archiver Sync', () => { const lastBlockInCheckpoint2 = cp2.blocks[cp2.blocks.length - 1].number; expect(await archiver.getBlockNumber()).toEqual(lastBlockInCheckpoint2); expect(await archiver.getSynchedCheckpointNumber()).toEqual(CheckpointNumber(1)); - expect((await archiver.getL2Block(cp2.blocks[0].number))!.equals(cp2.blocks[0])).toBe(true); + expect((await archiver.getBlock({ number: cp2.blocks[0].number }))!.equals(cp2.blocks[0])).toBe(true); // Verify L2Tips after adding blocks: proposed advances but checkpointed stays at checkpoint 1 const tipsAfterAddBlock = await archiver.getL2Tips(); @@ -1535,13 +1540,17 @@ describe('Archiver Sync', () => { expect(tipsAfterAddBlock.checkpointed.block.number).toEqual(lastBlockInCheckpoint1); expect(tipsAfterAddBlock.checkpointed.checkpoint.number).toEqual(CheckpointNumber(1)); - // getCheckpointedBlock should return undefined for the new blocks since checkpoint 2 hasn't synced + // getBlocks with onlyCheckpointed should return empty for the new blocks since checkpoint 2 hasn't synced const firstNewBlockNumber = BlockNumber(lastBlockInCheckpoint1 + 1); - const uncheckpointedBlock = await archiver.getCheckpointedBlock(firstNewBlockNumber); - expect(uncheckpointedBlock).toBeUndefined(); + const uncheckpointedBlocks = await archiver.getBlocks({ + from: firstNewBlockNumber, + limit: 1, + onlyCheckpointed: true, + }); + expect(uncheckpointedBlocks).toHaveLength(0); - // But getL2Block should work (it retrieves both checkpointed and uncheckpointed blocks) - const block = await archiver.getL2Block(firstNewBlockNumber); + // But getBlock should work (it retrieves both checkpointed and uncheckpointed blocks) + const block = await archiver.getBlock({ number: firstNewBlockNumber }); expect(block).toBeDefined(); // Now advance L1 so checkpoint 2 becomes visible @@ -1561,10 +1570,14 @@ describe('Archiver Sync', () => { expect(tipsAfterCheckpoint2.checkpointed.block.number).toEqual(lastBlockInCheckpoint2); expect(tipsAfterCheckpoint2.checkpointed.checkpoint.number).toEqual(CheckpointNumber(2)); - // getCheckpointedBlock should now work for the new blocks - const checkpointedBlock = await archiver.getCheckpointedBlock(firstNewBlockNumber); - expect(checkpointedBlock).toBeDefined(); - expect(checkpointedBlock!.checkpointNumber).toEqual(2); + // getBlocks with onlyCheckpointed should now include the new blocks + const checkpointedBlocks = await archiver.getBlocks({ + from: firstNewBlockNumber, + limit: 1, + onlyCheckpointed: true, + }); + expect(checkpointedBlocks).toHaveLength(1); + expect(checkpointedBlocks[0].checkpointNumber).toEqual(2); }, 10_000); it('rejects adding blocks that are already checkpointed', async () => { diff --git a/yarn-project/archiver/src/archiver.ts b/yarn-project/archiver/src/archiver.ts index 257a34f28b7c..23f417c088e3 100644 --- a/yarn-project/archiver/src/archiver.ts +++ b/yarn-project/archiver/src/archiver.ts @@ -14,6 +14,7 @@ import { RunningPromise, makeLoggingErrorHandler } from '@aztec/foundation/runni import { DateProvider, elapsed } from '@aztec/foundation/timer'; import { type ArchiverEmitter, + type BlockHash, L2Block, type L2BlockSink, type L2Tips, @@ -28,6 +29,7 @@ import { getTimestampForSlot, getTimestampRangeForEpoch, } from '@aztec/stdlib/epoch-helpers'; +import type { BlockHeader } from '@aztec/stdlib/tx'; import { type TelemetryClient, type Traceable, type Tracer, trackSpan } from '@aztec/telemetry-client'; import { type ArchiverConfig, mapArchiverConfig } from './config.js'; @@ -140,17 +142,19 @@ export class Archiver extends ArchiverDataSourceBase implements L2BlockSink, Tra }, synchronizer: ArchiverL1Synchronizer, events: ArchiverEmitter, - l2TipsCache?: L2TipsCache, + initialHeader: BlockHeader, + initialBlockHash: BlockHash, + l2TipsCache: L2TipsCache, private readonly log: Logger = createLogger('archiver'), ) { - super(dataStores, l1Constants); + super(dataStores, l1Constants, initialHeader, initialBlockHash, l1Constants.genesisArchiveRoot); this.tracer = instrumentation.tracer; this.instrumentation = instrumentation; this.initialSyncPromise = promiseWithResolvers(); this.synchronizer = synchronizer; this.events = events; - this.l2TipsCache = l2TipsCache ?? new L2TipsCache(this.dataStores.blocks); + this.l2TipsCache = l2TipsCache; this.updater = new ArchiverDataStoreUpdater(this.dataStores, this.l2TipsCache, { rollupManaLimit: l1Constants.rollupManaLimit, }); @@ -455,8 +459,7 @@ export class Archiver extends ArchiverDataSourceBase implements L2BlockSink, Tra // The epoch is complete if the current checkpointed L2 block is the last one in the epoch (or later). // We use the checkpointed block number (synced from L1) instead of 'latest' to avoid returning true // prematurely when proposed blocks have been pushed to the archiver but not yet checkpointed on L1. - const checkpointedBlockNumber = await this.getCheckpointedL2BlockNumber(); - const header = checkpointedBlockNumber > 0 ? await this.getBlockHeader(checkpointedBlockNumber) : undefined; + const header = (await this.getBlockData({ tag: 'checkpointed' }))?.header; const slot = header ? header.globalVariables.slotNumber : undefined; const [_startSlot, endSlot] = getSlotRangeForEpoch(epochNumber, this.l1Constants); if (slot && slot >= endSlot) { @@ -512,29 +515,34 @@ export class Archiver extends ArchiverDataSourceBase implements L2BlockSink, Tra if (targetL2BlockNumber >= currentL2Block) { throw new Error(`Target L2 block ${targetL2BlockNumber} must be less than current L2 block ${currentL2Block}`); } - const targetL2Block = await this.stores.blocks.getCheckpointedBlock(targetL2BlockNumber); - if (!targetL2Block) { + const checkpointedTip = await this.stores.blocks.getCheckpointedL2BlockNumber(); + if (targetL2BlockNumber > checkpointedTip) { + throw new Error(`Target L2 block ${targetL2BlockNumber} is not checkpointed yet`); + } + const targetBlockData = await this.stores.blocks.getBlockData({ number: targetL2BlockNumber }); + if (!targetBlockData) { throw new Error(`Target L2 block ${targetL2BlockNumber} not found`); } - const targetCheckpointNumber = targetL2Block.checkpointNumber; + const targetCheckpointNumber = targetBlockData.checkpointNumber; // Rollback operates at checkpoint granularity: the target block must be the last block of its checkpoint. const checkpointData = await this.stores.blocks.getCheckpointData(targetCheckpointNumber); - if (checkpointData) { - const lastBlockInCheckpoint = BlockNumber(checkpointData.startBlock + checkpointData.blockCount - 1); - if (targetL2BlockNumber !== lastBlockInCheckpoint) { - const previousCheckpointBoundary = - checkpointData.startBlock > 1 ? BlockNumber(checkpointData.startBlock - 1) : BlockNumber(0); - throw new Error( - `Target L2 block ${targetL2BlockNumber} is not at a checkpoint boundary. ` + - `Checkpoint ${targetCheckpointNumber} spans blocks ${checkpointData.startBlock} to ${lastBlockInCheckpoint}. ` + - `Use block ${lastBlockInCheckpoint} to roll back to this checkpoint, ` + - `or block ${previousCheckpointBoundary} to roll back to the previous one.`, - ); - } + if (!checkpointData) { + throw new Error(`Checkpoint ${targetCheckpointNumber} not found for block ${targetL2BlockNumber}`); + } + const lastBlockInCheckpoint = BlockNumber(checkpointData.startBlock + checkpointData.blockCount - 1); + if (targetL2BlockNumber !== lastBlockInCheckpoint) { + const previousCheckpointBoundary = + checkpointData.startBlock > 1 ? BlockNumber(checkpointData.startBlock - 1) : BlockNumber(0); + throw new Error( + `Target L2 block ${targetL2BlockNumber} is not at a checkpoint boundary. ` + + `Checkpoint ${targetCheckpointNumber} spans blocks ${checkpointData.startBlock} to ${lastBlockInCheckpoint}. ` + + `Use block ${lastBlockInCheckpoint} to roll back to this checkpoint, ` + + `or block ${previousCheckpointBoundary} to roll back to the previous one.`, + ); } - const targetL1BlockNumber = targetL2Block.l1.blockNumber; + const targetL1BlockNumber = checkpointData.l1.blockNumber; const targetL1Block = await this.publicClient.getBlock({ blockNumber: targetL1BlockNumber, includeTransactions: false, diff --git a/yarn-project/archiver/src/errors.ts b/yarn-project/archiver/src/errors.ts index 1b42d82d66d6..64fae2aa69dd 100644 --- a/yarn-project/archiver/src/errors.ts +++ b/yarn-project/archiver/src/errors.ts @@ -29,25 +29,31 @@ export class InitialCheckpointNumberNotSequentialError extends Error { } } -export class BlockCheckpointNumberNotSequentialError extends Error { +export class CheckpointNumberNotSequentialError extends Error { constructor( - blockNumber: BlockNumber, - blockCheckpointNumber: CheckpointNumber, + newCheckpointNumber: CheckpointNumber, previous: CheckpointNumber | undefined, + source?: 'proposed' | 'confirmed', ) { + const qualifier = source ? `${source} ` : ''; super( - `Cannot insert new block ${blockNumber} for checkpoint ${blockCheckpointNumber} given previous checkpoint number is ${previous ?? 'undefined'}`, + `Cannot insert new checkpoint ${newCheckpointNumber} given previous ${qualifier}checkpoint number is ${previous ?? 'undefined'}`, ); - this.name = 'BlockCheckpointNumberNotSequentialError'; + this.name = 'CheckpointNumberNotSequentialError'; } } -export class CheckpointNumberNotSequentialError extends Error { - constructor(newCheckpointNumber: CheckpointNumber, previous: CheckpointNumber | undefined) { +/** Thrown when a proposed block carries a checkpoint number that does not follow the latest one. */ +export class BlockCheckpointNumberNotSequentialError extends Error { + constructor( + blockNumber: BlockNumber, + blockCheckpointNumber: CheckpointNumber, + previous: CheckpointNumber | undefined, + ) { super( - `Cannot insert new checkpoint ${newCheckpointNumber} given previous checkpoint number is ${previous ?? 'undefined'}`, + `Cannot insert new block ${blockNumber} for checkpoint ${blockCheckpointNumber} given previous checkpoint number is ${previous ?? 'undefined'}`, ); - this.name = 'CheckpointNumberNotSequentialError'; + this.name = 'BlockCheckpointNumberNotSequentialError'; } } diff --git a/yarn-project/archiver/src/factory.ts b/yarn-project/archiver/src/factory.ts index 53a555485d87..e5eb2eb1bac4 100644 --- a/yarn-project/archiver/src/factory.ts +++ b/yarn-project/archiver/src/factory.ts @@ -12,9 +12,10 @@ import { createStore } from '@aztec/kv-store/lmdb-v2'; import { protocolContractNames } from '@aztec/protocol-contracts'; import { BundledProtocolContractsProvider } from '@aztec/protocol-contracts/providers/bundle'; import { FunctionType, decodeFunctionSignature } from '@aztec/stdlib/abi'; -import type { ArchiverEmitter } from '@aztec/stdlib/block'; +import type { ArchiverEmitter, BlockHash } from '@aztec/stdlib/block'; import { type ContractClassPublicWithCommitment, computePublicBytecodeCommitment } from '@aztec/stdlib/contract'; import type { DataStoreConfig } from '@aztec/stdlib/kv-store'; +import type { BlockHeader } from '@aztec/stdlib/tx'; import { getTelemetryClient } from '@aztec/telemetry-client'; import { EventEmitter } from 'events'; @@ -46,12 +47,17 @@ export async function createArchiverStore( * @param config - The archiver configuration. * @param deps - The archiver dependencies (blobClient, epochCache, dateProvider, telemetry). * @param opts - The options. + * @param initialHeader - The genesis block header from world-state, used to answer block-0 queries. + * @param initialBlockHash - Precomputed hash of `initialHeader`. Hoisted to the caller so the archiver + * can expose `getGenesisBlockHash()` synchronously. * @returns The local archiver. */ export async function createArchiver( config: ArchiverConfig & DataStoreConfig, deps: ArchiverDeps, opts: { blockUntilSync: boolean } = { blockUntilSync: true }, + initialHeader: BlockHeader, + initialBlockHash: BlockHash, ): Promise { const archiverStore = await createArchiverStore(config); await registerProtocolContracts(archiverStore); @@ -133,8 +139,10 @@ export async function createArchiver( // Create the event emitter that will be shared by archiver and synchronizer const events = new EventEmitter() as ArchiverEmitter; - // Create L2 tips cache shared by archiver and synchronizer - const l2TipsCache = new L2TipsCache(archiverStore.blocks); + // Create L2 tips cache shared by archiver and synchronizer. The genesis block hash is dynamic — + // it depends on the injected initial header (genesisTimestamp + prefilled state). Hoisted to the + // caller so we can pass the same value to the archiver and expose it via `getGenesisBlockHash()`. + const l2TipsCache = new L2TipsCache(archiverStore.blocks, initialBlockHash); // Create the L1 synchronizer const synchronizer = new ArchiverL1Synchronizer( @@ -167,6 +175,8 @@ export async function createArchiver( l1Constants, synchronizer, events, + initialHeader, + initialBlockHash, l2TipsCache, ); diff --git a/yarn-project/archiver/src/modules/contract_data_source_adapter.ts b/yarn-project/archiver/src/modules/contract_data_source_adapter.ts index 5292dcb33a5a..9fc7a39bce20 100644 --- a/yarn-project/archiver/src/modules/contract_data_source_adapter.ts +++ b/yarn-project/archiver/src/modules/contract_data_source_adapter.ts @@ -35,12 +35,8 @@ export class ArchiverContractDataSourceAdapter implements ContractDataSource { let timestamp = maybeTimestamp; if (timestamp === undefined) { const latest = await this.stores.blocks.getLatestL2BlockNumber(); - if ((latest as BlockNumber) === 0) { - timestamp = 0n; - } else { - const [header] = await this.stores.blocks.getBlockHeaders(latest, 1); - timestamp = header ? header.globalVariables.timestamp : 0n; - } + const blockData = latest > 0 ? await this.stores.blocks.getBlockData({ number: latest }) : undefined; + timestamp = blockData ? blockData.header.globalVariables.timestamp : 0n; } return this.stores.contractInstances.getContractInstance(address, timestamp); } diff --git a/yarn-project/archiver/src/modules/data_source_base.ts b/yarn-project/archiver/src/modules/data_source_base.ts index 3078d115689c..ad051843475c 100644 --- a/yarn-project/archiver/src/modules/data_source_base.ts +++ b/yarn-project/archiver/src/modules/data_source_base.ts @@ -1,11 +1,25 @@ -import { range } from '@aztec/foundation/array'; -import { BlockNumber, CheckpointNumber, type EpochNumber, type SlotNumber } from '@aztec/foundation/branded-types'; +import { INITIAL_L2_BLOCK_NUM } from '@aztec/constants'; +import { + BlockNumber, + CheckpointNumber, + type EpochNumber, + IndexWithinCheckpoint, + type SlotNumber, +} from '@aztec/foundation/branded-types'; import type { Fr } from '@aztec/foundation/curves/bn254'; import type { EthAddress } from '@aztec/foundation/eth-address'; -import { isDefined } from '@aztec/foundation/types'; import type { FunctionSelector } from '@aztec/stdlib/abi'; import type { AztecAddress } from '@aztec/stdlib/aztec-address'; -import { type BlockData, type BlockHash, CheckpointedL2Block, L2Block, type L2Tips } from '@aztec/stdlib/block'; +import { + type BlockData, + type BlockHash, + type BlockQuery, + type BlockTag, + type BlocksQuery, + Body, + L2Block, + type L2Tips, +} from '@aztec/stdlib/block'; import { Checkpoint, type CheckpointData, @@ -20,13 +34,22 @@ import type { L2LogsSource } from '@aztec/stdlib/interfaces/server'; import type { LogFilter, SiloedTag, Tag, TxScopedL2Log } from '@aztec/stdlib/logs'; import type { L1ToL2MessageSource } from '@aztec/stdlib/messaging'; import type { CheckpointHeader } from '@aztec/stdlib/rollup'; +import { AppendOnlyTreeSnapshot } from '@aztec/stdlib/trees'; import type { BlockHeader, IndexedTxEffect, TxHash, TxReceipt } from '@aztec/stdlib/tx'; import type { UInt64 } from '@aztec/stdlib/types'; import type { ArchiverDataSource } from '../interfaces.js'; +import type { ResolvedBlockQuery, ResolvedBlocksQuery } from '../store/block_store.js'; import type { ArchiverDataStores } from '../store/data_stores.js'; import type { ValidateCheckpointResult } from './validation.js'; +/** + * Sentinel returned by {@link ArchiverDataSourceBase#resolveBlockQuery} when a query resolves + * to the genesis block. Forces single-block lookup methods to take the genesis branch + * explicitly rather than silently falling through to the BlockStore (which never has a block 0). + */ +type GenesisBlockQuery = { genesis: true }; + /** * Abstract base class implementing ArchiverDataSource using a bundle of archiver substores. * Provides implementations for all read-side methods and declares abstract methods for @@ -35,10 +58,69 @@ import type { ValidateCheckpointResult } from './validation.js'; export abstract class ArchiverDataSourceBase implements ArchiverDataSource, L2LogsSource, ContractDataSource, L1ToL2MessageSource { + /** The injected genesis block header. */ + protected readonly initialHeader: BlockHeader; + /** Precomputed hash of the initial header, exposed via {@link getGenesisBlockHash}. */ + protected readonly initialBlockHash: BlockHash; + /** Archive root after block 0 was appended — read from L1 (`Rollup.getGenesisArchiveTreeRoot`). */ + protected readonly genesisArchiveRoot: Fr; + + /** Memoized synthetic genesis block — callers rely on referential identity for caching. */ + private readonly genesisBlock: L2Block; + /** Memoized synthetic genesis block data — kept consistent with {@link genesisBlock}. */ + private readonly genesisBlockData: BlockData; + constructor( protected readonly stores: ArchiverDataStores, - protected readonly l1Constants?: L1RollupConstants, - ) {} + protected readonly l1Constants: L1RollupConstants | undefined, + initialHeader: BlockHeader, + initialBlockHash: BlockHash, + genesisArchiveRoot: Fr, + ) { + this.initialHeader = initialHeader; + this.initialBlockHash = initialBlockHash; + this.genesisArchiveRoot = genesisArchiveRoot; + + const genesisArchive = new AppendOnlyTreeSnapshot(genesisArchiveRoot, 1); + this.genesisBlock = new L2Block( + genesisArchive, + initialHeader, + Body.empty(), + CheckpointNumber.ZERO, + IndexWithinCheckpoint(0), + ); + this.genesisBlockData = { + header: initialHeader, + archive: genesisArchive, + blockHash: initialBlockHash, + checkpointNumber: CheckpointNumber.ZERO, + indexWithinCheckpoint: IndexWithinCheckpoint(0), + }; + } + + /** Returns the precomputed hash of the genesis block header. */ + public getGenesisBlockHash(): BlockHash { + return this.initialBlockHash; + } + + /** Returns the synthetic genesis L2Block (memoized — same instance across calls). */ + private getGenesisBlock(): L2Block { + return this.genesisBlock; + } + + /** Returns genesis block data (memoized — same instance across calls). */ + private getGenesisBlockData(): BlockData { + return this.genesisBlockData; + } + + /** + * Type guard distinguishing the genesis sentinel from a {@link ResolvedBlockQuery}. + * `resolveBlockQuery` already rewrites every genesis-matching shape to the sentinel, + * so callers only need this single sync check. + */ + private isGenesisBlockQuery(query: ResolvedBlockQuery | GenesisBlockQuery): query is GenesisBlockQuery { + return 'genesis' in query; + } abstract getRollupAddress(): Promise; @@ -72,25 +154,24 @@ export abstract class ArchiverDataSourceBase return this.stores.blocks.getProvenCheckpointNumber(); } - public getBlockNumber(): Promise { - return this.stores.blocks.getLatestL2BlockNumber(); - } - - public getProvenBlockNumber(): Promise { - return this.stores.blocks.getProvenBlockNumber(); - } - - public async getBlockHeader(number: BlockNumber | 'latest'): Promise { - const blockNumber = number === 'latest' ? await this.stores.blocks.getLatestL2BlockNumber() : number; - if (blockNumber === 0) { + public getBlockNumber(): Promise; + public getBlockNumber(query: BlockQuery): Promise; + public async getBlockNumber(query?: BlockQuery): Promise { + if (!query) { + return this.stores.blocks.getLatestL2BlockNumber(); + } + const resolved = await this.resolveBlockQuery(query); + if (resolved === undefined) { return undefined; } - const headers = await this.stores.blocks.getBlockHeaders(blockNumber, 1); - return headers.length === 0 ? undefined : headers[0]; + if (this.isGenesisBlockQuery(resolved)) { + return BlockNumber.ZERO; + } + return this.stores.blocks.getBlockNumber(resolved); } - public getCheckpointedBlock(number: BlockNumber): Promise { - return this.stores.blocks.getCheckpointedBlock(number); + public getProvenBlockNumber(): Promise { + return this.stores.blocks.getProvenBlockNumber(); } public getCheckpointedL2BlockNumber(): Promise { @@ -123,10 +204,6 @@ export abstract class ArchiverDataSourceBase return BlockNumber(checkpointData.startBlock + checkpointData.blockCount - 1); } - public getCheckpointedBlocks(from: BlockNumber, limit: number): Promise { - return this.stores.blocks.getCheckpointedBlocks(from, limit); - } - public getCheckpointData(checkpointNumber: CheckpointNumber): Promise { return this.stores.blocks.getCheckpointData(checkpointNumber); } @@ -139,37 +216,6 @@ export abstract class ArchiverDataSourceBase return this.stores.blocks.getCheckpointNumberBySlot(slot); } - public getBlockDataWithCheckpointContext(blockNumber: BlockNumber) { - return this.stores.blocks.getBlockDataWithCheckpointContext(blockNumber); - } - - public getBlockHeaderByHash(blockHash: BlockHash): Promise { - return this.stores.blocks.getBlockHeaderByHash(blockHash); - } - - public getBlockHeaderByArchive(archive: Fr): Promise { - return this.stores.blocks.getBlockHeaderByArchive(archive); - } - - public getBlockData(number: BlockNumber): Promise { - return this.stores.blocks.getBlockData(number); - } - - public getBlockDataByArchive(archive: Fr): Promise { - return this.stores.blocks.getBlockDataByArchive(archive); - } - - public async getL2Block(number: BlockNumber): Promise { - // If the number provided is -ve, then return the latest block. - if (number < 0) { - number = await this.stores.blocks.getLatestL2BlockNumber(); - } - if (number === 0) { - return undefined; - } - return this.stores.blocks.getBlock(number); - } - public getTxEffect(txHash: TxHash): Promise { return this.stores.blocks.getTxEffect(txHash); } @@ -233,9 +279,8 @@ export abstract class ArchiverDataSourceBase ): Promise { let timestamp; if (maybeTimestamp === undefined) { - const latestBlockHeader = await this.getBlockHeader('latest'); - // If we get undefined block header, it means that the archiver has not yet synced any block so we default to 0. - timestamp = latestBlockHeader ? latestBlockHeader.globalVariables.timestamp : 0n; + const latestBlockData = await this.getBlockData({ tag: 'proposed' }); + timestamp = latestBlockData ? latestBlockData.header.globalVariables.timestamp : 0n; } else { timestamp = maybeTimestamp; } @@ -289,30 +334,6 @@ export abstract class ArchiverDataSourceBase return this.stores.blocks.getBlocksForSlot(slotNumber); } - public async getCheckpointedBlocksForEpoch(epochNumber: EpochNumber): Promise { - const checkpointsData = await this.getCheckpointsDataForEpoch(epochNumber); - const blocks = await Promise.all( - checkpointsData.flatMap(checkpoint => - range(checkpoint.blockCount, checkpoint.startBlock).map(blockNumber => - this.getCheckpointedBlock(BlockNumber(blockNumber)), - ), - ), - ); - return blocks.filter(isDefined); - } - - public async getCheckpointedBlockHeadersForEpoch(epochNumber: EpochNumber): Promise { - const checkpointsData = await this.getCheckpointsDataForEpoch(epochNumber); - const blocks = await Promise.all( - checkpointsData.flatMap(checkpoint => - range(checkpoint.blockCount, checkpoint.startBlock).map(blockNumber => - this.getBlockHeader(BlockNumber(blockNumber)), - ), - ), - ); - return blocks.filter(isDefined); - } - public async getCheckpointsForEpoch(epochNumber: EpochNumber): Promise { const checkpointsData = await this.getCheckpointsDataForEpoch(epochNumber); return Promise.all( @@ -330,36 +351,124 @@ export abstract class ArchiverDataSourceBase return this.stores.blocks.getCheckpointDataForSlotRange(start, end); } - public async getBlock(number: BlockNumber): Promise { - // If the number provided is -ve, then return the latest block. - if (number < 0) { - number = await this.stores.blocks.getLatestL2BlockNumber(); + /** Returns just the checkpoint numbers for all checkpoints whose slot falls within the given epoch. */ + public getCheckpointNumbersForEpoch(epochNumber: EpochNumber): Promise { + if (!this.l1Constants) { + throw new Error('L1 constants not set'); } - if (number === 0) { + + const [start, end] = getSlotRangeForEpoch(epochNumber, this.l1Constants); + return this.stores.blocks.getCheckpointNumbersForSlotRange(start, end); + } + + public async getBlock(query: BlockQuery): Promise { + const resolved = await this.resolveBlockQuery(query); + if (resolved === undefined) { return undefined; } - return this.stores.blocks.getBlock(number); + if (this.isGenesisBlockQuery(resolved)) { + return this.getGenesisBlock(); + } + return this.stores.blocks.getBlock(resolved); } - public getBlocks(from: BlockNumber, limit: number): Promise { - return this.stores.blocks.getBlocks(from, limit); + /** + * Range queries iterate physical blocks only; the genesis block is NOT prepended. + * `L2BlockStream` consumers (`world-state.handleL2Blocks`, etc.) emit `blocks-added` events for + * real blocks and would be surprised by a synthetic block 0. Use {@link getBlock} or + * {@link getBlockData} for genesis-aware single-block lookups. + */ + public async getBlocks(query: BlocksQuery): Promise { + const resolved = await this.resolveBlocksQuery(query); + return resolved ? this.stores.blocks.getBlocks(resolved) : []; } - public getCheckpointedBlockByHash(blockHash: BlockHash): Promise { - return this.stores.blocks.getCheckpointedBlockByHash(blockHash); + public async getBlockData(query: BlockQuery): Promise { + const resolved = await this.resolveBlockQuery(query); + if (resolved === undefined) { + return undefined; + } + if (this.isGenesisBlockQuery(resolved)) { + return this.getGenesisBlockData(); + } + return this.stores.blocks.getBlockData(resolved); } - public getCheckpointedBlockByArchive(archive: Fr): Promise { - return this.stores.blocks.getCheckpointedBlockByArchive(archive); + /** See {@link getBlocks} — range queries do not prepend the genesis block. */ + public async getBlocksData(query: BlocksQuery): Promise { + const resolved = await this.resolveBlocksQuery(query); + return resolved ? this.stores.blocks.getBlocksData(resolved) : []; } - public async getL2BlockByHash(blockHash: BlockHash): Promise { - const checkpointedBlock = await this.stores.blocks.getCheckpointedBlockByHash(blockHash); - return checkpointedBlock?.block; + /** + * Resolves a {@link BlockQuery} to either the genesis sentinel or a {@link ResolvedBlockQuery} + * understood by BlockStore. Detects every shape that points at block 0 — `{number:0}`, + * `{hash}` matching the initial header, `{archive}` matching the post-genesis archive root, + * and `{tag}` resolving to 0 — and rewrites them to the sentinel so callers branch once. + */ + private async resolveBlockQuery(query: BlockQuery): Promise { + if ('number' in query) { + return query.number === BlockNumber.ZERO ? { genesis: true } : query; + } + if ('hash' in query) { + return query.hash.equals(this.initialBlockHash) ? { genesis: true } : query; + } + if ('archive' in query) { + return query.archive.equals(this.genesisArchiveRoot) ? { genesis: true } : query; + } + const number = await this.resolveBlockTag(query.tag); + if (number === BlockNumber.ZERO) { + return { genesis: true }; + } + return { number }; + } + + /** Maps a {@link BlockTag} to the matching block number for the current chain state. */ + private resolveBlockTag(tag: BlockTag): Promise { + switch (tag) { + case 'latest': + case 'proposed': + return this.stores.blocks.getLatestL2BlockNumber(); + case 'checkpointed': + return this.stores.blocks.getCheckpointedL2BlockNumber(); + case 'proven': + return this.stores.blocks.getProvenBlockNumber(); + case 'finalized': + return this.stores.blocks.getFinalizedL2BlockNumber(); + } } - public async getL2BlockByArchive(archive: Fr): Promise { - const checkpointedBlock = await this.stores.blocks.getCheckpointedBlockByArchive(archive); - return checkpointedBlock?.block; + /** + * Converts an epoch-based BlocksQuery to a from/limit query using l1Constants. + * Returns undefined when the epoch has no checkpoints, so callers can return [] without + * entering BlockStore. Reads only the two endpoint checkpoints rather than the whole epoch. + */ + private async resolveBlocksQuery(query: BlocksQuery): Promise { + if (!('epoch' in query)) { + if (query.from < INITIAL_L2_BLOCK_NUM) { + throw new Error( + `getBlocks/getBlocksData: 'from' must be >= ${INITIAL_L2_BLOCK_NUM}, got ${query.from}. ` + + `Use getBlock({number:0})/getBlockData({number:0}) for genesis-aware single-block lookups.`, + ); + } + return query; + } + const checkpointNumbers = await this.getCheckpointNumbersForEpoch(query.epoch); + if (checkpointNumbers.length === 0) { + return undefined; + } + const firstNumber = checkpointNumbers[0]; + const lastNumber = checkpointNumbers[checkpointNumbers.length - 1]; + const first = await this.stores.blocks.getCheckpointData(firstNumber); + if (!first) { + return undefined; + } + const last = firstNumber === lastNumber ? first : await this.stores.blocks.getCheckpointData(lastNumber); + if (!last) { + return undefined; + } + const from = BlockNumber(first.startBlock); + const limit = last.startBlock + last.blockCount - first.startBlock; + return { from, limit, onlyCheckpointed: true }; } } diff --git a/yarn-project/archiver/src/modules/data_store_updater.test.ts b/yarn-project/archiver/src/modules/data_store_updater.test.ts index b0395494ce1a..3722d6eb8654 100644 --- a/yarn-project/archiver/src/modules/data_store_updater.test.ts +++ b/yarn-project/archiver/src/modules/data_store_updater.test.ts @@ -190,7 +190,7 @@ describe('ArchiverDataStoreUpdater', () => { await updater.addCheckpoints([makePublishedCheckpoint(makeCheckpoint([checkpointBlock]), 10)]); // Verify checkpoint block is stored - const storedBlock = await store.blocks.getBlock(BlockNumber(1)); + const storedBlock = await store.blocks.getBlock({ number: BlockNumber(1) }); expect(storedBlock?.archive.root.equals(checkpointBlock.archive.root)).toBe(true); const publicLogsAfter = await store.logs.getPublicLogs({}); expect(publicLogsAfter.logs.map(l => l.log)).toEqual(checkpointBlock.body.txEffects.flatMap(tx => tx.publicLogs)); diff --git a/yarn-project/archiver/src/modules/data_store_updater.ts b/yarn-project/archiver/src/modules/data_store_updater.ts index e02c51a214ee..e1b50131d589 100644 --- a/yarn-project/archiver/src/modules/data_store_updater.ts +++ b/yarn-project/archiver/src/modules/data_store_updater.ts @@ -177,10 +177,10 @@ export class ArchiverDataStoreUpdater { } // Get all uncheckpointed local blocks - const uncheckpointedLocalBlocks = await this.stores.blocks.getBlocks( - BlockNumber.add(lastCheckpointedBlockNumber, 1), - lastBlockNumber - lastCheckpointedBlockNumber, - ); + const uncheckpointedLocalBlocks = await this.stores.blocks.getBlocks({ + from: BlockNumber.add(lastCheckpointedBlockNumber, 1), + limit: lastBlockNumber - lastCheckpointedBlockNumber, + }); let lastAlreadyInsertedBlockNumber: BlockNumber | undefined; diff --git a/yarn-project/archiver/src/modules/l1_synchronizer.ts b/yarn-project/archiver/src/modules/l1_synchronizer.ts index 03fa5a373c64..f31f08cd0658 100644 --- a/yarn-project/archiver/src/modules/l1_synchronizer.ts +++ b/yarn-project/archiver/src/modules/l1_synchronizer.ts @@ -283,11 +283,10 @@ export class ArchiverL1Synchronizer implements Traceable { const firstUncheckpointedBlockNumber = BlockNumber(lastCheckpointedBlockNumber + 1); // What's the slot of the first uncheckpointed block? - const [firstUncheckpointedBlockHeader] = await this.stores.blocks.getBlockHeaders( - firstUncheckpointedBlockNumber, - 1, - ); - const firstUncheckpointedBlockSlot = firstUncheckpointedBlockHeader?.getSlot(); + const firstUncheckpointedBlockData = await this.stores.blocks.getBlockData({ + number: firstUncheckpointedBlockNumber, + }); + const firstUncheckpointedBlockSlot = firstUncheckpointedBlockData?.header.getSlot(); if (firstUncheckpointedBlockSlot === undefined || firstUncheckpointedBlockSlot >= slotAtNextL1Block) { return; @@ -297,7 +296,7 @@ export class ArchiverL1Synchronizer implements Traceable { // This also clears any proposed checkpoint whose blocks are being pruned. this.log.warn( `Pruning blocks after block ${lastCheckpointedBlockNumber} due to slot ${firstUncheckpointedBlockSlot} not being checkpointed`, - { firstUncheckpointedBlockHeader: firstUncheckpointedBlockHeader.toInspect(), slotAtNextL1Block }, + { firstUncheckpointedBlockHeader: firstUncheckpointedBlockData?.header.toInspect(), slotAtNextL1Block }, ); const prunedBlocks = await this.updater.removeUncheckpointedBlocksAfter(lastCheckpointedBlockNumber); @@ -1086,7 +1085,10 @@ export class ArchiverL1Synchronizer implements Traceable { { proposedHeader: proposed.header.toInspect(), proposedArchiveRoot: proposed.archive.root.toString() }, ); - const blocks = await this.stores.blocks.getBlocks(BlockNumber(proposed.startBlock), proposed.blockCount); + const blocks = await this.stores.blocks.getBlocks({ + from: BlockNumber(proposed.startBlock), + limit: proposed.blockCount, + }); if (blocks.length !== proposed.blockCount) { this.log.warn( `Local proposed checkpoint ${proposed.checkpointNumber} has wrong block count (expected ${proposed.blockCount} blocks starting at ${proposed.startBlock} but got ${blocks.length})`, diff --git a/yarn-project/archiver/src/store/block_store.test.ts b/yarn-project/archiver/src/store/block_store.test.ts index 66becf646ffb..08e72e029d08 100644 --- a/yarn-project/archiver/src/store/block_store.test.ts +++ b/yarn-project/archiver/src/store/block_store.test.ts @@ -11,9 +11,9 @@ import { sleep } from '@aztec/foundation/sleep'; import { openTmpStore } from '@aztec/kv-store/lmdb-v2'; import { BlockHash, - CheckpointedL2Block, CommitteeAttestation, EthAddress, + GENESIS_BLOCK_HEADER_HASH, L2Block, type ValidateCheckpointResult, } from '@aztec/stdlib/block'; @@ -40,6 +40,7 @@ import { makeStateForBlock, } from '../test/mock_structs.js'; import { BlockStore } from './block_store.js'; +import { L2TipsCache } from './l2_tips_cache.js'; async function addProposedBlocks( blockStore: BlockStore, @@ -63,15 +64,16 @@ describe('BlockStore', () => { [5, () => publishedCheckpoints[4].checkpoint.blocks[0]], ]; - const expectCheckpointedBlockEquals = ( - actual: CheckpointedL2Block, + const expectCheckpointedBlockEquals = async ( + actual: L2Block, expectedBlock: L2Block, expectedCheckpoint: PublishedCheckpoint, ) => { - expect(actual.l1).toEqual(expectedCheckpoint.l1); - expect(actual.block.header.equals(expectedBlock.header)).toBe(true); + expect(actual.header.equals(expectedBlock.header)).toBe(true); expect(actual.checkpointNumber).toEqual(expectedCheckpoint.checkpoint.number); - expect(actual.attestations.every((a, i) => a.equals(expectedCheckpoint.attestations[i]))).toBe(true); + const checkpointData = await blockStore.getCheckpointData(actual.checkpointNumber); + expect(checkpointData?.l1).toEqual(expectedCheckpoint.l1); + expect(checkpointData?.attestations.every((a, i) => a.equals(expectedCheckpoint.attestations[i]))).toBe(true); }; beforeEach(async () => { @@ -158,7 +160,7 @@ describe('BlockStore', () => { const checkpoint = await Checkpoint.random(CheckpointNumber(2), { numBlocks: 1, startBlockNumber: 2 }); const block = makePublishedCheckpoint(checkpoint, 2); await expect(blockStore.addCheckpoints([block])).rejects.toThrow(InitialCheckpointNumberNotSequentialError); - await expect(blockStore.getCheckpointedBlock(BlockNumber(1))).resolves.toBeUndefined(); + await expect(blockStore.getBlock({ number: BlockNumber(1) })).resolves.toBeUndefined(); }); it('throws an error if there is a gap in the blocks being added', async () => { @@ -166,7 +168,7 @@ describe('BlockStore', () => { const checkpoint3 = await Checkpoint.random(CheckpointNumber(3), { numBlocks: 1, startBlockNumber: 3 }); const checkpoints = [makePublishedCheckpoint(checkpoint1, 1), makePublishedCheckpoint(checkpoint3, 3)]; await expect(blockStore.addCheckpoints(checkpoints)).rejects.toThrow(CheckpointNumberNotSequentialError); - await expect(blockStore.getCheckpointedBlock(BlockNumber(1))).resolves.toBeUndefined(); + await expect(blockStore.getBlock({ number: BlockNumber(1) })).resolves.toBeUndefined(); }); it('throws an error if blocks within a checkpoint are not sequential', async () => { @@ -183,7 +185,7 @@ describe('BlockStore', () => { const publishedCheckpoint = makePublishedCheckpoint(checkpoint, 10); await expect(blockStore.addCheckpoints([publishedCheckpoint])).rejects.toThrow(BlockNumberNotSequentialError); - await expect(blockStore.getCheckpointedBlock(BlockNumber(1))).resolves.toBeUndefined(); + await expect(blockStore.getBlock({ number: BlockNumber(1) })).resolves.toBeUndefined(); }); it('throws an error if blocks within a checkpoint do not have sequential indexes', async () => { @@ -206,7 +208,7 @@ describe('BlockStore', () => { const publishedCheckpoint = makePublishedCheckpoint(checkpoint, 10); await expect(blockStore.addCheckpoints([publishedCheckpoint])).rejects.toThrow(BlockIndexNotSequentialError); - await expect(blockStore.getCheckpointedBlock(BlockNumber(1))).resolves.toBeUndefined(); + await expect(blockStore.getBlock({ number: BlockNumber(1) })).resolves.toBeUndefined(); }); it('throws an error if blocks within a checkpoint do not start from index 0', async () => { @@ -229,7 +231,7 @@ describe('BlockStore', () => { const publishedCheckpoint = makePublishedCheckpoint(checkpoint, 10); await expect(blockStore.addCheckpoints([publishedCheckpoint])).rejects.toThrow(BlockIndexNotSequentialError); - await expect(blockStore.getCheckpointedBlock(BlockNumber(1))).resolves.toBeUndefined(); + await expect(blockStore.getBlock({ number: BlockNumber(1) })).resolves.toBeUndefined(); }); it('throws an error if block has invalid checkpoint index', async () => { @@ -248,7 +250,7 @@ describe('BlockStore', () => { const publishedCheckpoint = makePublishedCheckpoint(checkpoint, 10); await expect(blockStore.addCheckpoints([publishedCheckpoint])).rejects.toThrow(BlockIndexNotSequentialError); - await expect(blockStore.getCheckpointedBlock(BlockNumber(BlockNumber(1)))).resolves.toBeUndefined(); + await expect(blockStore.getBlock({ number: BlockNumber(1) })).resolves.toBeUndefined(); }); it('throws an error if checkpoint has invalid initial number', async () => { @@ -355,10 +357,10 @@ describe('BlockStore', () => { await expect(blockStore.addCheckpoints(checkpoints)).resolves.toBe(true); // Verify blocks have correct checkpoint assignments - const block1 = await blockStore.getCheckpointedBlock(BlockNumber(1)); - const block2 = await blockStore.getCheckpointedBlock(BlockNumber(2)); - const block3 = await blockStore.getCheckpointedBlock(BlockNumber(3)); - const block4 = await blockStore.getCheckpointedBlock(BlockNumber(4)); + const block1 = await blockStore.getBlock({ number: BlockNumber(1) }); + const block2 = await blockStore.getBlock({ number: BlockNumber(2) }); + const block3 = await blockStore.getBlock({ number: BlockNumber(3) }); + const block4 = await blockStore.getBlock({ number: BlockNumber(4) }); expect(block1!.checkpointNumber).toBe(1); expect(block2!.checkpointNumber).toBe(1); @@ -375,15 +377,15 @@ describe('BlockStore', () => { const lastBlockNumber = lastCheckpoint.checkpoint.blocks[0].number; // Verify block exists before removing - const retrievedBlock = await blockStore.getCheckpointedBlock(BlockNumber(BlockNumber(lastBlockNumber))); + const retrievedBlock = await blockStore.getBlock({ number: BlockNumber(lastBlockNumber) }); expect(retrievedBlock).toBeDefined(); - expect(retrievedBlock!.block.header.equals(lastCheckpoint.checkpoint.blocks[0].header)).toBe(true); + expect(retrievedBlock!.header.equals(lastCheckpoint.checkpoint.blocks[0].header)).toBe(true); expect(retrievedBlock!.checkpointNumber).toEqual(checkpointNumber); await blockStore.removeCheckpointsAfter(CheckpointNumber(checkpointNumber - 1)); expect(await blockStore.getLatestCheckpointNumber()).toBe(checkpointNumber - 1); - await expect(blockStore.getCheckpointedBlock(BlockNumber(BlockNumber(lastBlockNumber)))).resolves.toBeUndefined(); + await expect(blockStore.getBlock({ number: BlockNumber(lastBlockNumber) })).resolves.toBeUndefined(); }); it('can remove multiple checkpoints', async () => { @@ -417,19 +419,19 @@ describe('BlockStore', () => { const archive = lastBlock.archive.root; // Verify block and header exist before removing - const retrievedByHash = await blockStore.getCheckpointedBlockByHash(blockHash); + const retrievedByHash = await blockStore.getBlock({ hash: blockHash }); expect(retrievedByHash).toBeDefined(); - expect(retrievedByHash!.block.header.equals(lastBlock.header)).toBe(true); + expect(retrievedByHash!.header.equals(lastBlock.header)).toBe(true); - const retrievedByArchive = await blockStore.getCheckpointedBlockByArchive(archive); + const retrievedByArchive = await blockStore.getBlock({ archive: archive }); expect(retrievedByArchive).toBeDefined(); - expect(retrievedByArchive!.block.header.equals(lastBlock.header)).toBe(true); + expect(retrievedByArchive!.header.equals(lastBlock.header)).toBe(true); - const headerByHash = await blockStore.getBlockHeaderByHash(blockHash); + const headerByHash = (await blockStore.getBlockData({ hash: blockHash }))?.header; expect(headerByHash).toBeDefined(); expect(headerByHash!.equals(lastBlock.header)).toBe(true); - const headerByArchive = await blockStore.getBlockHeaderByArchive(archive); + const headerByArchive = (await blockStore.getBlockData({ archive: archive }))?.header; expect(headerByArchive).toBeDefined(); expect(headerByArchive!.equals(lastBlock.header)).toBe(true); @@ -437,10 +439,10 @@ describe('BlockStore', () => { await blockStore.removeCheckpointsAfter(CheckpointNumber(lastCheckpoint.checkpoint.number - 1)); // Verify neither block nor header can be retrieved after removal - expect(await blockStore.getCheckpointedBlockByHash(blockHash)).toBeUndefined(); - expect(await blockStore.getCheckpointedBlockByArchive(archive)).toBeUndefined(); - expect(await blockStore.getBlockHeaderByHash(blockHash)).toBeUndefined(); - expect(await blockStore.getBlockHeaderByArchive(archive)).toBeUndefined(); + expect(await blockStore.getBlock({ hash: blockHash })).toBeUndefined(); + expect(await blockStore.getBlock({ archive: archive })).toBeUndefined(); + expect(await blockStore.getBlockData({ hash: blockHash })).toBeUndefined(); + expect(await blockStore.getBlockData({ archive: archive })).toBeUndefined(); }); it('orphaned blocks are removed when removing checkpoints', async () => { @@ -472,7 +474,7 @@ describe('BlockStore', () => { expect(await blockStore.getLatestCheckpointNumber()).toBe(1); expect(await blockStore.getLatestL2BlockNumber()).toBe(2); expect(await blockStore.getCheckpointedL2BlockNumber()).toBe(1); - expect(await blockStore.getBlock(BlockNumber(2))).toBeDefined(); + expect(await blockStore.getBlock({ number: BlockNumber(2) })).toBeDefined(); // Remove checkpoint 1 (simulating L1 reorg) await blockStore.removeCheckpointsAfter(CheckpointNumber(0)); @@ -481,8 +483,8 @@ describe('BlockStore', () => { expect(await blockStore.getLatestCheckpointNumber()).toBe(0); expect(await blockStore.getLatestL2BlockNumber()).toBe(0); expect(await blockStore.getCheckpointedL2BlockNumber()).toBe(0); - expect(await blockStore.getBlock(BlockNumber(1))).toBeUndefined(); - expect(await blockStore.getBlock(BlockNumber(2))).toBeUndefined(); + expect(await blockStore.getBlock({ number: BlockNumber(1) })).toBeUndefined(); + expect(await blockStore.getBlock({ number: BlockNumber(2) })).toBeUndefined(); }); it('multiple orphaned blocks are removed when removing checkpoints', async () => { @@ -521,7 +523,7 @@ describe('BlockStore', () => { expect(await blockStore.getLatestCheckpointNumber()).toBe(0); expect(await blockStore.getLatestL2BlockNumber()).toBe(0); for (let i = 1; i <= 4; i++) { - expect(await blockStore.getBlock(BlockNumber(i))).toBeUndefined(); + expect(await blockStore.getBlock({ number: BlockNumber(i) })).toBeUndefined(); } }); }); @@ -644,12 +646,12 @@ describe('BlockStore', () => { // Verify blocks 1-5 still exist (from checkpoints 1 and 2) for (let blockNumber = 1; blockNumber <= 5; blockNumber++) { - expect(await blockStore.getCheckpointedBlock(BlockNumber(BlockNumber(blockNumber)))).toBeDefined(); + expect(await blockStore.getBlock({ number: BlockNumber(blockNumber) })).toBeDefined(); } // Verify blocks 6-10 are gone (from checkpoints 3 and 4) for (let blockNumber = 6; blockNumber <= 10; blockNumber++) { - expect(await blockStore.getCheckpointedBlock(BlockNumber(BlockNumber(blockNumber)))).toBeUndefined(); + expect(await blockStore.getBlock({ number: BlockNumber(blockNumber) })).toBeUndefined(); } // Remove remaining checkpoints 1 and 2 (which together have 5 blocks) @@ -660,7 +662,7 @@ describe('BlockStore', () => { // Verify all blocks are gone for (let blockNumber = 1; blockNumber <= 10; blockNumber++) { - expect(await blockStore.getCheckpointedBlock(BlockNumber(BlockNumber(blockNumber)))).toBeUndefined(); + expect(await blockStore.getBlock({ number: BlockNumber(blockNumber) })).toBeUndefined(); } }); @@ -684,25 +686,27 @@ describe('BlockStore', () => { // Check blocks from the first checkpoint (blocks 1, 2, 3) for (let i = 0; i < 3; i++) { const blockNumber = i + 1; - const retrievedBlock = await blockStore.getCheckpointedBlock(BlockNumber(BlockNumber(blockNumber))); + const retrievedBlock = await blockStore.getBlock({ number: BlockNumber(blockNumber) }); expect(retrievedBlock).toBeDefined(); expect(retrievedBlock!.checkpointNumber).toBe(1); - expect(retrievedBlock!.block.number).toBe(blockNumber); - expect(retrievedBlock!.l1).toEqual(checkpoint1.l1); - expect(retrievedBlock!.attestations.every((a, j) => a.equals(checkpoint1.attestations[j]))).toBe(true); + expect(retrievedBlock!.number).toBe(blockNumber); + const checkpointData1 = await blockStore.getCheckpointData(retrievedBlock!.checkpointNumber); + expect(checkpointData1?.l1).toEqual(checkpoint1.l1); + expect(checkpointData1?.attestations.every((a, j) => a.equals(checkpoint1.attestations[j]))).toBe(true); } // Check blocks from the second checkpoint (blocks 4, 5) for (let i = 0; i < 2; i++) { const blockNumber = i + 4; - const retrievedBlock = await blockStore.getCheckpointedBlock(BlockNumber(BlockNumber(blockNumber))); + const retrievedBlock = await blockStore.getBlock({ number: BlockNumber(blockNumber) }); expect(retrievedBlock).toBeDefined(); expect(retrievedBlock!.checkpointNumber).toBe(2); - expect(retrievedBlock!.block.number).toBe(blockNumber); - expect(retrievedBlock!.l1).toEqual(checkpoint2.l1); - expect(retrievedBlock!.attestations.every((a, j) => a.equals(checkpoint2.attestations[j]))).toBe(true); + expect(retrievedBlock!.number).toBe(blockNumber); + const checkpointData2 = await blockStore.getCheckpointData(retrievedBlock!.checkpointNumber); + expect(checkpointData2?.l1).toEqual(checkpoint2.l1); + expect(checkpointData2?.attestations.every((a, j) => a.equals(checkpoint2.attestations[j]))).toBe(true); } }); @@ -718,12 +722,13 @@ describe('BlockStore', () => { for (let i = 0; i < checkpoint.checkpoint.blocks.length; i++) { const block = checkpoint.checkpoint.blocks[i]; const blockHash = await block.header.hash(); - const retrievedBlock = await blockStore.getCheckpointedBlockByHash(blockHash); + const retrievedBlock = await blockStore.getBlock({ hash: blockHash }); expect(retrievedBlock).toBeDefined(); expect(retrievedBlock!.checkpointNumber).toBe(1); - expect(retrievedBlock!.block.number).toBe(i + 1); - expect(retrievedBlock!.l1).toEqual(checkpoint.l1); + expect(retrievedBlock!.number).toBe(i + 1); + const checkpointData = await blockStore.getCheckpointData(retrievedBlock!.checkpointNumber); + expect(checkpointData?.l1).toEqual(checkpoint.l1); } }); @@ -739,12 +744,13 @@ describe('BlockStore', () => { for (let i = 0; i < checkpoint.checkpoint.blocks.length; i++) { const block = checkpoint.checkpoint.blocks[i]; const archive = block.archive.root; - const retrievedBlock = await blockStore.getCheckpointedBlockByArchive(archive); + const retrievedBlock = await blockStore.getBlock({ archive: archive }); expect(retrievedBlock).toBeDefined(); expect(retrievedBlock!.checkpointNumber).toBe(1); - expect(retrievedBlock!.block.number).toBe(i + 1); - expect(retrievedBlock!.l1).toEqual(checkpoint.l1); + expect(retrievedBlock!.number).toBe(i + 1); + const checkpointData = await blockStore.getCheckpointData(retrievedBlock!.checkpointNumber); + expect(checkpointData?.l1).toEqual(checkpoint.l1); } }); @@ -758,7 +764,7 @@ describe('BlockStore', () => { // Verify all 3 blocks exist for (let blockNumber = 1; blockNumber <= 3; blockNumber++) { - expect(await blockStore.getCheckpointedBlock(BlockNumber(blockNumber))).toBeDefined(); + expect(await blockStore.getBlock({ number: BlockNumber(blockNumber) })).toBeDefined(); } // Remove the checkpoint @@ -766,7 +772,7 @@ describe('BlockStore', () => { // Verify all 3 blocks are removed for (let blockNumber = 1; blockNumber <= 3; blockNumber++) { - expect(await blockStore.getCheckpointedBlock(BlockNumber(blockNumber))).toBeUndefined(); + expect(await blockStore.getBlock({ number: BlockNumber(blockNumber) })).toBeUndefined(); } expect(await blockStore.getLatestCheckpointNumber()).toBe(0); @@ -836,11 +842,11 @@ describe('BlockStore', () => { await addProposedBlocks(blockStore, [block3, block4]); // getBlock should work for both checkpointed and uncheckpointed blocks - expect((await blockStore.getBlock(BlockNumber(1)))?.number).toBe(1); - expect((await blockStore.getBlock(BlockNumber(2)))?.number).toBe(2); - expect((await blockStore.getBlock(BlockNumber(3)))?.equals(block3)).toBe(true); - expect((await blockStore.getBlock(BlockNumber(4)))?.equals(block4)).toBe(true); - expect(await blockStore.getBlock(BlockNumber(5))).toBeUndefined(); + expect((await blockStore.getBlock({ number: BlockNumber(1) }))?.number).toBe(1); + expect((await blockStore.getBlock({ number: BlockNumber(2) }))?.number).toBe(2); + expect((await blockStore.getBlock({ number: BlockNumber(3) }))?.equals(block3)).toBe(true); + expect((await blockStore.getBlock({ number: BlockNumber(4) }))?.equals(block4)).toBe(true); + expect(await blockStore.getBlock({ number: BlockNumber(5) })).toBeUndefined(); const block5 = await L2Block.random(BlockNumber(5), { checkpointNumber: CheckpointNumber(2), @@ -850,13 +856,13 @@ describe('BlockStore', () => { await blockStore.addProposedBlock(block5); // Verify the uncheckpointed blocks have correct data - const retrieved3 = await blockStore.getBlock(BlockNumber(3)); + const retrieved3 = await blockStore.getBlock({ number: BlockNumber(3) }); expect(retrieved3!.number).toBe(3); expect(retrieved3!.equals(block3)).toBe(true); - const retrieved4 = await blockStore.getBlock(BlockNumber(4)); + const retrieved4 = await blockStore.getBlock({ number: BlockNumber(4) }); expect(retrieved4!.number).toBe(4); expect(retrieved4!.equals(block4)).toBe(true); - const retrieved5 = await blockStore.getBlock(BlockNumber(5)); + const retrieved5 = await blockStore.getBlock({ number: BlockNumber(5) }); expect(retrieved5!.number).toBe(5); expect(retrieved5!.equals(block5)).toBe(true); }); @@ -878,10 +884,10 @@ describe('BlockStore', () => { const hash1 = await block1.header.hash(); const hash2 = await block2.header.hash(); - const retrieved1 = await blockStore.getBlockByHash(hash1); + const retrieved1 = await blockStore.getBlock({ hash: hash1 }); expect(retrieved1!.equals(block1)).toBe(true); - const retrieved2 = await blockStore.getBlockByHash(hash2); + const retrieved2 = await blockStore.getBlock({ hash: hash2 }); expect(retrieved2!.equals(block2)).toBe(true); }); @@ -902,82 +908,13 @@ describe('BlockStore', () => { const archive1 = block1.archive.root; const archive2 = block2.archive.root; - const retrieved1 = await blockStore.getBlockByArchive(archive1); + const retrieved1 = await blockStore.getBlock({ archive: archive1 }); expect(retrieved1!.equals(block1)).toBe(true); - const retrieved2 = await blockStore.getBlockByArchive(archive2); + const retrieved2 = await blockStore.getBlock({ archive: archive2 }); expect(retrieved2!.equals(block2)).toBe(true); }); - it('getCheckpointedBlock returns undefined for uncheckpointed blocks', async () => { - // Add a checkpoint with blocks 1-2 - const checkpoint1 = makePublishedCheckpoint( - await Checkpoint.random(CheckpointNumber(1), { numBlocks: 2, startBlockNumber: 1 }), - 10, - ); - await blockStore.addCheckpoints([checkpoint1]); - - // Add uncheckpointed blocks 3-4 for upcoming checkpoint 2, chaining archive roots - const lastBlockArchive = checkpoint1.checkpoint.blocks.at(-1)!.archive; - const block3 = await L2Block.random(BlockNumber(3), { - checkpointNumber: CheckpointNumber(2), - indexWithinCheckpoint: IndexWithinCheckpoint(0), - lastArchive: lastBlockArchive, - }); - const block4 = await L2Block.random(BlockNumber(4), { - checkpointNumber: CheckpointNumber(2), - indexWithinCheckpoint: IndexWithinCheckpoint(1), - lastArchive: block3.archive, - }); - await addProposedBlocks(blockStore, [block3, block4]); - - // getCheckpointedBlock should work for checkpointed blocks - expect((await blockStore.getCheckpointedBlock(BlockNumber(1)))?.block.number).toBe(1); - expect((await blockStore.getCheckpointedBlock(BlockNumber(2)))?.block.number).toBe(2); - - // getCheckpointedBlock should return undefined for uncheckpointed blocks - expect(await blockStore.getCheckpointedBlock(BlockNumber(3))).toBeUndefined(); - expect(await blockStore.getCheckpointedBlock(BlockNumber(4))).toBeUndefined(); - - // But getBlock should work for all blocks - expect((await blockStore.getBlock(BlockNumber(3)))?.equals(block3)).toBe(true); - expect((await blockStore.getBlock(BlockNumber(4)))?.equals(block4)).toBe(true); - }); - - it('getCheckpointedBlockByHash returns undefined for uncheckpointed blocks', async () => { - // Add uncheckpointed blocks for initial checkpoint 1 - const block1 = await L2Block.random(BlockNumber(1), { - checkpointNumber: CheckpointNumber(1), - indexWithinCheckpoint: IndexWithinCheckpoint(0), - }); - await blockStore.addProposedBlock(block1); - - const hash = await block1.header.hash(); - - // getCheckpointedBlockByHash should return undefined - expect(await blockStore.getCheckpointedBlockByHash(hash)).toBeUndefined(); - - // But getBlockByHash should work - expect((await blockStore.getBlockByHash(hash))?.equals(block1)).toBe(true); - }); - - it('getCheckpointedBlockByArchive returns undefined for uncheckpointed blocks', async () => { - // Add uncheckpointed blocks for initial checkpoint 1 - const block1 = await L2Block.random(BlockNumber(1), { - checkpointNumber: CheckpointNumber(1), - indexWithinCheckpoint: IndexWithinCheckpoint(0), - }); - await blockStore.addProposedBlock(block1); - - const archive = block1.archive.root; - - // getCheckpointedBlockByArchive should return undefined - expect(await blockStore.getCheckpointedBlockByArchive(archive)).toBeUndefined(); - - // But getBlockByArchive should work - expect((await blockStore.getBlockByArchive(archive))?.equals(block1)).toBe(true); - }); - it('checkpoint adopts previously added uncheckpointed blocks', async () => { // Add blocks 1-3 without a checkpoint (for initial checkpoint 1), chaining archive roots const block1 = await L2Block.random(BlockNumber(1), { @@ -999,11 +936,6 @@ describe('BlockStore', () => { expect(await blockStore.getLatestCheckpointNumber()).toBe(0); expect(await blockStore.getLatestL2BlockNumber()).toBe(3); - // getCheckpointedBlock should return undefined for all - expect(await blockStore.getCheckpointedBlock(BlockNumber(1))).toBeUndefined(); - expect(await blockStore.getCheckpointedBlock(BlockNumber(2))).toBeUndefined(); - expect(await blockStore.getCheckpointedBlock(BlockNumber(3))).toBeUndefined(); - // Now add a checkpoint that covers blocks 1-3 const checkpoint1 = makePublishedCheckpoint( await Checkpoint.random(CheckpointNumber(1), { numBlocks: 3, startBlockNumber: 1 }), @@ -1014,17 +946,18 @@ describe('BlockStore', () => { expect(await blockStore.getLatestCheckpointNumber()).toBe(1); expect(await blockStore.getLatestL2BlockNumber()).toBe(3); - // Now getCheckpointedBlock should work for all blocks - const checkpointed1 = await blockStore.getCheckpointedBlock(BlockNumber(1)); + // Now getBlock should return all blocks + const checkpointed1 = await blockStore.getBlock({ number: BlockNumber(1) }); expect(checkpointed1).toBeDefined(); expect(checkpointed1!.checkpointNumber).toBe(1); - expect(checkpointed1!.l1).toEqual(checkpoint1.l1); + const checkpointData1 = await blockStore.getCheckpointData(checkpointed1!.checkpointNumber); + expect(checkpointData1?.l1).toEqual(checkpoint1.l1); - const checkpointed2 = await blockStore.getCheckpointedBlock(BlockNumber(2)); + const checkpointed2 = await blockStore.getBlock({ number: BlockNumber(2) }); expect(checkpointed2).toBeDefined(); expect(checkpointed2!.checkpointNumber).toBe(1); - const checkpointed3 = await blockStore.getCheckpointedBlock(BlockNumber(3)); + const checkpointed3 = await blockStore.getBlock({ number: BlockNumber(3) }); expect(checkpointed3).toBeDefined(); expect(checkpointed3!.checkpointNumber).toBe(1); }); @@ -1059,11 +992,6 @@ describe('BlockStore', () => { expect(await blockStore.getLatestCheckpointNumber()).toBe(1); expect(await blockStore.getLatestL2BlockNumber()).toBe(5); - // Blocks 3-5 are not checkpointed yet - expect(await blockStore.getCheckpointedBlock(BlockNumber(3))).toBeUndefined(); - expect(await blockStore.getCheckpointedBlock(BlockNumber(4))).toBeUndefined(); - expect(await blockStore.getCheckpointedBlock(BlockNumber(5))).toBeUndefined(); - // Add checkpoint 2 covering blocks 3-5, chaining from checkpoint1 const checkpoint2 = makePublishedCheckpoint( await Checkpoint.random(CheckpointNumber(2), { @@ -1079,16 +1007,17 @@ describe('BlockStore', () => { expect(await blockStore.getLatestL2BlockNumber()).toBe(5); // Now blocks 3-5 should be checkpointed with checkpoint 2's info - const checkpointed3 = await blockStore.getCheckpointedBlock(BlockNumber(3)); + const checkpointed3 = await blockStore.getBlock({ number: BlockNumber(3) }); expect(checkpointed3).toBeDefined(); expect(checkpointed3!.checkpointNumber).toBe(2); - expect(checkpointed3!.l1).toEqual(checkpoint2.l1); + const checkpointData2 = await blockStore.getCheckpointData(checkpointed3!.checkpointNumber); + expect(checkpointData2?.l1).toEqual(checkpoint2.l1); - const checkpointed4 = await blockStore.getCheckpointedBlock(BlockNumber(4)); + const checkpointed4 = await blockStore.getBlock({ number: BlockNumber(4) }); expect(checkpointed4).toBeDefined(); expect(checkpointed4!.checkpointNumber).toBe(2); - const checkpointed5 = await blockStore.getCheckpointedBlock(BlockNumber(5)); + const checkpointed5 = await blockStore.getBlock({ number: BlockNumber(5) }); expect(checkpointed5).toBeDefined(); expect(checkpointed5!.checkpointNumber).toBe(2); }); @@ -1116,7 +1045,7 @@ describe('BlockStore', () => { await addProposedBlocks(blockStore, [block3, block4]); // getBlocks should retrieve all blocks - const allBlocks = await blockStore.getBlocks(BlockNumber(1), 10); + const allBlocks = await blockStore.getBlocks({ from: BlockNumber(1), limit: 10 }); expect(allBlocks.length).toBe(4); expect(allBlocks.map(b => b.number)).toEqual([1, 2, 3, 4]); }); @@ -1164,8 +1093,8 @@ describe('BlockStore', () => { await expect(addProposedBlocks(blockStore, [block3, block4])).resolves.toBe(true); // Verify blocks were added - expect((await blockStore.getBlock(BlockNumber(3)))?.equals(block3)).toBe(true); - expect((await blockStore.getBlock(BlockNumber(4)))?.equals(block4)).toBe(true); + expect((await blockStore.getBlock({ number: BlockNumber(3) }))?.equals(block3)).toBe(true); + expect((await blockStore.getBlock({ number: BlockNumber(4) }))?.equals(block4)).toBe(true); }); it('allows blocks for the initial checkpoint when store is empty', async () => { @@ -1183,8 +1112,8 @@ describe('BlockStore', () => { await expect(addProposedBlocks(blockStore, [block1, block2])).resolves.toBe(true); // Verify blocks were added - expect((await blockStore.getBlock(BlockNumber(1)))?.equals(block1)).toBe(true); - expect((await blockStore.getBlock(BlockNumber(2)))?.equals(block2)).toBe(true); + expect((await blockStore.getBlock({ number: BlockNumber(1) }))?.equals(block1)).toBe(true); + expect((await blockStore.getBlock({ number: BlockNumber(2) }))?.equals(block2)).toBe(true); expect(await blockStore.getLatestL2BlockNumber()).toBe(2); }); @@ -1688,26 +1617,95 @@ describe('BlockStore', () => { }); }); + describe('getCheckpointNumbersForSlotRange', () => { + it('returns empty array when no checkpoints exist', async () => { + const numbers = await blockStore.getCheckpointNumbersForSlotRange(SlotNumber(0), SlotNumber(100)); + expect(numbers).toEqual([]); + }); + + it('returns checkpoint numbers for checkpoints whose slot is within the range (inclusive)', async () => { + const cp1 = makePublishedCheckpoint( + await Checkpoint.random(CheckpointNumber(1), { numBlocks: 1, startBlockNumber: 1, slotNumber: SlotNumber(5) }), + 10, + ); + const previousArchive1 = cp1.checkpoint.blocks.at(-1)!.archive; + const cp2 = makePublishedCheckpoint( + await Checkpoint.random(CheckpointNumber(2), { + numBlocks: 1, + startBlockNumber: 2, + previousArchive: previousArchive1, + slotNumber: SlotNumber(8), + }), + 11, + ); + const previousArchive2 = cp2.checkpoint.blocks.at(-1)!.archive; + const cp3 = makePublishedCheckpoint( + await Checkpoint.random(CheckpointNumber(3), { + numBlocks: 1, + startBlockNumber: 3, + previousArchive: previousArchive2, + slotNumber: SlotNumber(12), + }), + 12, + ); + await blockStore.addCheckpoints([cp1, cp2, cp3]); + + // Inclusive range covering all three slots + expect(await blockStore.getCheckpointNumbersForSlotRange(SlotNumber(0), SlotNumber(20))).toEqual([1, 2, 3]); + + // Range that excludes the first checkpoint + expect(await blockStore.getCheckpointNumbersForSlotRange(SlotNumber(6), SlotNumber(20))).toEqual([2, 3]); + + // Range that includes only the middle checkpoint (endpoints are inclusive) + expect(await blockStore.getCheckpointNumbersForSlotRange(SlotNumber(8), SlotNumber(8))).toEqual([2]); + + // Range with no matching checkpoints + expect(await blockStore.getCheckpointNumbersForSlotRange(SlotNumber(9), SlotNumber(11))).toEqual([]); + }); + + it('reflects unwound checkpoints', async () => { + const cp1 = makePublishedCheckpoint( + await Checkpoint.random(CheckpointNumber(1), { numBlocks: 1, startBlockNumber: 1, slotNumber: SlotNumber(1) }), + 10, + ); + const previousArchive1 = cp1.checkpoint.blocks.at(-1)!.archive; + const cp2 = makePublishedCheckpoint( + await Checkpoint.random(CheckpointNumber(2), { + numBlocks: 1, + startBlockNumber: 2, + previousArchive: previousArchive1, + slotNumber: SlotNumber(2), + }), + 11, + ); + await blockStore.addCheckpoints([cp1, cp2]); + + await blockStore.removeCheckpointsAfter(CheckpointNumber(1)); + + expect(await blockStore.getCheckpointNumbersForSlotRange(SlotNumber(0), SlotNumber(10))).toEqual([1]); + }); + }); + describe('getCheckpointedBlock', () => { beforeEach(async () => { await blockStore.addCheckpoints(publishedCheckpoints); }); it.each(blockNumberTests)('retrieves previously stored block %i', async (blockNumber, getExpectedBlock) => { - const retrievedBlock = await blockStore.getCheckpointedBlock(BlockNumber(blockNumber)); + const retrievedBlock = await blockStore.getBlock({ number: BlockNumber(blockNumber) }); const expectedBlock = getExpectedBlock(); const expectedCheckpoint = publishedCheckpoints[blockNumber - 1]; expect(retrievedBlock).toBeDefined(); - expectCheckpointedBlockEquals(retrievedBlock!, expectedBlock, expectedCheckpoint); + await expectCheckpointedBlockEquals(retrievedBlock!, expectedBlock, expectedCheckpoint); }); it('returns undefined if block is not found', async () => { - await expect(blockStore.getCheckpointedBlock(BlockNumber(12))).resolves.toBeUndefined(); + await expect(blockStore.getBlock({ number: BlockNumber(12) })).resolves.toBeUndefined(); }); it('returns undefined for block number 0', async () => { - await expect(blockStore.getCheckpointedBlock(BlockNumber(0))).resolves.toBeUndefined(); + await expect(blockStore.getBlock({ number: BlockNumber(0) })).resolves.toBeUndefined(); }); }); @@ -1720,15 +1718,15 @@ describe('BlockStore', () => { const expectedCheckpoint = publishedCheckpoints[5]; const expectedBlock = expectedCheckpoint.checkpoint.blocks[0]; const blockHash = await expectedBlock.header.hash(); - const retrievedBlock = await blockStore.getCheckpointedBlockByHash(blockHash); + const retrievedBlock = await blockStore.getBlock({ hash: blockHash }); expect(retrievedBlock).toBeDefined(); - expectCheckpointedBlockEquals(retrievedBlock!, expectedBlock, expectedCheckpoint); + await expectCheckpointedBlockEquals(retrievedBlock!, expectedBlock, expectedCheckpoint); }); it('returns undefined for non-existent block hash', async () => { const nonExistentHash = BlockHash.random(); - await expect(blockStore.getCheckpointedBlockByHash(nonExistentHash)).resolves.toBeUndefined(); + await expect(blockStore.getBlock({ hash: nonExistentHash })).resolves.toBeUndefined(); }); }); @@ -1741,27 +1739,27 @@ describe('BlockStore', () => { const expectedCheckpoint = publishedCheckpoints[3]; const expectedBlock = expectedCheckpoint.checkpoint.blocks[0]; const archive = expectedBlock.archive.root; - const retrievedBlock = await blockStore.getCheckpointedBlockByArchive(archive); + const retrievedBlock = await blockStore.getBlock({ archive: archive }); expect(retrievedBlock).toBeDefined(); - expectCheckpointedBlockEquals(retrievedBlock!, expectedBlock, expectedCheckpoint); + await expectCheckpointedBlockEquals(retrievedBlock!, expectedBlock, expectedCheckpoint); }); it('returns undefined for non-existent archive root', async () => { const nonExistentArchive = Fr.random(); - await expect(blockStore.getCheckpointedBlockByArchive(nonExistentArchive)).resolves.toBeUndefined(); + await expect(blockStore.getBlock({ archive: nonExistentArchive })).resolves.toBeUndefined(); }); }); - describe('getBlockHeaderByHash', () => { + describe('getBlockData by hash', () => { beforeEach(async () => { await blockStore.addCheckpoints(publishedCheckpoints); }); - it('retrieves a block header by its hash', async () => { + it('retrieves block header by hash', async () => { const expectedBlock = publishedCheckpoints[7].checkpoint.blocks[0]; const blockHash = await expectedBlock.header.hash(); - const retrievedHeader = await blockStore.getBlockHeaderByHash(blockHash); + const retrievedHeader = (await blockStore.getBlockData({ hash: blockHash }))?.header; expect(retrievedHeader).toBeDefined(); expect(retrievedHeader!.equals(expectedBlock.header)).toBe(true); @@ -1769,19 +1767,19 @@ describe('BlockStore', () => { it('returns undefined for non-existent block hash', async () => { const nonExistentHash = BlockHash.random(); - await expect(blockStore.getBlockHeaderByHash(nonExistentHash)).resolves.toBeUndefined(); + await expect(blockStore.getBlockData({ hash: nonExistentHash })).resolves.toBeUndefined(); }); }); - describe('getBlockHeaderByArchive', () => { + describe('getBlockData by archive', () => { beforeEach(async () => { await blockStore.addCheckpoints(publishedCheckpoints); }); - it('retrieves a block header by its archive root', async () => { + it('retrieves block header by archive root', async () => { const expectedBlock = publishedCheckpoints[2].checkpoint.blocks[0]; const archive = expectedBlock.archive.root; - const retrievedHeader = await blockStore.getBlockHeaderByArchive(archive); + const retrievedHeader = (await blockStore.getBlockData({ archive: archive }))?.header; expect(retrievedHeader).toBeDefined(); expect(retrievedHeader!.equals(expectedBlock.header)).toBe(true); @@ -1789,7 +1787,36 @@ describe('BlockStore', () => { it('returns undefined for non-existent archive root', async () => { const nonExistentArchive = Fr.random(); - await expect(blockStore.getBlockHeaderByArchive(nonExistentArchive)).resolves.toBeUndefined(); + await expect(blockStore.getBlockData({ archive: nonExistentArchive })).resolves.toBeUndefined(); + }); + }); + + describe('getBlockNumber', () => { + beforeEach(async () => { + await blockStore.addCheckpoints(publishedCheckpoints); + }); + + it('resolves a number query', async () => { + await expect(blockStore.getBlockNumber({ number: BlockNumber(3) })).resolves.toBe(3); + }); + + it('resolves a hash query', async () => { + const block = publishedCheckpoints[4].checkpoint.blocks[0]; + const hash = await block.header.hash(); + await expect(blockStore.getBlockNumber({ hash })).resolves.toBe(block.number); + }); + + it('resolves an archive query', async () => { + const block = publishedCheckpoints[6].checkpoint.blocks[0]; + await expect(blockStore.getBlockNumber({ archive: block.archive.root })).resolves.toBe(block.number); + }); + + it('returns undefined for unknown hash', async () => { + await expect(blockStore.getBlockNumber({ hash: BlockHash.random() })).resolves.toBeUndefined(); + }); + + it('returns undefined for unknown archive', async () => { + await expect(blockStore.getBlockNumber({ archive: Fr.random() })).resolves.toBeUndefined(); }); }); @@ -1989,7 +2016,7 @@ describe('BlockStore', () => { await expect(blockStore.addCheckpoints([publishedCheckpoint2])).resolves.toBe(true); // Verify block exists and is consistent - const storedBlock = await blockStore.getBlock(BlockNumber(2)); + const storedBlock = await blockStore.getBlock({ number: BlockNumber(2) }); expect(storedBlock?.archive.root.equals(provisionalBlock.archive.root)).toBe(true); }); @@ -2079,6 +2106,511 @@ describe('BlockStore', () => { }); }); + describe('proposedCheckpointNumber', () => { + /** Adds proposed blocks to the store so addProposedCheckpoint can validate them. + * Uses force: true to skip addProposedBlock's own chaining checks (we only want to test addProposedCheckpoint). */ + async function addBlocksForProposedCheckpoint( + startBlock: number, + blockCount: number, + checkpointNumber: number, + previousArchive?: AppendOnlyTreeSnapshot, + ): Promise { + for (let i = 0; i < blockCount; i++) { + const opts: Parameters[1] = { + checkpointNumber: CheckpointNumber(checkpointNumber), + indexWithinCheckpoint: IndexWithinCheckpoint(i), + }; + if (i === 0 && previousArchive) { + (opts as any).lastArchive = previousArchive; + } + const block = await L2Block.random(BlockNumber(startBlock + i), opts); + await blockStore.addProposedBlock(block, { force: true }); + } + } + + it('returns initial value when no proposed checkpoint is set', async () => { + const pending = await blockStore.getProposedCheckpointNumber(); + expect(pending).toBe(INITIAL_CHECKPOINT_NUMBER - 1); + }); + + it('stores and retrieves proposed checkpoint number', async () => { + await addBlocksForProposedCheckpoint(1, 1, 1); + await blockStore.addProposedCheckpoint({ + checkpointNumber: CheckpointNumber(1), + header: CheckpointHeader.empty(), + startBlock: BlockNumber(1), + blockCount: 1, + totalManaUsed: 100n, + feeAssetPriceModifier: 50n, + }); + const pending = await blockStore.getProposedCheckpointNumber(); + expect(pending).toBe(1); + }); + + it('stores and retrieves proposed checkpoint data with fee fields', async () => { + await addBlocksForProposedCheckpoint(1, 1, 1); + await blockStore.addProposedCheckpoint({ + checkpointNumber: CheckpointNumber(1), + header: CheckpointHeader.empty(), + startBlock: BlockNumber(1), + blockCount: 1, + totalManaUsed: 12345n, + feeAssetPriceModifier: -75n, + }); + const pending = await blockStore.getLastProposedCheckpoint(); + expect(pending).toBeDefined(); + expect(pending!.checkpointNumber).toBe(1); + expect(pending!.totalManaUsed).toBe(12345n); + expect(pending!.feeAssetPriceModifier).toBe(-75n); + }); + + it('clears proposed checkpoint when confirmed checkpoints are added', async () => { + // Add checkpoint 1 + const checkpoint1 = makePublishedCheckpoint( + await Checkpoint.random(CheckpointNumber(1), { numBlocks: 1, startBlockNumber: 1 }), + 10, + ); + await blockStore.addCheckpoints([checkpoint1]); + + // Add blocks for proposed checkpoint 2, chaining from checkpoint 1's last block + await addBlocksForProposedCheckpoint(2, 1, 2, checkpoint1.checkpoint.blocks[0].archive); + + // Set proposed checkpoint to 2 (attested but not yet on L1) + await blockStore.addProposedCheckpoint({ + checkpointNumber: CheckpointNumber(2), + header: CheckpointHeader.empty(), + startBlock: BlockNumber(2), + blockCount: 1, + totalManaUsed: 100n, + feeAssetPriceModifier: 50n, + }); + expect(await blockStore.getProposedCheckpointNumber()).toBe(2); + + // Confirm checkpoint 2 on L1 + const checkpoint2 = makePublishedCheckpoint( + await Checkpoint.random(CheckpointNumber(2), { + numBlocks: 1, + startBlockNumber: 2, + previousArchive: checkpoint1.checkpoint.blocks[0].archive, + }), + 20, + ); + await blockStore.addCheckpoints([checkpoint2]); + + // Proposed checkpoint should be cleared + expect(await blockStore.hasProposedCheckpoint()).toBe(false); + }); + + it('throws on proposed checkpoint that is more than 1 ahead of confirmed', async () => { + // Add checkpoint 1 + const checkpoint1 = makePublishedCheckpoint( + await Checkpoint.random(CheckpointNumber(1), { numBlocks: 1, startBlockNumber: 1 }), + 10, + ); + await blockStore.addCheckpoints([checkpoint1]); + + // Try to set proposed checkpoint to 3 (confirmed=1, expected=2) + await expect( + blockStore.addProposedCheckpoint({ + checkpointNumber: CheckpointNumber(3), + header: CheckpointHeader.empty(), + startBlock: BlockNumber(1), + blockCount: 1, + totalManaUsed: 100n, + feeAssetPriceModifier: 50n, + }), + ).rejects.toThrow('not sequential'); + + // Proposed checkpoint should remain unset (3 !== 1 + 1) + expect(await blockStore.hasProposedCheckpoint()).toBe(false); + }); + + it('throws on proposed checkpoint that equals the confirmed checkpoint', async () => { + // Add checkpoint 1 + const checkpoint1 = makePublishedCheckpoint( + await Checkpoint.random(CheckpointNumber(1), { numBlocks: 1, startBlockNumber: 1 }), + 10, + ); + await blockStore.addCheckpoints([checkpoint1]); + + // Try to set proposed checkpoint to 1 (confirmed=1, expected=2). + // With fallback behavior, getProposedCheckpointNumber returns 1 (confirmed), so this triggers the stale check. + await expect( + blockStore.addProposedCheckpoint({ + checkpointNumber: CheckpointNumber(1), + header: CheckpointHeader.empty(), + startBlock: BlockNumber(1), + blockCount: 1, + totalManaUsed: 100n, + feeAssetPriceModifier: 50n, + }), + ).rejects.toThrow('not sequential'); + + // Proposed checkpoint should remain unset + expect(await blockStore.hasProposedCheckpoint()).toBe(false); + }); + + it('clears proposed checkpoint when checkpoints are removed past it', async () => { + // Add checkpoints 1 and 2 + const checkpoint1 = makePublishedCheckpoint( + await Checkpoint.random(CheckpointNumber(1), { numBlocks: 1, startBlockNumber: 1 }), + 10, + ); + const checkpoint2 = makePublishedCheckpoint( + await Checkpoint.random(CheckpointNumber(2), { + numBlocks: 1, + startBlockNumber: 2, + previousArchive: checkpoint1.checkpoint.blocks[0].archive, + }), + 20, + ); + await blockStore.addCheckpoints([checkpoint1, checkpoint2]); + + // Add blocks for proposed checkpoint 3, chaining from checkpoint 2's last block + await addBlocksForProposedCheckpoint(3, 1, 3, checkpoint2.checkpoint.blocks[0].archive); + + // Set proposed checkpoint to 3 + await blockStore.addProposedCheckpoint({ + checkpointNumber: CheckpointNumber(3), + header: CheckpointHeader.empty(), + startBlock: BlockNumber(3), + blockCount: 1, + totalManaUsed: 100n, + feeAssetPriceModifier: 50n, + }); + + // Remove checkpoints after 1 (removes checkpoint 2, and pending 3 should be cleared) + await blockStore.removeCheckpointsAfter(CheckpointNumber(1)); + + expect(await blockStore.hasProposedCheckpoint()).toBe(false); + }); + + it('does not clear proposed checkpoint when removing checkpoints before it', async () => { + // Add checkpoints 1, 2 + const checkpoint1 = makePublishedCheckpoint( + await Checkpoint.random(CheckpointNumber(1), { numBlocks: 1, startBlockNumber: 1 }), + 10, + ); + const checkpoint2 = makePublishedCheckpoint( + await Checkpoint.random(CheckpointNumber(2), { + numBlocks: 1, + startBlockNumber: 2, + previousArchive: checkpoint1.checkpoint.blocks[0].archive, + }), + 20, + ); + await blockStore.addCheckpoints([checkpoint1, checkpoint2]); + + // Add blocks for proposed checkpoint 3, chaining from checkpoint 2's last block + await addBlocksForProposedCheckpoint(3, 1, 3, checkpoint2.checkpoint.blocks[0].archive); + + // Set pending to 3 (confirmed=2, 3===2+1 ✓) + await blockStore.addProposedCheckpoint({ + checkpointNumber: CheckpointNumber(3), + header: CheckpointHeader.empty(), + startBlock: BlockNumber(3), + blockCount: 1, + totalManaUsed: 100n, + feeAssetPriceModifier: 50n, + }); + + // Remove checkpoints after 2 (nothing removed since latest is 2, pending=3 stays) + await blockStore.removeCheckpointsAfter(CheckpointNumber(2)); + + expect(await blockStore.getProposedCheckpointNumber()).toBe(3); + }); + + it('allows addProposedBlocks when proposed checkpoint matches expected', async () => { + // Add checkpoint 1 + const checkpoint1 = makePublishedCheckpoint( + await Checkpoint.random(CheckpointNumber(1), { numBlocks: 1, startBlockNumber: 1 }), + 10, + ); + await blockStore.addCheckpoints([checkpoint1]); + + // Add blocks for proposed checkpoint 2, chaining from checkpoint 1's last block + await addBlocksForProposedCheckpoint(2, 1, 2, checkpoint1.checkpoint.blocks[0].archive); + + // Set proposed checkpoint to 2 (attested but not on L1 yet) + await blockStore.addProposedCheckpoint({ + checkpointNumber: CheckpointNumber(2), + header: CheckpointHeader.empty(), + startBlock: BlockNumber(2), + blockCount: 1, + totalManaUsed: 100n, + feeAssetPriceModifier: 50n, + }); + + // Add a block for checkpoint 3 — this should succeed because + // proposed checkpoint (2) matches expectedCheckpointNumber (3 - 1 = 2) + const pendingBlock = await blockStore.getBlock({ number: BlockNumber(2) }); + const block3 = await L2Block.random(BlockNumber(3), { + checkpointNumber: CheckpointNumber(3), + indexWithinCheckpoint: IndexWithinCheckpoint(0), + lastArchive: pendingBlock!.archive, + }); + + await expect(blockStore.addProposedBlock(block3)).resolves.toBe(true); + }); + + it('throws with proposed checkpoint value when neither confirmed nor pending matches', async () => { + // Add checkpoint 1 + const checkpoint1 = makePublishedCheckpoint( + await Checkpoint.random(CheckpointNumber(1), { numBlocks: 1, startBlockNumber: 1 }), + 10, + ); + await blockStore.addCheckpoints([checkpoint1]); + + // Add blocks for proposed checkpoint 2, chaining from checkpoint 1's last block + await addBlocksForProposedCheckpoint(2, 1, 2, checkpoint1.checkpoint.blocks[0].archive); + + // Set proposed checkpoint to 2 + await blockStore.addProposedCheckpoint({ + checkpointNumber: CheckpointNumber(2), + header: CheckpointHeader.empty(), + startBlock: BlockNumber(2), + blockCount: 1, + totalManaUsed: 100n, + feeAssetPriceModifier: 50n, + }); + + // Try to add a block for checkpoint 4 (expected = 3, confirmed = 1, pending = 2 — neither matches) + const pendingBlock = await blockStore.getBlock({ number: BlockNumber(2) }); + const block3 = await L2Block.random(BlockNumber(3), { + checkpointNumber: CheckpointNumber(4), + indexWithinCheckpoint: IndexWithinCheckpoint(0), + lastArchive: pendingBlock!.archive, + }); + + await expect(blockStore.addProposedBlock(block3)).rejects.toThrow( + // Error should report the proposed checkpoint number (2), not the confirmed one (1) + 'Cannot insert new block 3 for checkpoint 4 given previous checkpoint number is 2', + ); + }); + + it('throws with confirmed checkpoint value when pending is not set', async () => { + // Add checkpoint 1 (no pending set, so pending defaults to 0) + const checkpoint1 = makePublishedCheckpoint( + await Checkpoint.random(CheckpointNumber(1), { numBlocks: 1, startBlockNumber: 1 }), + 10, + ); + await blockStore.addCheckpoints([checkpoint1]); + + // Try to add a block for checkpoint 4 (expected = 3, confirmed = 1, pending = 0) + // Error should report confirmed (1) since it's higher than the default pending (0) + const lastBlockArchive = checkpoint1.checkpoint.blocks[0].archive; + const block2 = await L2Block.random(BlockNumber(2), { + checkpointNumber: CheckpointNumber(4), + indexWithinCheckpoint: IndexWithinCheckpoint(0), + lastArchive: lastBlockArchive, + }); + + await expect(blockStore.addProposedBlock(block2)).rejects.toThrow( + 'Cannot insert new block 2 for checkpoint 4 given previous checkpoint number is 1', + ); + }); + + it('getProposedCheckpointL2BlockNumber defaults to checkpointed block number', async () => { + // Add checkpoint 1 with blocks 1-3 + const checkpoint1 = makePublishedCheckpoint( + await Checkpoint.random(CheckpointNumber(1), { numBlocks: 3, startBlockNumber: 1 }), + 10, + ); + await blockStore.addCheckpoints([checkpoint1]); + + // No proposed checkpoint set — should fall back to the checkpointed block number + const pendingBlockNumber = await blockStore.getProposedCheckpointL2BlockNumber(); + const checkpointedBlockNumber = await blockStore.getCheckpointedL2BlockNumber(); + expect(pendingBlockNumber).toBe(checkpointedBlockNumber); + expect(pendingBlockNumber).toBe(3); + }); + + it('getProposedCheckpointL2BlockNumber returns pending block number when set', async () => { + // Add checkpoint 1 with block 1 + const checkpoint1 = makePublishedCheckpoint( + await Checkpoint.random(CheckpointNumber(1), { numBlocks: 1, startBlockNumber: 1 }), + 10, + ); + await blockStore.addCheckpoints([checkpoint1]); + + // Add proposed block for proposed checkpoint 2 + await addBlocksForProposedCheckpoint(2, 1, 2, checkpoint1.checkpoint.blocks[0].archive); + + // Set proposed checkpoint + await blockStore.addProposedCheckpoint({ + checkpointNumber: CheckpointNumber(2), + header: CheckpointHeader.empty(), + startBlock: BlockNumber(2), + blockCount: 1, + totalManaUsed: 100n, + feeAssetPriceModifier: 50n, + }); + + // Should return last block of proposed checkpoint (startBlock + blockCount - 1) + const pendingBlockNumber = await blockStore.getProposedCheckpointL2BlockNumber(); + expect(pendingBlockNumber).toBe(2); + // And it should be greater than the checkpointed block number + expect(pendingBlockNumber).toBeGreaterThan(await blockStore.getCheckpointedL2BlockNumber()); + }); + + it('getProposedCheckpointL2BlockNumber falls back to checkpointed after pending is cleared', async () => { + // Add checkpoint 1 + const checkpoint1 = makePublishedCheckpoint( + await Checkpoint.random(CheckpointNumber(1), { numBlocks: 1, startBlockNumber: 1 }), + 10, + ); + await blockStore.addCheckpoints([checkpoint1]); + + // Add blocks and set proposed checkpoint 2 + await addBlocksForProposedCheckpoint(2, 1, 2, checkpoint1.checkpoint.blocks[0].archive); + await blockStore.addProposedCheckpoint({ + checkpointNumber: CheckpointNumber(2), + header: CheckpointHeader.empty(), + startBlock: BlockNumber(2), + blockCount: 1, + totalManaUsed: 100n, + feeAssetPriceModifier: 50n, + }); + expect(await blockStore.getProposedCheckpointL2BlockNumber()).toBe(2); + + // Confirm checkpoint 2 on L1 (clears pending) + const checkpoint2 = makePublishedCheckpoint( + await Checkpoint.random(CheckpointNumber(2), { + numBlocks: 1, + startBlockNumber: 2, + previousArchive: checkpoint1.checkpoint.blocks[0].archive, + }), + 20, + ); + await blockStore.addCheckpoints([checkpoint2]); + + // Pending cleared — should fall back to the new checkpointed block number + const pendingBlockNumber = await blockStore.getProposedCheckpointL2BlockNumber(); + const checkpointedBlockNumber = await blockStore.getCheckpointedL2BlockNumber(); + expect(pendingBlockNumber).toBe(checkpointedBlockNumber); + expect(pendingBlockNumber).toBe(2); + }); + }); + + describe('promoteProposedToCheckpointed', () => { + async function setupProposedCheckpoint() { + // Add confirmed checkpoint 1 + const checkpoint1 = makePublishedCheckpoint( + await Checkpoint.random(CheckpointNumber(1), { numBlocks: 1, startBlockNumber: 1 }), + 10, + ); + await blockStore.addCheckpoints([checkpoint1]); + + // Add proposed blocks for checkpoint 2 + const block2 = await L2Block.random(BlockNumber(2), { + checkpointNumber: CheckpointNumber(2), + indexWithinCheckpoint: IndexWithinCheckpoint(0), + lastArchive: checkpoint1.checkpoint.blocks[0].archive, + }); + await blockStore.addProposedBlock(block2, { force: true }); + + // Set proposed checkpoint 2 + await blockStore.addProposedCheckpoint({ + checkpointNumber: CheckpointNumber(2), + header: CheckpointHeader.empty(), + startBlock: BlockNumber(2), + blockCount: 1, + totalManaUsed: 100n, + feeAssetPriceModifier: 50n, + }); + + const proposed = await blockStore.getLastProposedCheckpoint(); + return { checkpoint1, proposed: proposed! }; + } + + it('promotes proposed checkpoint to confirmed', async () => { + const { proposed } = await setupProposedCheckpoint(); + const l1 = makeL1PublishedData(20); + const attestations = [CommitteeAttestation.random()]; + + await blockStore.promoteProposedToCheckpointed( + proposed.checkpointNumber, + l1, + attestations, + proposed.archive.root, + ); + + expect(await blockStore.hasProposedCheckpoint()).toBe(false); + expect(await blockStore.getLatestCheckpointNumber()).toBe(2); + }); + + it('throws when no proposed checkpoint exists', async () => { + await expect( + blockStore.promoteProposedToCheckpointed(CheckpointNumber(1), makeL1PublishedData(20), [], Fr.random()), + ).rejects.toThrow('no proposed checkpoint exists'); + }); + + it('throws on archive root mismatch', async () => { + const { proposed } = await setupProposedCheckpoint(); + + await expect( + blockStore.promoteProposedToCheckpointed(proposed.checkpointNumber, makeL1PublishedData(20), [], Fr.random()), + ).rejects.toThrow('archive root mismatch'); + + // Proposed checkpoint should still exist (transaction rolled back) + expect(await blockStore.hasProposedCheckpoint()).toBe(true); + }); + }); + + describe('L2TipsCache proposedCheckpoint', () => { + it('returns proposedCheckpoint equal to checkpointed when no pending exists', async () => { + // Add checkpoint 1 with blocks 1-3 + const checkpoint1 = makePublishedCheckpoint( + await Checkpoint.random(CheckpointNumber(1), { numBlocks: 3, startBlockNumber: 1 }), + 10, + ); + await blockStore.addCheckpoints([checkpoint1]); + + const l2TipsCache = new L2TipsCache(blockStore, GENESIS_BLOCK_HEADER_HASH); + const tips = await l2TipsCache.getL2Tips(); + + // proposedCheckpoint should always be defined + expect(tips.proposedCheckpoint).toBeDefined(); + // With no proposed checkpoint, it should equal the checkpointed tip + expect(tips.proposedCheckpoint!.block.number).toBe(tips.checkpointed.block.number); + expect(tips.proposedCheckpoint!.checkpoint.number).toBe(tips.checkpointed.checkpoint.number); + }); + + it('returns proposedCheckpoint ahead of checkpointed when pending is set', async () => { + // Add checkpoint 1 + const checkpoint1 = makePublishedCheckpoint( + await Checkpoint.random(CheckpointNumber(1), { numBlocks: 1, startBlockNumber: 1 }), + 10, + ); + await blockStore.addCheckpoints([checkpoint1]); + + // Add a proposed block for proposed checkpoint 2, chaining from checkpoint 1 + const block2 = await L2Block.random(BlockNumber(2), { + checkpointNumber: CheckpointNumber(2), + indexWithinCheckpoint: IndexWithinCheckpoint(0), + lastArchive: checkpoint1.checkpoint.blocks[0].archive, + }); + await blockStore.addProposedBlock(block2, { force: true }); + + // Set proposed checkpoint + await blockStore.addProposedCheckpoint({ + checkpointNumber: CheckpointNumber(2), + header: CheckpointHeader.empty(), + startBlock: BlockNumber(2), + blockCount: 1, + totalManaUsed: 100n, + feeAssetPriceModifier: 50n, + }); + + const l2TipsCache = new L2TipsCache(blockStore, GENESIS_BLOCK_HEADER_HASH); + const tips = await l2TipsCache.getL2Tips(); + + expect(tips.proposedCheckpoint).toBeDefined(); + expect(tips.proposedCheckpoint!.block.number).toBeGreaterThan(tips.checkpointed.block.number); + expect(tips.proposedCheckpoint!.checkpoint.number).toBeGreaterThan(tips.checkpointed.checkpoint.number); + }); + }); + describe('removeBlocksAfterBlock', () => { it('removes blocks with number > given blockNumber', async () => { // Create blocks for initial checkpoint @@ -2109,10 +2641,10 @@ describe('BlockStore', () => { await blockStore.removeBlocksAfter(BlockNumber(2)); expect(await blockStore.getLatestL2BlockNumber()).toBe(2); - expect(await blockStore.getBlock(BlockNumber(1))).toBeDefined(); - expect(await blockStore.getBlock(BlockNumber(2))).toBeDefined(); - expect(await blockStore.getBlock(BlockNumber(3))).toBeUndefined(); - expect(await blockStore.getBlock(BlockNumber(4))).toBeUndefined(); + expect(await blockStore.getBlock({ number: BlockNumber(1) })).toBeDefined(); + expect(await blockStore.getBlock({ number: BlockNumber(2) })).toBeDefined(); + expect(await blockStore.getBlock({ number: BlockNumber(3) })).toBeUndefined(); + expect(await blockStore.getBlock({ number: BlockNumber(4) })).toBeUndefined(); }); it('returns the removed blocks', async () => { @@ -2186,8 +2718,8 @@ describe('BlockStore', () => { const block2Hash = await block2.header.hash(); const block2Archive = block2.archive.root; - expect(await blockStore.getBlockByHash(block2Hash)).toBeDefined(); - expect(await blockStore.getBlockByArchive(block2Archive)).toBeDefined(); + expect(await blockStore.getBlock({ hash: block2Hash })).toBeDefined(); + expect(await blockStore.getBlock({ archive: block2Archive })).toBeDefined(); // Verify tx effects for block2 are retrievable before removal for (const txEffect of block2.body.txEffects) { @@ -2199,8 +2731,8 @@ describe('BlockStore', () => { await blockStore.removeBlocksAfter(BlockNumber(1)); // Verify block2 is no longer retrievable by hash or archive - expect(await blockStore.getBlockByHash(block2Hash)).toBeUndefined(); - expect(await blockStore.getBlockByArchive(block2Archive)).toBeUndefined(); + expect(await blockStore.getBlock({ hash: block2Hash })).toBeUndefined(); + expect(await blockStore.getBlock({ archive: block2Archive })).toBeUndefined(); // Verify tx effects for block2 are no longer retrievable for (const txEffect of block2.body.txEffects) { @@ -2212,8 +2744,8 @@ describe('BlockStore', () => { const block1Hash = await block1.header.hash(); const block1Archive = block1.archive.root; - expect(await blockStore.getBlockByHash(block1Hash)).toBeDefined(); - expect(await blockStore.getBlockByArchive(block1Archive)).toBeDefined(); + expect(await blockStore.getBlock({ hash: block1Hash })).toBeDefined(); + expect(await blockStore.getBlock({ archive: block1Archive })).toBeDefined(); for (const txEffect of block1.body.txEffects) { const retrieved = await blockStore.getTxEffect(txEffect.txHash); @@ -2238,8 +2770,8 @@ describe('BlockStore', () => { expect(removedBlocks.length).toBe(2); expect(await blockStore.getLatestL2BlockNumber()).toBe(0); - expect(await blockStore.getBlock(BlockNumber(1))).toBeUndefined(); - expect(await blockStore.getBlock(BlockNumber(2))).toBeUndefined(); + expect(await blockStore.getBlock({ number: BlockNumber(1) })).toBeUndefined(); + expect(await blockStore.getBlock({ number: BlockNumber(2) })).toBeUndefined(); }); }); }); diff --git a/yarn-project/archiver/src/store/block_store.ts b/yarn-project/archiver/src/store/block_store.ts index 5770adad285a..6ec9d07f19b2 100644 --- a/yarn-project/archiver/src/store/block_store.ts +++ b/yarn-project/archiver/src/store/block_store.ts @@ -10,10 +10,8 @@ import type { AztecAsyncKVStore, AztecAsyncMap, AztecAsyncSingleton, Range } fro import type { AztecAddress } from '@aztec/stdlib/aztec-address'; import { type BlockData, - type BlockDataWithCheckpointContext, BlockHash, Body, - CheckpointedL2Block, CommitteeAttestation, L2Block, type ValidateCheckpointResult, @@ -96,6 +94,19 @@ type ProposedCheckpointStorage = CommonCheckpointStorage & { export type RemoveCheckpointsResult = { blocksRemoved: L2Block[] | undefined }; +/** + * Single-block lookup with the chain-tip `tag` variant of {@link BlockQuery} already resolved + * to a concrete block number. The `tag` branch is unrepresentable here so storage code does + * not need to handle it at runtime. + */ +export type ResolvedBlockQuery = { number: BlockNumber } | { hash: BlockHash } | { archive: Fr }; + +/** + * Range lookup with the `epoch` variant of {@link BlocksQuery} already resolved to a + * `{ from, limit }` pair. Storage code never needs to map epoch numbers to block ranges. + */ +export type ResolvedBlocksQuery = { from: BlockNumber; limit: number; onlyCheckpointed?: boolean }; + /** * LMDB-based block storage for the archiver. */ @@ -196,7 +207,7 @@ export class BlockStore { const lastCheckpointedBlockNumber = await this.getCheckpointedL2BlockNumber(); if (!opts.force && blockNumber <= lastCheckpointedBlockNumber) { // Check if the proposed block matches the already-checkpointed one - const existingBlock = await this.getBlock(BlockNumber(blockNumber)); + const existingBlock = await this.getBlockData({ number: BlockNumber(blockNumber) }); if (existingBlock && existingBlock.archive.root.equals(block.archive.root)) { throw new BlockAlreadyCheckpointedError(blockNumber); } @@ -215,11 +226,11 @@ export class BlockStore { if (!opts.force && latestCheckpointNumber !== expectedCheckpointNumber && !hasPendingAtExpected) { const [latestPendingKey] = await toArray(this.#proposedCheckpoints.keysAsync({ reverse: true, limit: 1 })); const previous = CheckpointNumber(Math.max(latestCheckpointNumber, latestPendingKey ?? 0)); - throw new BlockCheckpointNumberNotSequentialError(blockNumber, blockCheckpointNumber, previous); + throw new BlockCheckpointNumberNotSequentialError(BlockNumber(blockNumber), blockCheckpointNumber, previous); } // Extract the previous block if there is one and see if it is for the same checkpoint or not - const previousBlockResult = await this.getBlock(previousBlockNumber); + const previousBlockResult = await this.getBlockData({ number: previousBlockNumber }); let expectedBlockIndex = 0; let previousBlockIndex: number | undefined = undefined; @@ -232,7 +243,7 @@ export class BlockStore { if (!previousBlockResult.archive.root.equals(blockLastArchive)) { throw new BlockArchiveNotConsistentError( blockNumber, - previousBlockResult.number, + previousBlockResult.header.globalVariables.blockNumber, blockLastArchive, previousBlockResult.archive.root, ); @@ -398,7 +409,7 @@ export class BlockStore { } const previousBlockNumber = BlockNumber(predecessor.startBlock + predecessor.blockCount - 1); - const previousBlock = await this.getBlock(previousBlockNumber); + const previousBlock = await this.getBlock({ number: previousBlockNumber }); if (previousBlock === undefined) { throw new BlockNotFoundError(previousBlockNumber); } @@ -577,6 +588,22 @@ export class BlockStore { return result; } + /** + * Returns the checkpoint numbers for all checkpoints whose slot falls within the given range (inclusive). + * Lighter than {@link getCheckpointDataForSlotRange} when callers only need to identify which + * checkpoints fall in the range and will fetch full data for at most a few of them. + */ + async getCheckpointNumbersForSlotRange(startSlot: SlotNumber, endSlot: SlotNumber): Promise { + const result: CheckpointNumber[] = []; + for await (const [, checkpointNumber] of this.#slotToCheckpoint.entriesAsync({ + start: startSlot, + end: endSlot + 1, + })) { + result.push(CheckpointNumber(checkpointNumber)); + } + return result; + } + private checkpointDataFromCheckpointStorage(checkpointStorage: CheckpointStorage): CheckpointData { return { header: CheckpointHeader.fromBuffer(checkpointStorage.header), @@ -648,7 +675,7 @@ export class BlockStore { // Iterate from blockNumber + 1 to latestBlockNumber for (let bn = blockNumber + 1; bn <= latestBlockNumber; bn++) { - const block = await this.getBlock(BlockNumber(bn)); + const block = await this.getBlock({ number: BlockNumber(bn) }); if (block === undefined) { this.#log.warn(`Cannot remove block ${bn} from the store since we don't have it`); @@ -770,15 +797,6 @@ export class BlockStore { return stored ? this.convertToProposedCheckpointData(stored) : undefined; } - /** Returns all pending checkpoints in ascending checkpoint-number order. */ - async getProposedCheckpoints(): Promise { - const results: ProposedCheckpointData[] = []; - for await (const [, stored] of this.#proposedCheckpoints.entriesAsync()) { - results.push(this.convertToProposedCheckpointData(stored)); - } - return results; - } - /** * Evicts all pending checkpoints with checkpoint number >= fromNumber. * Used for divergent-mined-checkpoint cleanup: when L1 mines checkpoint N with a different archive, @@ -846,195 +864,33 @@ export class BlockStore { return BlockNumber(proposed.startBlock + proposed.blockCount - 1); } - async getCheckpointedBlock(number: BlockNumber): Promise { - const blockStorage = await this.#blocks.getAsync(number); - if (!blockStorage) { - return undefined; - } - const checkpoint = await this.#checkpoints.getAsync(blockStorage.checkpointNumber); - if (!checkpoint) { - return undefined; - } - const block = await this.getBlockFromBlockStorage(number, blockStorage); - if (!block) { - return undefined; - } - return new CheckpointedL2Block( - CheckpointNumber(checkpoint.checkpointNumber), - block, - L1PublishedData.fromBuffer(checkpoint.l1), - checkpoint.attestations.map(buf => CommitteeAttestation.fromBuffer(buf)), - ); - } - - /** - * Gets up to `limit` amount of Checkpointed L2 blocks starting from `from`. - * @param start - Number of the first block to return (inclusive). - * @param limit - The number of blocks to return. - * @returns The requested L2 blocks - */ - getCheckpointedBlocks(start: BlockNumber, limit: number): Promise { - return toArray(this.iterateCheckpointedBlocks(start, limit)); - } - - /** Async iterator variant of {@link getCheckpointedBlocks}. */ - async *iterateCheckpointedBlocks(start: BlockNumber, limit: number): AsyncIterableIterator { - const checkpointCache = new Map(); - for await (const [blockNumber, blockStorage] of this.getBlockStorages(start, limit)) { - const block = await this.getBlockFromBlockStorage(blockNumber, blockStorage); - if (block) { - const checkpoint = - checkpointCache.get(CheckpointNumber(blockStorage.checkpointNumber)) ?? - (await this.#checkpoints.getAsync(blockStorage.checkpointNumber)); - if (checkpoint) { - checkpointCache.set(CheckpointNumber(blockStorage.checkpointNumber), checkpoint); - const checkpointedBlock = new CheckpointedL2Block( - CheckpointNumber(checkpoint.checkpointNumber), - block, - L1PublishedData.fromBuffer(checkpoint.l1), - checkpoint.attestations.map(buf => CommitteeAttestation.fromBuffer(buf)), - ); - yield checkpointedBlock; - } - } - } - } - - async getCheckpointedBlockByHash(blockHash: BlockHash): Promise { - const blockNumber = await this.#blockHashIndex.getAsync(blockHash.toString()); - if (blockNumber === undefined) { - return undefined; - } - return this.getCheckpointedBlock(BlockNumber(blockNumber)); - } - - async getCheckpointedBlockByArchive(archive: Fr): Promise { - const blockNumber = await this.#blockArchiveIndex.getAsync(archive.toString()); - if (blockNumber === undefined) { - return undefined; - } - return this.getCheckpointedBlock(BlockNumber(blockNumber)); - } - - /** - * Gets up to `limit` amount of L2 blocks starting from `from`. - * @param start - Number of the first block to return (inclusive). - * @param limit - The number of blocks to return. - * @returns The requested L2 blocks - */ - getBlocks(start: BlockNumber, limit: number): Promise { - return toArray(this.iterateBlocks(start, limit)); - } - - /** Async iterator variant of {@link getBlocks}. */ - async *iterateBlocks(start: BlockNumber, limit: number): AsyncIterableIterator { - for await (const [blockNumber, blockStorage] of this.getBlockStorages(start, limit)) { - const block = await this.getBlockFromBlockStorage(blockNumber, blockStorage); - if (block) { - yield block; - } - } - } - - /** - * Gets block metadata (without tx data) by block number. - * @param blockNumber - The number of the block to return. - * @returns The requested block data. - */ - async getBlockData(blockNumber: BlockNumber): Promise { - const blockStorage = await this.#blocks.getAsync(blockNumber); - if (!blockStorage || !blockStorage.header) { - return undefined; - } - return this.getBlockDataFromBlockStorage(blockStorage); - } - - /** - * Gets block metadata plus checkpoint-derived context (L1 publish info, attestations) without - * deserializing tx bodies. When the block's containing checkpoint has not yet been L1-confirmed, - * `checkpoint` and `l1` are `undefined` and `attestations` is empty. - */ - async getBlockDataWithCheckpointContext( - blockNumber: BlockNumber, - ): Promise { - const blockStorage = await this.#blocks.getAsync(blockNumber); - if (!blockStorage || !blockStorage.header) { - return undefined; - } - const data = this.getBlockDataFromBlockStorage(blockStorage); - const checkpointStorage = await this.#checkpoints.getAsync(blockStorage.checkpointNumber); - if (!checkpointStorage) { - return { data, checkpoint: undefined, l1: undefined, attestations: [] }; - } - const checkpoint = this.checkpointDataFromCheckpointStorage(checkpointStorage); - return { data, checkpoint, l1: checkpoint.l1, attestations: checkpoint.attestations }; - } - /** Returns the checkpoint number that contains the given slot (or undefined if not found). */ async getCheckpointNumberBySlot(slot: SlotNumber): Promise { const checkpointNumber = await this.#slotToCheckpoint.getAsync(slot); return checkpointNumber === undefined ? undefined : CheckpointNumber(checkpointNumber); } - /** - * Gets block metadata (without tx data) by archive root. - * @param archive - The archive root of the block to return. - * @returns The requested block data. - */ - async getBlockDataByArchive(archive: Fr): Promise { - const blockNumber = await this.#blockArchiveIndex.getAsync(archive.toString()); + /** Gets a single L2 block matching the given resolved query. */ + async getBlock(query: ResolvedBlockQuery): Promise { + const blockNumber = await this.getBlockNumber(query); if (blockNumber === undefined) { return undefined; } - return this.getBlockData(BlockNumber(blockNumber)); - } - - /** - * Gets an L2 block. - * @param blockNumber - The number of the block to return. - * @returns The requested L2 block. - */ - async getBlock(blockNumber: BlockNumber): Promise { const blockStorage = await this.#blocks.getAsync(blockNumber); - if (!blockStorage || !blockStorage.header) { - return Promise.resolve(undefined); + if (!blockStorage) { + return undefined; } return this.getBlockFromBlockStorage(blockNumber, blockStorage); } - /** - * Gets an L2 block by its hash. - * @param blockHash - The hash of the block to return. - * @returns The requested L2 block. - */ - async getBlockByHash(blockHash: BlockHash): Promise { - const blockNumber = await this.#blockHashIndex.getAsync(blockHash.toString()); - if (blockNumber === undefined) { - return undefined; - } - return this.getBlock(BlockNumber(blockNumber)); + /** Gets a collection of L2 blocks for a resolved range. */ + getBlocks(query: ResolvedBlocksQuery): Promise { + return toArray(this.iterateBlocks(query)); } - /** - * Gets an L2 block by its archive root. - * @param archive - The archive root of the block to return. - * @returns The requested L2 block. - */ - async getBlockByArchive(archive: Fr): Promise { - const blockNumber = await this.#blockArchiveIndex.getAsync(archive.toString()); - if (blockNumber === undefined) { - return undefined; - } - return this.getBlock(BlockNumber(blockNumber)); - } - - /** - * Gets a block header by its hash. - * @param blockHash - The hash of the block to return. - * @returns The requested block header. - */ - async getBlockHeaderByHash(blockHash: BlockHash): Promise { - const blockNumber = await this.#blockHashIndex.getAsync(blockHash.toString()); + /** Gets single block metadata matching the given resolved query. */ + async getBlockData(query: ResolvedBlockQuery): Promise { + const blockNumber = await this.getBlockNumber(query); if (blockNumber === undefined) { return undefined; } @@ -1042,46 +898,36 @@ export class BlockStore { if (!blockStorage || !blockStorage.header) { return undefined; } - return BlockHeader.fromBuffer(blockStorage.header); + return this.getBlockDataFromBlockStorage(blockStorage); } - /** - * Gets a block header by its archive root. - * @param archive - The archive root of the block to return. - * @returns The requested block header. - */ - async getBlockHeaderByArchive(archive: Fr): Promise { - const blockNumber = await this.#blockArchiveIndex.getAsync(archive.toString()); - if (blockNumber === undefined) { - return undefined; - } - const blockStorage = await this.#blocks.getAsync(blockNumber); - if (!blockStorage || !blockStorage.header) { - return undefined; - } - return BlockHeader.fromBuffer(blockStorage.header); + /** Gets a collection of block metadata entries for a resolved range. */ + getBlocksData(query: ResolvedBlocksQuery): Promise { + return toArray(this.iterateBlocksData(query)); } - /** - * Gets the headers for a sequence of L2 blocks. - * @param start - Number of the first block to return (inclusive). - * @param limit - The number of blocks to return. - * @returns The requested L2 block headers - */ - getBlockHeaders(start: BlockNumber, limit: number): Promise { - return toArray(this.iterateBlockHeaders(start, limit)); + /** Async iterator over L2 blocks for a resolved range. */ + private async *iterateBlocks(query: ResolvedBlocksQuery): AsyncIterableIterator { + const cap = query.onlyCheckpointed ? await this.getCheckpointedL2BlockNumber() : undefined; + for await (const [blockNumber, blockStorage] of this.getBlockStorages(query.from, query.limit)) { + if (cap !== undefined && blockNumber > cap) { + break; + } + const block = await this.getBlockFromBlockStorage(blockNumber, blockStorage); + if (block) { + yield block; + } + } } - /** Async iterator variant of {@link getBlockHeaders}. */ - async *iterateBlockHeaders(start: BlockNumber, limit: number): AsyncIterableIterator { - for await (const [blockNumber, blockStorage] of this.getBlockStorages(start, limit)) { - const header = BlockHeader.fromBuffer(blockStorage.header); - if (header.getBlockNumber() !== blockNumber) { - throw new Error( - `Block number mismatch when retrieving block header from archive (expected ${blockNumber} but got ${header.getBlockNumber()})`, - ); + /** Async iterator over block metadata for a resolved range. */ + private async *iterateBlocksData(query: ResolvedBlocksQuery): AsyncIterableIterator { + const cap = query.onlyCheckpointed ? await this.getCheckpointedL2BlockNumber() : undefined; + for await (const [blockNumber, blockStorage] of this.getBlockStorages(query.from, query.limit)) { + if (cap !== undefined && blockNumber > cap) { + break; } - yield header; + yield this.getBlockDataFromBlockStorage(blockStorage); } } @@ -1098,6 +944,24 @@ export class BlockStore { } } + /** Resolves a ResolvedBlockQuery discriminant to a block number, or undefined if not found. */ + async getBlockNumber(query: ResolvedBlockQuery): Promise { + let blockNumber: BlockNumber | undefined; + if ('number' in query) { + blockNumber = query.number; + } else if ('hash' in query) { + const n = await this.#blockHashIndex.getAsync(query.hash.toString()); + blockNumber = n !== undefined ? BlockNumber(n) : undefined; + } else { + const n = await this.#blockArchiveIndex.getAsync(query.archive.toString()); + blockNumber = n !== undefined ? BlockNumber(n) : undefined; + } + if (blockNumber === undefined) { + return undefined; + } + return blockNumber; + } + private getBlockDataFromBlockStorage(blockStorage: BlockStorage): BlockData { return { header: BlockHeader.fromBuffer(blockStorage.header), @@ -1180,7 +1044,7 @@ export class BlockStore { this.getProvenBlockNumber(), this.getCheckpointedL2BlockNumber(), this.getFinalizedL2BlockNumber(), - this.getBlockData(blockNumber), + this.getBlockData({ number: blockNumber }), ]); let status: TxStatus; @@ -1284,7 +1148,7 @@ export class BlockStore { const previousBlock = await this.getPreviousCheckpointBlock(proposed.checkpointNumber); const blocks: L2Block[] = []; for (let i = 0; i < proposed.blockCount; i++) { - const block = await this.getBlock(BlockNumber(proposed.startBlock + i)); + const block = await this.getBlock({ number: BlockNumber(proposed.startBlock + i) }); if (!block) { throw new BlockNotFoundError(proposed.startBlock + i); } diff --git a/yarn-project/archiver/src/store/l2_tips_cache.ts b/yarn-project/archiver/src/store/l2_tips_cache.ts index bb2b26d522b6..21c4b08f47d0 100644 --- a/yarn-project/archiver/src/store/l2_tips_cache.ts +++ b/yarn-project/archiver/src/store/l2_tips_cache.ts @@ -2,8 +2,8 @@ import { INITIAL_L2_BLOCK_NUM } from '@aztec/constants'; import { BlockNumber, CheckpointNumber } from '@aztec/foundation/branded-types'; import { type BlockData, + type BlockHash, type CheckpointId, - GENESIS_BLOCK_HEADER_HASH, GENESIS_CHECKPOINT_HEADER_HASH, type L2Tips, } from '@aztec/stdlib/block'; @@ -18,7 +18,16 @@ import type { BlockStore } from './block_store.js'; export class L2TipsCache { #tipsPromise: Promise | undefined; - constructor(private blockStore: BlockStore) {} + /** + * Asymmetric by design: the genesis block hash is dynamic — derived from the injected initial header, + * which depends on `genesisTimestamp` and any prefilled state. The genesis checkpoint hash is static — + * checkpoint 0 is fully synthetic (no real checkpoint header exists at 0), so it stays at the protocol + * constant `GENESIS_CHECKPOINT_HEADER_HASH`. + */ + constructor( + private blockStore: BlockStore, + private readonly initialBlockHash: BlockHash, + ) {} /** Returns the cached L2 tips. Loads from the block store on first call. */ public getL2Tips(): Promise { @@ -47,13 +56,15 @@ export class L2TipsCache { ]); const genesisBlockHeader = { - blockHash: GENESIS_BLOCK_HEADER_HASH, + blockHash: this.initialBlockHash, checkpointNumber: CheckpointNumber.ZERO, } as const; const beforeInitialBlockNumber = BlockNumber(INITIAL_L2_BLOCK_NUM - 1); const getBlockData = (blockNumber: BlockNumber) => - blockNumber > beforeInitialBlockNumber ? this.blockStore.getBlockData(blockNumber) : genesisBlockHeader; + blockNumber > beforeInitialBlockNumber + ? this.blockStore.getBlockData({ number: blockNumber }) + : genesisBlockHeader; const [latestBlockData, provenBlockData, proposedCheckpointBlockData, checkpointedBlockData, finalizedBlockData] = await Promise.all( diff --git a/yarn-project/archiver/src/test/mock_l2_block_source.ts b/yarn-project/archiver/src/test/mock_l2_block_source.ts index 484c804fe778..0250dd7159ad 100644 --- a/yarn-project/archiver/src/test/mock_l2_block_source.ts +++ b/yarn-project/archiver/src/test/mock_l2_block_source.ts @@ -1,6 +1,12 @@ import { GENESIS_ARCHIVE_ROOT } from '@aztec/constants'; import { DefaultL1ContractsConfig } from '@aztec/ethereum/config'; -import { BlockNumber, CheckpointNumber, EpochNumber, SlotNumber } from '@aztec/foundation/branded-types'; +import { + BlockNumber, + CheckpointNumber, + EpochNumber, + IndexWithinCheckpoint, + SlotNumber, +} from '@aztec/foundation/branded-types'; import { Buffer32 } from '@aztec/foundation/buffer'; import { Fr } from '@aztec/foundation/curves/bn254'; import { EthAddress } from '@aztec/foundation/eth-address'; @@ -9,8 +15,13 @@ import type { FunctionSelector } from '@aztec/stdlib/abi'; import type { AztecAddress } from '@aztec/stdlib/aztec-address'; import { type BlockData, - BlockHash, - CheckpointedL2Block, + type BlockHash, + type BlockQuery, + type BlockTag, + type BlocksQuery, + Body, + GENESIS_BLOCK_HEADER_HASH, + GENESIS_CHECKPOINT_HEADER_HASH, L2Block, type L2BlockSource, type L2Tips, @@ -32,7 +43,8 @@ import { } from '@aztec/stdlib/epoch-helpers'; import { computeCheckpointOutHash } from '@aztec/stdlib/messaging'; import { CheckpointHeader } from '@aztec/stdlib/rollup'; -import { type BlockHeader, TxExecutionResult, TxHash, TxReceipt, TxStatus } from '@aztec/stdlib/tx'; +import { AppendOnlyTreeSnapshot } from '@aztec/stdlib/trees'; +import { BlockHeader, TxExecutionResult, TxHash, TxReceipt, TxStatus } from '@aztec/stdlib/tx'; import type { UInt64 } from '@aztec/stdlib/types'; /** @@ -47,8 +59,66 @@ export class MockL2BlockSource implements L2BlockSource, ContractDataSource { private checkpointedBlockNumber: number = 0; private proposedCheckpointBlockNumber: number = 0; + private initialHeader: BlockHeader = BlockHeader.empty(); + private initialHeaderHash: BlockHash = GENESIS_BLOCK_HEADER_HASH; + private genesisArchiveRoot?: Fr; + private genesisBlock?: L2Block; + private log = createLogger('archiver:mock_l2_block_source'); + /** Returns the initial header used to synthesize block 0. */ + public getInitialHeader(): BlockHeader { + return this.initialHeader; + } + + /** + * Sets the initial header used to synthesize block 0. Tests that wire up a real + * world-state should call this with `worldState.getInitialHeader()` so the L2BlockStream + * agrees on the genesis hash on both sides. Precomputes and caches the header hash so + * `getGenesisBlockHash()` can return synchronously. + */ + public async setInitialHeader(header: BlockHeader): Promise { + this.initialHeader = header; + this.initialHeaderHash = await header.hash(); + this.genesisBlock = undefined; + } + + /** + * Returns the precomputed hash of the genesis block header. Defaults to the static + * {@link GENESIS_BLOCK_HEADER_HASH} unless {@link setInitialHeader} has been called with a + * custom header. + */ + public getGenesisBlockHash(): BlockHash { + return this.initialHeaderHash; + } + + /** + * Sets the post-genesis archive root used to synthesize block 0. Mirrors the real archiver, + * whose synthetic block 0 carries `new AppendOnlyTreeSnapshot(genesisArchiveRoot, 1)` rather + * than `AppendOnlyTreeSnapshot.empty()`. Tests wiring up a real world-state should set this so + * archive-based block lookups against the mock match production semantics. + */ + public setGenesisArchiveRoot(root: Fr): void { + this.genesisArchiveRoot = root; + this.genesisBlock = undefined; + } + + private getGenesisBlock(): L2Block { + if (this.genesisBlock) { + return this.genesisBlock; + } + const archive = this.genesisArchiveRoot + ? new AppendOnlyTreeSnapshot(this.genesisArchiveRoot, 1) + : AppendOnlyTreeSnapshot.empty(); + return (this.genesisBlock = new L2Block( + archive, + this.initialHeader, + Body.empty(), + CheckpointNumber.ZERO, + IndexWithinCheckpoint(0), + )); + } + /** Creates blocks grouped into single-block checkpoints. */ public async createBlocks(numBlocks: number) { await this.createCheckpoints(numBlocks, 1); @@ -171,8 +241,20 @@ export class MockL2BlockSource implements L2BlockSource, ContractDataSource { * Gets the number of the latest L2 block processed by the block source implementation. * @returns In this mock instance, returns the number of L2 blocks that we've mocked. */ - public getBlockNumber() { - return Promise.resolve(BlockNumber(this.l2Blocks.length)); + public getBlockNumber(): Promise; + public getBlockNumber(query: BlockQuery): Promise; + public async getBlockNumber(query?: BlockQuery): Promise { + if (!query) { + return BlockNumber(this.l2Blocks.length); + } + if ('number' in query) { + return query.number; + } + if ('tag' in query) { + return BlockNumber(this.resolveBlockTag(query.tag)); + } + const block = await this.getBlock(query); + return block ? block.header.globalVariables.blockNumber : undefined; } public getProvenBlockNumber() { @@ -191,62 +273,6 @@ export class MockL2BlockSource implements L2BlockSource, ContractDataSource { return Promise.resolve(BlockNumber(this.proposedCheckpointBlockNumber)); } - public getCheckpointedBlock(number: BlockNumber): Promise { - if (number > this.checkpointedBlockNumber) { - return Promise.resolve(undefined); - } - const block = this.l2Blocks[number - 1]; - if (!block) { - return Promise.resolve(undefined); - } - return Promise.resolve(this.toCheckpointedBlock(block)); - } - - public async getCheckpointedBlocks(from: BlockNumber, limit: number): Promise { - const result: CheckpointedL2Block[] = []; - for (let i = 0; i < limit; i++) { - const blockNum = from + i; - if (blockNum > this.checkpointedBlockNumber) { - break; - } - const block = await this.getCheckpointedBlock(BlockNumber(blockNum)); - if (block) { - result.push(block); - } - } - return result; - } - - /** - * Gets an l2 block. - * @param number - The block number to return (inclusive). - * @returns The requested L2 block. - */ - public getBlock(number: number): Promise { - const block = this.l2Blocks[number - 1]; - return Promise.resolve(block); - } - - /** - * Gets an L2 block (new format). - * @param number - The block number to return. - * @returns The requested L2 block. - */ - public getL2Block(number: BlockNumber): Promise { - const block = this.l2Blocks[number - 1]; - return Promise.resolve(block); - } - - /** - * Gets up to `limit` amount of L2 blocks starting from `from`. - * @param from - Number of the first block to return (inclusive). - * @param limit - The maximum number of blocks to return. - * @returns The requested mocked L2 blocks. - */ - public getBlocks(from: number, limit: number): Promise { - return Promise.resolve(this.l2Blocks.slice(from - 1, from - 1 + limit)); - } - public getCheckpoints(from: CheckpointNumber, limit: number) { const checkpoints = this.checkpointList.slice(from - 1, from - 1 + limit); return Promise.resolve( @@ -259,68 +285,6 @@ export class MockL2BlockSource implements L2BlockSource, ContractDataSource { return Promise.resolve(checkpoint); } - public async getCheckpointedBlockByHash(blockHash: BlockHash): Promise { - for (const block of this.l2Blocks) { - const hash = await block.hash(); - if (hash.equals(blockHash)) { - return this.toCheckpointedBlock(block); - } - } - return undefined; - } - - public getCheckpointedBlockByArchive(archive: Fr): Promise { - const block = this.l2Blocks.find(b => b.archive.root.equals(archive)); - if (!block) { - return Promise.resolve(undefined); - } - return Promise.resolve(this.toCheckpointedBlock(block)); - } - - public async getL2BlockByHash(blockHash: BlockHash): Promise { - for (const block of this.l2Blocks) { - const hash = await block.hash(); - if (hash.equals(blockHash)) { - return block; - } - } - return undefined; - } - - public getL2BlockByArchive(archive: Fr): Promise { - const block = this.l2Blocks.find(b => b.archive.root.equals(archive)); - return Promise.resolve(block); - } - - public async getBlockHeaderByHash(blockHash: BlockHash): Promise { - for (const block of this.l2Blocks) { - const hash = await block.hash(); - if (hash.equals(blockHash)) { - return block.header; - } - } - return undefined; - } - - public getBlockHeaderByArchive(archive: Fr): Promise { - const block = this.l2Blocks.find(b => b.archive.root.equals(archive)); - return Promise.resolve(block?.header); - } - - public async getBlockData(number: BlockNumber): Promise { - const block = this.l2Blocks[number - 1]; - if (!block) { - return undefined; - } - return { - header: block.header, - archive: block.archive, - blockHash: await block.hash(), - checkpointNumber: block.checkpointNumber, - indexWithinCheckpoint: block.indexWithinCheckpoint, - }; - } - public getCheckpointData(_n: CheckpointNumber): Promise { return Promise.resolve(undefined); } @@ -333,32 +297,6 @@ export class MockL2BlockSource implements L2BlockSource, ContractDataSource { return Promise.resolve(undefined); } - public async getBlockDataWithCheckpointContext(number: BlockNumber) { - const data = await this.getBlockData(number); - if (!data) { - return undefined; - } - return { data, checkpoint: undefined, l1: undefined, attestations: [] }; - } - - public async getBlockDataByArchive(archive: Fr): Promise { - const block = this.l2Blocks.find(b => b.archive.root.equals(archive)); - if (!block) { - return undefined; - } - return { - header: block.header, - archive: block.archive, - blockHash: await block.hash(), - checkpointNumber: block.checkpointNumber, - indexWithinCheckpoint: block.indexWithinCheckpoint, - }; - } - - getBlockHeader(number: number | 'latest'): Promise { - return Promise.resolve(this.l2Blocks.at(typeof number === 'number' ? number - 1 : -1)?.header); - } - getCheckpointsForEpoch(epochNumber: EpochNumber): Promise { return Promise.resolve(this.getCheckpointsInEpoch(epochNumber)); } @@ -384,23 +322,11 @@ export class MockL2BlockSource implements L2BlockSource, ContractDataSource { ); } - getCheckpointedBlocksForEpoch(epochNumber: EpochNumber): Promise { - const checkpoints = this.getCheckpointsInEpoch(epochNumber); - return Promise.resolve( - checkpoints.flatMap(checkpoint => checkpoint.blocks.map(block => this.toCheckpointedBlock(block))), - ); - } - getBlocksForSlot(slotNumber: SlotNumber): Promise { const blocks = this.l2Blocks.filter(b => b.header.globalVariables.slotNumber === slotNumber); return Promise.resolve(blocks); } - async getCheckpointedBlockHeadersForEpoch(epochNumber: EpochNumber): Promise { - const checkpointedBlocks = await this.getCheckpointedBlocksForEpoch(epochNumber); - return checkpointedBlocks.map(b => b.block.header); - } - /** * Gets a tx effect. * @param txHash - The hash of the tx corresponding to the tx effect. @@ -463,34 +389,48 @@ export class MockL2BlockSource implements L2BlockSource, ContractDataSource { const checkpointedBlock = this.l2Blocks[checkpointed - 1]; const proposedCheckpointBlock = this.l2Blocks[proposedCheckpoint - 1]; + // For genesis tips (block number 0) report the dynamic initial header hash so consumers + // running L2BlockStream against this mock agree at block 0 with their local tip store. + const genesisHash = (await this.initialHeader.hash()).toString(); + const tipHash = async (block: L2Block | undefined, number: number): Promise => { + if (block) { + return (await block.hash()).toString(); + } + return number === 0 ? genesisHash : ''; + }; + const latestBlockId = { number: BlockNumber(latest), - hash: (await latestBlock?.hash())?.toString(), + hash: await tipHash(latestBlock, latest), }; const provenBlockId = { number: BlockNumber(proven), - hash: (await provenBlock?.hash())?.toString(), + hash: await tipHash(provenBlock, proven), }; const finalizedBlockId = { number: BlockNumber(finalized), - hash: (await finalizedBlock?.hash())?.toString(), + hash: await tipHash(finalizedBlock, finalized), }; const checkpointedBlockId = { number: BlockNumber(checkpointed), - hash: (await checkpointedBlock?.hash())?.toString(), + hash: await tipHash(checkpointedBlock, checkpointed), }; const proposedCheckpointBlockId = { number: BlockNumber(proposedCheckpoint), - hash: (await proposedCheckpointBlock?.hash())?.toString(), + hash: await tipHash(proposedCheckpointBlock, proposedCheckpoint), }; - const makeTipId = (blockId: typeof latestBlockId) => ({ - block: blockId, - checkpoint: { - number: this.findCheckpointNumberForBlock(blockId.number) ?? CheckpointNumber(0), - hash: blockId.hash, - }, - }); + const makeTipId = (blockId: typeof latestBlockId) => { + const checkpointNumber = this.findCheckpointNumberForBlock(blockId.number) ?? CheckpointNumber(0); + // Match production semantics: checkpoint 0 is fully synthetic (no real checkpoint header + // exists at 0), so its hash stays at the protocol constant `GENESIS_CHECKPOINT_HEADER_HASH` + // even though the block-0 hash is dynamic. See L2TipsCache for the production path. + const hash = checkpointNumber === 0 ? GENESIS_CHECKPOINT_HEADER_HASH.toString() : blockId.hash; + return { + block: blockId, + checkpoint: { number: checkpointNumber, hash }, + }; + }; return { proposed: latestBlockId, @@ -518,7 +458,7 @@ export class MockL2BlockSource implements L2BlockSource, ContractDataSource { } getGenesisValues(): Promise<{ genesisArchiveRoot: Fr }> { - return Promise.resolve({ genesisArchiveRoot: new Fr(GENESIS_ARCHIVE_ROOT) }); + return Promise.resolve({ genesisArchiveRoot: this.genesisArchiveRoot ?? new Fr(GENESIS_ARCHIVE_ROOT) }); } getL1Timestamp(): Promise { @@ -571,6 +511,95 @@ export class MockL2BlockSource implements L2BlockSource, ContractDataSource { return Promise.resolve(); } + async getBlock(query: BlockQuery): Promise { + if ('number' in query) { + if (query.number === 0) { + return this.getGenesisBlock(); + } + return this.l2Blocks[query.number - 1]; + } + if ('hash' in query) { + const genesis = this.getGenesisBlock(); + if ((await genesis.hash()).equals(query.hash)) { + return genesis; + } + for (const b of this.l2Blocks) { + const hash = await b.hash(); + if (hash.equals(query.hash)) { + return b; + } + } + return undefined; + } + if ('archive' in query) { + const genesis = this.getGenesisBlock(); + if (genesis.archive.root.equals(query.archive)) { + return genesis; + } + return this.l2Blocks.find(b => b.archive.root.equals(query.archive)); + } + const number = this.resolveBlockTag(query.tag); + if (number === 0) { + return this.getGenesisBlock(); + } + return this.l2Blocks[number - 1]; + } + + private resolveBlockTag(tag: BlockTag): number { + switch (tag) { + case 'latest': + case 'proposed': + return this.l2Blocks.length; + case 'checkpointed': + return this.checkpointedBlockNumber; + case 'proven': + return this.provenBlockNumber; + case 'finalized': + return this.finalizedBlockNumber; + } + } + + getBlocks(query: BlocksQuery): Promise { + let blocks: L2Block[]; + if ('from' in query) { + blocks = this.l2Blocks.slice(query.from - 1, query.from - 1 + query.limit); + } else { + const epochCheckpoints = this.getCheckpointsInEpoch(query.epoch); + blocks = epochCheckpoints.flatMap(c => c.blocks); + } + if (query.onlyCheckpointed) { + blocks = blocks.filter(b => b.header.globalVariables.blockNumber <= this.checkpointedBlockNumber); + } + return Promise.resolve(blocks); + } + + async getBlockData(query: BlockQuery): Promise { + const block = await this.getBlock(query); + if (!block) { + return undefined; + } + return { + header: block.header, + archive: block.archive, + blockHash: await block.hash(), + checkpointNumber: block.checkpointNumber, + indexWithinCheckpoint: block.indexWithinCheckpoint, + }; + } + + async getBlocksData(query: BlocksQuery): Promise { + const blocks = await this.getBlocks(query); + return Promise.all( + blocks.map(async block => ({ + header: block.header, + archive: block.archive, + blockHash: await block.hash(), + checkpointNumber: block.checkpointNumber, + indexWithinCheckpoint: block.indexWithinCheckpoint, + })), + ); + } + isPendingChainInvalid(): Promise { return Promise.resolve(false); } @@ -599,22 +628,6 @@ export class MockL2BlockSource implements L2BlockSource, ContractDataSource { return new L1PublishedData(BigInt(checkpoint.number), BigInt(checkpoint.number), Buffer32.random().toString()); } - /** Creates a CheckpointedL2Block from a block using stored checkpoint info. */ - private toCheckpointedBlock(block: L2Block): CheckpointedL2Block { - const checkpoint = this.checkpointList.find(c => c.blocks.some(b => b.number === block.number)); - const checkpointNumber = checkpoint?.number ?? block.checkpointNumber; - return new CheckpointedL2Block( - checkpointNumber, - block, - new L1PublishedData( - BigInt(block.number), - BigInt(block.number), - `0x${block.number.toString(16).padStart(64, '0')}`, - ), - [], - ); - } - /** Finds the checkpoint number for a block, or undefined if the block is not in any checkpoint. */ private findCheckpointNumberForBlock(blockNumber: BlockNumber): CheckpointNumber | undefined { const checkpoint = this.checkpointList.find(c => c.blocks.some(b => b.number === blockNumber)); diff --git a/yarn-project/archiver/src/test/noop_l1_archiver.ts b/yarn-project/archiver/src/test/noop_l1_archiver.ts index 95ad0831f878..e4601b30fdfe 100644 --- a/yarn-project/archiver/src/test/noop_l1_archiver.ts +++ b/yarn-project/archiver/src/test/noop_l1_archiver.ts @@ -6,8 +6,9 @@ import { Buffer32 } from '@aztec/foundation/buffer'; import { Fr } from '@aztec/foundation/curves/bn254'; import { EthAddress } from '@aztec/foundation/eth-address'; import type { FunctionsOf } from '@aztec/foundation/types'; -import type { ArchiverEmitter } from '@aztec/stdlib/block'; +import type { ArchiverEmitter, BlockHash } from '@aztec/stdlib/block'; import type { L1RollupConstants } from '@aztec/stdlib/epoch-helpers'; +import type { BlockHeader } from '@aztec/stdlib/tx'; import { type TelemetryClient, type Tracer, getTelemetryClient } from '@aztec/telemetry-client'; import { mock } from 'jest-mock-extended'; @@ -17,6 +18,7 @@ import { Archiver } from '../archiver.js'; import { ArchiverInstrumentation } from '../modules/instrumentation.js'; import type { ArchiverL1Synchronizer } from '../modules/l1_synchronizer.js'; import type { ArchiverDataStores } from '../store/data_stores.js'; +import { L2TipsCache } from '../store/l2_tips_cache.js'; /** Noop L1 synchronizer for testing without L1 connectivity. */ class NoopL1Synchronizer implements FunctionsOf { @@ -51,6 +53,9 @@ export class NoopL1Archiver extends Archiver { dataStores: ArchiverDataStores, l1Constants: L1RollupConstants & { genesisArchiveRoot: Fr }, instrumentation: ArchiverInstrumentation, + initialHeader: BlockHeader, + initialBlockHash: BlockHash, + l2TipsCache: L2TipsCache, ) { // Create mocks for L1 clients const publicClient = mock(); @@ -90,6 +95,9 @@ export class NoopL1Archiver extends Archiver { { ...l1Constants, l1StartBlockHash: Buffer32.random() }, synchronizer as ArchiverL1Synchronizer, events, + initialHeader, + initialBlockHash, + l2TipsCache, ); } @@ -111,7 +119,14 @@ export async function createNoopL1Archiver( dataStores: ArchiverDataStores, l1Constants: L1RollupConstants & { genesisArchiveRoot: Fr }, telemetry: TelemetryClient = getTelemetryClient(), + initialHeader: BlockHeader, ): Promise { const instrumentation = await ArchiverInstrumentation.new(telemetry, () => dataStores.db.estimateSize()); - return new NoopL1Archiver(dataStores, l1Constants, instrumentation); + // Mirror the production factory: precompute the dynamic genesis block hash from the injected + // initial header so `L2TipsCache` reports the correct tip hash at block 0. Without this, the + // cache falls back to the static `GENESIS_BLOCK_HEADER_HASH`, which only matches deployments + // with default empty genesis. + const initialBlockHash = await initialHeader.hash(); + const l2TipsCache = new L2TipsCache(dataStores.blocks, initialBlockHash); + return new NoopL1Archiver(dataStores, l1Constants, instrumentation, initialHeader, initialBlockHash, l2TipsCache); } diff --git a/yarn-project/aztec-node/src/aztec-node/block_response_helpers.ts b/yarn-project/aztec-node/src/aztec-node/block_response_helpers.ts index 518027789433..672c031329a0 100644 --- a/yarn-project/aztec-node/src/aztec-node/block_response_helpers.ts +++ b/yarn-project/aztec-node/src/aztec-node/block_response_helpers.ts @@ -1,12 +1,6 @@ import { BlockNumber } from '@aztec/foundation/branded-types'; -import { - type BlockData, - type BlockDataWithCheckpointContext, - type CheckpointedL2Block, - type CommitteeAttestation, - L2Block, -} from '@aztec/stdlib/block'; -import type { CheckpointData, PublishedCheckpoint } from '@aztec/stdlib/checkpoint'; +import { type BlockData, type CommitteeAttestation, L2Block } from '@aztec/stdlib/block'; +import type { CheckpointData, L1PublishedData, PublishedCheckpoint } from '@aztec/stdlib/checkpoint'; import { type BlockIncludeOptions, type BlockResponse, @@ -19,7 +13,7 @@ import { export async function blockResponseFromL2Block( block: L2Block, options: BlockIncludeOptions, - context?: { l1?: BlockDataWithCheckpointContext['l1']; attestations?: CommitteeAttestation[] }, + context?: { l1?: L1PublishedData; attestations?: CommitteeAttestation[] }, ): Promise { const response: BlockResponse = { header: block.header, @@ -44,9 +38,8 @@ export async function blockResponseFromL2Block( /** Projects metadata-only {@link BlockData} into a {@link BlockResponse}. */ export function blockResponseFromBlockData( data: BlockData, - blockNumber: BlockNumber, options: BlockIncludeOptions, - context?: { l1?: BlockDataWithCheckpointContext['l1']; attestations?: CommitteeAttestation[] }, + context?: { l1?: L1PublishedData; attestations?: CommitteeAttestation[] }, ): BlockResponse { const response: BlockResponse = { header: data.header, @@ -54,7 +47,7 @@ export function blockResponseFromBlockData( hash: data.blockHash, checkpointNumber: data.checkpointNumber, indexWithinCheckpoint: data.indexWithinCheckpoint, - number: blockNumber, + number: data.header.getBlockNumber(), }; if (options.includeL1PublishInfo) { (response as BlockResponse).l1 = l1PublishInfoFromL1PublishedData(context?.l1); @@ -65,14 +58,6 @@ export function blockResponseFromBlockData( return response; } -/** Projects a {@link CheckpointedL2Block} into a {@link BlockResponse}. */ -export function blockResponseFromCheckpointedL2Block( - cp: CheckpointedL2Block, - options: BlockIncludeOptions, -): Promise { - return blockResponseFromL2Block(cp.block, options, { l1: cp.l1, attestations: cp.attestations }); -} - /** Projects a {@link PublishedCheckpoint} into a {@link CheckpointResponse}. */ export async function checkpointResponseFromPublishedCheckpoint( pc: PublishedCheckpoint, diff --git a/yarn-project/aztec-node/src/aztec-node/server.test.ts b/yarn-project/aztec-node/src/aztec-node/server.test.ts index 9df49ffbe54f..21b5e558fa91 100644 --- a/yarn-project/aztec-node/src/aztec-node/server.test.ts +++ b/yarn-project/aztec-node/src/aztec-node/server.test.ts @@ -1,7 +1,13 @@ import { TestCircuitVerifier } from '@aztec/bb-prover'; import { EpochCache } from '@aztec/epoch-cache'; import type { RollupContract } from '@aztec/ethereum/contracts'; -import { BlockNumber, CheckpointNumber, EpochNumber, SlotNumber } from '@aztec/foundation/branded-types'; +import { + BlockNumber, + CheckpointNumber, + EpochNumber, + IndexWithinCheckpoint, + SlotNumber, +} from '@aztec/foundation/branded-types'; import { Fr } from '@aztec/foundation/curves/bn254'; import { EthAddress } from '@aztec/foundation/eth-address'; import { BadRequestError } from '@aztec/foundation/json-rpc'; @@ -16,8 +22,14 @@ import { computeFeePayerBalanceLeafSlot } from '@aztec/protocol-contracts/fee-ju import type { GlobalVariableBuilder, Sequencer, SequencerClient } from '@aztec/sequencer-client'; import type { SlasherClientInterface } from '@aztec/slasher'; import { AztecAddress } from '@aztec/stdlib/aztec-address'; -import { BlockHash, type BlockParameter, CheckpointedL2Block, L2Block, type L2BlockSource } from '@aztec/stdlib/block'; -import { L1PublishedData } from '@aztec/stdlib/checkpoint'; +import { + type BlockData, + BlockHash, + type BlockParameter, + type BlockQuery, + L2Block, + type L2BlockSource, +} from '@aztec/stdlib/block'; import type { ContractDataSource } from '@aztec/stdlib/contract'; import { EmptyL1RollupConstants } from '@aztec/stdlib/epoch-helpers'; import { GasFees } from '@aztec/stdlib/gas'; @@ -50,7 +62,6 @@ import { dirname, join, resolve } from 'path'; import { fileURLToPath } from 'url'; import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts'; -import { blockResponseFromL2Block } from './block_response_helpers.js'; import { type AztecNodeConfig, getConfigEnvVars } from './config.js'; import { AztecNodeService } from './server.js'; @@ -149,8 +160,17 @@ describe('aztec node', () => { worldState.syncImmediate.mockImplementation(() => Promise.resolve(lastBlockNumber)); l2BlockSource = mock(); - l2BlockSource.getBlockNumber.mockImplementation(() => Promise.resolve(lastBlockNumber)); + l2BlockSource.getBlockNumber.mockImplementation(((query?: BlockQuery) => { + if (!query) { + return Promise.resolve(lastBlockNumber); + } + if ('number' in query) { + return Promise.resolve(query.number); + } + return Promise.resolve(undefined); + }) as L2BlockSource['getBlockNumber']); l2BlockSource.getL1Constants.mockResolvedValue(EmptyL1RollupConstants); + l2BlockSource.getGenesisBlockHash.mockReturnValue(BlockHash.random()); const l2LogsSource = mock(); @@ -356,68 +376,78 @@ describe('aztec node', () => { }); header2 = BlockHeader.empty({ globalVariables: GlobalVariables.empty({ blockNumber: BlockNumber(2) }) }); - merkleTreeOps.getInitialHeader.mockReturnValue(initialHeader); + // Archiver returns the genesis block data for block 0 queries (including {tag:'proposed'} at genesis). + l2BlockSource.getBlockData.mockResolvedValue({ header: initialHeader } as any); l2BlockSource.getBlockNumber.mockResolvedValue(BlockNumber(2)); }); it('returns requested block number', async () => { - l2BlockSource.getBlockHeader.mockResolvedValue(header1); + l2BlockSource.getBlockData.mockResolvedValue({ header: header1 } as any); expect(await node.getBlockHeader(BlockNumber(1))).toEqual(header1); }); it('returns latest', async () => { - l2BlockSource.getBlockHeader.mockResolvedValue(header2); + l2BlockSource.getBlockData.mockResolvedValue({ header: header2 } as any); expect(await node.getBlockHeader('latest')).toEqual(header2); }); it('returns initial header on zero', async () => { + // Archiver returns synthetic genesis block data when queried with block 0. expect(await node.getBlockHeader(BlockNumber.ZERO)).toEqual(initialHeader); }); it('returns initial header if no blocks mined', async () => { + // When no blocks have been mined, {tag:'proposed'} resolves to the genesis block. l2BlockSource.getBlockNumber.mockResolvedValue(BlockNumber.ZERO); expect(await node.getBlockHeader('latest')).toEqual(initialHeader); }); it('returns undefined for non-existent block', async () => { - l2BlockSource.getBlockHeader.mockResolvedValue(undefined); + l2BlockSource.getBlockData.mockResolvedValue(undefined); expect(await node.getBlockHeader(BlockNumber(3))).toEqual(undefined); }); }); describe('getBlock', () => { - let block1: L2Block; - let block2: L2Block; + let blockData1: BlockData; + let blockData2: BlockData; beforeEach(() => { - block1 = L2Block.empty( - BlockHeader.empty({ globalVariables: GlobalVariables.empty({ blockNumber: BlockNumber(1) }) }), - ); - block2 = L2Block.empty( - BlockHeader.empty({ globalVariables: GlobalVariables.empty({ blockNumber: BlockNumber(2) }) }), - ); + blockData1 = { + header: BlockHeader.empty({ globalVariables: GlobalVariables.empty({ blockNumber: BlockNumber(1) }) }), + archive: L2Block.empty().archive, + blockHash: BlockHash.random(), + checkpointNumber: CheckpointNumber(1), + indexWithinCheckpoint: IndexWithinCheckpoint(0), + }; + blockData2 = { + header: BlockHeader.empty({ globalVariables: GlobalVariables.empty({ blockNumber: BlockNumber(2) }) }), + archive: L2Block.empty().archive, + blockHash: BlockHash.random(), + checkpointNumber: CheckpointNumber(1), + indexWithinCheckpoint: IndexWithinCheckpoint(1), + }; l2BlockSource.getBlockNumber.mockResolvedValue(BlockNumber(2)); }); - it('returns requested block number with transactions', async () => { - l2BlockSource.getL2Block.mockResolvedValue(block1); - const expected = await blockResponseFromL2Block(block1, { includeTransactions: true }); - expect(await node.getBlock(BlockNumber(1), { includeTransactions: true })).toEqual(expected); - expect(l2BlockSource.getL2Block).toHaveBeenCalledWith(BlockNumber(1)); + it('returns requested block number', async () => { + l2BlockSource.getBlockData.mockResolvedValue(blockData1); + const result = await node.getBlock(BlockNumber(1)); + expect(result?.header).toEqual(blockData1.header); + expect(result?.number).toEqual(BlockNumber(1)); }); - it('returns latest block with transactions', async () => { - l2BlockSource.getL2Block.mockResolvedValue(block2); - const expected = await blockResponseFromL2Block(block2, { includeTransactions: true }); - expect(await node.getBlock('latest', { includeTransactions: true })).toEqual(expected); - expect(l2BlockSource.getL2Block).toHaveBeenCalledWith(2); + it('returns latest block', async () => { + l2BlockSource.getBlockData.mockResolvedValue(blockData2); + const result = await node.getBlock('latest'); + expect(result?.header).toEqual(blockData2.header); + expect(result?.number).toEqual(BlockNumber(2)); }); it('returns undefined for non-existent block', async () => { - l2BlockSource.getL2Block.mockResolvedValue(undefined); - expect(await node.getBlock(BlockNumber(3), { includeTransactions: true })).toEqual(undefined); - expect(l2BlockSource.getL2Block).toHaveBeenCalledWith(BlockNumber(3)); + l2BlockSource.getBlockData.mockResolvedValue(undefined); + expect(await node.getBlock(BlockNumber(3))).toEqual(undefined); }); }); @@ -576,11 +606,24 @@ describe('aztec node', () => { let snapshotMerkleTreeOps: MockProxy; let initialHeader: BlockHeader; - beforeEach(() => { + beforeEach(async () => { lastBlockNumber = BlockNumber(5); initialHeader = BlockHeader.empty({ globalVariables: GlobalVariables.empty({ blockNumber: BlockNumber.ZERO }), }); + // Archiver resolves the initial block hash to block number 0 directly. + const initialBlockHash = await initialHeader.hash(); + l2BlockSource.getBlockNumber.mockImplementation(((query?: BlockQuery) => + Promise.resolve( + !query + ? lastBlockNumber + : 'number' in query + ? query.number + : 'hash' in query && query.hash.equals(initialBlockHash) + ? BlockNumber.ZERO + : undefined, + )) as L2BlockSource['getBlockNumber']); + // #getInitialHeaderHash still sources from worldStateSynchronizer (used in error messages). merkleTreeOps.getInitialHeader.mockReturnValue(initialHeader); snapshotMerkleTreeOps = mock(); worldState.getSnapshot.mockReturnValue(snapshotMerkleTreeOps); @@ -604,20 +647,20 @@ describe('aztec node', () => { it('throws for a block hash whose block number is beyond sync range', async () => { const blockHash = BlockHash.random(); - const header = BlockHeader.empty({ - globalVariables: GlobalVariables.empty({ blockNumber: BlockNumber(10) }), - }); - l2BlockSource.getBlockHeaderByHash.mockResolvedValue(header); + l2BlockSource.getBlockNumber.mockImplementation(((query?: BlockQuery) => + Promise.resolve( + query && 'hash' in query ? BlockNumber(10) : lastBlockNumber, + )) as L2BlockSource['getBlockNumber']); await expect(node.getWorldState(blockHash)).rejects.toThrow(/not yet synced/); }); it('resolves block hash to block number via archiver and returns snapshot', async () => { const blockHash = BlockHash.random(); - const header = BlockHeader.empty({ - globalVariables: GlobalVariables.empty({ blockNumber: BlockNumber(3) }), - }); - l2BlockSource.getBlockHeaderByHash.mockResolvedValue(header); + l2BlockSource.getBlockNumber.mockImplementation(((query?: BlockQuery) => + Promise.resolve( + query && 'hash' in query ? BlockNumber(3) : lastBlockNumber, + )) as L2BlockSource['getBlockNumber']); snapshotMerkleTreeOps.getLeafValue.mockResolvedValue(blockHash); const result = await node.getWorldState(blockHash); @@ -627,7 +670,6 @@ describe('aztec node', () => { it('throws when block hash is not found in archiver', async () => { const blockHash = BlockHash.random(); - l2BlockSource.getBlockHeaderByHash.mockResolvedValue(undefined); await expect(node.getWorldState(blockHash)).rejects.toThrow(/not found when querying world state/); }); @@ -635,10 +677,10 @@ describe('aztec node', () => { it('throws when world-state block hash does not match requested hash (reorg)', async () => { const blockHash = BlockHash.random(); const differentHash = BlockHash.random(); - const header = BlockHeader.empty({ - globalVariables: GlobalVariables.empty({ blockNumber: BlockNumber(3) }), - }); - l2BlockSource.getBlockHeaderByHash.mockResolvedValue(header); + l2BlockSource.getBlockNumber.mockImplementation(((query?: BlockQuery) => + Promise.resolve( + query && 'hash' in query ? BlockNumber(3) : lastBlockNumber, + )) as L2BlockSource['getBlockNumber']); // World state returns a different hash for the same block number snapshotMerkleTreeOps.getLeafValue.mockResolvedValue(differentHash); @@ -660,23 +702,16 @@ describe('aztec node', () => { }); describe('getBlockHashMembershipWitness', () => { - let initialHeader: BlockHeader; - - beforeEach(() => { - lastBlockNumber = BlockNumber(5); - initialHeader = BlockHeader.empty({ + it('returns undefined when reference block is the initial block hash', async () => { + // Block 0 has an empty archive — no block hashes exist in it yet. + // getBlockHashMembershipWitness short-circuits at block 0 and returns undefined. + const initialHeader = BlockHeader.empty({ globalVariables: GlobalVariables.empty({ blockNumber: BlockNumber.ZERO }), }); - merkleTreeOps.getInitialHeader.mockReturnValue(initialHeader); - }); - - it('returns undefined when reference block is the initial block hash', async () => { - // The initial block (block 0) has an empty archive — no block hashes exist in it. - // getBlockHashMembershipWitness computes referenceBlockNumber - 1, which would be 0 - 1 = -1. - // This should return undefined (empty archive has no witnesses) rather than crashing. const initialBlockHash = await initialHeader.hash(); - const someBlockHash = BlockHash.random(); + l2BlockSource.getBlockNumber.mockResolvedValue(BlockNumber.ZERO); + const someBlockHash = BlockHash.random(); const result = await node.getBlockHashMembershipWitness(initialBlockHash, someBlockHash); expect(result).toBeUndefined(); }); @@ -1042,7 +1077,7 @@ describe('aztec node', () => { }); describe('getL2ToL1Messages', () => { - const makeCheckpointedBlock = (slotNumber: number, l2ToL1MsgsByTx: Fr[][]): CheckpointedL2Block => { + const makeBlock = (slotNumber: number, l2ToL1MsgsByTx: Fr[][]): L2Block => { const block = L2Block.empty( BlockHeader.empty({ globalVariables: GlobalVariables.empty({ slotNumber: SlotNumber(slotNumber) }), @@ -1050,7 +1085,7 @@ describe('aztec node', () => { ); // Override the body's txEffects with our custom l2ToL1Msgs unfreeze(block.body).txEffects = l2ToL1MsgsByTx.map(msgs => ({ l2ToL1Msgs: msgs }) as TxEffect); - return new CheckpointedL2Block(CheckpointNumber(0), block, new L1PublishedData(0n, 0n, '0x0'), []); + return block; }; it('groups blocks by slot number into checkpoints', async () => { @@ -1059,13 +1094,9 @@ describe('aztec node', () => { const msg3 = Fr.random(); // Two blocks in slot 1, one block in slot 2 - const blocks = [ - makeCheckpointedBlock(1, [[msg1]]), - makeCheckpointedBlock(1, [[msg2]]), - makeCheckpointedBlock(2, [[msg3]]), - ]; + const blocks = [makeBlock(1, [[msg1]]), makeBlock(1, [[msg2]]), makeBlock(2, [[msg3]])]; - l2BlockSource.getCheckpointedBlocksForEpoch.mockResolvedValue(blocks); + l2BlockSource.getBlocks.mockResolvedValue(blocks); const result = await node.getL2ToL1Messages(EpochNumber(0)); @@ -1079,9 +1110,9 @@ describe('aztec node', () => { const msg2 = Fr.random(); // Block in slot 0, block in slot 1 - const blocks = [makeCheckpointedBlock(0, [[msg1]]), makeCheckpointedBlock(1, [[msg2]])]; + const blocks = [makeBlock(0, [[msg1]]), makeBlock(1, [[msg2]])]; - l2BlockSource.getCheckpointedBlocksForEpoch.mockResolvedValue(blocks); + l2BlockSource.getBlocks.mockResolvedValue(blocks); const result = await node.getL2ToL1Messages(EpochNumber(0)); diff --git a/yarn-project/aztec-node/src/aztec-node/server.ts b/yarn-project/aztec-node/src/aztec-node/server.ts index a48a5dcd9bbf..975f9bfcce78 100644 --- a/yarn-project/aztec-node/src/aztec-node/server.ts +++ b/yarn-project/aztec-node/src/aztec-node/server.ts @@ -52,11 +52,14 @@ import { AztecAddress } from '@aztec/stdlib/aztec-address'; import { BlockHash, type BlockParameter, + BlockTag, + type CommitteeAttestation, type DataInBlock, - L2Block, type L2BlockSource, + type NormalizedBlockParameter, inspectBlockParameter, } from '@aztec/stdlib/block'; +import { L1PublishedData } from '@aztec/stdlib/checkpoint'; import type { ContractClassPublic, ContractDataSource, @@ -129,7 +132,7 @@ import { createValidatorClient, } from '@aztec/validator-client'; import type { SlashingProtectionDatabase } from '@aztec/validator-ha-signer/types'; -import { createWorldStateSynchronizer } from '@aztec/world-state'; +import { createWorldState, createWorldStateSynchronizer } from '@aztec/world-state'; import { createPublicClient } from 'viem'; @@ -149,8 +152,6 @@ import { NodeMetrics } from './node_metrics.js'; */ export class AztecNodeService implements AztecNode, AztecNodeAdmin, AztecNodeDebug, Traceable { private metrics: NodeMetrics; - private initialHeaderHashPromise: Promise | undefined = undefined; - // Prevent two snapshot operations to happen simultaneously private isUploadingSnapshot = false; @@ -219,15 +220,24 @@ export class AztecNodeService implements AztecNode, AztecNodeAdmin, AztecNodeDeb } public async getBlockHeader(number: BlockNumber | 'latest'): Promise { - const resolvedNumber = number === 'latest' ? await this.blockSource.getBlockNumber() : number; - if (resolvedNumber === BlockNumber.ZERO) { - return this.worldStateSynchronizer.getCommitted().getInitialHeader(); - } - return this.blockSource.getBlockHeader(resolvedNumber); - } - - public async getCheckpointedBlocks(from: BlockNumber, limit: number) { - return (await this.blockSource.getCheckpointedBlocks(from, limit)) ?? []; + if (number === 'latest') { + return (await this.blockSource.getBlockData({ tag: 'proposed' }))?.header; + } + return (await this.blockSource.getBlockData({ number }))?.header; + } + + public async getCheckpointedBlocks(from: BlockNumber, limit: number): Promise { + const blocks = await this.blockSource.getBlocks({ from, limit, onlyCheckpointed: true }); + const ctxByCheckpoint = await this.#getCheckpointContextsForBlocks(blocks); + return Promise.all( + blocks.map(block => + blockResponseFromL2Block( + block, + { includeTransactions: true, includeL1PublishInfo: true, includeAttestations: true }, + ctxByCheckpoint.get(block.checkpointNumber), + ), + ), + ); } public getCheckpointsDataForEpoch(epoch: EpochNumber) { @@ -266,20 +276,23 @@ export class AztecNodeService implements AztecNode, AztecNodeAdmin, AztecNodeDeb return value === 'proposed' || value === 'checkpointed' || value === 'proven' || value === 'finalized'; } - private async resolveBlockParameter( - param: BlockParameter, - ): Promise<{ number?: BlockNumber; hash?: BlockHash; archive?: Fr }> { + /** + * Normalizes a {@link BlockParameter} (which may be a bare value) into a + * {@link NormalizedBlockParameter} object form. Performs no chain-tip resolution — tag + * lookups are deferred to the underlying block source. + */ + private normalizeBlockParameter(param: BlockParameter): NormalizedBlockParameter { if (BlockHash.isBlockHash(param)) { return { hash: param }; } if (typeof param === 'number') { return { number: param as BlockNumber }; } - if (param === 'latest') { - return { number: await this.blockSource.getBlockNumber() }; - } - if (this.isChainTip(param)) { - return { number: await this.getBlockNumber(param) }; + if (typeof param === 'string') { + if (this.isBlockTag(param)) { + return { tag: param === 'latest' ? 'proposed' : param }; + } + throw new BadRequestError(`Invalid BlockParameter tag: ${param}`); } if (typeof param === 'object' && param !== null) { if ('number' in param) { @@ -291,10 +304,20 @@ export class AztecNodeService implements AztecNode, AztecNodeAdmin, AztecNodeDeb if ('archive' in param) { return { archive: param.archive }; } + if ('tag' in param) { + if (this.isBlockTag(param.tag)) { + return { tag: param.tag }; + } + throw new BadRequestError(`Invalid BlockParameter tag: ${param.tag}`); + } } throw new BadRequestError(`Invalid BlockParameter: ${JSON.stringify(param)}`); } + private isBlockTag(value: string): value is BlockTag { + return BlockTag.includes(value as BlockTag); + } + private async resolveCheckpointParameter( param: CheckpointParameter, ): Promise<{ number?: CheckpointNumber; slot?: SlotNumber }> { @@ -318,78 +341,39 @@ export class AztecNodeService implements AztecNode, AztecNodeAdmin, AztecNodeDeb throw new BadRequestError(`Invalid CheckpointParameter: ${JSON.stringify(param)}`); } + /** Fetches checkpoint-level L1 and attestation data for use as block response context. */ + async #getCheckpointContext( + checkpointNumber: CheckpointNumber, + ): Promise<{ l1?: L1PublishedData; attestations?: CommitteeAttestation[] } | undefined> { + const checkpoint = await this.blockSource.getCheckpointData(checkpointNumber); + if (!checkpoint) { + return undefined; + } + return { l1: checkpoint.l1, attestations: checkpoint.attestations }; + } + public async getBlock( param: BlockParameter, options: Opts = {} as Opts, ): Promise | undefined> { - const resolved = await this.resolveBlockParameter(param); + const query = this.normalizeBlockParameter(param); const wantTxs = !!options.includeTransactions; const wantContext = !!options.includeL1PublishInfo || !!options.includeAttestations; - if (resolved.hash !== undefined) { - const initial = await this.#getInitialHeaderHash(); - if (resolved.hash.equals(initial)) { - return (await this.buildGenesisBlockResponse(options)) as BlockResponse; - } - if (wantTxs) { - const block = await this.blockSource.getL2BlockByHash(resolved.hash); - if (!block) { - return undefined; - } - const ctx = wantContext ? await this.blockSource.getBlockDataWithCheckpointContext(block.number) : undefined; - return (await blockResponseFromL2Block(block, options, ctx)) as BlockResponse; - } - const data = await this.blockSource.getBlockHeaderByHash(resolved.hash); - if (!data) { - return undefined; - } - const blockNumber = data.globalVariables.blockNumber; - const ctx = wantContext ? await this.blockSource.getBlockDataWithCheckpointContext(blockNumber) : undefined; - if (ctx) { - return blockResponseFromBlockData(ctx.data, blockNumber, options, ctx) as BlockResponse; - } - const blockData = await this.blockSource.getBlockData(blockNumber); - if (!blockData) { - return undefined; - } - return blockResponseFromBlockData(blockData, blockNumber, options) as BlockResponse; - } - - if (resolved.archive !== undefined) { - if (wantTxs) { - const block = await this.blockSource.getL2BlockByArchive(resolved.archive); - if (!block) { - return undefined; - } - const ctx = wantContext ? await this.blockSource.getBlockDataWithCheckpointContext(block.number) : undefined; - return (await blockResponseFromL2Block(block, options, ctx)) as BlockResponse; - } - const data = await this.blockSource.getBlockDataByArchive(resolved.archive); - if (!data) { - return undefined; - } - const blockNumber = data.header.globalVariables.blockNumber; - const ctx = wantContext ? await this.blockSource.getBlockDataWithCheckpointContext(blockNumber) : undefined; - return blockResponseFromBlockData(data, blockNumber, options, ctx) as BlockResponse; - } - - const blockNumber = resolved.number!; - if (blockNumber === BlockNumber.ZERO) { - return (await this.buildGenesisBlockResponse(options)) as BlockResponse; - } if (wantTxs) { - const block = await this.blockSource.getL2Block(blockNumber); + const block = await this.blockSource.getBlock(query); if (!block) { return undefined; } - const ctx = wantContext ? await this.blockSource.getBlockDataWithCheckpointContext(blockNumber) : undefined; + const ctx = wantContext ? await this.#getCheckpointContext(block.checkpointNumber) : undefined; return (await blockResponseFromL2Block(block, options, ctx)) as BlockResponse; } - const ctx = await this.blockSource.getBlockDataWithCheckpointContext(blockNumber); - if (!ctx) { + const data = await this.blockSource.getBlockData(query); + if (!data) { return undefined; } - return blockResponseFromBlockData(ctx.data, blockNumber, options, ctx) as BlockResponse; + const ctx = wantContext ? await this.#getCheckpointContext(data.checkpointNumber) : undefined; + return blockResponseFromBlockData(data, options, ctx) as BlockResponse; } public async getBlocks( @@ -400,24 +384,28 @@ export class AztecNodeService implements AztecNode, AztecNodeAdmin, AztecNodeDeb const wantTxs = !!options.includeTransactions; const wantContext = !!options.includeL1PublishInfo || !!options.includeAttestations; if (wantTxs) { - const blocks = await this.blockSource.getBlocks(from, limit); + const blocks = await this.blockSource.getBlocks({ from, limit }); + const ctxByCheckpoint = await this.#getCheckpointContextsForBlocks(wantContext ? blocks : []); return (await Promise.all( - blocks.map(async block => { - const ctx = wantContext ? await this.blockSource.getBlockDataWithCheckpointContext(block.number) : undefined; - return blockResponseFromL2Block(block, options, ctx); - }), + blocks.map(block => blockResponseFromL2Block(block, options, ctxByCheckpoint.get(block.checkpointNumber))), )) as BlockResponse[]; } - const results: BlockResponse[] = []; - for (let i = 0; i < limit; i++) { - const blockNumber = BlockNumber(from + i); - const ctx = await this.blockSource.getBlockDataWithCheckpointContext(blockNumber); - if (!ctx) { - break; - } - results.push(blockResponseFromBlockData(ctx.data, blockNumber, options, ctx) as BlockResponse); - } - return results; + const dataItems = await this.blockSource.getBlocksData({ from, limit }); + const ctxByCheckpoint = await this.#getCheckpointContextsForBlocks(wantContext ? dataItems : []); + return (await Promise.all( + dataItems.map(data => blockResponseFromBlockData(data, options, ctxByCheckpoint.get(data.checkpointNumber))), + )) as BlockResponse[]; + } + + /** Fetches checkpoint context for a set of blocks, deduplicating shared checkpoints. */ + async #getCheckpointContextsForBlocks( + blocks: { checkpointNumber: CheckpointNumber }[], + // TODO(palla): CheckpointNumber should be accepted by this lint rule + // eslint-disable-next-line aztec-custom/no-non-primitive-in-collections + ): Promise> { + const unique = Array.from(new Set(blocks.map(b => b.checkpointNumber))); + const entries = await Promise.all(unique.map(async n => [n, await this.#getCheckpointContext(n)] as const)); + return new Map(entries); } public async getCheckpoint( @@ -461,29 +449,6 @@ export class AztecNodeService implements AztecNode, AztecNodeAdmin, AztecNodeDeb return datas.map(d => checkpointResponseFromCheckpointData(d, options)) as CheckpointResponse[]; } - private async buildGenesisBlockResponse(options: BlockIncludeOptions): Promise { - const initial = this.worldStateSynchronizer.getCommitted().getInitialHeader(); - const empty = L2Block.empty(initial); - const response: BlockResponse = { - header: empty.header, - archive: empty.archive, - hash: await this.#getInitialHeaderHash(), - checkpointNumber: empty.checkpointNumber, - indexWithinCheckpoint: empty.indexWithinCheckpoint, - number: empty.number, - }; - if (options.includeTransactions) { - (response as BlockResponse).body = empty.body; - } - if (options.includeL1PublishInfo) { - (response as BlockResponse).l1 = { published: false }; - } - if (options.includeAttestations) { - (response as BlockResponse).attestations = []; - } - return response; - } - /** * initializes the Aztec Node, wait for component to sync. * @param config - The configuration to be used by the aztec node. @@ -600,15 +565,21 @@ export class AztecNodeService implements AztecNode, AztecNodeAdmin, AztecNodeDeb // Track started resources so we can clean up on partial failure during node creation. const started: { stop?(): Promise | void }[] = []; try { + // Create world-state first so we can retrieve the initial header before constructing the archiver. + const nativeWs = await createWorldState(config, options.genesis); + const initialHeader = nativeWs.getInitialHeader(); + const initialBlockHash = await initialHeader.hash(); const archiver = await createArchiver( config, { blobClient, epochCache, telemetry, dateProvider }, { blockUntilSync: !config.skipArchiverInitialSync }, + initialHeader, + initialBlockHash, ); started.push(archiver); - // now create the merkle trees and the world state synchronizer - const worldStateSynchronizer = await createWorldStateSynchronizer(config, archiver, options.genesis, telemetry); + // The synchronizer takes ownership of the native world-state from here + const worldStateSynchronizer = await createWorldStateSynchronizer(config, archiver, nativeWs, telemetry); started.push(worldStateSynchronizer); const useRealVerifiers = config.realProofs || config.debugForceTxProofVerification; let peerProofVerifier: ClientProtocolCircuitVerifier; @@ -663,6 +634,7 @@ export class AztecNodeService implements AztecNode, AztecNodeAdmin, AztecNodeDeb dateProvider, telemetry, deps.p2pClientDeps, + initialBlockHash, ); started.push(p2pClient); @@ -1075,18 +1047,13 @@ export class AztecNodeService implements AztecNode, AztecNodeAdmin, AztecNodeDeb ): Promise { let upToBlockNumber: BlockNumber | undefined; if (referenceBlock) { - const initialBlockHash = await this.#getInitialHeaderHash(); - if (referenceBlock.equals(initialBlockHash)) { - upToBlockNumber = BlockNumber(0); - } else { - const header = await this.blockSource.getBlockHeaderByHash(referenceBlock); - if (!header) { - throw new Error( - `Block ${referenceBlock.toString()} not found in the node. This might indicate a reorg has occurred.`, - ); - } - upToBlockNumber = header.globalVariables.blockNumber; + const data = await this.blockSource.getBlockData({ hash: referenceBlock }); + if (!data) { + throw new Error( + `Block ${referenceBlock.toString()} not found in the node. This might indicate a reorg has occurred.`, + ); } + upToBlockNumber = data.header.globalVariables.blockNumber; } return this.logsSource.getPrivateLogsByTags(tags, page, upToBlockNumber); } @@ -1099,18 +1066,13 @@ export class AztecNodeService implements AztecNode, AztecNodeAdmin, AztecNodeDeb ): Promise { let upToBlockNumber: BlockNumber | undefined; if (referenceBlock) { - const initialBlockHash = await this.#getInitialHeaderHash(); - if (referenceBlock.equals(initialBlockHash)) { - upToBlockNumber = BlockNumber(0); - } else { - const header = await this.blockSource.getBlockHeaderByHash(referenceBlock); - if (!header) { - throw new Error( - `Block ${referenceBlock.toString()} not found in the node. This might indicate a reorg has occurred.`, - ); - } - upToBlockNumber = header.globalVariables.blockNumber; + const data = await this.blockSource.getBlockData({ hash: referenceBlock }); + if (!data) { + throw new Error( + `Block ${referenceBlock.toString()} not found in the node. This might indicate a reorg has occurred.`, + ); } + upToBlockNumber = data.header.globalVariables.blockNumber; } return this.logsSource.getPublicLogsByTagsFromContract(contractAddress, tags, page, upToBlockNumber); } @@ -1398,13 +1360,10 @@ export class AztecNodeService implements AztecNode, AztecNodeAdmin, AztecNodeDeb * @returns The L2 to L1 messages (empty array if the epoch is not found). */ public async getL2ToL1Messages(epoch: EpochNumber): Promise { - // Assumes `getCheckpointedBlocksForEpoch` returns blocks in ascending order of block number. - const checkpointedBlocks = await this.blockSource.getCheckpointedBlocksForEpoch(epoch); - const blocksInCheckpoints = chunkBy(checkpointedBlocks, cb => cb.block.header.globalVariables.slotNumber).map( - group => group.map(cb => cb.block), - ); - return blocksInCheckpoints.map(blocks => - blocks.map(block => block.body.txEffects.map(txEffect => txEffect.l2ToL1Msgs)), + const blocks = await this.blockSource.getBlocks({ epoch, onlyCheckpointed: true }); + const blocksInCheckpoints = chunkBy(blocks, block => block.header.globalVariables.slotNumber); + return blocksInCheckpoints.map(slotBlocks => + slotBlocks.map(block => block.body.txEffects.map(txEffect => txEffect.l2ToL1Msgs)), ); } @@ -1887,13 +1846,6 @@ export class AztecNodeService implements AztecNode, AztecNodeAdmin, AztecNodeDeb } } - #getInitialHeaderHash(): Promise { - if (!this.initialHeaderHashPromise) { - this.initialHeaderHashPromise = this.worldStateSynchronizer.getCommitted().getInitialHeader().hash(); - } - return this.initialHeaderHashPromise; - } - /** * Returns an instance of MerkleTreeOperations having first ensured the world state is fully synched * @param block - The block parameter (block number, block hash, or 'latest') at which to get the data. @@ -1908,32 +1860,13 @@ export class AztecNodeService implements AztecNode, AztecNodeAdmin, AztecNodeDeb this.log.error(`Error getting world state: ${err}`); } - if (block === 'latest') { - this.log.debug(`Using committed db for block 'latest', world state synced upto ${blockSyncedTo}`); + const query = this.normalizeBlockParameter(block); + if ('tag' in query && query.tag === 'proposed') { + this.log.debug(`Using committed db for latest block, world state synced upto ${blockSyncedTo}`); return this.worldStateSynchronizer.getCommitted(); } - // Get the block number, either directly from the parameter or by quering the archiver with the block hash - let blockNumber: BlockNumber; - if (BlockHash.isBlockHash(block)) { - const initialBlockHash = await this.#getInitialHeaderHash(); - if (block.equals(initialBlockHash)) { - // Block 0 is a first-class historical block: its state lives in the trees' persisted - // block-0 payload. Resolving the genesis hash to block number 0 lets the snapshot path - // pin reads to genesis state even after the node has advanced past it. - blockNumber = BlockNumber.ZERO; - } else { - const header = await this.blockSource.getBlockHeaderByHash(block); - if (!header) { - throw new Error( - `Block hash ${block.toString()} not found when querying world state. If the node API has been queried with anchor block hash possibly a reorg has occurred.`, - ); - } - blockNumber = header.getBlockNumber(); - } - } else { - blockNumber = block as BlockNumber; - } + const blockNumber = await this.resolveBlockNumber(block); // Check it's within world state sync range if (blockNumber > blockSyncedTo) { @@ -1945,13 +1878,17 @@ export class AztecNodeService implements AztecNode, AztecNodeAdmin, AztecNodeDeb const snapshot = this.worldStateSynchronizer.getSnapshot(blockNumber); - // Double-check world-state synced to the same block hash as was requested - if (BlockHash.isBlockHash(block)) { + // Double-check world-state synced to the same block hash as was requested. + // Block 0 is skipped: the snapshot returned by `getSnapshot(0)` is the *pre*-genesis archive + // (size 0), so leaf 0 is not yet inserted from that snapshot's view even though block 0's hash + // does live at archive index 0 in the committed tree. The genesis hash is already validated by + // the archiver when it resolves the hash query to block number 0. + const requestedHash = 'hash' in query ? query.hash : undefined; + if (requestedHash !== undefined && blockNumber !== BlockNumber.ZERO) { const blockHash = await snapshot.getLeafValue(MerkleTreeId.ARCHIVE, BigInt(blockNumber)); - if (!blockHash || !block.equals(blockHash)) { - const initialBlockHash = await this.#getInitialHeaderHash(); + if (!blockHash || !requestedHash.equals(blockHash)) { throw new Error( - `Block hash ${block.toString()} not found in world state at block number ${blockNumber} (world state has ${blockHash?.toString() ?? 'no hash'} at that index, genesis header hash is ${initialBlockHash.toString()}). If the node API has been queried with anchor block hash possibly a reorg has occurred.`, + `Block hash ${requestedHash.toString()} not found in world state at block number ${blockNumber} (world state has ${blockHash?.toString() ?? 'no hash'} at that index, genesis header hash is ${this.blockSource.getGenesisBlockHash().toString()}). If the node API has been queried with anchor block hash possibly a reorg has occurred.`, ); } } @@ -1961,29 +1898,20 @@ export class AztecNodeService implements AztecNode, AztecNodeAdmin, AztecNodeDeb /** Resolves any {@link BlockParameter} variant to a concrete block number. */ protected async resolveBlockNumber(block: BlockParameter): Promise { - const resolved = await this.resolveBlockParameter(block); - if (resolved.number !== undefined) { - return resolved.number; - } - if (resolved.hash !== undefined) { - const initialBlockHash = await this.#getInitialHeaderHash(); - if (resolved.hash.equals(initialBlockHash)) { - return BlockNumber.ZERO; - } - const header = await this.blockSource.getBlockHeaderByHash(resolved.hash); - if (!header) { - throw new Error(`Block hash ${resolved.hash.toString()} not found.`); + const query = this.normalizeBlockParameter(block); + const blockNumber = await this.blockSource.getBlockNumber(query); + if (blockNumber === undefined) { + if ('hash' in query) { + throw new Error( + `Block hash ${query.hash.toString()} not found when querying world state. If the node API has been queried with anchor block hash possibly a reorg has occurred.`, + ); } - return header.getBlockNumber(); - } - if (resolved.archive !== undefined) { - const header = await this.blockSource.getBlockHeaderByArchive(resolved.archive); - if (!header) { - throw new Error(`Block with archive ${resolved.archive.toString()} not found.`); + if ('archive' in query) { + throw new Error(`Block with archive ${query.archive.toString()} not found.`); } - return header.getBlockNumber(); + throw new Error(`Block not found for ${inspectBlockParameter(block)}.`); } - throw new BadRequestError(`Invalid BlockParameter: ${JSON.stringify(block)}`); + return blockNumber; } /** diff --git a/yarn-project/aztec-node/src/sentinel/sentinel.test.ts b/yarn-project/aztec-node/src/sentinel/sentinel.test.ts index 7e54ee16cd79..1d745c9d4582 100644 --- a/yarn-project/aztec-node/src/sentinel/sentinel.test.ts +++ b/yarn-project/aztec-node/src/sentinel/sentinel.test.ts @@ -8,6 +8,7 @@ import type { P2PClient } from '@aztec/p2p'; import { OffenseType, WANT_TO_SLASH_EVENT, type WantToSlashArgs } from '@aztec/slasher'; import { CommitteeAttestation, + GENESIS_BLOCK_HEADER_HASH, L2Block, type L2BlockSource, type L2BlockStream, @@ -61,6 +62,7 @@ describe('sentinel', () => { beforeEach(async () => { epochCache = mock(); archiver = mock(); + archiver.getGenesisBlockHash.mockReturnValue(GENESIS_BLOCK_HEADER_HASH); p2p = mock(); blockStream = mock(); @@ -607,7 +609,7 @@ describe('sentinel', () => { epochCache.getTargetSlot.mockReturnValue(slot); epochCache.getEpochNow.mockReturnValue(epochNumber); epochCache.getTargetEpoch.mockReturnValue(epochNumber); - archiver.getBlockHeader.calledWith(blockNumber).mockResolvedValue(mockBlock.header); + archiver.getBlockData.mockResolvedValue({ header: mockBlock.header } as any); archiver.getL1Constants.mockResolvedValue(l1Constants); epochCache.getL1Constants.mockReturnValue(l1Constants); @@ -729,7 +731,7 @@ describe('sentinel', () => { const epochNumber = getEpochAtSlot(blockSlot, l1Constants); const validator1 = EthAddress.random(); - archiver.getBlockHeader.calledWith(blockNumber).mockResolvedValue(mockBlock.header); + archiver.getBlockData.mockResolvedValue({ header: mockBlock.header } as any); epochCache.getCommittee.mockResolvedValue({ committee: [validator1], diff --git a/yarn-project/aztec-node/src/sentinel/sentinel.ts b/yarn-project/aztec-node/src/sentinel/sentinel.ts index a3b2ce0d4a62..611a67cb36bf 100644 --- a/yarn-project/aztec-node/src/sentinel/sentinel.ts +++ b/yarn-project/aztec-node/src/sentinel/sentinel.ts @@ -1,5 +1,11 @@ import type { EpochCache } from '@aztec/epoch-cache'; -import { BlockNumber, CheckpointNumber, EpochNumber, SlotNumber } from '@aztec/foundation/branded-types'; +import { + BlockNumber, + CheckpointNumber, + CheckpointProposalHash, + EpochNumber, + SlotNumber, +} from '@aztec/foundation/branded-types'; import { countWhile, filterAsync, fromEntries, getEntries, mapValues } from '@aztec/foundation/collection'; import { EthAddress } from '@aztec/foundation/eth-address'; import { createLogger } from '@aztec/foundation/log'; @@ -23,7 +29,7 @@ import { } from '@aztec/stdlib/block'; import type { ChainConfig } from '@aztec/stdlib/config'; import { getEpochAtSlot, getSlotRangeForEpoch, getTimestampForSlot } from '@aztec/stdlib/epoch-helpers'; -import type { CoordinationSignatureContext } from '@aztec/stdlib/p2p'; +import { ConsensusPayload, type CoordinationSignatureContext } from '@aztec/stdlib/p2p'; import type { SingleValidatorStats, ValidatorStats, @@ -62,10 +68,15 @@ export class Sentinel extends (EventEmitter as new () => WatcherEmitter) impleme protected initialSlot: SlotNumber | undefined; protected lastProcessedSlot: SlotNumber | undefined; - // eslint-disable-next-line aztec-custom/no-non-primitive-in-collections protected slotNumberToCheckpoint: Map< SlotNumber, - { checkpointNumber: CheckpointNumber; archive: string; attestors: EthAddress[] } + { + checkpointNumber: CheckpointNumber; + archive: string; + /** Hex keccak256 of the consensus payload bytes; used to fetch matching p2p attestations. */ + proposalPayloadHash: CheckpointProposalHash; + attestors: EthAddress[]; + } > = new Map(); constructor( @@ -77,7 +88,7 @@ export class Sentinel extends (EventEmitter as new () => WatcherEmitter) impleme protected logger = createLogger('node:sentinel'), ) { super(); - this.l2TipsStore = new L2TipsMemoryStore(); + this.l2TipsStore = new L2TipsMemoryStore(archiver.getGenesisBlockHash()); const interval = (epochCache.getL1Constants().ethereumSlotDuration * 1000) / 4; this.runningPromise = new RunningPromise(this.work.bind(this), logger, interval); } @@ -125,11 +136,17 @@ export class Sentinel extends (EventEmitter as new () => WatcherEmitter) impleme } const checkpoint = event.checkpoint; - // Store mapping from slot to archive, checkpoint number, and attestors + // Store mapping from slot to archive, checkpoint number, attestors, and the consensus payload + // hash (used to query matching p2p attestations regardless of feeAssetPriceModifier variants). + const signatureContext = this.getSignatureContext(); + const proposalPayloadHash = CheckpointProposalHash.fromBuffer( + ConsensusPayload.fromCheckpoint(checkpoint.checkpoint, signatureContext).getPayloadHash(), + ); this.slotNumberToCheckpoint.set(checkpoint.checkpoint.header.slotNumber, { checkpointNumber: checkpoint.checkpoint.number, archive: checkpoint.checkpoint.archive.root.toString(), - attestors: getAttestationInfoFromPublishedCheckpoint(checkpoint, this.getSignatureContext()) + proposalPayloadHash, + attestors: getAttestationInfoFromPublishedCheckpoint(checkpoint, signatureContext) .filter(a => a.status === 'recovered-from-signature') .map(a => a.address!), }); @@ -151,7 +168,7 @@ export class Sentinel extends (EventEmitter as new () => WatcherEmitter) impleme return; } const blockNumber = event.block.number; - const header = await this.archiver.getBlockHeader(blockNumber); + const header = (await this.archiver.getBlockData({ number: blockNumber }))?.header; if (!header) { this.logger.error(`Failed to get block header ${blockNumber}`); return; @@ -373,7 +390,7 @@ export class Sentinel extends (EventEmitter as new () => WatcherEmitter) impleme // We gather from both p2p (contains the ones seen on the p2p layer) and archiver // (contains the ones synced from mined checkpoints, which we may have missed from p2p). const checkpoint = this.slotNumberToCheckpoint.get(slot); - const p2pAttested = await this.p2p.getCheckpointAttestationsForSlot(slot, checkpoint?.archive); + const p2pAttested = await this.p2p.getCheckpointAttestationsForSlot(slot, checkpoint?.proposalPayloadHash); // Filter out attestations with invalid signatures const p2pAttestors = p2pAttested.map(a => a.getSender()).filter((s): s is EthAddress => s !== undefined); const attestors = new Set( diff --git a/yarn-project/aztec/src/cli/aztec_start_action.ts b/yarn-project/aztec/src/cli/aztec_start_action.ts index bc46c50af937..65bb2b2d7aa2 100644 --- a/yarn-project/aztec/src/cli/aztec_start_action.ts +++ b/yarn-project/aztec/src/cli/aztec_start_action.ts @@ -67,9 +67,6 @@ export async function aztecStart(options: any, userLog: LogFn, debugLogger: Logg } else if (options.bot) { const { startBot } = await import('./cmds/start_bot.js'); await startBot(options, signalHandlers, services, userLog); - } else if (options.archiver) { - const { startArchiver } = await import('./cmds/start_archiver.js'); - ({ config } = await startArchiver(options, signalHandlers, services)); } else if (options.p2pBootstrap) { const { startP2PBootstrap } = await import('./cmds/start_p2p_bootstrap.js'); ({ config } = await startP2PBootstrap(options, signalHandlers, services, userLog)); diff --git a/yarn-project/aztec/src/cli/aztec_start_options.ts b/yarn-project/aztec/src/cli/aztec_start_options.ts index 6b5a605da08f..dec92e996fd7 100644 --- a/yarn-project/aztec/src/cli/aztec_start_options.ts +++ b/yarn-project/aztec/src/cli/aztec_start_options.ts @@ -222,12 +222,6 @@ export const aztecStartOptions: { [key: string]: AztecStartOption[] } = { }, ], ARCHIVER: [ - { - flag: '--archiver', - description: 'Starts Aztec Archiver with options', - defaultValue: undefined, - env: undefined, - }, ...getOptions( 'archiver', omitConfigMappings(archiverConfigMappings, Object.keys(l1ContractsConfigMappings) as (keyof ArchiverConfig)[]), diff --git a/yarn-project/aztec/src/cli/cmds/start_archiver.ts b/yarn-project/aztec/src/cli/cmds/start_archiver.ts deleted file mode 100644 index e4bb2bd72db9..000000000000 --- a/yarn-project/aztec/src/cli/cmds/start_archiver.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { type ArchiverConfig, archiverConfigMappings, createArchiver, getArchiverConfigFromEnv } from '@aztec/archiver'; -import { createLogger } from '@aztec/aztec.js/log'; -import { type BlobClientConfig, blobClientConfigMapping, createBlobClient } from '@aztec/blob-client/client'; -import { getL1Config } from '@aztec/cli/config'; -import type { NamespacedApiHandlers } from '@aztec/foundation/json-rpc/server'; -import { ArchiverApiSchema } from '@aztec/stdlib/interfaces/server'; -import { type DataStoreConfig, dataConfigMappings } from '@aztec/stdlib/kv-store'; -import { getConfigEnvVars as getTelemetryClientConfig, initTelemetryClient } from '@aztec/telemetry-client'; - -import { extractRelevantOptions } from '../util.js'; - -export type { ArchiverConfig, DataStoreConfig }; - -/** Starts a standalone archiver. */ -export async function startArchiver( - options: any, - signalHandlers: (() => Promise)[], - services: NamespacedApiHandlers, -): Promise<{ config: ArchiverConfig & DataStoreConfig }> { - const envConfig = getArchiverConfigFromEnv(); - const cliOptions = extractRelevantOptions( - options, - { - // dataConfigMappings must come first: its l1Contracts only maps rollupAddress, - // while archiverConfigMappings (spread later) maps all L1 contract addresses. - ...dataConfigMappings, - ...archiverConfigMappings, - ...blobClientConfigMapping, - }, - 'archiver', - ); - - let archiverConfig = { ...envConfig, ...cliOptions }; - archiverConfig.dataStoreMapSizeKb = archiverConfig.archiverStoreMapSizeKb ?? archiverConfig.dataStoreMapSizeKb; - - if (!archiverConfig.l1Contracts.registryAddress || archiverConfig.l1Contracts.registryAddress.isZero()) { - throw new Error('L1 registry address is required to start an Archiver'); - } - - const { addresses, config: l1Config } = await getL1Config( - archiverConfig.l1Contracts.registryAddress, - archiverConfig.l1RpcUrls, - archiverConfig.l1ChainId, - ); - - archiverConfig.l1Contracts = addresses; - archiverConfig = { ...archiverConfig, ...l1Config }; - - const telemetry = await initTelemetryClient(getTelemetryClientConfig()); - const blobClient = createBlobClient(archiverConfig, { logger: createLogger('archiver:blob-client:client') }); - const archiver = await createArchiver(archiverConfig, { telemetry, blobClient }, { blockUntilSync: true }); - services.archiver = [archiver, ArchiverApiSchema]; - signalHandlers.push(archiver.stop); - - return { config: archiverConfig }; -} diff --git a/yarn-project/aztec/src/testing/epoch_test_settler.ts b/yarn-project/aztec/src/testing/epoch_test_settler.ts index c2fa06dcf024..37697b74ed29 100644 --- a/yarn-project/aztec/src/testing/epoch_test_settler.ts +++ b/yarn-project/aztec/src/testing/epoch_test_settler.ts @@ -31,8 +31,7 @@ export class EpochTestSettler { } async handleEpochReadyToProve(epoch: EpochNumber): Promise { - const checkpointedBlocks = await this.l2BlockSource.getCheckpointedBlocksForEpoch(epoch); - const blocks = checkpointedBlocks.map(b => b.block); + const blocks = await this.l2BlockSource.getBlocks({ epoch, onlyCheckpointed: true }); this.log.info( `Settling epoch ${epoch} with blocks ${blocks[0]?.header.getBlockNumber()} to ${blocks.at(-1)?.header.getBlockNumber()}`, { blocks: blocks.map(b => b.toBlockInfo()) }, @@ -58,7 +57,7 @@ export class EpochTestSettler { this.log.info(`No L2 to L1 messages in epoch ${epoch}`); } - const lastCheckpoint = checkpointedBlocks.at(-1)?.checkpointNumber; + const lastCheckpoint = blocks.at(-1)?.checkpointNumber; if (lastCheckpoint !== undefined) { await this.rollupCheatCodes.markAsProven(lastCheckpoint); } else { diff --git a/yarn-project/end-to-end/src/composed/ha/e2e_ha_full.test.ts b/yarn-project/end-to-end/src/composed/ha/e2e_ha_full.test.ts index b78b7f79bd32..71df12761fdb 100644 --- a/yarn-project/end-to-end/src/composed/ha/e2e_ha_full.test.ts +++ b/yarn-project/end-to-end/src/composed/ha/e2e_ha_full.test.ts @@ -350,8 +350,8 @@ describe('HA Full Setup', () => { } // Verify txs were included in the block (tests full signing path) - expect(block.block.body.txEffects.length).toBeGreaterThan(0); - logger.info(`Block contains ${block.block.body.txEffects.length} transaction(s)`); + expect(block.body!.txEffects.length).toBeGreaterThan(0); + logger.info(`Block contains ${block.body!.txEffects.length} transaction(s)`); // get attestations from checkpoint const [checkpoint] = await aztecNode.getCheckpoints(block.checkpointNumber, 1, { includeAttestations: true }); @@ -372,7 +372,7 @@ describe('HA Full Setup', () => { logger.info(`Verified ${attestations.length} signatures from Web3Signer`); // Query database to verify HA coordination - const slotNumber = BigInt(block.block.header.globalVariables.slotNumber); + const slotNumber = BigInt(block.header.globalVariables.slotNumber); logger.info(`Querying duties for slot ${slotNumber} (block ${receipt.blockNumber})`); const allDuties = await getValidatorDuties(mainPool, slotNumber); expect(allDuties.length).toBeGreaterThan(0); @@ -461,7 +461,7 @@ describe('HA Full Setup', () => { if (!block) { throw new Error(`Block ${receipt.blockNumber} not found`); } - const blockSlot = block.block.header.globalVariables.slotNumber; + const blockSlot = block.header.globalVariables.slotNumber; logger.info(`Block was built in slot ${blockSlot}`); // Compute round for governance voting from the block slot @@ -668,7 +668,7 @@ describe('HA Full Setup', () => { if (!block) { throw new Error(`Block ${receipt.blockNumber} not found`); } - const slotNumber = BigInt(block.block.header.globalVariables.slotNumber); + const slotNumber = BigInt(block.header.globalVariables.slotNumber); const duties = await getValidatorDuties(mainPool, slotNumber); const blockProposalDuty = duties.find(d => d.dutyType === 'BLOCK_PROPOSAL'); @@ -731,7 +731,7 @@ describe('HA Full Setup', () => { if (!block) { throw new Error(`Block ${receipt.blockNumber} not found`); } - const slotNumber = BigInt(block.block.header.globalVariables.slotNumber); + const slotNumber = BigInt(block.header.globalVariables.slotNumber); // PRIMARY CHECK: Database records show all attestation duties attempted/completed const duties = await getValidatorDuties(mainPool, slotNumber); diff --git a/yarn-project/end-to-end/src/e2e_epochs/epochs_ha_sync.test.ts b/yarn-project/end-to-end/src/e2e_epochs/epochs_ha_sync.test.ts index 58db53710795..938426711a38 100644 --- a/yarn-project/end-to-end/src/e2e_epochs/epochs_ha_sync.test.ts +++ b/yarn-project/end-to-end/src/e2e_epochs/epochs_ha_sync.test.ts @@ -191,8 +191,8 @@ describe('e2e_epochs/epochs_ha_sync', () => { expect(minProposed).toBeGreaterThan(initialCheckpointedBlock); logger.warn(`Verifying block hashes at proposed block ${minProposed}.`, { proposedNumbers }); - const headers = await Promise.all(allArchivers.map(a => a.getBlockHeader(minProposed))); - const hashes = await Promise.all(headers.map(h => h!.hash())); + const blockDatas = await Promise.all(allArchivers.map(a => a.getBlockData({ number: minProposed }))); + const hashes = await Promise.all(blockDatas.map(d => d!.header.hash())); for (let i = 1; i < hashes.length; i++) { expect(hashes[i].toString()).toBe(hashes[0].toString()); } diff --git a/yarn-project/end-to-end/src/e2e_epochs/epochs_high_tps_block_building.test.ts b/yarn-project/end-to-end/src/e2e_epochs/epochs_high_tps_block_building.test.ts index d70747193f1d..303436897d52 100644 --- a/yarn-project/end-to-end/src/e2e_epochs/epochs_high_tps_block_building.test.ts +++ b/yarn-project/end-to-end/src/e2e_epochs/epochs_high_tps_block_building.test.ts @@ -177,16 +177,19 @@ describe('e2e_epochs/epochs_high_tps_block_building', () => { let checkedFullCheckpoints = 0; for (const checkpointBlocks of checkpoints) { const first = checkpointBlocks[0]; - const slotStartTimestamp = getTimestampForSlot(first.block.slot, test.constants); - const l1OffsetInSlot = Number(first.l1.timestamp - slotStartTimestamp) / ethereumSlotDuration; + const firstSlot = first.header.globalVariables.slotNumber; + const slotStartTimestamp = getTimestampForSlot(firstSlot, test.constants); + const l1OffsetInSlot = first.l1?.published + ? Number(first.l1.timestamp - slotStartTimestamp) / ethereumSlotDuration + : undefined; logger.warn( - `Checkpoint ${first.checkpointNumber} (target slot ${first.block.slot}) mined at L1 block ${first.l1.blockNumber} ` + + `Checkpoint ${first.checkpointNumber} (target slot ${firstSlot}) mined at L1 block ${first.l1?.published ? first.l1.blockNumber : 'pending'} ` + `(offset ${l1OffsetInSlot} into L2 slot) with ${checkpointBlocks.length} blocks`, { - blocks: checkpointBlocks.map(b => ({ number: b.block.number, txs: b.block.body.txEffects.length })), + blocks: checkpointBlocks.map(b => ({ number: b.number, txs: b.body?.txEffects.length })), }, ); - if (first.block.slot < targetSlot || checkedFullCheckpoints >= CHECKPOINTS_TO_CHECK) { + if (firstSlot < targetSlot || checkedFullCheckpoints >= CHECKPOINTS_TO_CHECK) { continue; } @@ -195,7 +198,7 @@ describe('e2e_epochs/epochs_high_tps_block_building', () => { for (const block of checkpointBlocks) { // We don't test for exactly TXS_PER_BLOCK since CI delays make this flakey - const txCount = block.block.body.txEffects.length; + const txCount = block.body!.txEffects.length; expect(txCount).toBeGreaterThanOrEqual(1); expect(txCount).toBeLessThanOrEqual(TXS_PER_BLOCK); } @@ -206,7 +209,7 @@ describe('e2e_epochs/epochs_high_tps_block_building', () => { // Check that we've gone through all checkpoints, and at least one checkpoint reached // expected number of blocks, and at least one block reached the expected number of txs. expect(checkedFullCheckpoints).toBe(CHECKPOINTS_TO_CHECK); - expect(Math.max(...blocks.map(b => b.block.body.txEffects.length))).toEqual(TXS_PER_BLOCK); + expect(Math.max(...blocks.map(b => b.body!.txEffects.length))).toEqual(TXS_PER_BLOCK); expect(Math.max(...checkpoints.map(c => c.length))).toEqual(BLOCKS_PER_CHECKPOINT); // Expect no failures from sequencers during block building. Filter out the self-proposal 'Rollup contract diff --git a/yarn-project/end-to-end/src/e2e_epochs/epochs_mbps.parallel.test.ts b/yarn-project/end-to-end/src/e2e_epochs/epochs_mbps.parallel.test.ts index b0ba59e5624c..ec13d41a8ed4 100644 --- a/yarn-project/end-to-end/src/e2e_epochs/epochs_mbps.parallel.test.ts +++ b/yarn-project/end-to-end/src/e2e_epochs/epochs_mbps.parallel.test.ts @@ -442,15 +442,15 @@ describe('e2e_epochs/epochs_mbps', () => { if (tips.proposed.number <= tips.checkpointed.block.number) { return false; } - const header = await nonValidatorArchiver.getBlockHeader(tips.proposed.number); - if (!header) { + const blockData = await nonValidatorArchiver.getBlockData({ number: tips.proposed.number }); + if (!blockData) { return false; } - const blocksInSlot = await nonValidatorArchiver.getBlocksForSlot(header.globalVariables.slotNumber); + const blocksInSlot = await nonValidatorArchiver.getBlocksForSlot(blockData.header.globalVariables.slotNumber); if (blocksInSlot.length < 2) { return false; } - multiBlockSlotNumber = header.globalVariables.slotNumber; + multiBlockSlotNumber = blockData.header.globalVariables.slotNumber; checkpointedBlockNumber = tips.checkpointed.block.number; return true; }, diff --git a/yarn-project/end-to-end/src/e2e_epochs/epochs_missed_l1_publish.test.ts b/yarn-project/end-to-end/src/e2e_epochs/epochs_missed_l1_publish.test.ts new file mode 100644 index 000000000000..9ab521186a71 --- /dev/null +++ b/yarn-project/end-to-end/src/e2e_epochs/epochs_missed_l1_publish.test.ts @@ -0,0 +1,365 @@ +import type { Archiver } from '@aztec/archiver'; +import type { AztecNodeService } from '@aztec/aztec-node'; +import { EthAddress } from '@aztec/aztec.js/addresses'; +import { Fr } from '@aztec/aztec.js/fields'; +import type { Logger } from '@aztec/aztec.js/log'; +import { waitUntilL1Timestamp } from '@aztec/ethereum/l1-tx-utils'; +import { asyncMap } from '@aztec/foundation/async-map'; +import { BlockNumber, SlotNumber } from '@aztec/foundation/branded-types'; +import { times } from '@aztec/foundation/collection'; +import { SecretValue } from '@aztec/foundation/config'; +import { retryUntil } from '@aztec/foundation/retry'; +import { bufferToHex } from '@aztec/foundation/string'; +import { timeoutPromise } from '@aztec/foundation/timer'; +import { type L2Block, L2BlockSourceEvents, type L2Tips } from '@aztec/stdlib/block'; +import { getTimestampForSlot } from '@aztec/stdlib/epoch-helpers'; + +import { jest } from '@jest/globals'; +import { privateKeyToAccount } from 'viem/accounts'; + +import { getPrivateKeyFromIndex } from '../fixtures/utils.js'; +import { EpochsTestContext } from './epochs_test.js'; + +jest.setTimeout(1000 * 60 * 15); + +const NODE_COUNT = 4; + +/** + * E2E test for the "missed L1 publish" scenario under proposer pipelining. + * + * Each of 4 nodes holds exactly one validator key. We pick four consecutive slots + * (slotZero, slotOne, slotTwo, slotThree) such that the proposers for slotOne, slotTwo, and + * slotThree are three distinct validators, then warp to one L1 block before slotZero begins. + * The proposer for slotOne is configured to skip its L1 publish. + * + * With pipelining, the proposer for slot N+1 builds and gossips its checkpoint during slot N, + * then publishes that checkpoint to L1 during slot N+1. So gossip-driven `proposed` chain + * advances arrive one slot earlier than the L1-driven `checkpointed` advance. + * + * Expected behavior: + * - During slotZero, the pipelined proposer for slotOne gossips its build → every node's + * `proposed` tip advances to a block at slotOne. + * - During slotOne, the pipelined proposer for slotTwo gossips on top of the slotOne proposal → + * `proposed` advances to a block at slotTwo. Meanwhile the proposer for slotOne attempts L1 + * publish but is configured to skip it, so no checkpoint lands. + * - When slotOne ends with no checkpoint mined, every node's archiver prunes the + * uncheckpointed slotOne and slotTwo blocks; we verify rollback via the prune event. + * We then re-enable publishing on the formerly suppressed node so recovery can proceed. + * - During slotTwo, the pipelined proposer for slotThree builds on top of the (now genesis) + * checkpointed tip → `proposed` advances again. + * - During slotThree, that pipelined work is published → `checkpointed` finally advances. + */ +describe('e2e_epochs/epochs_missed_l1_publish', () => { + let logger: Logger; + let test: EpochsTestContext; + let nodes: AztecNodeService[]; + + afterEach(async () => { + jest.restoreAllMocks(); + await test?.teardown(); + }); + + it('all nodes prune and recover when proposer fails to publish to L1', async () => { + // Build 4 distinct validators (V1..V4). One key per node, no overlap. + const validators = times(NODE_COUNT, i => { + const privateKey = bufferToHex(getPrivateKeyFromIndex(i + 3)!); + const attester = EthAddress.fromString(privateKeyToAccount(privateKey).address); + return { attester, withdrawer: attester, privateKey, bn254SecretKey: new SecretValue(Fr.random().toBigInt()) }; + }); + + test = await EpochsTestContext.setup({ + numberOfAccounts: 0, + initialValidators: validators, + enableProposerPipelining: true, + inboxLag: 2, + mockGossipSubNetwork: true, + disableAnvilTestWatcher: true, + startProverNode: false, + aztecEpochDuration: 4, + aztecProofSubmissionEpochs: 1024, + enforceTimeTable: true, + ethereumSlotDuration: 6, + aztecSlotDuration: 36, + blockDurationMs: 8000, + attestationPropagationTime: 0.5, + l1PublishingTime: 2, + aztecTargetCommitteeSize: NODE_COUNT, + skipInitialSequencer: true, + }); + + logger = test.logger; + + // One node per validator. dontStartSequencer until after the warp so timing is deterministic. + nodes = await asyncMap(validators, ({ privateKey }, i) => + test.createValidatorNode([privateKey], { + dontStartSequencer: true, + coinbase: EthAddress.fromNumber(0xa + i), + buildCheckpointIfEmpty: true, + minTxsPerBlock: 0, + }), + ); + + const attesterAddresses = validators.map(v => v.attester); + logger.warn('Validator nodes created', { + validators: attesterAddresses.map((a, i) => ({ idx: i, attester: a.toString() })), + }); + + // Find slotOne (>=4 ahead) such that proposers for slotOne, slotTwo, slotThree are three + // distinct validators. The +4 margin (vs +2 in equivocation) gives the warp+sequencer-start + // path enough headroom to reach the build window for slotZero even if node creation jitters. + const { slot: currentSlot } = test.epochCache.getEpochAndSlotNow(); + const scanStart = currentSlot + 4; + const scanEnd = currentSlot + 60; + let slotOne: SlotNumber | undefined; + let proposerOne: EthAddress | undefined; + let proposerTwo: EthAddress | undefined; + let proposerThree: EthAddress | undefined; + for (let candidate = scanStart; candidate <= scanEnd; candidate++) { + const [p1, p2, p3] = await Promise.all([ + test.epochCache.getProposerAttesterAddressInSlot(SlotNumber(candidate)), + test.epochCache.getProposerAttesterAddressInSlot(SlotNumber(candidate + 1)), + test.epochCache.getProposerAttesterAddressInSlot(SlotNumber(candidate + 2)), + ]); + if (p1 && p2 && p3 && !p1.equals(p2) && !p1.equals(p3) && !p2.equals(p3)) { + slotOne = SlotNumber(candidate); + proposerOne = p1; + proposerTwo = p2; + proposerThree = p3; + break; + } + } + if (slotOne === undefined || !proposerOne || !proposerTwo || !proposerThree) { + throw new Error(`Could not find a slot in [${scanStart}, ${scanEnd}] with three distinct consecutive proposers`); + } + + const slotZero = SlotNumber(slotOne - 1); + const slotTwo = SlotNumber(slotOne + 1); + const slotThree = SlotNumber(slotOne + 2); + + const proposerOneNodeIndex = validators.findIndex(v => v.attester.equals(proposerOne!)); + if (proposerOneNodeIndex < 0) { + throw new Error(`No node holds the key for proposer ${proposerOne}`); + } + + logger.warn(`Selected target slotOne=${slotOne}`, { + slotOne, + slotZero, + slotTwo, + slotThree, + proposerOne: proposerOne.toString(), + proposerOneNodeIndex, + proposerTwo: proposerTwo.toString(), + proposerThree: proposerThree.toString(), + }); + + // Prevent the proposer for slotOne from publishing the checkpoint to L1 (build & gossip still happen). + await nodes[proposerOneNodeIndex].setConfig({ skipPublishingCheckpointsPercent: 100 }); + + // Subscribe to the prune event on every node before sequencers start, so we never miss it. + // We capture the L2 tips synchronously inside the handler — the archiver has already removed + // the pruned blocks at emit time, so this snapshot reflects the rolled-back state before any + // new pipelined block can be applied. + type PruneObservation = { slotNumber: SlotNumber; blocks: L2Block[]; tipsAtPrune: L2Tips }; + const prunePromises: Promise[] = nodes.map( + (node, idx) => + new Promise(resolve => { + const archiver = node.getBlockSource() as Archiver; + // eslint-disable-next-line @typescript-eslint/no-misused-promises + archiver.events.once(L2BlockSourceEvents.L2PruneUncheckpointed, async ev => { + const tipsAtPrune = await node.getL2Tips(); + logger.warn(`Node ${idx} pruned uncheckpointed blocks`, { + slotNumber: ev.slotNumber, + blocks: ev.blocks.map(b => ({ number: b.number, slot: b.header.globalVariables.slotNumber })), + tipsAtPrune, + }); + resolve({ slotNumber: ev.slotNumber, blocks: ev.blocks, tipsAtPrune }); + }); + }), + ); + + // Warp L1 to one L1 block before slotZero begins. Pipelining will then engage during slotZero. + const slotZeroStart = getTimestampForSlot(slotZero, test.constants); + const warpTo = slotZeroStart - BigInt(test.L1_BLOCK_TIME_IN_S); + logger.warn(`Warping L1 to timestamp ${warpTo} (one L1 block before slot ${slotZero})`); + await test.context.cheatCodes.eth.warp(Number(warpTo), { resetBlockInterval: true }); + + // Check that the chain is empty + const node = nodes[0]; + const blockNumber = await node.getBlockNumber(); + expect(blockNumber).toEqual(0); + + // Start all sequencers. + const sequencers = nodes.map(n => n.getSequencer()!); + const { failEvents } = test.watchSequencerEvents(sequencers, i => ({ validator: `V${i + 1}` })); + + // Subscribe to the proposerTwo pipelined-discard event — this is the most direct signal + // that the pipelined slotTwo work was correctly thrown away because parent slotOne did not land. + const proposerTwoNodeIndex = validators.findIndex(v => v.attester.equals(proposerTwo!)); + const pipelinedDiscardEvents: { slot: SlotNumber; checkpointNumber: number; reason: string }[] = []; + sequencers[proposerTwoNodeIndex].getSequencer().on('pipelined-checkpoint-discarded', args => { + pipelinedDiscardEvents.push({ slot: args.slot, checkpointNumber: args.checkpointNumber, reason: args.reason }); + logger.warn(`proposerTwo (node ${proposerTwoNodeIndex}) discarded pipelined work`, args); + }); + + await Promise.all(sequencers.map(s => s.start())); + logger.warn('All sequencers started'); + + const slotAdvanceTimeout = test.L2_SLOT_DURATION_IN_S * 3; + + // (1) During slotZero: the pipelined proposer for slotOne broadcasts. Every node sees a proposed block at slotOne. + logger.warn(`Waiting for proposed chain to reach slot ${slotOne} on all nodes (build during slotZero)`); + await Promise.all( + nodes.map((node, idx) => + retryUntil( + async () => { + const tips = await node.getL2Tips(); + if (tips.proposed.number === 0) { + return false; + } + const block = await node.getBlock(tips.proposed.number); + return !!block && block.header.globalVariables.slotNumber === slotOne; + }, + `node ${idx} proposed advanced to slot ${slotOne}`, + slotAdvanceTimeout, + 0.5, + ), + ), + ); + + // (2) During slotOne: the pipelined proposer for slotTwo broadcasts on top of slotOne → proposed reaches slotTwo. + logger.warn(`Waiting for proposed chain to reach slot ${slotTwo} on all nodes (build during slotOne)`); + await Promise.all( + nodes.map((node, idx) => + retryUntil( + async () => { + const tips = await node.getL2Tips(); + if (tips.proposed.number === 0) { + return false; + } + const block = await node.getBlock(tips.proposed.number); + return !!block && block.header.globalVariables.slotNumber === slotTwo; + }, + `node ${idx} proposed advanced to slot ${slotTwo}`, + slotAdvanceTimeout, + 0.5, + ), + ), + ); + + // (3) Wait until slotOne has fully ended on L1 — the archiver only prunes once slotAtNextL1Block > slotOne. + // The end-of-slotOne timestamp equals the start-of-slotTwo timestamp. + const slotOneEndTimestamp = getTimestampForSlot(slotTwo, test.constants); + logger.warn(`Waiting until L1 timestamp ${slotOneEndTimestamp} (end of slot ${slotOne})`); + await waitUntilL1Timestamp(test.l1Client, slotOneEndTimestamp, undefined, test.L2_SLOT_DURATION_IN_S * 3); + + // (4) After slotOne ends without a checkpoint, all nodes should prune. + // Verify rollback via the prune event itself: the pruned slot must equal slotOne, and the + // pruned blocks must include the broadcast blocks for slotOne (proposerOne) and slotTwo + // (pipelined proposerTwo, whose work is now invalid because parent slotOne did not land). + logger.warn('Waiting for L2PruneUncheckpointed on every node'); + const pruneTimeoutMs = test.L2_SLOT_DURATION_IN_S * 2 * 1000; + const pruneObservations = await Promise.all( + prunePromises.map((p, idx) => + Promise.race([p, timeoutPromise(pruneTimeoutMs, `Node ${idx} did not emit prune event in time`)]), + ), + ); + + logger.warn('Asserting prune event details on every node'); + for (const [idx, obs] of pruneObservations.entries()) { + expect({ idx, slotNumber: obs.slotNumber }).toEqual({ idx, slotNumber: slotOne }); + // proposerOne broadcasts during slotZero, so its block must always be in the pruned set. + // The pipelined slotTwo broadcast may or may not have arrived in time on every node, so + // we don't strictly require it here. + const prunedSlots = obs.blocks.map(b => b.header.globalVariables.slotNumber); + expect(prunedSlots).toContain(slotOne); + } + + // (5) Allow the formerly suppressed node to publish again so the chain can recover. + logger.warn(`Re-enabling checkpoint publishing on node ${proposerOneNodeIndex}`); + await nodes[proposerOneNodeIndex].setConfig({ skipPublishingCheckpointsPercent: 0 }); + + // (6) During slotTwo: the pipelined proposer for slotThree builds and broadcasts → proposed advances again. + // The chain must have rewound past slotOne and slotTwo and now build on whatever was + // checkpointed before slotZero — genesis, in this test, since no checkpoints have landed yet. + const postPruneProposedNumbers = pruneObservations.map(o => o.tipsAtPrune.proposed.number); + expect(postPruneProposedNumbers[0]).toBe(0); + + logger.warn(`Waiting for proposed chain to advance to slot ${slotThree} on all nodes (build during slotTwo)`); + await Promise.all( + nodes.map((node, idx) => + retryUntil( + async () => { + const tips = await node.getL2Tips(); + if (tips.proposed.number === 0) { + return false; + } + const block = await node.getBlock(tips.proposed.number); + return !!block && block.header.globalVariables.slotNumber >= slotThree; + }, + `node ${idx} proposed advanced to slot >= ${slotThree}`, + slotAdvanceTimeout, + 0.5, + ), + ), + ); + + // The first block in the chain after the prune must be the slotThree block — there should be + // nothing between genesis and the new pipelined work, since slotOne and slotTwo were pruned. + for (const node of nodes) { + const blocks = await node.getBlocks(BlockNumber(1), 50); + const firstSlotThreeIdx = blocks.findIndex(b => b.header.globalVariables.slotNumber === slotThree); + expect(firstSlotThreeIdx).toEqual(0); + } + + // (7) During slotThree: proposerThree publishes → checkpointed advances on every node. + logger.warn(`Waiting for checkpointed chain to reach slot >= ${slotThree} on all nodes`); + await Promise.all( + nodes.map((node, idx) => + retryUntil( + async () => { + const tips = await node.getL2Tips(); + if (tips.checkpointed.checkpoint.number === 0) { + return false; + } + const block = await node.getBlock(tips.checkpointed.block.number); + return ( + !!block && block.header.globalVariables.slotNumber >= slotThree && tips.checkpointed.block.number > 0 + ); + }, + `node ${idx} checkpointed advanced to slot >= ${slotThree}`, + slotAdvanceTimeout, + 0.5, + ), + ), + ); + + // Sanity: the only fail events we tolerate are the deliberate skip-publish on the suppressed + // node for slotOne, the pipelined-discard knock-on from proposerTwo (its parent slotOne + // never landed), and proposer-rollup-check noise that any non-proposer emits when the rollup + // contract rejects them. + const unexpectedFailEvents = failEvents.filter(e => { + if ( + e.type === 'checkpoint-publish-failed' && + e.sequencerIndex === proposerOneNodeIndex + 2 && + e.slot === slotOne + ) { + return false; + } + if ( + e.type === 'checkpoint-publish-failed' && + e.sequencerIndex === proposerTwoNodeIndex + 2 && + e.slot === slotTwo + ) { + return false; + } + if (e.type === 'proposer-rollup-check-failed') { + return false; + } + return true; + }); + if (unexpectedFailEvents.length > 0) { + logger.error('Unexpected fail events from sequencers', unexpectedFailEvents); + } + expect(unexpectedFailEvents).toEqual([]); + }); +}); 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 e77df10cb097..eb25a0beed48 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 @@ -25,7 +25,13 @@ import { EthCheatCodesWithState, RollupCheatCodes, startAnvil } from '@aztec/eth import type { Anvil } from '@aztec/ethereum/test'; import type { ExtendedViemWalletClient } from '@aztec/ethereum/types'; import { range } from '@aztec/foundation/array'; -import { BlockNumber, CheckpointNumber, EpochNumber, SlotNumber } from '@aztec/foundation/branded-types'; +import { + BlockNumber, + CheckpointNumber, + EpochNumber, + IndexWithinCheckpoint, + SlotNumber, +} from '@aztec/foundation/branded-types'; import { Buffer32 } from '@aztec/foundation/buffer'; import { times, timesParallel } from '@aztec/foundation/collection'; import { SecretValue } from '@aztec/foundation/config'; @@ -42,10 +48,12 @@ import { ProtocolContractsList, protocolContractsHash } from '@aztec/protocol-co import { LightweightCheckpointBuilder } from '@aztec/prover-client/light'; import { SequencerPublisher, SequencerPublisherMetrics } from '@aztec/sequencer-client'; import { - CheckpointedL2Block, + type BlockData, + type BlockQuery, + type BlocksQuery, + Body, type CommitteeAttestation, CommitteeAttestationsAndSigners, - GENESIS_BLOCK_HEADER_HASH, L2Block, type L2Tips, Signature, @@ -63,13 +71,9 @@ import { } from '@aztec/stdlib/p2p'; import { CheckpointHeader } from '@aztec/stdlib/rollup'; import { fr, mockProcessedTx } from '@aztec/stdlib/testing'; +import { AppendOnlyTreeSnapshot } from '@aztec/stdlib/trees'; import type { BlockHeader, CheckpointGlobalVariables, ProcessedTx } from '@aztec/stdlib/tx'; -import { - type MerkleTreeAdminDatabase, - NativeWorldStateService, - ServerWorldStateSynchronizer, - type WorldStateConfig, -} from '@aztec/world-state'; +import { NativeWorldStateService, ServerWorldStateSynchronizer, type WorldStateConfig } from '@aztec/world-state'; import { beforeEach, describe, expect, it, jest } from '@jest/globals'; import { type MockProxy, mock } from 'jest-mock-extended'; @@ -110,7 +114,7 @@ describe('L1Publisher integration', () => { let publisher: SequencerPublisher; - let builderDb: MerkleTreeAdminDatabase; + let builderDb: NativeWorldStateService; // The header of the last block let prevHeader: BlockHeader; @@ -203,25 +207,47 @@ describe('L1Publisher integration', () => { builderDb = await NativeWorldStateService.tmp(EthAddress.fromString(rollupAddress)); blocks = []; + // World-state derives block 0's hash from its initial header (which depends on prefilled state and + // genesisTimestamp), not from the static GENESIS_BLOCK_HEADER_HASH constant. The mock must report the + // same hash so L2BlockStream's reorg-search at genesis sees matching local/source hashes. + const initialHeader = builderDb.getInitialHeader(); + const initialHeaderHash = (await initialHeader.hash()).toString(); + const genesisArchiveSnapshot = new AppendOnlyTreeSnapshot( + deployL1ContractsArgs.genesisArchiveRoot ?? new Fr(GENESIS_ARCHIVE_ROOT), + 1, + ); + const genesisBlock = new L2Block( + genesisArchiveSnapshot, + initialHeader, + Body.empty(), + CheckpointNumber.ZERO, + IndexWithinCheckpoint(0), + ); + const genesisBlockData: BlockData = { + header: initialHeader, + archive: genesisArchiveSnapshot, + blockHash: await initialHeader.hash(), + checkpointNumber: CheckpointNumber.ZERO, + indexWithinCheckpoint: IndexWithinCheckpoint(0), + }; blockSource = mock({ - getBlocks(from, limit) { - return Promise.resolve(blocks.slice(from - 1, from - 1 + limit)); + getBlocks(query: BlocksQuery) { + if (!('from' in query)) { + return Promise.resolve([]); + } + return Promise.resolve(blocks.slice(query.from - 1, query.from - 1 + query.limit)); }, - // Methods needed by L2BlockStream for world state sync - getCheckpointedBlocks(from, limit) { - const slicedBlocks = blocks.slice(from - 1, from - 1 + limit); - return Promise.all( - slicedBlocks.map( - async block => - new CheckpointedL2Block( - // Test uses 1-block-per-checkpoint, so checkpoint number equals block number - CheckpointNumber.fromBlockNumber(block.number), - block, - new L1PublishedData(BigInt(block.number), BigInt(block.number), (await block.hash()).toString()), - [], - ), - ), - ); + getBlock(query: BlockQuery) { + if ('number' in query && Number(query.number) === 0) { + return Promise.resolve(genesisBlock); + } + return Promise.resolve(undefined); + }, + getBlockData(query: BlockQuery) { + if ('number' in query && Number(query.number) === 0) { + return Promise.resolve(genesisBlockData); + } + return Promise.resolve(undefined); }, async getCheckpoints(checkpointNumber, _limit) { // Test uses 1-block-per-checkpoint, so we find block by checkpoint number @@ -247,7 +273,7 @@ describe('L1Publisher integration', () => { const latestBlock = blocks.at(-1); const blockId = latestBlock ? { number: latestBlock.number, hash: (await latestBlock.hash()).toString() } - : { number: BlockNumber.ZERO, hash: GENESIS_BLOCK_HEADER_HASH.toString() }; + : { number: BlockNumber.ZERO, hash: initialHeaderHash }; // Test uses 1-block-per-checkpoint, so checkpoint number equals block number const tipId = { block: blockId, diff --git a/yarn-project/end-to-end/src/e2e_multi_validator/e2e_multi_validator_node.test.ts b/yarn-project/end-to-end/src/e2e_multi_validator/e2e_multi_validator_node.test.ts index 2e1be2bb0450..3d0481027068 100644 --- a/yarn-project/end-to-end/src/e2e_multi_validator/e2e_multi_validator_node.test.ts +++ b/yarn-project/end-to-end/src/e2e_multi_validator/e2e_multi_validator_node.test.ts @@ -12,7 +12,7 @@ import { createExtendedL1Client } from '@aztec/ethereum/client'; import { getL1ContractsConfigEnvVars } from '@aztec/ethereum/config'; import { RollupContract } from '@aztec/ethereum/contracts'; import type { DeployAztecL1ContractsReturnType } from '@aztec/ethereum/deploy-aztec-l1-contracts'; -import { EpochNumber } from '@aztec/foundation/branded-types'; +import { BlockNumber, EpochNumber } from '@aztec/foundation/branded-types'; import { SecretValue } from '@aztec/foundation/config'; import { Signature } from '@aztec/foundation/eth-signature'; import { retryUntil } from '@aztec/foundation/retry'; @@ -123,8 +123,8 @@ describe('e2e_multi_validator_node', () => { expect(tx.blockNumber).toBeDefined(); const dataStore = (aztecNode as AztecNodeService).getBlockSource() as Archiver; - const checkpointedBlock = await dataStore.getCheckpointedBlock(tx.blockNumber!); - const [publishedCheckpoint] = await dataStore.getCheckpoints(checkpointedBlock!.checkpointNumber, 1); + const blockData = await dataStore.getBlockData({ number: BlockNumber(tx.blockNumber!) }); + const [publishedCheckpoint] = await dataStore.getCheckpoints(blockData!.checkpointNumber, 1); const signatureContext = { chainId: config.l1ChainId, rollupAddress: deployL1ContractsValues.l1ContractAddresses.rollupAddress, @@ -185,8 +185,8 @@ describe('e2e_multi_validator_node', () => { expect(tx.blockNumber).toBeDefined(); const dataStore = (aztecNode as AztecNodeService).getBlockSource() as Archiver; - const checkpointedBlock = await dataStore.getCheckpointedBlock(tx.blockNumber!); - const [publishedCheckpoint] = await dataStore.getCheckpoints(checkpointedBlock!.checkpointNumber, 1); + const blockData = await dataStore.getBlockData({ number: BlockNumber(tx.blockNumber!) }); + const [publishedCheckpoint] = await dataStore.getCheckpoints(blockData!.checkpointNumber, 1); const signatureContext = { chainId: config.l1ChainId, rollupAddress: deployL1ContractsValues.l1ContractAddresses.rollupAddress, diff --git a/yarn-project/end-to-end/src/e2e_p2p/gossip_network.test.ts b/yarn-project/end-to-end/src/e2e_p2p/gossip_network.test.ts index e499925375e8..7cedb449aa54 100644 --- a/yarn-project/end-to-end/src/e2e_p2p/gossip_network.test.ts +++ b/yarn-project/end-to-end/src/e2e_p2p/gossip_network.test.ts @@ -2,6 +2,7 @@ import type { Archiver } from '@aztec/archiver'; import type { AztecNodeConfig, AztecNodeService } from '@aztec/aztec-node'; import { waitForTx } from '@aztec/aztec.js/node'; import { TxHash } from '@aztec/aztec.js/tx'; +import { BlockNumber } from '@aztec/foundation/branded-types'; import { Signature } from '@aztec/foundation/eth-signature'; import { retryUntil } from '@aztec/foundation/retry'; import type { SequencerClient } from '@aztec/sequencer-client'; @@ -186,8 +187,8 @@ describe('e2e_p2p_network', () => { const receipt = await nodes[0].getTxReceipt(txsSentViaDifferentNodes[0][0]); const blockNumber = receipt.blockNumber!; const dataStore = (nodes[0] as AztecNodeService).getBlockSource() as Archiver; - const checkpointedBlock = await dataStore.getCheckpointedBlock(blockNumber); - const [publishedCheckpoint] = await dataStore.getCheckpoints(checkpointedBlock!.checkpointNumber, 1); + const blockData = await dataStore.getBlockData({ number: BlockNumber(blockNumber) }); + const [publishedCheckpoint] = await dataStore.getCheckpoints(blockData!.checkpointNumber, 1); const signatureContext = { chainId: t.ctx.aztecNodeConfig.l1ChainId, rollupAddress: t.ctx.deployL1ContractsValues.l1ContractAddresses.rollupAddress, diff --git a/yarn-project/end-to-end/src/e2e_p2p/gossip_network_no_cheat.test.ts b/yarn-project/end-to-end/src/e2e_p2p/gossip_network_no_cheat.test.ts index cc99e6283744..5dc5504822ca 100644 --- a/yarn-project/end-to-end/src/e2e_p2p/gossip_network_no_cheat.test.ts +++ b/yarn-project/end-to-end/src/e2e_p2p/gossip_network_no_cheat.test.ts @@ -6,7 +6,7 @@ import { waitForTx } from '@aztec/aztec.js/node'; import { TxHash } from '@aztec/aztec.js/tx'; import { addL1Validator } from '@aztec/cli/l1/validators'; import { RollupContract } from '@aztec/ethereum/contracts'; -import { EpochNumber } from '@aztec/foundation/branded-types'; +import { BlockNumber, EpochNumber } from '@aztec/foundation/branded-types'; import { Signature } from '@aztec/foundation/eth-signature'; import { retryUntil } from '@aztec/foundation/retry'; import { sleep } from '@aztec/foundation/sleep'; @@ -246,8 +246,8 @@ describe('e2e_p2p_network', () => { // Gather signers from attestations downloaded from L1 const blockNumber = await nodes[0].getTxReceipt(txsSentViaDifferentNodes[0][0]).then(r => r.blockNumber!); const dataStore = (nodes[0] as AztecNodeService).getBlockSource() as Archiver; - const checkpointedBlock = await dataStore.getCheckpointedBlock(blockNumber); - const [publishedCheckpoint] = await dataStore.getCheckpoints(checkpointedBlock!.checkpointNumber, 1); + const blockData = await dataStore.getBlockData({ number: BlockNumber(blockNumber) }); + const [publishedCheckpoint] = await dataStore.getCheckpoints(blockData!.checkpointNumber, 1); const signatureContext = { chainId: t.ctx.aztecNodeConfig.l1ChainId, rollupAddress: t.ctx.deployL1ContractsValues.l1ContractAddresses.rollupAddress, diff --git a/yarn-project/end-to-end/src/e2e_p2p/preferred_gossip_network.test.ts b/yarn-project/end-to-end/src/e2e_p2p/preferred_gossip_network.test.ts index 25c96a232ea2..7e13b565cf65 100644 --- a/yarn-project/end-to-end/src/e2e_p2p/preferred_gossip_network.test.ts +++ b/yarn-project/end-to-end/src/e2e_p2p/preferred_gossip_network.test.ts @@ -2,6 +2,7 @@ import type { Archiver } from '@aztec/archiver'; import type { AztecNodeConfig, AztecNodeService } from '@aztec/aztec-node'; import { waitForTx } from '@aztec/aztec.js/node'; import { TxHash } from '@aztec/aztec.js/tx'; +import { BlockNumber } from '@aztec/foundation/branded-types'; import { Signature } from '@aztec/foundation/eth-signature'; import { retryUntil } from '@aztec/foundation/retry'; import { ENR, type P2PClient, type P2PService, type PeerId } from '@aztec/p2p'; @@ -355,8 +356,8 @@ describe('e2e_p2p_preferred_network', () => { // Gather signers from attestations downloaded from L1 const blockNumber = receipts[0].blockNumber!; const dataStore = (nodes[0] as AztecNodeService).getBlockSource() as Archiver; - const checkpointedBlock = await dataStore.getCheckpointedBlock(blockNumber); - const [publishedCheckpoint] = await dataStore.getCheckpoints(checkpointedBlock!.checkpointNumber, 1); + const blockData = await dataStore.getBlockData({ number: BlockNumber(blockNumber) }); + const [publishedCheckpoint] = await dataStore.getCheckpoints(blockData!.checkpointNumber, 1); const signatureContext = { chainId: t.ctx.aztecNodeConfig.l1ChainId, rollupAddress: t.ctx.deployL1ContractsValues.l1ContractAddresses.rollupAddress, diff --git a/yarn-project/end-to-end/src/e2e_synching.test.ts b/yarn-project/end-to-end/src/e2e_synching.test.ts index fb140b65439a..0ac2cfc8e6de 100644 --- a/yarn-project/end-to-end/src/e2e_synching.test.ts +++ b/yarn-project/end-to-end/src/e2e_synching.test.ts @@ -60,7 +60,7 @@ import { AztecAddress } from '@aztec/stdlib/aztec-address'; import { CommitteeAttestationsAndSigners, L2Block } from '@aztec/stdlib/block'; import { Checkpoint } from '@aztec/stdlib/checkpoint'; import { tryStop } from '@aztec/stdlib/interfaces/server'; -import { createWorldStateSynchronizer } from '@aztec/world-state'; +import { createWorldState, createWorldStateSynchronizer } from '@aztec/world-state'; import * as fs from 'fs'; import { type MockProxy, mock } from 'jest-mock-extended'; @@ -595,14 +595,19 @@ describe('e2e_synching', () => { opts.config!, createLogger('test:blob-client:client'), ); + const nativeWs = await createWorldState(opts.config!, opts.genesis); + const initialHeader = nativeWs.getInitialHeader(); + const initialBlockHash = await initialHeader.hash(); const archiver = await createArchiver( opts.config!, { blobClient, dateProvider: opts.dateProvider! }, { blockUntilSync: true }, + initialHeader, + initialBlockHash, ); const pendingCheckpointNumber = CheckpointNumber.fromBigInt(await rollup.read.getPendingCheckpointNumber()); - const worldState = await createWorldStateSynchronizer(opts.config!, archiver, opts.genesis); + const worldState = await createWorldStateSynchronizer(opts.config!, archiver, nativeWs); await worldState.start(); // We prune the last token and schnorr contract @@ -616,7 +621,7 @@ describe('e2e_synching', () => { await opts.cheatCodes!.eth.warp(Number(timeJumpTo), { resetBlockInterval: true }); expect(await archiver.getCheckpointNumber()).toBeGreaterThan(provenThrough); - const blockTip = (await archiver.getBlock(await archiver.getBlockNumber()))!; + const blockTip = (await archiver.getBlock({ number: await archiver.getBlockNumber() }))!; const txHash = blockTip.body.txEffects[0].txHash; const contractClassIds = await archiver.getContractClassIds(); diff --git a/yarn-project/epoch-cache/src/epoch_cache.ts b/yarn-project/epoch-cache/src/epoch_cache.ts index 24b4b0048bb3..d3967c03979c 100644 --- a/yarn-project/epoch-cache/src/epoch_cache.ts +++ b/yarn-project/epoch-cache/src/epoch_cache.ts @@ -97,7 +97,6 @@ export class EpochCache implements EpochCacheInterface { * Single map holding both resolved entries and in-flight promises. * A `Promise` value means a fetch is in progress; concurrent callers await it. */ - // eslint-disable-next-line aztec-custom/no-non-primitive-in-collections protected cache: Map> = new Map(); private allValidators: Set = new Set(); private lastValidatorRefresh = 0; diff --git a/yarn-project/foundation/eslint-rules/no-non-primitive-in-collections.js b/yarn-project/foundation/eslint-rules/no-non-primitive-in-collections.js index 25bae69abc41..eff593fa3d20 100644 --- a/yarn-project/foundation/eslint-rules/no-non-primitive-in-collections.js +++ b/yarn-project/foundation/eslint-rules/no-non-primitive-in-collections.js @@ -5,6 +5,21 @@ import * as ts from 'typescript'; * @fileoverview Rule to disallow non-primitive types in Set and Map collections */ +/** + * Branded primitive types whose underlying representation is a primitive (number/string/etc.) + * and are therefore safe to use as Set/Map keys. Used as a fallback when the TypeScript type + * checker is unavailable (AST-only mode). The type-checker path detects these structurally + * via the `Branded` intersection encoding, so this list only needs to cover the + * names — not their full definitions. + */ +const BRANDED_PRIMITIVE_TYPES = new Set([ + 'BlockNumber', + 'SlotNumber', + 'CheckpointNumber', + 'EpochNumber', + 'IndexWithinCheckpoint', +]); + /** @type {import('eslint').Rule.RuleModule} */ export default { meta: { @@ -100,9 +115,61 @@ export default { } } + // Check for branded primitive types: T & { _branding: Brand } where T is a primitive. + // Permits SlotNumber, BlockNumber, etc. while still rejecting branded class types + // such as BlockProposalHash = Branded. + if (flags & ts.TypeFlags.Intersection) { + if (tsType.isIntersection && tsType.isIntersection()) { + return isBrandedPrimitive(tsType); + } + } + return false; } + /** + * Detect a Branded intersection. The intersection must contain + * at least one primitive constituent and all non-primitive constituents must be + * the brand marker object (a single `_branding` property and nothing else). + */ + function isBrandedPrimitive(tsType) { + const components = tsType.types; + if (!components || components.length === 0) return false; + + const allowedPrimitiveFlags = + ts.TypeFlags.String | + ts.TypeFlags.Number | + ts.TypeFlags.BigInt | + ts.TypeFlags.Boolean | + ts.TypeFlags.ESSymbol | + ts.TypeFlags.Literal | + ts.TypeFlags.TemplateLiteral | + ts.TypeFlags.StringMapping | + ts.TypeFlags.Enum | + ts.TypeFlags.EnumLiteral; + + let hasPrimitive = false; + let hasBrandMarker = false; + + for (const component of components) { + const componentFlags = component.getFlags(); + if (componentFlags & allowedPrimitiveFlags) { + hasPrimitive = true; + continue; + } + if (componentFlags & ts.TypeFlags.Object) { + const props = component.getProperties ? component.getProperties() : []; + if (props.length === 1 && props[0].getName() === '_branding') { + hasBrandMarker = true; + continue; + } + } + return false; + } + + return hasPrimitive && hasBrandMarker; + } + /** * Fallback: Check if a type node represents a primitive type (AST-only) * This is only used if TypeScript type checker is not available @@ -148,6 +215,11 @@ export default { if (primitives.includes(name)) { return true; } + + // Branded primitives that wrap number/string — safe to use as keys + if (BRANDED_PRIMITIVE_TYPES.has(name)) { + return true; + } } return false; diff --git a/yarn-project/foundation/src/branded-types/block_number.test.ts b/yarn-project/foundation/src/branded-types/block_number.test.ts index 8002aac31223..4e78aa2bb8e3 100644 --- a/yarn-project/foundation/src/branded-types/block_number.test.ts +++ b/yarn-project/foundation/src/branded-types/block_number.test.ts @@ -91,6 +91,25 @@ describe('BlockNumber', () => { it('rejects non-integer values', () => { expect(() => BlockNumberSchema.parse(1.5)).toThrow(); }); + + it('rejects hex strings (must not be coerced as a number)', () => { + expect(BlockNumberSchema.safeParse('0x1234').success).toBe(false); + expect( + BlockNumberSchema.safeParse('0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff').success, + ).toBe(false); + }); + + it('rejects scientific-notation strings', () => { + expect(BlockNumberSchema.safeParse('1e5').success).toBe(false); + }); + + it('rejects values above MAX_SAFE_INTEGER', () => { + expect(BlockNumberSchema.safeParse(Number.MAX_SAFE_INTEGER + 1).success).toBe(false); + }); + + it('rejects bigints above MAX_SAFE_INTEGER', () => { + expect(BlockNumberSchema.safeParse(BigInt(Number.MAX_SAFE_INTEGER) + 1n).success).toBe(false); + }); }); describe('type safety', () => { diff --git a/yarn-project/foundation/src/branded-types/block_number.ts b/yarn-project/foundation/src/branded-types/block_number.ts index 50de2cebb9d9..9092c5d95111 100644 --- a/yarn-project/foundation/src/branded-types/block_number.ts +++ b/yarn-project/foundation/src/branded-types/block_number.ts @@ -98,9 +98,27 @@ BlockNumber.add = function (bn: BlockNumber, increment: number): BlockNumber { BlockNumber.ZERO = BlockNumber(0); function makeBlockNumberSchema(minValue: number) { + const max = Number.MAX_SAFE_INTEGER; return z - .union([z.number(), z.bigint(), z.string()]) - .pipe(z.coerce.number().int().min(minValue)) + .union([ + z.number().int().min(minValue).max(max), + z + .bigint() + .min(BigInt(minValue)) + .max(BigInt(max)) + .transform(v => Number(v)), + z + .string() + .regex(/^\d+$/, 'BlockNumber string must be a non-negative decimal integer') + .transform((v, ctx) => { + const parsed = Number(v); + if (!Number.isInteger(parsed) || parsed > max || parsed < minValue) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: `BlockNumber out of range: ${v}` }); + return z.NEVER; + } + return parsed; + }), + ]) .transform(value => BlockNumber(value)); } diff --git a/yarn-project/foundation/src/branded-types/buffer32_hash.ts b/yarn-project/foundation/src/branded-types/buffer32_hash.ts index ca4baea97ab7..3de688a55ccb 100644 --- a/yarn-project/foundation/src/branded-types/buffer32_hash.ts +++ b/yarn-project/foundation/src/branded-types/buffer32_hash.ts @@ -1,42 +1,38 @@ -import type { BaseBuffer32 } from '../buffer/buffer32.js'; -import { Buffer32 } from '../buffer/buffer32.js'; import type { Branded } from './types.js'; -/** A branded Buffer32 representing a block proposal hash, used for p2p deduplication. */ -export type BlockProposalHash = Branded; - -/** Creates a BlockProposalHash from a BaseBuffer32. */ -export function BlockProposalHash(buf: BaseBuffer32): BlockProposalHash { - return buf as BlockProposalHash; +/** + * A branded `0x`-prefixed hex string representing a checkpoint proposal payload hash. + * + * A `CheckpointProposal` and the matching `CheckpointAttestation` sign the same + * `ConsensusPayload`, so they share this hash type. Used by the p2p attestation + * pool to dedup signed payloads and detect equivocations. + */ +export type CheckpointProposalHash = Branded<`0x${string}`, 'CheckpointProposalHash'>; + +/** Brands a `0x`-prefixed hex string as a CheckpointProposalHash. */ +export function CheckpointProposalHash(s: `0x${string}`): CheckpointProposalHash { + return s as CheckpointProposalHash; } -/** Creates a BlockProposalHash from a raw Buffer. */ -BlockProposalHash.fromBuffer = function (buf: Buffer): BlockProposalHash { - return new Buffer32(buf) as unknown as BlockProposalHash; -}; - -/** A branded Buffer32 representing a checkpoint proposal hash, used for p2p deduplication. */ -export type CheckpointProposalHash = Branded; - -/** Creates a CheckpointProposalHash from a BaseBuffer32. */ -export function CheckpointProposalHash(buf: BaseBuffer32): CheckpointProposalHash { - return buf as CheckpointProposalHash; -} - -/** Creates a CheckpointProposalHash from a raw Buffer. */ +/** Constructs a CheckpointProposalHash from a raw 32-byte hash buffer. */ CheckpointProposalHash.fromBuffer = function (buf: Buffer): CheckpointProposalHash { - return new Buffer32(buf) as unknown as CheckpointProposalHash; + return `0x${buf.toString('hex')}` as CheckpointProposalHash; }; -/** A branded Buffer32 representing a checkpoint attestation hash, used for p2p deduplication. */ -export type CheckpointAttestationHash = Branded; - -/** Creates a CheckpointAttestationHash from a BaseBuffer32. */ -export function CheckpointAttestationHash(buf: BaseBuffer32): CheckpointAttestationHash { - return buf as CheckpointAttestationHash; +/** + * A branded `0x`-prefixed hex string representing a block proposal payload hash. + * + * Used by the p2p attestation pool to dedup signed payloads at a given + * `(slot, indexWithinCheckpoint)` and detect equivocations. + */ +export type BlockProposalHash = Branded<`0x${string}`, 'BlockProposalHash'>; + +/** Brands a `0x`-prefixed hex string as a BlockProposalHash. */ +export function BlockProposalHash(s: `0x${string}`): BlockProposalHash { + return s as BlockProposalHash; } -/** Creates a CheckpointAttestationHash from a raw Buffer. */ -CheckpointAttestationHash.fromBuffer = function (buf: Buffer): CheckpointAttestationHash { - return new Buffer32(buf) as unknown as CheckpointAttestationHash; +/** Constructs a BlockProposalHash from a raw 32-byte hash buffer. */ +BlockProposalHash.fromBuffer = function (buf: Buffer): BlockProposalHash { + return `0x${buf.toString('hex')}` as BlockProposalHash; }; diff --git a/yarn-project/foundation/src/branded-types/index.ts b/yarn-project/foundation/src/branded-types/index.ts index 774680285f66..ab7ecd344073 100644 --- a/yarn-project/foundation/src/branded-types/index.ts +++ b/yarn-project/foundation/src/branded-types/index.ts @@ -1,5 +1,5 @@ export { BlockNumber, BlockNumberSchema, BlockNumberPositiveSchema } from './block_number.js'; -export { BlockProposalHash, CheckpointAttestationHash, CheckpointProposalHash } from './buffer32_hash.js'; +export { BlockProposalHash, CheckpointProposalHash } from './buffer32_hash.js'; export { CheckpointNumber, CheckpointNumberSchema, CheckpointNumberPositiveSchema } from './checkpoint_number.js'; export { EpochNumber, EpochNumberSchema } from './epoch.js'; export { IndexWithinCheckpoint, IndexWithinCheckpointSchema } from './index_within_checkpoint.js'; diff --git a/yarn-project/kv-store/src/stores/l2_tips_store.test.ts b/yarn-project/kv-store/src/stores/l2_tips_store.test.ts index 8480313a4c87..6bcb995b5bfd 100644 --- a/yarn-project/kv-store/src/stores/l2_tips_store.test.ts +++ b/yarn-project/kv-store/src/stores/l2_tips_store.test.ts @@ -1,5 +1,6 @@ import type { AztecAsyncKVStore } from '@aztec/kv-store'; import { openTmpStore } from '@aztec/kv-store/lmdb-v2'; +import { GENESIS_BLOCK_HEADER_HASH } from '@aztec/stdlib/block'; import { testL2TipsStore } from '@aztec/stdlib/block/test'; import { L2TipsKVStore } from './l2_tips_store.js'; @@ -13,6 +14,6 @@ describe('L2TipsStore', () => { testL2TipsStore(async () => { kvStore = await openTmpStore('test', true); - return new L2TipsKVStore(kvStore, 'test'); + return new L2TipsKVStore(kvStore, 'test', GENESIS_BLOCK_HEADER_HASH); }); }); diff --git a/yarn-project/kv-store/src/stores/l2_tips_store.ts b/yarn-project/kv-store/src/stores/l2_tips_store.ts index 92cf726c9f99..8d34baa72736 100644 --- a/yarn-project/kv-store/src/stores/l2_tips_store.ts +++ b/yarn-project/kv-store/src/stores/l2_tips_store.ts @@ -1,5 +1,5 @@ import { BlockNumber, CheckpointNumber } from '@aztec/foundation/branded-types'; -import { type L2BlockTag, L2TipsStoreBase } from '@aztec/stdlib/block'; +import { type BlockHash, type L2BlockTag, L2TipsStoreBase } from '@aztec/stdlib/block'; import { PublishedCheckpoint } from '@aztec/stdlib/checkpoint'; import type { AztecAsyncMap } from '../interfaces/map.js'; @@ -18,8 +18,9 @@ export class L2TipsKVStore extends L2TipsStoreBase { constructor( private store: AztecAsyncKVStore, namespace: string, + initialBlockHash: BlockHash, ) { - super(); + super(initialBlockHash); this.l2TipsStore = store.openMap([namespace, 'l2_tips'].join('_')); this.l2BlockHashesStore = store.openMap([namespace, 'l2_block_hashes'].join('_')); this.l2BlockNumberToCheckpointNumberStore = store.openMap( diff --git a/yarn-project/p2p/src/client/factory.ts b/yarn-project/p2p/src/client/factory.ts index 8e702494697e..8080d68ae005 100644 --- a/yarn-project/p2p/src/client/factory.ts +++ b/yarn-project/p2p/src/client/factory.ts @@ -4,7 +4,7 @@ import { type Logger, createLogger } from '@aztec/foundation/log'; import { DateProvider } from '@aztec/foundation/timer'; import type { AztecAsyncKVStore } from '@aztec/kv-store'; import { AztecLMDBStoreV2, createStore } from '@aztec/kv-store/lmdb-v2'; -import type { L2BlockSource } from '@aztec/stdlib/block'; +import type { BlockHash, L2BlockSource } from '@aztec/stdlib/block'; import type { ChainConfig } from '@aztec/stdlib/config'; import type { ContractDataSource } from '@aztec/stdlib/contract'; import type { BlockMinFeesProvider } from '@aztec/stdlib/gas'; @@ -58,6 +58,7 @@ export async function createP2PClient( dateProvider: DateProvider = new DateProvider(), telemetry: TelemetryClient = getTelemetryClient(), deps: P2PClientDeps = {}, + initialBlockHash: BlockHash, ) { const config = await configureP2PClientAddresses({ ...inputConfig, @@ -210,6 +211,8 @@ export async function createP2PClient( config, dateProvider, telemetry, + undefined, + initialBlockHash, ); } diff --git a/yarn-project/p2p/src/client/p2p_client.test.ts b/yarn-project/p2p/src/client/p2p_client.test.ts index e9d5f7a5ab74..12b921ca5e1b 100644 --- a/yarn-project/p2p/src/client/p2p_client.test.ts +++ b/yarn-project/p2p/src/client/p2p_client.test.ts @@ -58,11 +58,26 @@ describe('P2P Client', () => { mempools = { txPool, attestationPool }; kvStore = await openTmpStore('test'); - client = createClient(); + client = await createClient(); }); - const createClient = (config: Partial = {}) => - new P2PClient(kvStore, blockSource, mempools, p2pService, txCollection, undefined, epochCache, config); + const createClient = async (config: Partial = {}) => { + const initialBlockHash = await blockSource.getInitialHeader().hash(); + return new P2PClient( + kvStore, + blockSource, + mempools, + p2pService, + txCollection, + undefined, + epochCache, + config, + undefined, + undefined, + undefined, + initialBlockHash, + ); + }; const advanceToProvenBlock = async (blockNumber: BlockNumber) => { blockSource.setProvenBlockNumber(blockNumber); @@ -166,7 +181,7 @@ describe('P2P Client', () => { const synchedBlock = await client.getSyncedLatestBlockNum(); await client.stop(); - const client2 = createClient(); + const client2 = await createClient(); await expect(client2.getSyncedLatestBlockNum()).resolves.toEqual(synchedBlock); }); @@ -212,7 +227,7 @@ describe('P2P Client', () => { describe('Chain prunes', () => { it('detects checkpoint prune when checkpoint number stays the same', async () => { - client = createClient({ txPoolDeleteTxsAfterReorg: true }); + client = await createClient({ txPoolDeleteTxsAfterReorg: true }); blockSource.setProvenBlockNumber(0); // Only checkpoint up to block 90 — blocks 91-100 are proposed but not checkpointed blockSource.setCheckpointedBlockNumber(90); @@ -230,7 +245,7 @@ describe('P2P Client', () => { }); it('detects checkpoint prune when checkpoint number increases by one', async () => { - client = createClient({ txPoolDeleteTxsAfterReorg: true }); + client = await createClient({ txPoolDeleteTxsAfterReorg: true }); blockSource.setProvenBlockNumber(0); blockSource.setCheckpointedBlockNumber(100); await client.start(); @@ -254,7 +269,7 @@ describe('P2P Client', () => { }); it('detects checkpoint prune when checkpoint number decreases by one', async () => { - client = createClient({ txPoolDeleteTxsAfterReorg: true }); + client = await createClient({ txPoolDeleteTxsAfterReorg: true }); blockSource.setProvenBlockNumber(0); // Checkpoint all 100 blocks — client stores checkpoint number 100 blockSource.setCheckpointedBlockNumber(100); @@ -272,7 +287,7 @@ describe('P2P Client', () => { }); it('detects epoch prune when checkpoint number decreases by more than 1', async () => { - client = createClient({ txPoolDeleteTxsAfterReorg: true }); + client = await createClient({ txPoolDeleteTxsAfterReorg: true }); blockSource.setProvenBlockNumber(0); // Checkpoint all 100 blocks — client stores checkpoint number 100 blockSource.setCheckpointedBlockNumber(100); diff --git a/yarn-project/p2p/src/client/p2p_client.ts b/yarn-project/p2p/src/client/p2p_client.ts index 9f99cd20de7a..914e08a6b08f 100644 --- a/yarn-project/p2p/src/client/p2p_client.ts +++ b/yarn-project/p2p/src/client/p2p_client.ts @@ -1,14 +1,20 @@ import type { EpochCacheInterface } from '@aztec/epoch-cache'; -import { BlockNumber, CheckpointNumber, SlotNumber } from '@aztec/foundation/branded-types'; +import { + BlockNumber, + CheckpointNumber, + type CheckpointProposalHash, + SlotNumber, +} from '@aztec/foundation/branded-types'; import { createLogger } from '@aztec/foundation/log'; import { RunningPromise } from '@aztec/foundation/promise'; import { DateProvider } from '@aztec/foundation/timer'; import type { AztecAsyncKVStore, AztecAsyncSingleton } from '@aztec/kv-store'; import { L2TipsKVStore } from '@aztec/kv-store/stores'; import { + type BlockData, + type BlockHash, type CheckpointId, type EthAddress, - GENESIS_BLOCK_HEADER_HASH, type L2Block, type L2BlockId, type L2BlockSource, @@ -95,6 +101,7 @@ export class P2PClient extends WithTracer implements P2P { private _dateProvider: DateProvider = new DateProvider(), private telemetry: TelemetryClient = getTelemetryClient(), private log = createLogger('p2p'), + initialBlockHash: BlockHash, ) { super(telemetry, 'P2PClient'); @@ -110,7 +117,7 @@ export class P2PClient extends WithTracer implements P2P { this.telemetry, ); - this.l2Tips = new L2TipsKVStore(store, 'p2p_client'); + this.l2Tips = new L2TipsKVStore(store, 'p2p_client', initialBlockHash); this.synchedLatestSlot = store.openSingleton('p2p_pool_last_l2_slot'); } @@ -164,7 +171,7 @@ export class P2PClient extends WithTracer implements P2P { const from = BlockNumber(oldFinalizedBlockNum + 1); const limit = event.block.number - from + 1; if (limit > 0) { - const oldBlocks = await this.l2BlockSource.getBlocks(from, limit); + const oldBlocks = await this.l2BlockSource.getBlocksData({ from, limit }); await this.handleFinalizedL2Blocks(oldBlocks); } break; @@ -377,10 +384,10 @@ export class P2PClient extends WithTracer implements P2P { public async getCheckpointAttestationsForSlot( slot: SlotNumber, - proposalId?: string, + proposalPayloadHash?: CheckpointProposalHash, ): Promise { - return await (proposalId - ? this.attestationPool.getCheckpointAttestationsForSlotAndProposal(slot, proposalId) + return await (proposalPayloadHash + ? this.attestationPool.getCheckpointAttestationsForSlotAndProposal(slot, proposalPayloadHash) : this.attestationPool.getCheckpointAttestationsForSlot(slot)); } @@ -584,13 +591,10 @@ export class P2PClient extends WithTracer implements P2P { */ public async getStatus(): Promise { const blockNumber = await this.getSyncedLatestBlockNum(); - const blockHash = - blockNumber === 0 - ? GENESIS_BLOCK_HEADER_HASH.toString() - : await this.l2BlockSource - .getBlockHeader(blockNumber) - .then(header => header?.hash()) - .then(hash => hash?.toString()); + const blockHash = await this.l2BlockSource + .getBlockData({ number: blockNumber }) + .then(data => data?.header.hash()) + .then(hash => hash?.toString()); return { state: this.currentState, @@ -660,7 +664,7 @@ export class P2PClient extends WithTracer implements P2P { * @param blocks - A list of finalized L2 blocks. * @returns Empty promise. */ - private async handleFinalizedL2Blocks(blocks: L2Block[]): Promise { + private async handleFinalizedL2Blocks(blocks: BlockData[]): Promise { if (!blocks.length) { return; } diff --git a/yarn-project/p2p/src/client/test/p2p_client.integration_batch_txs.test.ts b/yarn-project/p2p/src/client/test/p2p_client.integration_batch_txs.test.ts index f6e76dbef7c8..1858f94786ac 100644 --- a/yarn-project/p2p/src/client/test/p2p_client.integration_batch_txs.test.ts +++ b/yarn-project/p2p/src/client/test/p2p_client.integration_batch_txs.test.ts @@ -221,7 +221,7 @@ describe('p2p client integration batch txs', () => { const peerIds = clients.map(client => (client as any).p2pService.node.peerId); connectionSampler.getPeerListSortedByConnectionCountAsc.mockReturnValue(peerIds); - attestationPool.getBlockProposal.mockResolvedValue(blockProposal); + attestationPool.getBlockProposalByArchive.mockResolvedValue(blockProposal); // Client 0 is missing all transactions const missingTxHashes = txHashes; diff --git a/yarn-project/p2p/src/client/test/p2p_client.integration_block_txs.test.ts b/yarn-project/p2p/src/client/test/p2p_client.integration_block_txs.test.ts index 9f8464e4dd08..50123143334d 100644 --- a/yarn-project/p2p/src/client/test/p2p_client.integration_block_txs.test.ts +++ b/yarn-project/p2p/src/client/test/p2p_client.integration_block_txs.test.ts @@ -108,7 +108,7 @@ describe('p2p client integration block txs protocol ', () => { txs = await Promise.all(times(5, i => createMockTxWithMetadata(p2pBaseConfig, i))); txHashes = await Promise.all(txs.map(tx => tx.getTxHash())); blockProposal = await createBlockProposal(BlockNumber(blockNumber), archiveRoot, txHashes); - attestationPool.getBlockProposal.mockResolvedValue(blockProposal); + attestationPool.getBlockProposalByArchive.mockResolvedValue(blockProposal); }); afterEach(async () => { @@ -153,7 +153,7 @@ describe('p2p client integration block txs protocol ', () => { }; it('responds with NOT_FOUND when peer does not have the requested block proposal', async () => { - attestationPool.getBlockProposal.mockResolvedValue(undefined); + attestationPool.getBlockProposalByArchive.mockResolvedValue(undefined); const missing = new TxHashArray(...Array.from({ length: 4 }, () => TxHash.random())); const blockProposal = await createBlockProposal(blockNumber, Fr.random(), missing); @@ -326,7 +326,7 @@ describe('p2p client integration block txs protocol ', () => { it('responds with txs when peer does not have proposal but has txs (includeFullTxHashes=true)', async () => { // Peer doesn't have the block proposal - attestationPool.getBlockProposal.mockResolvedValue(undefined); + attestationPool.getBlockProposalByArchive.mockResolvedValue(undefined); // But peer has some of the requested txs in their pool const availableTxs = [txs[1], txs[3]]; @@ -355,7 +355,7 @@ describe('p2p client integration block txs protocol ', () => { it('responds with partial txs when peer does not have proposal (includeFullTxHashes=true)', async () => { // Peer doesn't have the block proposal - attestationPool.getBlockProposal.mockResolvedValue(undefined); + attestationPool.getBlockProposalByArchive.mockResolvedValue(undefined); // Peer has only one of the requested txs const availableTx = txs[2]; @@ -384,7 +384,7 @@ describe('p2p client integration block txs protocol ', () => { it('responds with empty txs when peer does not have proposal or txs (includeFullTxHashes=true)', async () => { // Peer doesn't have the block proposal - attestationPool.getBlockProposal.mockResolvedValue(undefined); + attestationPool.getBlockProposalByArchive.mockResolvedValue(undefined); // Peer also doesn't have any of the requested txs txPool.getTxsByHash.mockResolvedValue([]); @@ -403,7 +403,7 @@ describe('p2p client integration block txs protocol ', () => { it('still responds with NOT_FOUND when peer does not have proposal and includeFullTxHashes=false', async () => { // Peer doesn't have the block proposal - attestationPool.getBlockProposal.mockResolvedValue(undefined); + attestationPool.getBlockProposalByArchive.mockResolvedValue(undefined); // Even if peer has the txs in pool const hashToTx = new Map(txs.map((tx, i) => [txHashes[i].toString(), tx])); diff --git a/yarn-project/p2p/src/client/test/tx_proposal_collector/proposal_tx_collector_worker.ts b/yarn-project/p2p/src/client/test/tx_proposal_collector/proposal_tx_collector_worker.ts index 06cf85f91a1b..8be78d419bf5 100644 --- a/yarn-project/p2p/src/client/test/tx_proposal_collector/proposal_tx_collector_worker.ts +++ b/yarn-project/p2p/src/client/test/tx_proposal_collector/proposal_tx_collector_worker.ts @@ -125,6 +125,7 @@ async function startClient(config: P2PConfig, clientIndex: number) { new DateProvider(), telemetry as TelemetryClient, deps, + await l2BlockSource.getInitialHeader().hash(), ); await client.start(); diff --git a/yarn-project/p2p/src/mem_pools/attestation_pool/attestation_pool.ts b/yarn-project/p2p/src/mem_pools/attestation_pool/attestation_pool.ts index 69c2e02f8e55..109e472aa35f 100644 --- a/yarn-project/p2p/src/mem_pools/attestation_pool/attestation_pool.ts +++ b/yarn-project/p2p/src/mem_pools/attestation_pool/attestation_pool.ts @@ -1,5 +1,4 @@ -import { IndexWithinCheckpoint, SlotNumber } from '@aztec/foundation/branded-types'; -import { Fr } from '@aztec/foundation/curves/bn254'; +import type { BlockProposalHash, CheckpointProposalHash, SlotNumber } from '@aztec/foundation/branded-types'; import { toArray } from '@aztec/foundation/iterable'; import { createLogger } from '@aztec/foundation/log'; import type { AztecAsyncKVStore, AztecAsyncMap, AztecAsyncMultiMap } from '@aztec/kv-store'; @@ -15,14 +14,14 @@ import { PoolInstrumentation, PoolName, type PoolStatsCallback } from '../instru /** Result of trying to add an item (proposal or attestation) to the pool */ export type TryAddResult = { - /** Whether the item was added */ + /** Whether the item was added to a main store. False when the slot/position/(slot,signer) already had a stored entry, even if a new equivocation hash was tracked. */ added: boolean; - /** Whether the exact item already existed */ + /** Whether the exact signed payload (matched by payload hash) already existed in the pool. */ alreadyExists: boolean; - /** Count of items for the position. Meaning varies by method: - * - tryAddBlockProposal: proposals at (slot, indexWithinCheckpoint) - * - tryAddCheckpointProposal: proposals at slot - * - tryAddCheckpointAttestation: attestations by this signer for this slot */ + /** Number of distinct signed-payload hashes seen for the position. Meaning varies by method: + * - tryAddBlockProposal: distinct payload hashes at (slot, indexWithinCheckpoint) + * - tryAddCheckpointProposal: distinct payload hashes at slot + * - tryAddCheckpointAttestation: distinct payload hashes by this signer for this slot */ count: number; }; @@ -35,7 +34,7 @@ export const MAX_ATTESTATIONS_PER_SLOT_AND_SIGNER = 2; export type AttestationPoolApi = Pick< AttestationPool, | 'tryAddBlockProposal' - | 'getBlockProposal' + | 'getBlockProposalByArchive' | 'tryAddCheckpointProposal' | 'getCheckpointProposal' | 'addOwnCheckpointAttestations' @@ -52,31 +51,46 @@ export type AttestationPoolApi = Pick< * * Attestations and proposals observed via the p2p network are stored for requests * from the validator to produce a block, or to serve to other peers. + * + * Equivocation detection: each main store holds at most one entry per equivocation + * position (one checkpoint proposal per slot, one block proposal per (slot, position), + * one attestation per (slot, signer)). Distinct *signed payload hashes* arriving at + * the same position are tracked in the matching index multimap so the equivocation + * count reaches 2 even when archive collides on `feeAssetPriceModifier` variants. */ export class AttestationPool { private metrics: PoolInstrumentation; - // Checkpoint attestations from attestation key (slot-proposalId-signer) to serialized CheckpointAttestation - // Keys are lexicographically sortable allowing range queries by slot or by (slot, proposalId) - private checkpointAttestations: AztecAsyncMap; + // Checkpoint attestations from `${paddedSlot}-${signer}` to serialized CheckpointAttestation. + // Stores the first attestation seen per (slot, signer); subsequent distinct payload + // hashes from the same signer are tracked only in `attestationHashesPerSlotAndSigner` + // for equivocation detection. + private attestationPerSlotAndSigner: AztecAsyncMap; + + // Distinct payload hashes seen per (slot, signer) for tracking attestation equivocations. + // Key: `${paddedSlot}-${signerAddress}`, Value: CheckpointProposalHash (`0x`-prefixed hex) + private attestationHashesPerSlotAndSigner: AztecAsyncMultiMap; - // Checkpoint proposals from proposal archive to serialized CheckpointProposal - private checkpointProposals: AztecAsyncMap; + // Checkpoint proposals from slot number to serialized CheckpointProposal. + // Stores the first proposal seen per slot. + private checkpointProposalPerSlot: AztecAsyncMap; - // Checkpoint proposals indexed by slot for querying all proposals in a slot - // Key: slot number, Value: proposal archive strings - private checkpointProposalsForSlot: AztecAsyncMultiMap; + // Distinct payload hashes seen per slot. Hash collision = duplicate. + // Hash count reaching 2 = equivocation. + // Key: slot number, Value: CheckpointProposalHash (`0x`-prefixed hex) + private checkpointProposalHashesPerSlot: AztecAsyncMultiMap; - // Block proposals from proposal archive to serialized BlockProposal - private blockProposals: AztecAsyncMap; + // Block proposals from positionKey to serialized BlockProposal. + // Stores the first proposal seen per (slot, indexWithinCheckpoint). + private blockProposalPerSlotAndIndex: AztecAsyncMap; - // Block proposals indexed by slot and index-within-checkpoint for duplicate detection - // Key: (slot << 10) | indexWithinCheckpoint, Value: archive string - private blockProposalsForSlotAndIndex: AztecAsyncMultiMap; + // Distinct payload hashes seen per (slot, indexWithinCheckpoint). + // Key: slot * (1 << INDEX_BITS) + indexWithinCheckpoint, Value: BlockProposalHash (`0x`-prefixed hex) + private blockProposalHashesPerSlotAndIndex: AztecAsyncMultiMap; - // Checkpoint attestations indexed by (slot, signer) for tracking attestations per (slot, signer) for duplicate detection - // Key: `${Fr(slot).toString()}-${signerAddress}` string (padded for lexicographic ordering), Value: `proposalId` strings - private checkpointAttestationsPerSlotAndSigner: AztecAsyncMultiMap; + // Secondary index from archive root to positionKey, so that the block-txs req/resp + // handler can still resolve a stored proposal by archive root. + private blockProposalSlotAndIndexPerArchive: AztecAsyncMap; constructor( private store: AztecAsyncKVStore, @@ -84,76 +98,65 @@ export class AttestationPool { private log = createLogger('aztec:attestation_pool'), ) { // Initialize block proposal storage - this.blockProposals = store.openMap('proposals'); - this.blockProposalsForSlotAndIndex = store.openMultiMap('block_proposals_for_slot_and_index'); + this.blockProposalPerSlotAndIndex = store.openMap('proposals'); + this.blockProposalHashesPerSlotAndIndex = store.openMultiMap('block_proposals_for_slot_and_index'); + this.blockProposalSlotAndIndexPerArchive = store.openMap('block_proposals_by_archive'); // Initialize checkpoint attestations storage - this.checkpointAttestations = store.openMap('checkpoint_attestations'); - this.checkpointAttestationsPerSlotAndSigner = store.openMultiMap('checkpoint_attestations_per_slot_and_signer'); + this.attestationPerSlotAndSigner = store.openMap('checkpoint_attestations'); + this.attestationHashesPerSlotAndSigner = store.openMultiMap('checkpoint_attestations_per_slot_and_signer'); // Initialize checkpoint proposal storage - this.checkpointProposals = store.openMap('checkpoint_proposals'); - this.checkpointProposalsForSlot = store.openMultiMap('checkpoint_proposals_for_slot'); + this.checkpointProposalPerSlot = store.openMap('checkpoint_proposals'); + this.checkpointProposalHashesPerSlot = store.openMultiMap('checkpoint_proposals_for_slot'); this.metrics = new PoolInstrumentation(telemetry, PoolName.ATTESTATION_POOL, this.poolStats); } private poolStats: PoolStatsCallback = async () => { return { - itemCount: await this.checkpointAttestations.sizeAsync(), + itemCount: await this.attestationPerSlotAndSigner.sizeAsync(), }; }; /** Returns whether the pool is empty. */ public async isEmpty(): Promise { - for await (const _ of this.checkpointAttestations.entriesAsync()) { + for await (const _ of this.attestationPerSlotAndSigner.entriesAsync()) { return false; } - for await (const _ of this.blockProposals.entriesAsync()) { + for await (const _ of this.blockProposalPerSlotAndIndex.entriesAsync()) { return false; } return true; } - private getProposalKey(slot: number | bigint | Fr | string, proposalId: Fr | string | Buffer): string { - const slotStr = typeof slot === 'string' ? slot : new Fr(slot).toString(); - const proposalIdStr = - typeof proposalId === 'string' - ? proposalId - : Buffer.isBuffer(proposalId) - ? Fr.fromBuffer(proposalId).toString() - : proposalId.toString(); + /** Number of bits reserved for indexWithinCheckpoint in position keys. */ + private static readonly INDEX_BITS = 10; + /** Maximum indexWithinCheckpoint value (2^10 - 1 = 1023). */ + private static readonly MAX_INDEX = (1 << AttestationPool.INDEX_BITS) - 1; + /** Decimal digits used to left-pad slot numbers in string keys. + * 10 digits ≈ 3500 years at 36 s/slot, leaving ample headroom. */ + private static readonly SLOT_PAD_DIGITS = 10; - return `${slotStr}-${proposalIdStr}`; + /** Fixed-width decimal slot string for use in composite string keys. */ + private slotPaddedKey(slot: SlotNumber | number): string { + return slot.toString().padStart(AttestationPool.SLOT_PAD_DIGITS, '0'); } - private getAttestationKey(slot: number | bigint | Fr | string, proposalId: Fr | string, address: string): string { - return `${this.getProposalKey(slot, proposalId)}-${address}`; + /** Key for the per-(slot, signer) attestation main store and equivocation index. */ + private getSlotSignerKey(slot: SlotNumber, signerAddress: string): string { + return `${this.slotPaddedKey(slot)}-${signerAddress}`; } - /** Returns range bounds for querying all attestations for a given slot. */ + /** + * Returns range bounds for querying all attestations for a given slot. + * Fixed-width padding ensures the slot prefix sorts cleanly, so using the next + * slot's prefix as the upper bound captures exactly the current slot's entries. + */ private getAttestationKeyRangeForSlot(slot: SlotNumber): { start: string; end: string } { - const slotStr = new Fr(slot).toString(); - return { start: `${slotStr}-`, end: `${slotStr}-Z` }; // 'Z' sorts after any hex character + return { start: `${this.slotPaddedKey(slot)}-`, end: `${this.slotPaddedKey(slot + 1)}-` }; } - /** Returns range bounds for querying all attestations for a given (slot, proposalId). */ - private getAttestationKeyRangeForProposal(slot: SlotNumber, proposalId: string): { start: string; end: string } { - const proposalKey = this.getProposalKey(slot, proposalId); - return { start: `${proposalKey}-`, end: `${proposalKey}-Z` }; - } - - /** Creates a key for the per-signer-per-slot attestation index. Uses padded slot for lexicographic ordering. */ - private getSlotSignerKey(slot: SlotNumber, signerAddress: string): string { - const slotStr = new Fr(slot).toString(); - return `${slotStr}-${signerAddress}`; - } - - /** Number of bits reserved for indexWithinCheckpoint in position keys. */ - private static readonly INDEX_BITS = 10; - /** Maximum indexWithinCheckpoint value (2^10 - 1 = 1023). */ - private static readonly MAX_INDEX = (1 << AttestationPool.INDEX_BITS) - 1; - /** Creates a position key for block proposals: slot * 1024 + indexWithinCheckpoint. * Uses multiplication instead of bit-shift to avoid 32-bit signed integer overflow * (bit-shift overflows after slot ~2^21, roughly 278 days of uptime). */ @@ -166,50 +169,64 @@ export class AttestationPool { return slot * (1 << AttestationPool.INDEX_BITS) + indexWithinCheckpoint; } + /** Returns true if the multimap already contains the given value for the given key. */ + private async multimapHasValue( + map: AztecAsyncMultiMap, + key: TKey, + value: TValue, + ): Promise { + const values = await toArray(map.getValuesAsync(key)); + return values.includes(value); + } + /** * Attempts to add a block proposal to the pool. * - * This method performs validation and addition in a single call: - * - Checks if the proposal already exists (returns alreadyExists: true if so) - * - Checks if the position has reached the proposal cap (returns added: false if so) - * - Adds the proposal if validation passes + * - Detects duplicates by signed-payload hash (not archive); a re-broadcast of the + * exact same signed payload returns `alreadyExists: true`. + * - Distinct payload hashes at the same `(slot, indexWithinCheckpoint)` are tracked + * in the equivocation index. The first hash also stores the proposal bytes; later + * distinct hashes only bump `count` so libp2p can fire its duplicate callback. * * @param blockProposal - The block proposal to add * @returns Result indicating whether the proposal was added and duplicate detection info */ public async tryAddBlockProposal(blockProposal: BlockProposal): Promise { return await this.store.transactionAsync(async () => { - const proposalId = blockProposal.archive.toString(); - - // Check if already exists - const alreadyExists = await this.blockProposals.hasAsync(proposalId); - if (alreadyExists) { - const count = await this.getBlockProposalCountForPosition( - blockProposal.slotNumber, - blockProposal.indexWithinCheckpoint, - ); + const positionKey = this.getBlockPositionKey(blockProposal.slotNumber, blockProposal.indexWithinCheckpoint); + const payloadHash = blockProposal.getPayloadHash(); + + // Hash already tracked => exact same signed payload was already received. + if (await this.multimapHasValue(this.blockProposalHashesPerSlotAndIndex, positionKey, payloadHash)) { + const count = await this.blockProposalHashesPerSlotAndIndex.getValueCountAsync(positionKey); return { added: false, alreadyExists: true, count }; } - // Get current count for position and check cap, do not add if exceeded - const count = await this.getBlockProposalCountForPosition( - blockProposal.slotNumber, - blockProposal.indexWithinCheckpoint, - ); - + // Cap reached for this position (no more new payload hashes accepted). + const count = await this.blockProposalHashesPerSlotAndIndex.getValueCountAsync(positionKey); if (count >= MAX_BLOCK_PROPOSALS_PER_POSITION) { return { added: false, alreadyExists: false, count }; } - // Add the proposal - await this.addBlockProposal(blockProposal); + // Track the new payload hash for equivocation detection. + await this.blockProposalHashesPerSlotAndIndex.set(positionKey, payloadHash); + + // Only the first distinct payload at this position is stored; later equivocations + // are detected via the multimap but their payload bytes are not retained. + const alreadyHasStored = await this.blockProposalPerSlotAndIndex.hasAsync(positionKey); + if (!alreadyHasStored) { + await this.blockProposalPerSlotAndIndex.set(positionKey, blockProposal.withoutSignedTxs().toBuffer()); + await this.blockProposalSlotAndIndexPerArchive.set(blockProposal.archive.toString(), positionKey); + } this.log.debug( `Added block proposal for slot ${blockProposal.slotNumber} and index ${blockProposal.indexWithinCheckpoint}`, { - proposalId, + archive: blockProposal.archive.toString(), + payloadHash, slotNumber: blockProposal.slotNumber, indexWithinCheckpoint: blockProposal.indexWithinCheckpoint, + stored: !alreadyHasStored, }, ); @@ -217,60 +234,60 @@ export class AttestationPool { }); } - /** Gets the count of block proposals for a given position (slot, indexWithinCheckpoint). */ - private getBlockProposalCountForPosition( - slot: SlotNumber, - indexWithinCheckpoint: IndexWithinCheckpoint, - ): Promise { - const positionKey = this.getBlockPositionKey(slot, indexWithinCheckpoint); - return this.blockProposalsForSlotAndIndex.getValueCountAsync(positionKey); - } - - /** Internal method - must be called within a transaction. */ - private async addBlockProposal(blockProposal: BlockProposal): Promise { - const proposalId = blockProposal.archive.toString(); - // Strip signedTxs before storing to avoid persisting full tx data - await this.blockProposals.set(proposalId, blockProposal.withoutSignedTxs().toBuffer()); - - // Index by slot and position for duplicate detection - const positionKey = this.getBlockPositionKey(blockProposal.slotNumber, blockProposal.indexWithinCheckpoint); - await this.blockProposalsForSlotAndIndex.set(positionKey, proposalId); - } - /** - * Get block proposal by its ID. + * Get block proposal by archive root. * - * @param id - The ID of the block proposal to retrieve. The ID is proposal.payload.archive + * Resolves the archive root to its `(slot, indexWithinCheckpoint)` via a secondary + * index, then fetches the stored proposal (if any). Returns the *first* proposal + * seen at that position, even if a later equivocating payload was tracked. + * Validates that the stored proposal's archive matches the requested one before + * returning, guarding against secondary-index corruption or position-key reuse. * - * @return The block proposal if it exists, otherwise undefined. + * @param archiveRoot - The archive root to look up + * @return The block proposal if it exists and its archive matches, otherwise undefined. */ - public async getBlockProposal(id: string): Promise { - const buffer = await this.blockProposals.getAsync(id); + public async getBlockProposalByArchive(archiveRoot: string): Promise { + const positionKey = await this.blockProposalSlotAndIndexPerArchive.getAsync(archiveRoot); + if (positionKey === undefined) { + return undefined; + } + const buffer = await this.blockProposalPerSlotAndIndex.getAsync(positionKey); + if (!buffer || buffer.length === 0) { + return undefined; + } + let proposal: BlockProposal; try { - if (buffer && buffer.length > 0) { - return BlockProposal.fromBuffer(buffer); - } + proposal = BlockProposal.fromBuffer(buffer); } catch { return undefined; } - - return undefined; + const storedArchive = proposal.archive.toString(); + if (storedArchive !== archiveRoot) { + this.log.warn(`Stored block proposal archive does not match requested archive root`, { + requestedArchive: archiveRoot, + storedArchive, + positionKey, + }); + return undefined; + } + return proposal; } /** Checks if any block proposals exist for a given slot (at index 0). */ public async hasBlockProposalsForSlot(slot: SlotNumber): Promise { const positionKey = this.getBlockPositionKey(slot, 0); - const count = await this.blockProposalsForSlotAndIndex.getValueCountAsync(positionKey); + const count = await this.blockProposalHashesPerSlotAndIndex.getValueCountAsync(positionKey); return count > 0; } /** * Attempts to add a checkpoint proposal to the pool. * - * This method performs validation and addition in a single call: - * - Checks if the proposal already exists (returns alreadyExists: true if so) - * - Checks if the slot has reached the proposal cap (returns added: false if so) - * - Adds the proposal if validation passes + * - Detects duplicates by signed-payload hash (not archive); a re-broadcast of the + * exact same signed payload returns `alreadyExists: true`. + * - Distinct payload hashes at the same slot are tracked in the equivocation index. + * Only the first distinct payload's bytes are stored; later distinct hashes bump + * `count` so libp2p can fire its duplicate callback. * * Note: This method only handles the CheckpointProposalCore. If the original * CheckpointProposal contains a lastBlock, the caller should extract it via @@ -280,56 +297,52 @@ export class AttestationPool { * @returns Result indicating whether the proposal was added and duplicate detection info */ public async tryAddCheckpointProposal(proposal: CheckpointProposalCore): Promise { - const result = await this.store.transactionAsync(async () => { - const proposalId = proposal.archive.toString(); + return await this.store.transactionAsync(async () => { + const slot = proposal.slotNumber; + const payloadHash = proposal.getPayloadHash(); - // Check if already exists - const alreadyExists = await this.checkpointProposals.hasAsync(proposalId); - if (alreadyExists) { - const count = await this.checkpointProposalsForSlot.getValueCountAsync(proposal.slotNumber); + if (await this.multimapHasValue(this.checkpointProposalHashesPerSlot, slot, payloadHash)) { + const count = await this.checkpointProposalHashesPerSlot.getValueCountAsync(slot); return { added: false, alreadyExists: true, count }; } - // Get current count for slot and check cap - const count = await this.checkpointProposalsForSlot.getValueCountAsync(proposal.slotNumber); + const count = await this.checkpointProposalHashesPerSlot.getValueCountAsync(slot); if (count >= MAX_CHECKPOINT_PROPOSALS_PER_SLOT) { return { added: false, alreadyExists: false, count }; } - // Add the proposal if cap not exceeded - await this.addCheckpointProposal(proposal); + // Track the new payload hash for equivocation detection. + await this.checkpointProposalHashesPerSlot.set(slot, payloadHash); + + // Only the first distinct payload at this slot is stored; later equivocations + // are detected via the multimap but their payload bytes are not retained. + const alreadyHasStored = await this.checkpointProposalPerSlot.hasAsync(slot); + if (!alreadyHasStored) { + await this.checkpointProposalPerSlot.set(slot, proposal.toBuffer()); + } - this.log.debug(`Added checkpoint proposal for slot ${proposal.slotNumber}`, { - proposalId, - slotNumber: proposal.slotNumber, + this.log.debug(`Added checkpoint proposal for slot ${slot}`, { + archive: proposal.archive.toString(), + payloadHash, + slotNumber: slot, + stored: !alreadyHasStored, }); return { added: true, alreadyExists: false, count: count + 1 }; }); - - return result; - } - - /** Internal method - must be called within a transaction. */ - private async addCheckpointProposal(proposal: CheckpointProposalCore): Promise { - const slotKey = proposal.slotNumber; - const proposalId = proposal.archive.toString(); - - await this.checkpointProposalsForSlot.set(slotKey, proposalId); - await this.checkpointProposals.set(proposalId, proposal.toBuffer()); } /** - * Get checkpoint proposal by its ID. + * Get the (first) checkpoint proposal stored for the given slot. * * Returns a CheckpointProposalCore (without lastBlock info) since the lastBlock * is extracted and stored separately as a BlockProposal when added. * - * @param id - The ID of the checkpoint proposal to retrieve (proposal.archive) - * @return The checkpoint proposal core if it exists, otherwise undefined. + * @param slot - The slot to look up + * @return The checkpoint proposal core if one is stored, otherwise undefined. */ - public async getCheckpointProposal(id: string): Promise { - const buffer = await this.checkpointProposals.getAsync(id); + public async getCheckpointProposal(slot: SlotNumber): Promise { + const buffer = await this.checkpointProposalPerSlot.getAsync(slot); try { if (buffer && buffer.length > 0) { return CheckpointProposal.fromBuffer(buffer); @@ -343,13 +356,13 @@ export class AttestationPool { /** * Adds own checkpoint attestations to the pool. - * Skips validations on number of checkpoint attestations stored for the given slot. + * Skips per-signer cap and equivocation tracking; the caller is trusted. + * Each (slot, signer) gets a single stored attestation; later additions overwrite. */ public async addOwnCheckpointAttestations(attestations: CheckpointAttestation[]): Promise { await this.store.transactionAsync(async () => { for (const attestation of attestations) { const slotNumber = attestation.payload.header.slotNumber; - const proposalId = attestation.archive.toString(); const sender = attestation.getSender(); // Skip attestations with invalid signatures @@ -357,22 +370,30 @@ export class AttestationPool { this.log.warn(`Skipping own checkpoint attestation with invalid signature for slot ${slotNumber}`, { signature: attestation.signature.toString(), slotNumber, - proposalId, + archive: attestation.archive.toString(), }); continue; } const address = sender.toString(); - const ownKey = this.getAttestationKey(slotNumber, proposalId, address); + const ownKey = this.getSlotSignerKey(slotNumber, address); + const payloadHash = attestation.getPayloadHash(); - await this.checkpointAttestations.set(ownKey, attestation.toBuffer()); + await this.attestationPerSlotAndSigner.set(ownKey, attestation.toBuffer()); this.metrics.trackMempoolItemAdded(ownKey); + // Track our own payload hash so that an equivocating attestation from another + // peer at the same (slot, signer) is detected as a duplicate. + if (!(await this.multimapHasValue(this.attestationHashesPerSlotAndSigner, ownKey, payloadHash))) { + await this.attestationHashesPerSlotAndSigner.set(ownKey, payloadHash); + } + this.log.debug(`Added own checkpoint attestation for slot ${slotNumber} from ${address}`, { signature: attestation.signature.toString(), slotNumber, address, - proposalId, + archive: attestation.archive.toString(), + payloadHash, }); } }); @@ -381,6 +402,10 @@ export class AttestationPool { /** * Get all checkpoint attestations for a given slot. * + * Returns one attestation per (slot, signer) — the first seen for each signer. + * Later equivocating attestations from the same signer are tracked in the index + * but their bytes are not retained. + * * @param slot - The slot to query * @return CheckpointAttestations */ @@ -388,7 +413,7 @@ export class AttestationPool { const range = this.getAttestationKeyRangeForSlot(slot); const attestations: CheckpointAttestation[] = []; - for await (const [_, buf] of this.checkpointAttestations.entriesAsync(range)) { + for await (const [_, buf] of this.attestationPerSlotAndSigner.entriesAsync(range)) { attestations.push(CheckpointAttestation.fromBuffer(buf)); } @@ -396,24 +421,19 @@ export class AttestationPool { } /** - * Get checkpoint attestations for slot and given proposal. + * Get checkpoint attestations for a slot whose signed payload matches the given + * proposal payload hash. * * @param slot - The slot to query - * @param proposalId - The proposal to query - * @return CheckpointAttestations + * @param proposalPayloadHash - Hex-encoded keccak256 of the target proposal's signed payload + * @return CheckpointAttestations whose `getPayloadHash()` matches `proposalPayloadHash` */ public async getCheckpointAttestationsForSlotAndProposal( slot: SlotNumber, - proposalId: string, + proposalPayloadHash: CheckpointProposalHash, ): Promise { - const range = this.getAttestationKeyRangeForProposal(slot, proposalId); - const attestations: CheckpointAttestation[] = []; - - for await (const [_, buf] of this.checkpointAttestations.entriesAsync(range)) { - attestations.push(CheckpointAttestation.fromBuffer(buf)); - } - - return attestations; + const all = await this.getCheckpointAttestationsForSlot(slot); + return all.filter(att => att.getPayloadHash() === proposalPayloadHash); } /** @@ -427,43 +447,46 @@ export class AttestationPool { let numberOfBlockProposals = 0; await this.store.transactionAsync(async () => { - // Delete checkpoint attestations with slot < oldestSlot - // Attestation keys start with Fr(slot).toString(), so we use end bound of Fr(oldestSlot).toString() - const attestationEndKey = new Fr(oldestSlot).toString(); - for await (const key of this.checkpointAttestations.keysAsync({ end: attestationEndKey })) { - await this.checkpointAttestations.delete(key); + const oldestSlotPadded = this.slotPaddedKey(oldestSlot); + + // Delete checkpoint attestations whose key < `${oldestSlotPadded}-`. Fixed-width + // decimal padding means the slot prefix sorts strictly before any key at that slot. + for await (const key of this.attestationPerSlotAndSigner.keysAsync({ end: `${oldestSlotPadded}-` })) { + await this.attestationPerSlotAndSigner.delete(key); this.metrics.trackMempoolItemRemoved(key); numberOfAttestations++; } - // Clean up per-signer-per-slot index. Keys are formatted as `${Fr(slot).toString()}-${signerAddress}`. - // Since Fr pads to fixed-width hex, Fr(oldestSlot) is lexicographically greater than any key with - // a smaller slot (even with the signer suffix), so using it as the exclusive end bound is correct. - const slotSignerEndKey = new Fr(oldestSlot).toString(); - for await (const key of this.checkpointAttestationsPerSlotAndSigner.keysAsync({ end: slotSignerEndKey })) { - await this.checkpointAttestationsPerSlotAndSigner.delete(key); + // Clean up per-signer-per-slot index using the same end bound. + for await (const key of this.attestationHashesPerSlotAndSigner.keysAsync({ end: `${oldestSlotPadded}-` })) { + await this.attestationHashesPerSlotAndSigner.delete(key); } - // Delete checkpoint proposals for slots < oldestSlot, using checkpointProposalsForSlot as index - for await (const slot of this.checkpointProposalsForSlot.keysAsync({ end: oldestSlot })) { - const proposalIds = await toArray(this.checkpointProposalsForSlot.getValuesAsync(slot)); - for (const proposalId of proposalIds) { - await this.checkpointProposals.delete(proposalId); + // Delete checkpoint proposals for slots < oldestSlot. + for await (const slot of this.checkpointProposalHashesPerSlot.keysAsync({ end: oldestSlot })) { + await this.checkpointProposalHashesPerSlot.delete(slot); + if (await this.checkpointProposalPerSlot.hasAsync(slot)) { + await this.checkpointProposalPerSlot.delete(slot); numberOfCheckpointProposals++; } - await this.checkpointProposalsForSlot.delete(slot); } - // Delete block proposals for slots < oldestSlot, using blockProposalsForSlotAndIndex as index - // Key format: (slot << INDEX_BITS) | indexWithinCheckpoint + // Delete block proposals for slots < oldestSlot, using blockProposalHashesPerSlotAndIndex as index. + // Key format: slot * (1 << INDEX_BITS) + indexWithinCheckpoint const blockPositionEndKey = oldestSlot * (1 << AttestationPool.INDEX_BITS); - for await (const positionKey of this.blockProposalsForSlotAndIndex.keysAsync({ end: blockPositionEndKey })) { - const proposalIds = await toArray(this.blockProposalsForSlotAndIndex.getValuesAsync(positionKey)); - for (const proposalId of proposalIds) { - await this.blockProposals.delete(proposalId); + for await (const positionKey of this.blockProposalHashesPerSlotAndIndex.keysAsync({ end: blockPositionEndKey })) { + await this.blockProposalHashesPerSlotAndIndex.delete(positionKey); + const stored = await this.blockProposalPerSlotAndIndex.getAsync(positionKey); + if (stored) { + try { + const proposal = BlockProposal.fromBuffer(stored); + await this.blockProposalSlotAndIndexPerArchive.delete(proposal.archive.toString()); + } catch { + // ignore decode errors when cleaning up + } + await this.blockProposalPerSlotAndIndex.delete(positionKey); numberOfBlockProposals++; } - await this.blockProposalsForSlotAndIndex.delete(positionKey); } }); @@ -478,18 +501,19 @@ export class AttestationPool { /** * Attempts to add a checkpoint attestation to the pool. * - * This method performs validation and addition in a single call: - * - Checks if the attestation already exists (returns alreadyExists: true if so) - * - Checks if this signer has reached the per-signer attestation cap for this slot - * - Adds the attestation if validation passes + * - Detects duplicates by signed-payload hash (not archive); a re-broadcast of the + * exact same signed payload from the same signer returns `alreadyExists: true`. + * - Distinct payload hashes from the same (slot, signer) are tracked in the + * equivocation index. The first one's bytes are stored; later distinct hashes + * bump `count` so libp2p can fire its duplicate callback. * * @param attestation - The checkpoint attestation to add - * @returns Result indicating whether the attestation was added, existence info, and count of - * attestations by this signer for this slot (for equivocation detection) + * @returns Result indicating whether the attestation was added, existence info, + * and number of distinct payload hashes by this signer for this slot + * (for equivocation detection). */ public async tryAddCheckpointAttestation(attestation: CheckpointAttestation): Promise { const slotNumber = attestation.payload.header.slotNumber; - const proposalId = attestation.archive.toString(); const sender = attestation.getSender(); if (!sender) { @@ -497,28 +521,23 @@ export class AttestationPool { } const signerAddress = sender.toString(); + const slotSignerKey = this.getSlotSignerKey(slotNumber, signerAddress); + const payloadHash = attestation.getPayloadHash(); return await this.store.transactionAsync(async () => { - const key = this.getAttestationKey(slotNumber, proposalId, signerAddress); - const alreadyExists = await this.checkpointAttestations.hasAsync(key); - - // Get count of attestations by this signer for this slot (for duplicate detection) - const signerAttestationCount = await this.getSignerAttestationCountForSlot(slotNumber, signerAddress); - - if (alreadyExists) { - return { - added: false, - alreadyExists: true, - count: signerAttestationCount, - }; + if (await this.multimapHasValue(this.attestationHashesPerSlotAndSigner, slotSignerKey, payloadHash)) { + const count = await this.attestationHashesPerSlotAndSigner.getValueCountAsync(slotSignerKey); + return { added: false, alreadyExists: true, count }; } - // Check if this signer has exceeded the per-signer cap for this slot + const signerAttestationCount = await this.attestationHashesPerSlotAndSigner.getValueCountAsync(slotSignerKey); + if (signerAttestationCount >= MAX_ATTESTATIONS_PER_SLOT_AND_SIGNER) { this.log.debug(`Rejecting attestation: signer ${signerAddress} exceeded per-slot cap for slot ${slotNumber}`, { slotNumber, signerAddress, - proposalId, + archive: attestation.archive.toString(), + payloadHash, signerAttestationCount, }); return { @@ -528,22 +547,26 @@ export class AttestationPool { }; } - // Add the attestation - await this.checkpointAttestations.set(key, attestation.toBuffer()); - this.metrics.trackMempoolItemAdded(key); + // Track the new payload hash for equivocation detection. + await this.attestationHashesPerSlotAndSigner.set(slotSignerKey, payloadHash); - // Track this attestation in the per-signer-per-slot index for duplicate detection - const slotSignerKey = this.getSlotSignerKey(slotNumber, signerAddress); - await this.checkpointAttestationsPerSlotAndSigner.set(slotSignerKey, proposalId); + // Only the first distinct payload at (slot, signer) is stored; later + // equivocations are detected via the multimap but their bytes are not retained. + const alreadyHasStored = await this.attestationPerSlotAndSigner.hasAsync(slotSignerKey); + if (!alreadyHasStored) { + await this.attestationPerSlotAndSigner.set(slotSignerKey, attestation.toBuffer()); + this.metrics.trackMempoolItemAdded(slotSignerKey); + } this.log.debug(`Added checkpoint attestation for slot ${slotNumber} from ${signerAddress}`, { signature: attestation.signature.toString(), slotNumber, address: signerAddress, - proposalId, + archive: attestation.archive.toString(), + payloadHash, + stored: !alreadyHasStored, }); - // Return the new count return { added: true, alreadyExists: false, @@ -551,12 +574,6 @@ export class AttestationPool { }; }); } - - /** Gets the count of attestations by a specific signer for a given slot. */ - private async getSignerAttestationCountForSlot(slot: SlotNumber, signerAddress: string): Promise { - const slotSignerKey = this.getSlotSignerKey(slot, signerAddress); - return await this.checkpointAttestationsPerSlotAndSigner.getValueCountAsync(slotSignerKey); - } } /** Creates an AttestationPool backed by a temporary store for testing. */ diff --git a/yarn-project/p2p/src/mem_pools/attestation_pool/attestation_pool_test_suite.ts b/yarn-project/p2p/src/mem_pools/attestation_pool/attestation_pool_test_suite.ts index 02b1f1357f27..7265d2e52a42 100644 --- a/yarn-project/p2p/src/mem_pools/attestation_pool/attestation_pool_test_suite.ts +++ b/yarn-project/p2p/src/mem_pools/attestation_pool/attestation_pool_test_suite.ts @@ -28,9 +28,16 @@ export function describeAttestationPool(getAttestationPool: () => AttestationPoo signers = Array.from({ length: NUMBER_OF_SIGNERS_PER_TEST }, () => Secp256k1Signer.random()); }); + /** + * Build attestations from each signer over the *same* signed payload (same header, + * archive, feeAssetPriceModifier). Required by the new pool, which deduplicates by + * payload hash and treats attestations from different signers as distinct entries + * only when the payload itself matches. + */ const createCheckpointAttestationsForSlot = (slotNumber: number, archive?: Fr) => { const archiveToUse = archive ?? Fr.random(); - return signers.map(signer => mockCheckpointAttestation(signer, slotNumber, archiveToUse)); + const sharedHeader = CheckpointHeader.random({ slotNumber: SlotNumber(slotNumber) }); + return signers.map(signer => mockCheckpointAttestation(signer, slotNumber, archiveToUse, sharedHeader)); }; const mockBlockProposalForPool = ( @@ -59,13 +66,17 @@ export function describeAttestationPool(getAttestationPool: () => AttestationPoo it('should add attestations to pool', async () => { const slotNumber = 420; const archive = Fr.random(); - const attestations = signers.slice(0, -1).map(signer => mockCheckpointAttestation(signer, slotNumber, archive)); + const sharedHeader = CheckpointHeader.random({ slotNumber: SlotNumber(slotNumber) }); + const attestations = signers + .slice(0, -1) + .map(signer => mockCheckpointAttestation(signer, slotNumber, archive, sharedHeader)); + const payloadHash = attestations[0].getPayloadHash(); await ap.addOwnCheckpointAttestations(attestations); const retrievedAttestations = await ap.getCheckpointAttestationsForSlotAndProposal( SlotNumber(slotNumber), - archive.toString(), + payloadHash, ); expect(retrievedAttestations.length).toBe(attestations.length); compareCheckpointAttestations(retrievedAttestations, attestations); @@ -75,11 +86,16 @@ export function describeAttestationPool(getAttestationPool: () => AttestationPoo compareCheckpointAttestations(retrievedAttestationsForSlot, attestations); // Add another one - const newAttestation = mockCheckpointAttestation(signers[NUMBER_OF_SIGNERS_PER_TEST - 1], slotNumber, archive); + const newAttestation = mockCheckpointAttestation( + signers[NUMBER_OF_SIGNERS_PER_TEST - 1], + slotNumber, + archive, + sharedHeader, + ); await ap.addOwnCheckpointAttestations([newAttestation]); const retrievedAttestationsAfterAdd = await ap.getCheckpointAttestationsForSlotAndProposal( SlotNumber(slotNumber), - archive.toString(), + payloadHash, ); expect(retrievedAttestationsAfterAdd.length).toBe(attestations.length + 1); compareCheckpointAttestations(retrievedAttestationsAfterAdd, [...attestations, newAttestation]); @@ -92,7 +108,7 @@ export function describeAttestationPool(getAttestationPool: () => AttestationPoo const retreivedAttestationsAfterDelete = await ap.getCheckpointAttestationsForSlotAndProposal( SlotNumber(slotNumber), - archive.toString(), + payloadHash, ); expect(retreivedAttestationsAfterDelete.length).toBe(0); }); @@ -108,13 +124,14 @@ export function describeAttestationPool(getAttestationPool: () => AttestationPoo for (let i = 0; i < NUMBER_OF_SIGNERS_PER_TEST; i++) { attestations.push(mockCheckpointAttestation(signer, slotNumber, archive, header)); } + const payloadHash = attestations[0].getPayloadHash(); // Add them to store and check we end up with only one await ap.addOwnCheckpointAttestations(attestations); const retreivedAttestations = await ap.getCheckpointAttestationsForSlotAndProposal( SlotNumber(slotNumber), - archive.toString(), + payloadHash, ); expect(retreivedAttestations.length).toBe(1); expect(retreivedAttestations[0].toBuffer()).toEqual(attestations[0].toBuffer()); @@ -122,9 +139,7 @@ export function describeAttestationPool(getAttestationPool: () => AttestationPoo // Try adding them on another operation and check they are still not duplicated await ap.addOwnCheckpointAttestations([attestations[0]]); - expect( - await ap.getCheckpointAttestationsForSlotAndProposal(SlotNumber(slotNumber), archive.toString()), - ).toHaveLength(1); + expect(await ap.getCheckpointAttestationsForSlotAndProposal(SlotNumber(slotNumber), payloadHash)).toHaveLength(1); }); it('should store attestations by differing slot', async () => { @@ -135,9 +150,9 @@ export function describeAttestationPool(getAttestationPool: () => AttestationPoo for (const attestation of attestations) { const slot = attestation.payload.header.slotNumber; - const archive = attestation.archive.toString(); + const payloadHash = attestation.getPayloadHash(); - const retreivedAttestations = await ap.getCheckpointAttestationsForSlotAndProposal(slot, archive); + const retreivedAttestations = await ap.getCheckpointAttestationsForSlotAndProposal(slot, payloadHash); expect(retreivedAttestations.length).toBe(1); expect(retreivedAttestations[0].toBuffer()).toEqual(attestation.toBuffer()); expect(retreivedAttestations[0].payload.header.slotNumber).toEqual(slot); @@ -153,9 +168,9 @@ export function describeAttestationPool(getAttestationPool: () => AttestationPoo for (const attestation of attestations) { const slot = attestation.payload.header.slotNumber; - const proposalId = attestation.archive.toString(); + const payloadHash = attestation.getPayloadHash(); - const retreivedAttestations = await ap.getCheckpointAttestationsForSlotAndProposal(slot, proposalId); + const retreivedAttestations = await ap.getCheckpointAttestationsForSlotAndProposal(slot, payloadHash); expect(retreivedAttestations.length).toBe(1); expect(retreivedAttestations[0].toBuffer()).toEqual(attestation.toBuffer()); expect(retreivedAttestations[0].payload.header.slotNumber).toEqual(slot); @@ -164,21 +179,25 @@ export function describeAttestationPool(getAttestationPool: () => AttestationPoo it('should delete attestations older than a given slot', async () => { const slotNumbers = [1, 2, 3, 69, 72, 74, 88, 420]; - const attestations = ( - await Promise.all(slotNumbers.map(slotNumber => createCheckpointAttestationsForSlot(slotNumber))) - ).flat(); - const proposalId = attestations[0].archive.toString(); + const attestationsPerSlot = await Promise.all( + slotNumbers.map(slotNumber => createCheckpointAttestationsForSlot(slotNumber)), + ); + const attestations = attestationsPerSlot.flat(); + const payloadHashForSlot1 = attestationsPerSlot[0][0].getPayloadHash(); await ap.addOwnCheckpointAttestations(attestations); - const attestationsForSlot1 = await ap.getCheckpointAttestationsForSlotAndProposal(SlotNumber(1), proposalId); + const attestationsForSlot1 = await ap.getCheckpointAttestationsForSlotAndProposal( + SlotNumber(1), + payloadHashForSlot1, + ); expect(attestationsForSlot1.length).toBe(signers.length); await ap.deleteOlderThan(SlotNumber(73)); const attestationsForSlot1AfterDelete = await ap.getCheckpointAttestationsForSlotAndProposal( SlotNumber(1), - proposalId, + payloadHashForSlot1, ); expect(attestationsForSlot1AfterDelete.length).toBe(0); }); @@ -189,7 +208,6 @@ export function describeAttestationPool(getAttestationPool: () => AttestationPoo const slotNumber = 420; const archive = Fr.random(); const proposal = await mockBlockProposalForPool(signers[0], slotNumber, archive); - const proposalId = proposal.archive.toString(); const result = await ap.tryAddBlockProposal(proposal); @@ -197,7 +215,7 @@ export function describeAttestationPool(getAttestationPool: () => AttestationPoo expect(result.alreadyExists).toBe(false); expect(result.count).toBe(1); - const retrievedProposal = await ap.getBlockProposal(proposalId); + const retrievedProposal = await ap.getBlockProposalByArchive(proposal.archive.toString()); expect(retrievedProposal).toBeDefined(); expect(retrievedProposal!).toEqual(proposal); @@ -205,31 +223,27 @@ export function describeAttestationPool(getAttestationPool: () => AttestationPoo it('should return undefined for non-existent block proposal', async () => { const nonExistentId = Fr.random().toString(); - const retrievedProposal = await ap.getBlockProposal(nonExistentId); + const retrievedProposal = await ap.getBlockProposalByArchive(nonExistentId); expect(retrievedProposal).toBeUndefined(); }); - it('should return alreadyExists when adding proposal with same id', async () => { + it('should return alreadyExists when re-adding the same signed payload', async () => { const slotNumber = 420; const archive = Fr.random(); - const proposal1 = await mockBlockProposalForPool(signers[0], slotNumber, archive); - const proposalId = proposal1.archive.toString(); + const proposal = await mockBlockProposalForPool(signers[0], slotNumber, archive); - const result1 = await ap.tryAddBlockProposal(proposal1); + const result1 = await ap.tryAddBlockProposal(proposal); expect(result1.added).toBe(true); expect(result1.alreadyExists).toBe(false); - // Create a new proposal with same archive but different signer - const proposal2 = await mockBlockProposalForPool(signers[1], slotNumber, archive); - - const result2 = await ap.tryAddBlockProposal(proposal2); + // Re-broadcasting the exact same proposal yields alreadyExists. + const result2 = await ap.tryAddBlockProposal(proposal); expect(result2.added).toBe(false); expect(result2.alreadyExists).toBe(true); - // Should still have the first proposal - const retrievedProposal = await ap.getBlockProposal(proposalId); + const retrievedProposal = await ap.getBlockProposalByArchive(proposal.archive.toString()); expect(retrievedProposal).toBeDefined(); - expect(retrievedProposal!.toBuffer()).toEqual(proposal1.toBuffer()); + expect(retrievedProposal!.toBuffer()).toEqual(proposal.toBuffer()); expect(retrievedProposal!.getSender()?.toString()).toBe(signers[0].address.toString()); }); }); @@ -239,12 +253,13 @@ export function describeAttestationPool(getAttestationPool: () => AttestationPoo signer: Secp256k1Signer, slotNumber: number, archive: Fr = Fr.random(), + checkpointHeader?: CheckpointHeader, ): Promise => { - const checkpointHeader = makeCheckpointHeader(1, { slotNumber: SlotNumber(slotNumber) }); + const headerToUse = checkpointHeader ?? makeCheckpointHeader(1, { slotNumber: SlotNumber(slotNumber) }); const blockHeader = makeBlockHeader(1); const proposal = await makeCheckpointProposal({ signer, - checkpointHeader, + checkpointHeader: headerToUse, archiveRoot: archive, lastBlock: { blockHeader }, }); @@ -256,7 +271,6 @@ export function describeAttestationPool(getAttestationPool: () => AttestationPoo const slotNumber = 420; const archive = Fr.random(); const proposal = await mockCheckpointProposalForPool(signers[0], slotNumber, archive); - const proposalId = proposal.archive.toString(); const result = await ap.tryAddCheckpointProposal(proposal); @@ -264,7 +278,7 @@ export function describeAttestationPool(getAttestationPool: () => AttestationPoo expect(result.alreadyExists).toBe(false); expect(result.count).toBe(1); - const retrievedProposal = await ap.getCheckpointProposal(proposalId); + const retrievedProposal = await ap.getCheckpointProposal(SlotNumber(slotNumber)); expect(retrievedProposal).toBeDefined(); expect(retrievedProposal!.toBuffer()).toEqual(proposal.toBuffer()); @@ -281,54 +295,100 @@ export function describeAttestationPool(getAttestationPool: () => AttestationPoo archiveRoot: archive, // No lastBlock }); - const proposalId = proposal.archive.toString(); // Add the checkpoint core - block extraction is now caller responsibility await ap.tryAddCheckpointProposal(proposal.toCore()); // The checkpoint proposal should be stored - const retrievedCheckpointProposal = await ap.getCheckpointProposal(proposalId); + const retrievedCheckpointProposal = await ap.getCheckpointProposal(SlotNumber(slotNumber)); expect(retrievedCheckpointProposal).toBeDefined(); // No block proposal was extracted (it had none anyway) - const retrievedBlockProposal = await ap.getBlockProposal(proposalId); + const retrievedBlockProposal = await ap.getBlockProposalByArchive(proposal.archive.toString()); expect(retrievedBlockProposal).toBeUndefined(); }); it('should return undefined for non-existent checkpoint proposal', async () => { - const nonExistentId = Fr.random().toString(); - const retrievedProposal = await ap.getCheckpointProposal(nonExistentId); + const retrievedProposal = await ap.getCheckpointProposal(SlotNumber(99999)); expect(retrievedProposal).toBeUndefined(); }); - it('should return alreadyExists when adding proposal with same id', async () => { + it('should return alreadyExists when re-adding the same signed payload', async () => { const slotNumber = 420; const archive = Fr.random(); - const proposal1 = await mockCheckpointProposalForPool(signers[0], slotNumber, archive); - const proposalId = proposal1.archive.toString(); + const proposal = await mockCheckpointProposalForPool(signers[0], slotNumber, archive); - const result1 = await ap.tryAddCheckpointProposal(proposal1); + const result1 = await ap.tryAddCheckpointProposal(proposal); expect(result1.added).toBe(true); expect(result1.alreadyExists).toBe(false); - // Create a new proposal with same archive but different signer - const proposal2 = await mockCheckpointProposalForPool(signers[1], slotNumber, archive); - - const result2 = await ap.tryAddCheckpointProposal(proposal2); + // Re-broadcasting the exact same signed payload yields alreadyExists. + const result2 = await ap.tryAddCheckpointProposal(proposal); expect(result2.added).toBe(false); expect(result2.alreadyExists).toBe(true); - // Should still have the first proposal - const retrievedProposal = await ap.getCheckpointProposal(proposalId); + // Should still have the first proposal stored at the slot + const retrievedProposal = await ap.getCheckpointProposal(SlotNumber(slotNumber)); expect(retrievedProposal).toBeDefined(); - expect(retrievedProposal!.toBuffer()).toEqual(proposal1.toBuffer()); + expect(retrievedProposal!.toBuffer()).toEqual(proposal.toBuffer()); expect(retrievedProposal!.getSender()?.toString()).toBe(signers[0].address.toString()); }); + it('should treat distinct payloads at the same slot as equivocations (count = 2)', async () => { + const slotNumber = 420; + // Two proposals at the same slot but with different headers (distinct payloads). + const proposal1 = await mockCheckpointProposalForPool(signers[0], slotNumber, Fr.random()); + const proposal2 = await mockCheckpointProposalForPool(signers[0], slotNumber, Fr.random()); + + const result1 = await ap.tryAddCheckpointProposal(proposal1); + expect(result1.added).toBe(true); + expect(result1.count).toBe(1); + + const result2 = await ap.tryAddCheckpointProposal(proposal2); + // The second distinct payload is tracked as an equivocation, count goes to 2, + // but its bytes are not retained — the first proposal stays in the main store. + expect(result2.added).toBe(true); + expect(result2.alreadyExists).toBe(false); + expect(result2.count).toBe(2); + + const retrievedProposal = await ap.getCheckpointProposal(SlotNumber(slotNumber)); + expect(retrievedProposal!.toBuffer()).toEqual(proposal1.toBuffer()); + }); + + it('should detect equivocation when only feeAssetPriceModifier differs', async () => { + const slotNumber = 420; + const archive = Fr.random(); + // Same checkpoint header + archive, but two different feeAssetPriceModifier values. + // This is the audit-finding scenario: archive collides but the signed payload differs. + const sharedHeader = makeCheckpointHeader(1, { slotNumber: SlotNumber(slotNumber) }); + const proposalA = await makeCheckpointProposal({ + signer: signers[0], + checkpointHeader: sharedHeader, + archiveRoot: archive, + feeAssetPriceModifier: 50n, + }); + const proposalB = await makeCheckpointProposal({ + signer: signers[0], + checkpointHeader: sharedHeader, + archiveRoot: archive, + feeAssetPriceModifier: -50n, + }); + + const result1 = await ap.tryAddCheckpointProposal(proposalA.toCore()); + expect(result1.count).toBe(1); + + const result2 = await ap.tryAddCheckpointProposal(proposalB.toCore()); + // The fix: archive collision no longer hides the equivocation; payload-hash dedup + // sees the distinct feeMod and bumps `count` to 2 so libp2p can fire the slash callback. + expect(result2.added).toBe(true); + expect(result2.alreadyExists).toBe(false); + expect(result2.count).toBe(2); + }); + it('should return added=false when exceeding capacity', async () => { const slotNumber = 420; - // Add MAX_CHECKPOINT_PROPOSALS_PER_SLOT proposals + // Add MAX_CHECKPOINT_PROPOSALS_PER_SLOT distinct proposals. for (let i = 0; i < MAX_CHECKPOINT_PROPOSALS_PER_SLOT; i++) { const proposal = await mockCheckpointProposalForPool(signers[i % NUMBER_OF_SIGNERS_PER_TEST], slotNumber); const result = await ap.tryAddCheckpointProposal(proposal); @@ -336,7 +396,7 @@ export function describeAttestationPool(getAttestationPool: () => AttestationPoo expect(result.count).toBe(i + 1); } - // The next proposal should not be added + // The next proposal should not be added. const extraProposal = await mockCheckpointProposalForPool(signers[0], slotNumber); const result = await ap.tryAddCheckpointProposal(extraProposal); expect(result.added).toBe(false); @@ -571,17 +631,17 @@ export function describeAttestationPool(getAttestationPool: () => AttestationPoo await ap.tryAddBlockProposal(newProposal); // Verify both proposals exist - expect(await ap.getBlockProposal(oldProposal.archive.toString())).toBeDefined(); - expect(await ap.getBlockProposal(newProposal.archive.toString())).toBeDefined(); + expect(await ap.getBlockProposalByArchive(oldProposal.archive.toString())).toBeDefined(); + expect(await ap.getBlockProposalByArchive(newProposal.archive.toString())).toBeDefined(); // Delete slots older than newSlot (should delete oldSlot) await ap.deleteOlderThan(SlotNumber(newSlot)); // Old proposal should be deleted from storage - expect(await ap.getBlockProposal(oldProposal.archive.toString())).toBeUndefined(); + expect(await ap.getBlockProposalByArchive(oldProposal.archive.toString())).toBeUndefined(); // New proposal should still exist - expect(await ap.getBlockProposal(newProposal.archive.toString())).toBeDefined(); + expect(await ap.getBlockProposalByArchive(newProposal.archive.toString())).toBeDefined(); }); }); @@ -590,12 +650,13 @@ export function describeAttestationPool(getAttestationPool: () => AttestationPoo signer: Secp256k1Signer, slotNumber: number, archive: Fr = Fr.random(), + checkpointHeader?: CheckpointHeader, ): Promise => { - const checkpointHeader = makeCheckpointHeader(1, { slotNumber: SlotNumber(slotNumber) }); + const headerToUse = checkpointHeader ?? makeCheckpointHeader(1, { slotNumber: SlotNumber(slotNumber) }); const blockHeader = makeBlockHeader(1); const proposal = await makeCheckpointProposal({ signer, - checkpointHeader, + checkpointHeader: headerToUse, archiveRoot: archive, lastBlock: { blockHeader }, }); diff --git a/yarn-project/p2p/src/msg_validators/attestation_validator/fisherman_attestation_validator.test.ts b/yarn-project/p2p/src/msg_validators/attestation_validator/fisherman_attestation_validator.test.ts index dc1d39576a16..4cf3c3acab58 100644 --- a/yarn-project/p2p/src/msg_validators/attestation_validator/fisherman_attestation_validator.test.ts +++ b/yarn-project/p2p/src/msg_validators/attestation_validator/fisherman_attestation_validator.test.ts @@ -148,7 +148,7 @@ describe('FishermanAttestationValidator', () => { const result = await validator.validate(mockAttestation); expect(result).toEqual({ result: 'accept' }); - expect(attestationPool.getCheckpointProposal).toHaveBeenCalledWith(mockAttestation.archive.toString()); + expect(attestationPool.getCheckpointProposal).toHaveBeenCalledWith(mockAttestation.payload.header.slotNumber); }); it('returns low tolerance error if attestation payload does not match proposal payload', async () => { @@ -173,7 +173,7 @@ describe('FishermanAttestationValidator', () => { const result = await validator.validate(mockAttestation); expect(result).toEqual({ result: 'reject', severity: PeerErrorSeverity.LowToleranceError }); - expect(attestationPool.getCheckpointProposal).toHaveBeenCalledWith(mockAttestation.archive.toString()); + expect(attestationPool.getCheckpointProposal).toHaveBeenCalledWith(mockAttestation.payload.header.slotNumber); }); it('returns accept if proposal is not found yet (attestation arrived before proposal)', async () => { @@ -189,7 +189,7 @@ describe('FishermanAttestationValidator', () => { const result = await validator.validate(mockAttestation); expect(result).toEqual({ result: 'accept' }); - expect(attestationPool.getCheckpointProposal).toHaveBeenCalledWith(mockAttestation.archive.toString()); + expect(attestationPool.getCheckpointProposal).toHaveBeenCalledWith(mockAttestation.payload.header.slotNumber); }); it('detects payload mismatch with different archive roots', async () => { diff --git a/yarn-project/p2p/src/msg_validators/attestation_validator/fisherman_attestation_validator.ts b/yarn-project/p2p/src/msg_validators/attestation_validator/fisherman_attestation_validator.ts index 3b83d030d9ea..5639c7e45a69 100644 --- a/yarn-project/p2p/src/msg_validators/attestation_validator/fisherman_attestation_validator.ts +++ b/yarn-project/p2p/src/msg_validators/attestation_validator/fisherman_attestation_validator.ts @@ -64,8 +64,7 @@ export class FishermanAttestationValidator extends CheckpointAttestationValidato return { result: 'accept' }; } - const proposalId = message.archive.toString(); - const proposal = await this.attestationPool.getCheckpointProposal(proposalId); + const proposal = await this.attestationPool.getCheckpointProposal(message.payload.header.slotNumber); if (proposal) { // Compare the attestation payload with the proposal payload @@ -94,9 +93,7 @@ export class FishermanAttestationValidator extends CheckpointAttestationValidato } } else { // We might receive attestations before proposals in some cases - this.logger.debug( - `Received attestation for slot ${slotNumberBigInt} but proposal not found yet. ` + `Proposal ID: ${proposalId}`, - ); + this.logger.debug(`Received attestation for slot ${slotNumberBigInt} but proposal not found yet.`); } return { result: 'accept' }; diff --git a/yarn-project/p2p/src/services/libp2p/libp2p_service.test.ts b/yarn-project/p2p/src/services/libp2p/libp2p_service.test.ts index be2ce9f19994..2dab754fe0d4 100644 --- a/yarn-project/p2p/src/services/libp2p/libp2p_service.test.ts +++ b/yarn-project/p2p/src/services/libp2p/libp2p_service.test.ts @@ -9,11 +9,12 @@ import type { L2BlockSource } from '@aztec/stdlib/block'; import type { ContractDataSource } from '@aztec/stdlib/contract'; import { GasFees } from '@aztec/stdlib/gas'; import type { ClientProtocolCircuitVerifier } from '@aztec/stdlib/interfaces/server'; -import { BlockProposal, PeerErrorSeverity } from '@aztec/stdlib/p2p'; +import { BlockProposal, type CheckpointAttestation, PeerErrorSeverity } from '@aztec/stdlib/p2p'; import { TEST_COORDINATION_SIGNATURE_CONTEXT, makeBlockHeader, makeBlockProposal, + makeCheckpointAttestation, makeCheckpointHeader, makeCheckpointProposal, mockTx, @@ -327,10 +328,10 @@ describe('LibP2PService', () => { /** Sets up the mempools with a mock attestation pool that returns a proposal with given tx hashes. */ function setProposalTxHashes(svc: TestLibP2PService, txHashes: string[]): void { - // Create a partial mock of the attestation pool that only implements getBlockProposal. + // Create a partial mock of the attestation pool that only implements getBlockProposalByArchive. // The validation code only accesses `txHashes` from the returned proposal. const mockAttestationPool: MockAttestationPoolForTests = { - getBlockProposal: (_: string) => + getBlockProposalByArchive: (_: string) => Promise.resolve({ txHashes: txHashes.map(s => ({ toString: () => s })), }), @@ -491,7 +492,7 @@ describe('LibP2PService', () => { // No proposal available - mock attestationPool to return undefined const mockAttestationPool: MockAttestationPoolForTests = { - getBlockProposal: (_: string) => Promise.resolve(undefined), + getBlockProposalByArchive: (_: string) => Promise.resolve(undefined), }; service.setAttestationPool(mockAttestationPool); @@ -557,7 +558,7 @@ describe('LibP2PService', () => { expect(reportMessageValidationResultSpy).toHaveBeenCalledWith('msg-1', MOCK_PEER_ID, TopicValidatorResult.Accept); // Verify block was stored in attestation pool - const stored = await attestationPool.getBlockProposal(proposal.archive.toString()); + const stored = await attestationPool.getBlockProposalByArchive(proposal.archive.toString()); expect(stored).toBeDefined(); }); @@ -732,6 +733,46 @@ describe('LibP2PService', () => { // Verify message was rejected expect(reportMessageValidationResultSpy).toHaveBeenCalledWith('msg-1', MOCK_PEER_ID, TopicValidatorResult.Reject); }); + + // Regression for A-1013: payloads sharing (slot, position, archive) but differing on another + // signed field (e.g. inHash) used to dedup by archive only and silently drop the second one. + // The pool now dedups by signed-payload hash, so the equivocation surfaces. + it('same archive but different signed payload triggers slash callback', async () => { + const blockHeader = makeBlockHeader(1, { slotNumber: targetSlot }); + const indexWithinCheckpoint = IndexWithinCheckpoint(0); + const sharedArchive = Fr.random(); + + const proposal1 = await makeBlockProposal({ + signer, + blockHeader, + indexWithinCheckpoint, + inHash: Fr.fromString('0x1'), + archiveRoot: sharedArchive, + }); + await service.processBlockFromPeer(proposal1.toBuffer(), 'msg-1', mockPeerId); + expect(duplicateProposalCallback).not.toHaveBeenCalled(); + + const proposal2 = await makeBlockProposal({ + signer, + blockHeader, + indexWithinCheckpoint, + inHash: Fr.fromString('0x2'), + archiveRoot: sharedArchive, + }); + expect(proposal2.archive.toString()).toBe(proposal1.archive.toString()); + expect(proposal2.getPayloadHash()).not.toEqual(proposal1.getPayloadHash()); + + await service.processBlockFromPeer(proposal2.toBuffer(), 'msg-2', mockPeerId); + + expect(reportMessageValidationResultSpy).toHaveBeenCalledWith('msg-2', MOCK_PEER_ID, TopicValidatorResult.Accept); + expect(blockReceivedCallback).toHaveBeenCalledTimes(1); // only the first one + expect(duplicateProposalCallback).toHaveBeenCalledTimes(1); + expect(duplicateProposalCallback).toHaveBeenCalledWith({ + slot: targetSlot, + proposer: signer.address, + type: 'block', + }); + }); }); describe('handleGossipedCheckpointProposal', () => { @@ -796,7 +837,7 @@ describe('LibP2PService', () => { expect(reportMessageValidationResultSpy).toHaveBeenCalledWith('msg-1', MOCK_PEER_ID, TopicValidatorResult.Accept); // Verify checkpoint was stored in attestation pool - const stored = await attestationPool.getCheckpointProposal(proposal.archive.toString()); + const stored = await attestationPool.getCheckpointProposal(proposal.slotNumber); expect(stored).toBeDefined(); }); @@ -861,10 +902,12 @@ describe('LibP2PService', () => { expect(mockTxPool.protectTxs).toHaveBeenCalledTimes(1); // Verify both were stored in attestation pool - const storedCheckpoint = await attestationPool.getCheckpointProposal(proposal.archive.toString()); + const storedCheckpoint = await attestationPool.getCheckpointProposal(proposal.slotNumber); expect(storedCheckpoint).toBeDefined(); - const storedBlock = await attestationPool.getBlockProposal(proposal.getBlockProposal()!.archive.toString()); + const storedBlock = await attestationPool.getBlockProposalByArchive( + proposal.getBlockProposal()!.archive.toString(), + ); expect(storedBlock).toBeDefined(); }); @@ -921,7 +964,9 @@ describe('LibP2PService', () => { expect(receivedBlock.archive.toString()).toBe(extraProposal.getBlockProposal()!.archive.toString()); // The lastBlock is stored in the attestation pool - const storedBlock = await attestationPool.getBlockProposal(extraProposal.getBlockProposal()!.archive.toString()); + const storedBlock = await attestationPool.getBlockProposalByArchive( + extraProposal.getBlockProposal()!.archive.toString(), + ); expect(storedBlock).toBeDefined(); // Txs were marked as non-evictable since the block was processed @@ -991,6 +1036,147 @@ describe('LibP2PService', () => { expect(allNodesCheckpointReceivedCallback).toHaveBeenCalledTimes(1); expect(allNodesCheckpointReceivedCallback).toHaveBeenCalledWith(expect.any(Object), expect.anything()); }); + + // Regression for A-1013: payloads sharing (slot, archive) but differing on feeAssetPriceModifier + // used to dedup by archive only and silently drop the second one. The pool now dedups by + // signed-payload hash, so the equivocation surfaces. + it('same archive but different feeAssetPriceModifier triggers slash callback', async () => { + const sharedHeader = makeCheckpointHeader(1, { slotNumber: targetSlot }); + const sharedArchive = Fr.random(); + + const checkpoint1 = await makeCheckpointProposal({ + signer, + checkpointHeader: sharedHeader, + archiveRoot: sharedArchive, + feeAssetPriceModifier: 50n, + }); + await service.handleGossipedCheckpointProposal(checkpoint1.toBuffer(), 'msg-1', mockPeerId); + expect(duplicateProposalCallback).not.toHaveBeenCalled(); + + const checkpoint2 = await makeCheckpointProposal({ + signer, + checkpointHeader: sharedHeader, + archiveRoot: sharedArchive, + feeAssetPriceModifier: -50n, + }); + expect(checkpoint2.archive.toString()).toBe(checkpoint1.archive.toString()); + expect(checkpoint2.getPayloadHash()).not.toEqual(checkpoint1.getPayloadHash()); + + await service.handleGossipedCheckpointProposal(checkpoint2.toBuffer(), 'msg-2', mockPeerId); + + expect(reportMessageValidationResultSpy).toHaveBeenCalledWith('msg-2', MOCK_PEER_ID, TopicValidatorResult.Accept); + expect(allNodesCheckpointReceivedCallback).toHaveBeenCalledTimes(1); // only the first one + expect(validatorCheckpointReceivedCallback).toHaveBeenCalledTimes(1); + expect(duplicateProposalCallback).toHaveBeenCalledTimes(1); + expect(duplicateProposalCallback).toHaveBeenCalledWith({ + slot: targetSlot, + proposer: signer.address, + type: 'checkpoint', + }); + }); + }); + + // Regression for A-1013 + describe('validateAndStoreCheckpointAttestation', () => { + let attestationPool: AttestationPool; + let mockEpochCache: MockProxy; + let proposerSigner: Secp256k1Signer; + let duplicateAttestationCallback: jest.Mock; + + const targetSlot = SlotNumber(100); + const nextSlot = SlotNumber(101); + + beforeEach(() => { + proposerSigner = Secp256k1Signer.random(); + attestationPool = new AttestationPool(openTmpStore(true)); + const mockTxPool = mock(); + mockTxPool.protectTxs.mockResolvedValue([]); + + mockEpochCache = mock(); + mockEpochCache.getProposerAttesterAddressInSlot.mockResolvedValue(proposerSigner.address); + mockEpochCache.getTargetAndNextSlot.mockReturnValue({ targetSlot, nextSlot }); + mockEpochCache.getTargetSlot.mockReturnValue(targetSlot); + mockEpochCache.isInCommittee.mockResolvedValue(true); + + mockPeerManager = mock(); + reportMessageValidationResultSpy = jest.fn(); + + service = createTestLibP2PServiceWithPools( + mockPeerManager, + reportMessageValidationResultSpy, + attestationPool, + mockTxPool, + mockEpochCache, + ); + + duplicateAttestationCallback = jest.fn(); + service.registerDuplicateAttestationCallback(duplicateAttestationCallback); + }); + + // Regression for A-1013: attestations sharing (slot, signer, archive) but differing on + // feeAssetPriceModifier used to dedup by archive only. The pool now dedups by signed-payload + // hash, so the equivocation surfaces. + it('same signer + same archive + different feeAssetPriceModifier triggers slash callback', async () => { + const attesterSigner = Secp256k1Signer.random(); + const sharedHeader = makeCheckpointHeader(1, { slotNumber: targetSlot }); + const sharedArchive = Fr.random(); + + const attestation1 = makeCheckpointAttestation({ + header: sharedHeader, + archive: sharedArchive, + feeAssetPriceModifier: 50n, + attesterSigner, + proposerSigner, + }); + await service.validateAndStoreCheckpointAttestation(mockPeerId, attestation1); + expect(duplicateAttestationCallback).not.toHaveBeenCalled(); + + const attestation2 = makeCheckpointAttestation({ + header: sharedHeader, + archive: sharedArchive, + feeAssetPriceModifier: -50n, + attesterSigner, + proposerSigner, + }); + expect(attestation2.archive.toString()).toBe(attestation1.archive.toString()); + expect(attestation2.getPayloadHash()).not.toEqual(attestation1.getPayloadHash()); + + await service.validateAndStoreCheckpointAttestation(mockPeerId, attestation2); + + expect(duplicateAttestationCallback).toHaveBeenCalledTimes(1); + expect(duplicateAttestationCallback).toHaveBeenCalledWith({ + slot: targetSlot, + attester: attesterSigner.address, + }); + }); + + it('different signers are not equivocations and do not trigger slash callback', async () => { + const attesterA = Secp256k1Signer.random(); + const attesterB = Secp256k1Signer.random(); + const sharedHeader = makeCheckpointHeader(1, { slotNumber: targetSlot }); + const sharedArchive = Fr.random(); + + const attestationA = makeCheckpointAttestation({ + header: sharedHeader, + archive: sharedArchive, + feeAssetPriceModifier: 50n, + attesterSigner: attesterA, + proposerSigner, + }); + await service.validateAndStoreCheckpointAttestation(mockPeerId, attestationA); + + const attestationB = makeCheckpointAttestation({ + header: sharedHeader, + archive: sharedArchive, + feeAssetPriceModifier: -50n, + attesterSigner: attesterB, + proposerSigner, + }); + await service.validateAndStoreCheckpointAttestation(mockPeerId, attestationB); + + // Two distinct signers are not an equivocation; the pool tracks per-(slot, signer). + expect(duplicateAttestationCallback).not.toHaveBeenCalled(); + }); }); }); @@ -1000,11 +1186,11 @@ interface MockTx { } /** - * Minimal attestation pool interface for tests that only need getBlockProposal. + * Minimal attestation pool interface for tests that only need getBlockProposalByArchive. * This allows creating partial mocks without implementing the full AttestationPool interface. */ interface MockAttestationPoolForTests { - getBlockProposal(id: string): Promise<{ txHashes: { toString(): string }[] } | undefined>; + getBlockProposalByArchive(id: string): Promise<{ txHashes: { toString(): string }[] } | undefined>; } /** Options for creating a test LibP2PService instance. */ @@ -1150,6 +1336,11 @@ class TestLibP2PService extends LibP2PService { return super.handleGossipedCheckpointProposal(payloadData, msgId, source); } + /** Exposes the protected validateAndStoreCheckpointAttestation for testing. */ + public override validateAndStoreCheckpointAttestation(peerId: PeerId, attestation: CheckpointAttestation) { + return super.validateAndStoreCheckpointAttestation(peerId, attestation); + } + /** Override to use the mock. */ protected override async validateRequestedTx( tx: Tx, diff --git a/yarn-project/p2p/src/services/libp2p/libp2p_service.ts b/yarn-project/p2p/src/services/libp2p/libp2p_service.ts index 79b5e9d69578..980d632a6d28 100644 --- a/yarn-project/p2p/src/services/libp2p/libp2p_service.ts +++ b/yarn-project/p2p/src/services/libp2p/libp2p_service.ts @@ -1535,7 +1535,7 @@ export class LibP2PService extends WithTracer implements P2PService { } // Given proposal (should have locally), ensure returned txs are valid subset and match request indices - const proposal = await this.mempools.attestationPool.getBlockProposal(request.archiveRoot.toString()); + const proposal = await this.mempools.attestationPool.getBlockProposalByArchive(request.archiveRoot.toString()); if (proposal) { // Build intersected indices const intersectIdx = request.txIndices.getTrueIndices().filter(i => response.txIndices.isSet(i)); diff --git a/yarn-project/p2p/src/services/reqresp/protocols/block_txs/block_txs_handler.test.ts b/yarn-project/p2p/src/services/reqresp/protocols/block_txs/block_txs_handler.test.ts index b43fbd537613..b6cea69ac2c2 100644 --- a/yarn-project/p2p/src/services/reqresp/protocols/block_txs/block_txs_handler.test.ts +++ b/yarn-project/p2p/src/services/reqresp/protocols/block_txs/block_txs_handler.test.ts @@ -45,8 +45,8 @@ describe('reqRespBlockTxsHandler', () => { txPool = mock(); peerId = mock(); - attestationPool.getBlockProposal.mockResolvedValue(undefined); - archiver.getL2BlockByArchive.mockResolvedValue(undefined); + attestationPool.getBlockProposalByArchive.mockResolvedValue(undefined); + archiver.getBlock.mockResolvedValue(undefined); txPool.getTxsByHash.mockResolvedValue([]); txPool.hasTxs.mockResolvedValue([]); }); @@ -91,7 +91,7 @@ describe('reqRespBlockTxsHandler', () => { const proposal = await createBlockProposal(txHashes); const txs = txHashes.map(h => makeTx(h)); - attestationPool.getBlockProposal.mockResolvedValue(proposal); + attestationPool.getBlockProposalByArchive.mockResolvedValue(proposal); txPool.hasTxs.mockResolvedValue([true, true, true]); txPool.getTxsByHash.mockResolvedValue(txs); @@ -107,7 +107,7 @@ describe('reqRespBlockTxsHandler', () => { const txHashes = [TxHash.random(), TxHash.random(), TxHash.random()]; const proposal = await createBlockProposal(txHashes); - attestationPool.getBlockProposal.mockResolvedValue(proposal); + attestationPool.getBlockProposalByArchive.mockResolvedValue(proposal); txPool.hasTxs.mockResolvedValue([true, false, true]); txPool.getTxsByHash.mockResolvedValue([makeTx(txHashes[0]), undefined, makeTx(txHashes[2])]); @@ -122,7 +122,7 @@ describe('reqRespBlockTxsHandler', () => { const txHashes = [TxHash.random(), TxHash.random()]; const proposal = await createBlockProposal(txHashes); - attestationPool.getBlockProposal.mockResolvedValue(proposal); + attestationPool.getBlockProposalByArchive.mockResolvedValue(proposal); txPool.hasTxs.mockResolvedValue([true, false]); txPool.getTxsByHash.mockResolvedValue([makeTx(txHashes[0]), undefined]); @@ -139,7 +139,7 @@ describe('reqRespBlockTxsHandler', () => { const txHashes = block.body.txEffects.map(e => e.txHash); const txs = txHashes.map(h => makeTx(h)); - archiver.getL2BlockByArchive.mockResolvedValue(block); + archiver.getBlock.mockResolvedValue(block); txPool.hasTxs.mockResolvedValue([true, true, true]); txPool.getTxsByHash.mockResolvedValue(txs); @@ -150,15 +150,15 @@ describe('reqRespBlockTxsHandler', () => { expect(response.txs.length).toBe(3); expect(response.txIndices.getTrueIndices()).toEqual([0, 1, 2]); - expect(attestationPool.getBlockProposal).toHaveBeenCalledWith(block.archive.root.toString()); - expect(archiver.getL2BlockByArchive).toHaveBeenCalledWith(block.archive.root); + expect(attestationPool.getBlockProposalByArchive).toHaveBeenCalledWith(block.archive.root.toString()); + expect(archiver.getBlock).toHaveBeenCalledWith({ archive: block.archive.root }); }); it('returns partial availability when some txs missing from pool', async () => { const block = await L2Block.random(BlockNumber(5), { txsPerBlock: 3 }); const txHashes = block.body.txEffects.map(e => e.txHash); - archiver.getL2BlockByArchive.mockResolvedValue(block); + archiver.getBlock.mockResolvedValue(block); txPool.hasTxs.mockResolvedValue([true, false, true]); txPool.getTxsByHash.mockResolvedValue([makeTx(txHashes[0]), undefined, makeTx(txHashes[2])]); @@ -168,8 +168,8 @@ describe('reqRespBlockTxsHandler', () => { expect(response.txs.length).toBe(2); expect(response.txIndices.getTrueIndices()).toEqual([0, 2]); - expect(attestationPool.getBlockProposal).toHaveBeenCalledWith(block.archive.root.toString()); - expect(archiver.getL2BlockByArchive).toHaveBeenCalledWith(block.archive.root); + expect(attestationPool.getBlockProposalByArchive).toHaveBeenCalledWith(block.archive.root.toString()); + expect(archiver.getBlock).toHaveBeenCalledWith({ archive: block.archive.root }); }); it('does not query archiver if attestation pool has the block', async () => { @@ -177,15 +177,15 @@ describe('reqRespBlockTxsHandler', () => { const proposal = await createBlockProposal(txHashes); const txs = txHashes.map(h => makeTx(h)); - attestationPool.getBlockProposal.mockResolvedValue(proposal); + attestationPool.getBlockProposalByArchive.mockResolvedValue(proposal); txPool.hasTxs.mockResolvedValue([true, true]); txPool.getTxsByHash.mockResolvedValue(txs); const request = new BlockTxsRequest(proposal.archive, new TxHashArray(), BitVector.init(2, [0, 1])); await callHandler(request); - expect(attestationPool.getBlockProposal).toHaveBeenCalledWith(proposal.archive.toString()); - expect(archiver.getL2BlockByArchive).not.toHaveBeenCalled(); + expect(attestationPool.getBlockProposalByArchive).toHaveBeenCalledWith(proposal.archive.toString()); + expect(archiver.getBlock).not.toHaveBeenCalled(); }); }); }); diff --git a/yarn-project/p2p/src/services/reqresp/protocols/block_txs/block_txs_handler.ts b/yarn-project/p2p/src/services/reqresp/protocols/block_txs/block_txs_handler.ts index 2ac1612547f9..19cb7f29a478 100644 --- a/yarn-project/p2p/src/services/reqresp/protocols/block_txs/block_txs_handler.ts +++ b/yarn-project/p2p/src/services/reqresp/protocols/block_txs/block_txs_handler.ts @@ -37,9 +37,11 @@ export function reqRespBlockTxsHandler( throw new ReqRespStatusError(ReqRespStatus.BADLY_FORMED_REQUEST, { cause: err }); } // First try attestation pool, then fall back to archiver - let txHashes = (await attestationPool.getBlockProposal(request.archiveRoot.toString()))?.txHashes; + let txHashes = (await attestationPool.getBlockProposalByArchive(request.archiveRoot.toString()))?.txHashes; if (!txHashes) { - txHashes = (await archiver.getL2BlockByArchive(request.archiveRoot))?.body.txEffects.map(effect => effect.txHash); + txHashes = (await archiver.getBlock({ archive: request.archiveRoot }))?.body.txEffects.map( + effect => effect.txHash, + ); } let requestedTxsHashes; diff --git a/yarn-project/p2p/src/test-helpers/make-test-p2p-clients.ts b/yarn-project/p2p/src/test-helpers/make-test-p2p-clients.ts index bdfe07c00df2..2098eafeda5e 100644 --- a/yarn-project/p2p/src/test-helpers/make-test-p2p-clients.ts +++ b/yarn-project/p2p/src/test-helpers/make-test-p2p-clients.ts @@ -114,6 +114,7 @@ export async function makeTestP2PClient( logger, p2pServiceFactory: mockGossipSubNetwork && getMockPubSubP2PServiceFactory(mockGossipSubNetwork), }, + await l2BlockSource.getInitialHeader().hash(), ); return client; diff --git a/yarn-project/p2p/src/test-helpers/testbench-utils.ts b/yarn-project/p2p/src/test-helpers/testbench-utils.ts index c8d142136278..e71a879d7659 100644 --- a/yarn-project/p2p/src/test-helpers/testbench-utils.ts +++ b/yarn-project/p2p/src/test-helpers/testbench-utils.ts @@ -1,4 +1,5 @@ import type { EpochCacheInterface } from '@aztec/epoch-cache'; +import type { CheckpointProposalHash } from '@aztec/foundation/branded-types'; import { EpochNumber, SlotNumber } from '@aztec/foundation/branded-types'; import type { Logger } from '@aztec/foundation/log'; import type { L2Block, L2BlockId } from '@aztec/stdlib/block'; @@ -225,7 +226,7 @@ export class InMemoryAttestationPool { return Promise.resolve({ added: true, alreadyExists: false, count: 1 }); } - getBlockProposal(id: string): Promise { + getBlockProposalByArchive(id: string): Promise { return Promise.resolve(this.proposals.get(id)); } @@ -233,7 +234,7 @@ export class InMemoryAttestationPool { return Promise.resolve({ added: true, alreadyExists: false, count: 1 }); } - getCheckpointProposal(_id: string): Promise { + getCheckpointProposal(_slot: SlotNumber): Promise { return Promise.resolve(undefined); } @@ -247,7 +248,7 @@ export class InMemoryAttestationPool { getCheckpointAttestationsForSlotAndProposal( _slot: SlotNumber, - _proposalId: string, + _proposalPayloadHash: CheckpointProposalHash, ): Promise { return Promise.resolve([]); } diff --git a/yarn-project/p2p/src/testbench/p2p_client_testbench_worker.ts b/yarn-project/p2p/src/testbench/p2p_client_testbench_worker.ts index cbcd849954c7..a8baa387ae16 100644 --- a/yarn-project/p2p/src/testbench/p2p_client_testbench_worker.ts +++ b/yarn-project/p2p/src/testbench/p2p_client_testbench_worker.ts @@ -424,6 +424,7 @@ process.on('message', async msg => { undefined, telemetry as TelemetryClient, deps, + await l2BlockSource.getInitialHeader().hash(), ); const testService = new TestLibP2PService( diff --git a/yarn-project/prover-client/src/orchestrator/orchestrator.ts b/yarn-project/prover-client/src/orchestrator/orchestrator.ts index 1dd893fe6af6..a74e7bb452c2 100644 --- a/yarn-project/prover-client/src/orchestrator/orchestrator.ts +++ b/yarn-project/prover-client/src/orchestrator/orchestrator.ts @@ -93,7 +93,6 @@ export class ProvingOrchestrator implements EpochProver { private provingPromise: Promise | undefined = undefined; private metrics: ProvingOrchestratorMetrics; - // eslint-disable-next-line aztec-custom/no-non-primitive-in-collections private dbs: Map = new Map(); private logger: Logger; private deferredJobQueue = new SerialQueue(); diff --git a/yarn-project/prover-node/src/job/epoch-proving-job.test.ts b/yarn-project/prover-node/src/job/epoch-proving-job.test.ts index 80d1a8f053a3..d6b6fcd8739f 100644 --- a/yarn-project/prover-node/src/job/epoch-proving-job.test.ts +++ b/yarn-project/prover-node/src/job/epoch-proving-job.test.ts @@ -116,10 +116,10 @@ describe('epoch-proving-job', () => { const txHashes = checkpoints.map(c => c.blocks.map(b => b.body.txEffects.map(tx => tx.txHash))).flat(2); txs = txHashes.map(txHash => ({ txHash, getTxHash: () => txHash }) as Tx); - l2BlockSource.getBlockHeader.mockResolvedValue(initialHeader); + l2BlockSource.getBlockData.mockResolvedValue({ header: initialHeader } as any); l2BlockSource.getL1Constants.mockResolvedValue({ ethereumSlotDuration: 0.1 } as L1RollupConstants); - l2BlockSource.getCheckpointedBlockHeadersForEpoch.mockResolvedValue( - checkpoints.map(c => c.blocks.map(b => b.header)).flat(), + l2BlockSource.getBlocksData.mockResolvedValue( + checkpoints.map(c => c.blocks.map(b => ({ header: b.header }) as any)).flat(), ); l2BlockSource.getCheckpoints.mockResolvedValue([ { checkpoint: checkpoints.at(-1)!, attestations } as PublishedCheckpoint, @@ -221,7 +221,7 @@ describe('epoch-proving-job', () => { it('halts if a new block for the epoch is found', async () => { const newHeaders = times(NUM_BLOCKS + 1, i => BlockHeader.random({ blockNumber: BlockNumber(i + 1) })); - l2BlockSource.getCheckpointedBlockHeadersForEpoch.mockResolvedValue(newHeaders); + l2BlockSource.getBlocksData.mockResolvedValue(newHeaders.map(h => ({ header: h }) as any)); const job = createJob(); await job.run(); diff --git a/yarn-project/prover-node/src/job/epoch-proving-job.ts b/yarn-project/prover-node/src/job/epoch-proving-job.ts index 1349cf517df6..022fcc02289e 100644 --- a/yarn-project/prover-node/src/job/epoch-proving-job.ts +++ b/yarn-project/prover-node/src/job/epoch-proving-job.ts @@ -410,7 +410,9 @@ export class EpochProvingJob implements Traceable { const intervalMs = Math.ceil((await l2BlockSource.getL1Constants()).ethereumSlotDuration / 2) * 1000; this.epochCheckPromise = new RunningPromise( async () => { - const blockHeaders = await l2BlockSource.getCheckpointedBlockHeadersForEpoch(this.epochNumber); + const blockHeaders = ( + await l2BlockSource.getBlocksData({ epoch: this.epochNumber, onlyCheckpointed: true }) + ).map(d => d.header); const blockHashes = await Promise.all(blockHeaders.map(header => header.hash())); const thisBlocks = this.checkpoints.flatMap(checkpoint => checkpoint.blocks); const thisBlockHashes = await Promise.all(thisBlocks.map(block => block.hash())); diff --git a/yarn-project/prover-node/src/monitors/epoch-monitor.test.ts b/yarn-project/prover-node/src/monitors/epoch-monitor.test.ts index f60efddf4c76..a32bb5d76933 100644 --- a/yarn-project/prover-node/src/monitors/epoch-monitor.test.ts +++ b/yarn-project/prover-node/src/monitors/epoch-monitor.test.ts @@ -34,18 +34,15 @@ describe('EpochMonitor', () => { isEpochComplete(epochNumber) { return Promise.resolve(epochNumber <= lastEpochComplete); }, - getBlockHeader(blockNumber: BlockNumber | 'latest') { - if (blockNumber === 'latest') { + getBlockData(query) { + if (!('number' in query)) { return Promise.resolve(undefined); } - const slot = blockToSlot[blockNumber]; + const slot = blockToSlot[query.number]; return Promise.resolve( slot === undefined ? undefined - : mock({ - getSlot: () => SlotNumber.fromBigInt(slot), - toString: () => `0x${slot.toString(16)}`, - }), + : ({ header: { getSlot: () => SlotNumber.fromBigInt(slot) } as unknown as BlockHeader } as any), ); }, getProvenBlockNumber() { diff --git a/yarn-project/prover-node/src/monitors/epoch-monitor.ts b/yarn-project/prover-node/src/monitors/epoch-monitor.ts index c1c7c89ddfbf..42694d81613b 100644 --- a/yarn-project/prover-node/src/monitors/epoch-monitor.ts +++ b/yarn-project/prover-node/src/monitors/epoch-monitor.ts @@ -98,7 +98,7 @@ export class EpochMonitor implements Traceable { private async getEpochNumberToProve() { const lastBlockProven = await this.l2BlockSource.getProvenBlockNumber(); const firstBlockToProve = BlockNumber(lastBlockProven + 1); - const firstBlockHeaderToProve = await this.l2BlockSource.getBlockHeader(firstBlockToProve); + const firstBlockHeaderToProve = (await this.l2BlockSource.getBlockData({ number: firstBlockToProve }))?.header; if (!firstBlockHeaderToProve) { return { epochToProve: undefined, blockNumber: firstBlockToProve }; } diff --git a/yarn-project/prover-node/src/prover-node.test.ts b/yarn-project/prover-node/src/prover-node.test.ts index 546c128bc234..45b09dc511a0 100644 --- a/yarn-project/prover-node/src/prover-node.test.ts +++ b/yarn-project/prover-node/src/prover-node.test.ts @@ -172,8 +172,12 @@ describe('prover-node', () => { proven: genesisTipId, finalized: genesisTipId, }); - l2BlockSource.getBlockHeader.mockImplementation(number => - Promise.resolve(number === checkpoints[0].blocks[0].number - 1 ? previousBlockHeader : undefined), + l2BlockSource.getBlockData.mockImplementation(query => + Promise.resolve( + 'number' in query && query.number === checkpoints[0].blocks[0].number - 1 + ? ({ header: previousBlockHeader } as any) + : undefined, + ), ); // L1 to L2 message source returns no messages diff --git a/yarn-project/prover-node/src/prover-node.ts b/yarn-project/prover-node/src/prover-node.ts index 48359691826e..9d7a4336054e 100644 --- a/yarn-project/prover-node/src/prover-node.ts +++ b/yarn-project/prover-node/src/prover-node.ts @@ -360,16 +360,13 @@ export class ProverNode implements EpochMonitorHandler, ProverNodeApi, Traceable } private async gatherPreviousBlockHeader(epochNumber: EpochNumber, previousBlockNumber: number) { - const header = await (previousBlockNumber === 0 - ? this.worldState.getCommitted().getInitialHeader() - : this.l2BlockSource.getBlockHeader(BlockNumber(previousBlockNumber))); - - if (!header) { + const data = await this.l2BlockSource.getBlockData({ number: BlockNumber(previousBlockNumber) }); + if (!data?.header) { throw new Error(`Previous block header ${previousBlockNumber} not found for proving epoch ${epochNumber}`); } - this.log.verbose(`Gathered previous block header ${header.getBlockNumber()} for epoch ${epochNumber}`); - return header; + this.log.verbose(`Gathered previous block header ${data.header.getBlockNumber()} for epoch ${epochNumber}`); + return data.header; } /** Extracted for testing purposes. */ diff --git a/yarn-project/pxe/src/block_synchronizer/block_stream_source.test.ts b/yarn-project/pxe/src/block_synchronizer/block_stream_source.test.ts new file mode 100644 index 000000000000..3a82b36a9649 --- /dev/null +++ b/yarn-project/pxe/src/block_synchronizer/block_stream_source.test.ts @@ -0,0 +1,85 @@ +import { BlockNumber, EpochNumber } from '@aztec/foundation/branded-types'; +import { Fr } from '@aztec/foundation/curves/bn254'; +import { BlockHash, L2Block } from '@aztec/stdlib/block'; +import type { AztecNode } from '@aztec/stdlib/interfaces/client'; + +import { type MockProxy, mock } from 'jest-mock-extended'; + +import { blockStreamSourceFromAztecNode } from './block_stream_source.js'; + +describe('blockStreamSourceFromAztecNode', () => { + let node: MockProxy; + let source: ReturnType; + + const buildResponse = async (block: L2Block) => ({ + header: block.header, + archive: block.archive, + hash: await block.hash(), + checkpointNumber: block.checkpointNumber, + indexWithinCheckpoint: block.indexWithinCheckpoint, + number: block.number, + }); + + beforeEach(() => { + node = mock(); + source = blockStreamSourceFromAztecNode(node); + }); + + describe('getBlockData', () => { + it('forwards a number query as a {number} parameter', async () => { + const block = await L2Block.random(BlockNumber(7)); + node.getBlock.mockResolvedValue((await buildResponse(block)) as any); + + const result = await source.getBlockData({ number: BlockNumber(7) }); + + expect(node.getBlock).toHaveBeenCalledWith({ number: BlockNumber(7) }); + expect(result?.header.equals(block.header)).toBe(true); + }); + + it('forwards a hash query as a {hash} parameter', async () => { + const block = await L2Block.random(BlockNumber(2)); + const hash = await block.hash(); + node.getBlock.mockResolvedValue((await buildResponse(block)) as any); + + const result = await source.getBlockData({ hash }); + + expect(node.getBlock).toHaveBeenCalledWith({ hash }); + expect(result?.blockHash.equals(hash)).toBe(true); + }); + + it('forwards an archive query as an {archive} parameter', async () => { + const block = await L2Block.random(BlockNumber(3)); + const archive = Fr.random(); + node.getBlock.mockResolvedValue((await buildResponse(block)) as any); + + const result = await source.getBlockData({ archive }); + + expect(node.getBlock).toHaveBeenCalledWith({ archive }); + expect(result?.header.equals(block.header)).toBe(true); + }); + + it('forwards a tag query as a {tag} parameter', async () => { + const block = await L2Block.random(BlockNumber(9)); + node.getBlock.mockResolvedValue((await buildResponse(block)) as any); + + const result = await source.getBlockData({ tag: 'proven' }); + + expect(node.getBlock).toHaveBeenCalledWith({ tag: 'proven' }); + expect(result?.header.equals(block.header)).toBe(true); + }); + + it('returns undefined when node returns undefined', async () => { + node.getBlock.mockResolvedValue(undefined); + const result = await source.getBlockData({ hash: BlockHash.random() }); + expect(result).toBeUndefined(); + }); + }); + + describe('getBlocks', () => { + it('throws on epoch query', async () => { + await expect(source.getBlocks({ epoch: EpochNumber(1), onlyCheckpointed: true })).rejects.toThrow( + /epoch query not supported/, + ); + }); + }); +}); diff --git a/yarn-project/pxe/src/block_synchronizer/block_stream_source.ts b/yarn-project/pxe/src/block_synchronizer/block_stream_source.ts index 507eb02699db..eb4b2ca4bfcc 100644 --- a/yarn-project/pxe/src/block_synchronizer/block_stream_source.ts +++ b/yarn-project/pxe/src/block_synchronizer/block_stream_source.ts @@ -1,28 +1,44 @@ -import type { BlockNumber, CheckpointNumber } from '@aztec/foundation/branded-types'; +import type { CheckpointNumber } from '@aztec/foundation/branded-types'; import { Fr } from '@aztec/foundation/curves/bn254'; -import { L2Block, type L2BlockSource } from '@aztec/stdlib/block'; +import { type BlockData, type BlockQuery, type BlocksQuery, L2Block, type L2BlockSource } from '@aztec/stdlib/block'; import { Checkpoint, L1PublishedData, PublishedCheckpoint } from '@aztec/stdlib/checkpoint'; import type { AztecNode } from '@aztec/stdlib/interfaces/client'; -// TODO(spl/new-rpc-api): delete once `L2BlockStream` is refactored to consume the new -// `BlockResponse` / `CheckpointResponse` shapes. For now the stream requires concrete `L2Block` -// and `PublishedCheckpoint` instances, so we rehydrate them from RPC responses. /** - * Lifts an {@link AztecNode} RPC client into the shape {@link L2BlockStream} expects. `getBlocks` - * requests transaction bodies so that real `L2Block` instances can be constructed; + * Lifts an {@link AztecNode} RPC client into the shape {@link L2BlockStream} expects. + * `getBlocks` requests transaction bodies so that real `L2Block` instances can be constructed; * `getCheckpoints` requests blocks + L1 info + attestations so that `PublishedCheckpoint` * instances are fully populated. */ export function blockStreamSourceFromAztecNode( node: AztecNode, -): Pick { +): Pick { return { getL2Tips: () => node.getL2Tips(), - getBlockHeader: number => node.getBlockHeader(number), - getCheckpointedBlocks: (from: BlockNumber, limit: number) => node.getCheckpointedBlocks(from, limit), - async getBlocks(from: BlockNumber, limit: number): Promise { - const responses = await node.getBlocks(from, limit, { includeTransactions: true }); + async getBlockData(query: BlockQuery): Promise { + const response = await node.getBlock(query); + if (!response) { + return undefined; + } + return { + header: response.header, + archive: response.archive, + blockHash: response.hash, + checkpointNumber: response.checkpointNumber, + indexWithinCheckpoint: response.indexWithinCheckpoint, + }; + }, + + async getBlocks(query: BlocksQuery): Promise { + // Epoch lookups are not exposed on the public AztecNode RPC; only `from + limit` is. + if (!('from' in query)) { + throw new Error('getBlocks with epoch query not supported via AztecNode RPC'); + } + if (query.onlyCheckpointed) { + throw new Error('getBlocks with onlyCheckpointed not supported via AztecNode RPC'); + } + const responses = await node.getBlocks(query.from, query.limit, { includeTransactions: true }); return responses.map(r => new L2Block(r.archive, r.header, r.body!, r.checkpointNumber, r.indexWithinCheckpoint)); }, diff --git a/yarn-project/pxe/src/block_synchronizer/block_synchronizer.test.ts b/yarn-project/pxe/src/block_synchronizer/block_synchronizer.test.ts index fc82fb6dcb53..af264b14f6de 100644 --- a/yarn-project/pxe/src/block_synchronizer/block_synchronizer.test.ts +++ b/yarn-project/pxe/src/block_synchronizer/block_synchronizer.test.ts @@ -4,7 +4,13 @@ import { Fr } from '@aztec/foundation/curves/bn254'; import type { AztecAsyncKVStore } from '@aztec/kv-store'; import { openTmpStore } from '@aztec/kv-store/lmdb-v2'; import { L2TipsKVStore } from '@aztec/kv-store/stores'; -import { BlockHash, GENESIS_CHECKPOINT_HEADER_HASH, L2Block, type L2BlockStream } from '@aztec/stdlib/block'; +import { + BlockHash, + GENESIS_BLOCK_HEADER_HASH, + GENESIS_CHECKPOINT_HEADER_HASH, + L2Block, + type L2BlockStream, +} from '@aztec/stdlib/block'; import { Checkpoint, L1PublishedData, PublishedCheckpoint } from '@aztec/stdlib/checkpoint'; import type { AztecNode } from '@aztec/stdlib/interfaces/client'; @@ -52,7 +58,7 @@ describe('BlockSynchronizer', () => { store = await openTmpStore('test'); blockStream = mock(); aztecNode = mock(); - tipsStore = new L2TipsKVStore(store, 'pxe'); + tipsStore = new L2TipsKVStore(store, 'pxe', GENESIS_BLOCK_HEADER_HASH); anchorBlockStore = new AnchorBlockStore(store); noteStore = new NoteStore(store); privateEventStore = new PrivateEventStore(store); @@ -143,14 +149,7 @@ describe('BlockSynchronizer', () => { }); blockStream.sync.mockReturnValue(syncBlocker); const genesisBlock = await L2Block.random(BlockNumber(0)); - aztecNode.getBlock.mockResolvedValue({ - header: genesisBlock.header, - archive: genesisBlock.archive, - hash: await genesisBlock.hash(), - checkpointNumber: genesisBlock.checkpointNumber, - indexWithinCheckpoint: genesisBlock.indexWithinCheckpoint, - number: genesisBlock.number, - } as any); + aztecNode.getBlock.mockResolvedValue({ header: genesisBlock.header } as any); // Start a sync (don't await) const syncPromise = synchronizer.sync(); @@ -254,16 +253,9 @@ describe('BlockSynchronizer', () => { const initialBlock = await L2Block.random(BlockNumber(0)); await anchorBlockStore.setHeader(initialBlock.header); - // Mock node to return block header + // Mock node to return block const provenBlock = await L2Block.random(BlockNumber(5)); - aztecNode.getBlock.mockResolvedValue({ - header: provenBlock.header, - archive: provenBlock.archive, - hash: await provenBlock.hash(), - checkpointNumber: provenBlock.checkpointNumber, - indexWithinCheckpoint: provenBlock.indexWithinCheckpoint, - number: provenBlock.number, - } as any); + aztecNode.getBlock.mockResolvedValue({ header: provenBlock.header } as any); await synchronizer.handleBlockStreamEvent({ type: 'chain-proven', @@ -281,16 +273,9 @@ describe('BlockSynchronizer', () => { const initialBlock = await L2Block.random(BlockNumber(0)); await anchorBlockStore.setHeader(initialBlock.header); - // Mock node to return block header + // Mock node to return block const finalizedBlock = await L2Block.random(BlockNumber(10)); - aztecNode.getBlock.mockResolvedValue({ - header: finalizedBlock.header, - archive: finalizedBlock.archive, - hash: await finalizedBlock.hash(), - checkpointNumber: finalizedBlock.checkpointNumber, - indexWithinCheckpoint: finalizedBlock.indexWithinCheckpoint, - number: finalizedBlock.number, - } as any); + aztecNode.getBlock.mockResolvedValue({ header: finalizedBlock.header } as any); await synchronizer.handleBlockStreamEvent({ type: 'chain-finalized', diff --git a/yarn-project/pxe/src/pxe.test.ts b/yarn-project/pxe/src/pxe.test.ts index 6cecfeb2cc39..311716cd32e2 100644 --- a/yarn-project/pxe/src/pxe.test.ts +++ b/yarn-project/pxe/src/pxe.test.ts @@ -1,6 +1,6 @@ import { BBBundlePrivateKernelProver } from '@aztec/bb-prover/client/bundle'; import type { L1ContractAddresses } from '@aztec/ethereum/l1-contract-addresses'; -import { BlockNumber, CheckpointNumber, IndexWithinCheckpoint } from '@aztec/foundation/branded-types'; +import { BlockNumber, CheckpointNumber } from '@aztec/foundation/branded-types'; import { Fr } from '@aztec/foundation/curves/bn254'; import { EthAddress } from '@aztec/foundation/eth-address'; import { AztecLMDBStoreV2, openTmpStore } from '@aztec/kv-store/lmdb-v2'; @@ -18,7 +18,6 @@ import { randomContractInstanceWithAddress, randomDeployedContract, } from '@aztec/stdlib/testing'; -import { AppendOnlyTreeSnapshot } from '@aztec/stdlib/trees'; import { BlockHeader, GlobalVariables, TxHash } from '@aztec/stdlib/tx'; import { mock } from 'jest-mock-extended'; @@ -184,14 +183,7 @@ describe('PXE', () => { globalVariables, }); node.getBlockHeader.mockResolvedValue(blockHeader); - node.getBlock.mockResolvedValue({ - header: blockHeader, - archive: AppendOnlyTreeSnapshot.empty(), - hash: GENESIS_BLOCK_HEADER_HASH, - checkpointNumber: CheckpointNumber.fromBlockNumber(lastKnownBlockNumber), - indexWithinCheckpoint: IndexWithinCheckpoint.ZERO, - number: lastKnownBlockNumber, - } as any); + node.getBlock.mockResolvedValue({ header: blockHeader } as any); // Mock getL2Tips which is needed for syncing tagged logs const tipId = { diff --git a/yarn-project/pxe/src/pxe.ts b/yarn-project/pxe/src/pxe.ts index 427587a6c693..3145bbacfd5f 100644 --- a/yarn-project/pxe/src/pxe.ts +++ b/yarn-project/pxe/src/pxe.ts @@ -18,7 +18,7 @@ import { } from '@aztec/stdlib/abi'; import type { AuthWitness } from '@aztec/stdlib/auth-witness'; import type { AztecAddress } from '@aztec/stdlib/aztec-address'; -import type { L2TipsProvider } from '@aztec/stdlib/block'; +import { GENESIS_BLOCK_HEADER_HASH, type L2TipsProvider } from '@aztec/stdlib/block'; import { CompleteAddress, type ContractInstanceWithAddress, @@ -212,6 +212,14 @@ export class PXE { const info = await node.getNodeInfo(); + // Source the genesis block hash from the node so PXE's L2BlockStream agrees with the node's + // archiver on the dynamic initial header hash. Without this the tip store would fall back to + // the static `GENESIS_BLOCK_HEADER_HASH` constant, which only matches deployments with the + // default empty genesis (timestamp 0, no prefilled public data) and diverges otherwise — the + // sync at block 0 would then get stuck in `areBlockHashesEqualAt` and abort. If the node does + // not return a genesis block (older node or test fixture) we fall back to the static constant. + const initialBlockHash = (await node.getBlock(BlockNumber.ZERO))?.hash ?? GENESIS_BLOCK_HEADER_HASH; + const proverEnabled = config.proverEnabled !== undefined ? config.proverEnabled : info.realProofs; const addressStore = new AddressStore(store); const privateEventStore = new PrivateEventStore(store); @@ -223,7 +231,7 @@ export class PXE { const recipientTaggingStore = new RecipientTaggingStore(store); const capsuleStore = new CapsuleStore(store); const keyStore = new KeyStore(store); - const tipsStore = new L2TipsKVStore(store, 'pxe'); + const tipsStore = new L2TipsKVStore(store, 'pxe', initialBlockHash); const contractSyncService = new ContractSyncService( node, contractStore, diff --git a/yarn-project/sequencer-client/src/sequencer/sequencer.test.ts b/yarn-project/sequencer-client/src/sequencer/sequencer.test.ts index e6ac31a7583d..4159fd2f11f9 100644 --- a/yarn-project/sequencer-client/src/sequencer/sequencer.test.ts +++ b/yarn-project/sequencer-client/src/sequencer/sequencer.test.ts @@ -297,6 +297,10 @@ describe('sequencer', () => { getBlockNumber: mockFn().mockResolvedValue(lastBlockNumber), getL2Tips: mockFn().mockResolvedValue({ proposed: { number: lastBlockNumber, hash }, + proposedCheckpoint: { + block: { number: lastBlockNumber, hash }, + checkpoint: { number: CheckpointNumber.ZERO, hash: GENESIS_CHECKPOINT_HEADER_HASH.toString() }, + }, checkpointed: { block: { number: lastBlockNumber, hash }, checkpoint: { number: CheckpointNumber.ZERO, hash: GENESIS_CHECKPOINT_HEADER_HASH.toString() }, @@ -313,7 +317,6 @@ describe('sequencer', () => { getL1Timestamp: mockFn().mockResolvedValue(1000n), isPendingChainInvalid: mockFn().mockResolvedValue(false), getPendingChainValidationStatus: mockFn().mockResolvedValue({ valid: true }), - getCheckpointedBlocksForEpoch: mockFn().mockResolvedValue([]), getCheckpointsForEpoch: mockFn().mockResolvedValue([]), getCheckpointsDataForEpoch: mockFn().mockResolvedValue([]), getSyncedL2SlotNumber: mockFn().mockResolvedValue(SlotNumber(Number.MAX_SAFE_INTEGER)), @@ -324,6 +327,10 @@ describe('sequencer', () => { getL1ToL2Messages: () => Promise.resolve(Array(NUMBER_OF_L1_L2_MESSAGES_PER_ROLLUP).fill(Fr.ZERO)), getL2Tips: mockFn().mockResolvedValue({ proposed: { number: lastBlockNumber, hash }, + proposedCheckpoint: { + block: { number: lastBlockNumber, hash }, + checkpoint: { number: CheckpointNumber.ZERO, hash: GENESIS_CHECKPOINT_HEADER_HASH.toString() }, + }, checkpointed: { block: { number: lastBlockNumber, hash }, checkpoint: { number: CheckpointNumber.ZERO, hash: GENESIS_CHECKPOINT_HEADER_HASH.toString() }, diff --git a/yarn-project/sequencer-client/src/sequencer/sequencer.ts b/yarn-project/sequencer-client/src/sequencer/sequencer.ts index 4fc638ceeae1..0e7f3ab55c29 100644 --- a/yarn-project/sequencer-client/src/sequencer/sequencer.ts +++ b/yarn-project/sequencer-client/src/sequencer/sequencer.ts @@ -1,5 +1,4 @@ import { getKzg } from '@aztec/blob-lib'; -import { INITIAL_L2_BLOCK_NUM } from '@aztec/constants'; import type { EpochCache } from '@aztec/epoch-cache'; import { NoCommitteeError, type RollupContract, SimulationOverridesBuilder } from '@aztec/ethereum/contracts'; import { BlockNumber, CheckpointNumber, EpochNumber, SlotNumber } from '@aztec/foundation/branded-types'; @@ -25,7 +24,6 @@ import { import type { L1ToL2MessageSource } from '@aztec/stdlib/messaging'; import type { CoordinationSignatureContext } from '@aztec/stdlib/p2p'; import { pickFromSchema } from '@aztec/stdlib/schemas'; -import { MerkleTreeId } from '@aztec/stdlib/trees'; import { Attributes, type TelemetryClient, type Tracer, getTelemetryClient, trackSpan } from '@aztec/telemetry-client'; import { FullNodeCheckpointsBuilder, NodeKeystoreAdapter, type ValidatorClient } from '@aztec/validator-client'; @@ -615,23 +613,12 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter TypedEventEmitter ({ - data: obj.data, - checkpoint: obj.checkpoint, - l1: obj.l1, - attestations: obj.attestations, - })); diff --git a/yarn-project/stdlib/src/block/block_parameter.test.ts b/yarn-project/stdlib/src/block/block_parameter.test.ts new file mode 100644 index 000000000000..084b1a1ba1ab --- /dev/null +++ b/yarn-project/stdlib/src/block/block_parameter.test.ts @@ -0,0 +1,49 @@ +import { BlockNumber } from '@aztec/foundation/branded-types'; +import { Fr } from '@aztec/foundation/curves/bn254'; + +import { BlockHash } from './block_hash.js'; +import { type BlockParameter, BlockParameterSchema } from './block_parameter.js'; + +describe('BlockParameterSchema', () => { + it.each<[string, BlockParameter]>([ + ['number', BlockNumber(7)], + ['BlockHash', BlockHash.fromBuffer(Buffer.alloc(32, 1))], + ['tag latest', 'latest'], + ['tag proposed', 'proposed'], + ['tag checkpointed', 'checkpointed'], + ['tag proven', 'proven'], + ['tag finalized', 'finalized'], + ['{ number }', { number: BlockNumber(7) }], + ['{ hash }', { hash: BlockHash.fromBuffer(Buffer.alloc(32, 1)) }], + ['{ archive }', { archive: new Fr(123) }], + ['{ tag }', { tag: 'proven' }], + ])('roundtrips %s', (_, param) => { + const json = JSON.parse(JSON.stringify(param)); + const parsed = BlockParameterSchema.parse(json); + expect(parsed).toEqual(param); + }); + + it('parses a 32-byte hex string as a BlockHash, never coercing it to a JS number', () => { + const blockHash = BlockHash.fromBuffer(Buffer.alloc(32, 0x07)); + const wire = blockHash.toString(); + const parsed = BlockParameterSchema.parse(wire); + expect(BlockHash.isBlockHash(parsed)).toBe(true); + expect((parsed as BlockHash).toString()).toEqual(wire); + }); + + it('rejects huge JS numbers (above MAX_SAFE_INTEGER) for block-number parsing', () => { + expect(BlockParameterSchema.safeParse(Number.MAX_SAFE_INTEGER + 1).success).toBe(false); + }); + + it('rejects negative numbers', () => { + expect(BlockParameterSchema.safeParse(-1).success).toBe(false); + }); + + it('rejects non-integer numbers', () => { + expect(BlockParameterSchema.safeParse(1.5).success).toBe(false); + }); + + it('rejects unknown tags', () => { + expect(BlockParameterSchema.safeParse('not-a-tag').success).toBe(false); + }); +}); diff --git a/yarn-project/stdlib/src/block/block_parameter.ts b/yarn-project/stdlib/src/block/block_parameter.ts index 686fd3abeaa4..da4940895137 100644 --- a/yarn-project/stdlib/src/block/block_parameter.ts +++ b/yarn-project/stdlib/src/block/block_parameter.ts @@ -1,31 +1,64 @@ -import { BlockNumberSchema } from '@aztec/foundation/branded-types'; +import { type BlockNumber, BlockNumberSchema } from '@aztec/foundation/branded-types'; +import type { Fr } from '@aztec/foundation/curves/bn254'; import { jsonStringify } from '@aztec/foundation/json-rpc'; import { schemas } from '@aztec/foundation/schemas'; import { z } from 'zod'; -import { ChainTipSchema } from '../interfaces/chain_tips.js'; import { BlockHash } from './block_hash.js'; +export const BlockTag = ['latest', 'proposed', 'checkpointed', 'proven', 'finalized'] as const; + +/** + * Tag identifying a block by its position in the chain rather than by an absolute identifier. + * - `latest` / `proposed`: Latest L2 block proposed (not necessarily checkpointed/proven yet). + * - `checkpointed`: Latest L2 block whose enclosing checkpoint has been published on L1. + * - `proven`: Latest L2 block whose enclosing checkpoint has been proven on L1. + * - `finalized`: Latest L2 block whose proving L1 transaction has reached L1 finality. + */ +export type BlockTag = (typeof BlockTag)[number]; + +export const BlockTagWithoutLatestSchema = z.union([ + z.literal('proposed'), + z.literal('checkpointed'), + z.literal('proven'), + z.literal('finalized'), +]); + +export const BlockTagSchema: z.ZodType = z.union([z.literal('latest'), BlockTagWithoutLatestSchema]); + +/** + * Object-only form of {@link BlockParameter}. Used as the building block for {@link BlockQuery}. + */ +export type NormalizedBlockParameter = + | { number: BlockNumber } + | { hash: BlockHash } + | { archive: Fr } + | { tag: Exclude }; + +export const NormalizedBlockParameterSchema: z.ZodType = z.union([ + z.object({ number: BlockNumberSchema }).strict(), + z.object({ hash: BlockHash.schema }).strict(), + z.object({ archive: schemas.Fr }).strict(), + z.object({ tag: BlockTagWithoutLatestSchema }).strict(), +]); + /** * Selector for a block in RPC calls. * * Accepts a block number, a {@link BlockHash}, a chain-tip name (e.g. `'proven'`, `'checkpointed'`), - * `'latest'` (alias for `'proposed'`), or the explicit object variants `{ number }`, `{ hash }`, - * and `{ archive }`. + * `'latest'` (alias for `'proposed'`), or any of the {@link NormalizedBlockParameter} object variants + * (`{ number }`, `{ hash }`, `{ archive }`, `{ tag }`). */ -export const BlockParameterSchema = z.union([ +export type BlockParameter = NormalizedBlockParameter | BlockNumber | BlockHash | BlockTag; + +export const BlockParameterSchema: z.ZodType = z.union([ + NormalizedBlockParameterSchema, BlockHash.schema, + BlockTagSchema, BlockNumberSchema, - ChainTipSchema, - z.literal('latest'), - z.object({ number: BlockNumberSchema }), - z.object({ hash: BlockHash.schema }), - z.object({ archive: schemas.Fr }), ]); -export type BlockParameter = z.infer; - export function inspectBlockParameter(param: BlockParameter) { if (typeof param === 'number') { return param.toString(); @@ -37,6 +70,8 @@ export function inspectBlockParameter(param: BlockParameter) { return `hash=${param.hash.toString()}`; } else if ('archive' in param) { return `archive=${param.archive.toString()}`; + } else if ('tag' in param) { + return `tag=${param.tag}`; } else { return jsonStringify(param); } diff --git a/yarn-project/stdlib/src/block/checkpointed_l2_block.ts b/yarn-project/stdlib/src/block/checkpointed_l2_block.ts deleted file mode 100644 index 3313e4ead84f..000000000000 --- a/yarn-project/stdlib/src/block/checkpointed_l2_block.ts +++ /dev/null @@ -1,70 +0,0 @@ -// Ignoring import issue to fix portable inferred type issue in zod schema -import { CheckpointNumber, CheckpointNumberSchema } from '@aztec/foundation/branded-types'; -import { BufferReader, serializeToBuffer } from '@aztec/foundation/serialize'; -import type { FieldsOf } from '@aztec/foundation/types'; - -import { z } from 'zod'; - -import { L1PublishedData } from '../checkpoint/published_checkpoint.js'; -import { MAX_BLOCK_HASH_STRING_LENGTH, MAX_COMMITTEE_SIZE } from '../deserialization/index.js'; -import { L2Block } from './l2_block.js'; -import { CommitteeAttestation } from './proposal/committee_attestation.js'; - -/** - * Encapsulates an L2 Block along with the checkpoint data associated with it. - */ -export class CheckpointedL2Block { - constructor( - public checkpointNumber: CheckpointNumber, - public block: L2Block, - public l1: L1PublishedData, - public attestations: CommitteeAttestation[], - ) {} - static get schema() { - return z - .object({ - checkpointNumber: CheckpointNumberSchema, - block: L2Block.schema, - l1: L1PublishedData.schema, - attestations: z.array(CommitteeAttestation.schema), - }) - .transform(obj => CheckpointedL2Block.fromFields(obj)); - } - - static fromBuffer(bufferOrReader: Buffer | BufferReader): CheckpointedL2Block { - const reader = BufferReader.asReader(bufferOrReader); - const checkpointNumber = reader.readNumber(); - const block = reader.readObject(L2Block); - const l1BlockNumber = reader.readBigInt(); - const l1BlockHash = reader.readString(MAX_BLOCK_HASH_STRING_LENGTH); - const l1Timestamp = reader.readBigInt(); - const attestations = reader.readVector(CommitteeAttestation, MAX_COMMITTEE_SIZE); - return new CheckpointedL2Block( - CheckpointNumber(checkpointNumber), - block, - new L1PublishedData(l1BlockNumber, l1Timestamp, l1BlockHash), - attestations, - ); - } - - static fromFields(fields: FieldsOf) { - return new CheckpointedL2Block( - CheckpointNumber(fields.checkpointNumber), - fields.block, - fields.l1, - fields.attestations, - ); - } - - public toBuffer(): Buffer { - return serializeToBuffer( - this.checkpointNumber, - this.block, - this.l1.blockNumber, - this.l1.blockHash, - this.l1.timestamp, - this.attestations.length, - this.attestations, - ); - } -} diff --git a/yarn-project/stdlib/src/block/index.ts b/yarn-project/stdlib/src/block/index.ts index ded8ffecdf57..39b0960a7984 100644 --- a/yarn-project/stdlib/src/block/index.ts +++ b/yarn-project/stdlib/src/block/index.ts @@ -6,7 +6,6 @@ export * from './body.js'; export * from './block_parameter.js'; export * from './l2_block_source.js'; export * from './block_hash.js'; -export * from './checkpointed_l2_block.js'; export * from './proposal/index.js'; export * from './validate_block_result.js'; export * from './l2_block_info.js'; diff --git a/yarn-project/stdlib/src/block/l2_block_source.ts b/yarn-project/stdlib/src/block/l2_block_source.ts index 72cbf27e2c5c..7fd024f7e42a 100644 --- a/yarn-project/stdlib/src/block/l2_block_source.ts +++ b/yarn-project/stdlib/src/block/l2_block_source.ts @@ -4,37 +4,57 @@ import { CheckpointNumber, CheckpointNumberSchema, type EpochNumber, + EpochNumberSchema, type SlotNumber, } from '@aztec/foundation/branded-types'; import type { Fr } from '@aztec/foundation/curves/bn254'; import type { EthAddress } from '@aztec/foundation/eth-address'; +import { schemas } from '@aztec/foundation/schemas'; import type { TypedEventEmitter } from '@aztec/foundation/types'; import { z } from 'zod'; import type { Checkpoint } from '../checkpoint/checkpoint.js'; import type { CheckpointData, CommonCheckpointData, ProposedCheckpointData } from '../checkpoint/checkpoint_data.js'; -import type { L1PublishedData, PublishedCheckpoint } from '../checkpoint/published_checkpoint.js'; +import type { PublishedCheckpoint } from '../checkpoint/published_checkpoint.js'; import type { L1RollupConstants } from '../epoch-helpers/index.js'; import { CheckpointHeader } from '../rollup/checkpoint_header.js'; -import type { BlockHeader } from '../tx/block_header.js'; import type { IndexedTxEffect } from '../tx/indexed_tx_effect.js'; import type { TxHash } from '../tx/tx_hash.js'; import type { TxReceipt } from '../tx/tx_receipt.js'; import type { BlockData } from './block_data.js'; -import type { BlockHash } from './block_hash.js'; -import type { CheckpointedL2Block } from './checkpointed_l2_block.js'; +import { BlockHash } from './block_hash.js'; +import { BlockTagWithoutLatestSchema, type NormalizedBlockParameter } from './block_parameter.js'; import type { L2Block } from './l2_block.js'; -import type { CommitteeAttestation } from './proposal/committee_attestation.js'; import type { ValidateCheckpointNegativeResult, ValidateCheckpointResult } from './validate_block_result.js'; -/** Block metadata plus checkpoint-derived context (L1 publish info, attestations). */ -export type BlockDataWithCheckpointContext = { - data: BlockData; - checkpoint?: CheckpointData; - l1?: L1PublishedData; - attestations: CommitteeAttestation[]; -}; +/** Lookup a single block by block number, hash, archive root, or chain-tip tag. */ +export type BlockQuery = NormalizedBlockParameter; + +/** + * Query a range of blocks by start/limit or by epoch. + * + * The `epoch` variant requires `onlyCheckpointed: true` because epoch boundaries are only + * meaningful for the checkpointed chain — the proposed chain may include uncheckpointed + * blocks past the epoch boundary that callers should not receive. + */ +export type BlocksQuery = + | { from: BlockNumber; limit: number; onlyCheckpointed?: boolean } + | { epoch: EpochNumber; onlyCheckpointed: true }; + +export const BlockQuerySchema: z.ZodType = z.union([ + z.object({ number: BlockNumberSchema }).strict(), + z.object({ hash: BlockHash.schema }).strict(), + z.object({ archive: schemas.Fr }).strict(), + z.object({ tag: BlockTagWithoutLatestSchema }).strict(), +]); + +export const BlocksQuerySchema: z.ZodType = z.union([ + z + .object({ from: BlockNumberSchema, limit: z.number().int().min(1), onlyCheckpointed: z.boolean().optional() }) + .strict(), + z.object({ epoch: EpochNumberSchema, onlyCheckpointed: z.literal(true) }).strict(), +]); /** * Interface of classes allowing for the retrieval of L2 blocks. @@ -58,6 +78,14 @@ export interface L2BlockSource { */ getBlockNumber(): Promise; + /** + * Resolves a {@link BlockQuery} to its concrete L2 block number. + * @param query - Lookup by block number, hash, archive root, or chain-tip tag. + * @returns The block number, or undefined if no block matches the query. + */ + getBlockNumber(query: BlockQuery): Promise; + getBlockNumber(query?: BlockQuery): Promise; + /** * Gets the number of the latest L2 checkpoint processed by the block source implementation. * @returns The number of the latest L2 checkpoint processed by the block source implementation. @@ -83,23 +111,6 @@ export interface L2BlockSource { */ getFinalizedL2BlockNumber(): Promise; - /** - * Gets an l2 block header. - * @param number - The block number to return or 'latest' for the most recent one. - * @returns The requested L2 block header. - */ - getBlockHeader(number: BlockNumber | 'latest'): Promise; - - /** - * Gets a checkpointed L2 block by block number. - * Returns undefined if the block doesn't exist or hasn't been checkpointed yet. - * @param number - The block number to retrieve. - * @returns The requested checkpointed L2 block (or undefined if not found or not checkpointed). - */ - getCheckpointedBlock(number: BlockNumber): Promise; - - getCheckpointedBlocks(from: BlockNumber, limit: number): Promise; - /** * Retrieves a collection of checkpoints. * @param checkpointNumber The first checkpoint to be retrieved. @@ -143,63 +154,6 @@ export interface L2BlockSource { */ getCheckpointNumberBySlot(slot: SlotNumber): Promise; - /** - * Gets block metadata plus checkpoint-derived context (L1 publish info, attestations) - * without deserializing tx bodies. Uses checkpoint-level values when the block is - * checkpointed; otherwise returns `l1: undefined` and empty attestations. - * @param number - The block number to retrieve. - */ - getBlockDataWithCheckpointContext(number: BlockNumber): Promise; - - /** - * Gets a block header by its hash. - * @param blockHash - The block hash to retrieve. - * @returns The requested block header (or undefined if not found). - */ - getBlockHeaderByHash(blockHash: BlockHash): Promise; - - /** - * Gets a block header by its archive root. - * @param archive - The archive root to retrieve. - * @returns The requested block header (or undefined if not found). - */ - getBlockHeaderByArchive(archive: Fr): Promise; - - /** - * Gets block metadata (without tx data) by block number. - * @param number - The block number to retrieve. - * @returns The requested block data (or undefined if not found). - */ - getBlockData(number: BlockNumber): Promise; - - /** - * Gets block metadata (without tx data) by archive root. - * @param archive - The archive root to retrieve. - * @returns The requested block data (or undefined if not found). - */ - getBlockDataByArchive(archive: Fr): Promise; - - /** - * Gets an L2 block by block number. - * @param number - The block number to return. - * @returns The requested L2 block (or undefined if not found). - */ - getL2Block(number: BlockNumber): Promise; - - /** - * Gets an L2 block by its hash. - * @param blockHash - The block hash to retrieve. - * @returns The requested L2 block (or undefined if not found). - */ - getL2BlockByHash(blockHash: BlockHash): Promise; - - /** - * Gets an L2 block by its archive root. - * @param archive - The archive root to retrieve. - * @returns The requested L2 block (or undefined if not found). - */ - getL2BlockByArchive(archive: Fr): Promise; - /** * Gets a tx effect. * @param txHash - The hash of the tx corresponding to the tx effect. @@ -228,13 +182,6 @@ export interface L2BlockSource { */ getSyncedL2EpochNumber(): Promise; - /** - * Returns all checkpointed block headers for a given epoch. - * @dev Use this method only with recent epochs, since it walks the block list backwards. - * @param epochNumber - The epoch number to return headers for. - */ - getCheckpointedBlockHeadersForEpoch(epochNumber: EpochNumber): Promise; - /** * Returns whether the given epoch is completed on L1, based on the current L1 and L2 block numbers. * @param epochNumber - The epoch number to check. @@ -254,6 +201,12 @@ export interface L2BlockSource { /** Returns values for the genesis block */ getGenesisValues(): Promise<{ genesisArchiveRoot: Fr }>; + /** + * Returns the precomputed hash of the genesis block header. Synchronous because the hash + * is derived from the initial block header at construction time and cached by implementers. + */ + getGenesisBlockHash(): BlockHash; + /** Latest synced L1 timestamp. */ getL1Timestamp(): Promise; @@ -278,50 +231,36 @@ export interface L2BlockSource { /** Force a sync. */ syncImmediate(): Promise; - /* Legacy APIS */ - /** - * Gets an l2 block. If a negative number is passed, the block returned is the most recent. - * @param number - The block number to return (inclusive). - * @returns The requested L2 block. + * Gets an L2 block matching the given query. + * @param query - Lookup by block number, hash, or archive root. */ - getBlock(number: BlockNumber): Promise; + getBlock(query: BlockQuery): Promise; /** - * Returns all checkpointed blocks for a given epoch. - * @dev Use this method only with recent epochs, since it walks the block list backwards. - * @param epochNumber - The epoch number to return blocks for. - */ - getCheckpointedBlocksForEpoch(epochNumber: EpochNumber): Promise; - - /** - * Returns all blocks for a given slot. - * @dev Use this method only with recent slots, since it walks the block list backwards. - * @param slotNumber - The slot number to return blocks for. + * Gets a collection of L2 blocks matching the given query. + * @param query - Range by start/limit or by epoch; optionally restricted to checkpointed blocks. */ - getBlocksForSlot(slotNumber: SlotNumber): Promise; + getBlocks(query: BlocksQuery): Promise; /** - * Gets a checkpointed block by its block hash. - * @param blockHash - The block hash to retrieve. - * @returns The requested block (or undefined if not found). + * Gets block metadata (without tx data) matching the given query. + * @param query - Lookup by block number, hash, or archive root. */ - getCheckpointedBlockByHash(blockHash: BlockHash): Promise; + getBlockData(query: BlockQuery): Promise; /** - * Gets a checkpointed block by its archive root. - * @param archive - The archive root to retrieve. - * @returns The requested block (or undefined if not found). + * Gets a collection of block metadata entries matching the given query. + * @param query - Range by start/limit or by epoch; optionally restricted to checkpointed blocks. */ - getCheckpointedBlockByArchive(archive: Fr): Promise; + getBlocksData(query: BlocksQuery): Promise; /** - * Gets up to `limit` amount of L2 blocks starting from `from`. - * @param from - Number of the first block to return (inclusive). - * @param limit - The maximum number of blocks to return. - * @returns The requested L2 blocks. + * Returns all blocks for a given slot. + * @dev Use this method only with recent slots, since it walks the block list backwards. + * @param slotNumber - The slot number to return blocks for. */ - getBlocks(from: BlockNumber, limit: number): Promise; + getBlocksForSlot(slotNumber: SlotNumber): Promise; } /** @@ -352,11 +291,18 @@ export interface L2BlockSourceEventEmitter extends L2BlockSource { } /** - * Identifier for L2 block tags. + * Identifier for L2 block tags. Internal counterpart to {@link BlockTag} that exposes + * the additional `proposedCheckpoint` value (used for the optimistic chain tip on the + * archiver side) and omits `latest` (which is an alias for `proposed` accepted only at + * the public RPC surface). + * * - proposed: Latest block proposed on L2. - * - checkpointed: Checkpointed block on L1. - * - proven: Proven block on L1. - * - finalized: Proven block on a finalized L1 block (not implemented, set to proven for now). + * - proposedCheckpoint: Latest block in the most recent proposed checkpoint (archiver-internal). + * - checkpointed: Latest block whose enclosing checkpoint has been published on L1. + * - proven: Latest block whose enclosing checkpoint has been proven on L1. + * - finalized: Latest block whose proving L1 transaction has reached L1 finality. + * + * TODO(palla): Remove `proposedCheckpoint` and unify with `proposed`. */ export type L2BlockTag = 'proposed' | 'proposedCheckpoint' | 'checkpointed' | 'proven' | 'finalized'; diff --git a/yarn-project/stdlib/src/block/l2_block_stream/l2_block_stream.test.ts b/yarn-project/stdlib/src/block/l2_block_stream/l2_block_stream.test.ts index a13ce4b8fa36..26216f7aff2b 100644 --- a/yarn-project/stdlib/src/block/l2_block_stream/l2_block_stream.test.ts +++ b/yarn-project/stdlib/src/block/l2_block_stream/l2_block_stream.test.ts @@ -2,15 +2,22 @@ import { BlockNumber, CheckpointNumber } from '@aztec/foundation/branded-types'; import { compactArray } from '@aztec/foundation/collection'; import { Fr } from '@aztec/foundation/curves/bn254'; +import { jest } from '@jest/globals'; import { type MockProxy, mock } from 'jest-mock-extended'; import times from 'lodash.times'; import type { PublishedCheckpoint } from '../../checkpoint/published_checkpoint.js'; import type { BlockHeader } from '../../tx/block_header.js'; +import type { BlockData } from '../block_data.js'; import { BlockHash, GENESIS_BLOCK_HEADER_HASH } from '../block_hash.js'; -import type { CheckpointedL2Block } from '../checkpointed_l2_block.js'; import type { L2Block } from '../l2_block.js'; -import { GENESIS_CHECKPOINT_HEADER_HASH, type L2BlockId, type L2BlockSource, type L2Tips } from '../l2_block_source.js'; +import { + type BlocksQuery, + GENESIS_CHECKPOINT_HEADER_HASH, + type L2BlockId, + type L2BlockSource, + type L2Tips, +} from '../l2_block_source.js'; import type { L2BlockStreamEvent, L2BlockStreamEventHandler, L2BlockStreamLocalDataProvider } from './interfaces.js'; import { L2BlockStream } from './l2_block_stream.js'; import { L2TipsMemoryStore } from './l2_tips_memory_store.js'; @@ -39,11 +46,12 @@ describe('L2BlockStream', () => { hash: () => Promise.resolve(new BlockHash(new Fr(number))), }) as L2Block; - const makeCheckpointedBlock = (number: number, checkpointNum: number): CheckpointedL2Block => + const makeBlockData = (number: number, checkpointNum: number): BlockData => ({ - block: makeBlock(number), - checkpointNumber: checkpointNum, - }) as CheckpointedL2Block; + header: makeHeader(number), + checkpointNumber: CheckpointNumber(checkpointNum), + indexWithinCheckpoint: 0, + }) as unknown as BlockData; const makeHeader = (number: number) => ({ hash: () => Promise.resolve(new BlockHash(new Fr(number))) }) as BlockHeader; @@ -107,27 +115,22 @@ describe('L2BlockStream', () => { beforeEach(() => { blockSource = mock(); - // Archiver returns headers with hashes equal to the block number for simplicity - // Note that we only return block headers for blocks that have not been pruned - blockSource.getBlockHeader.mockImplementation(number => - Promise.resolve( - typeof number === 'number' && number > latest ? undefined : makeHeader(number === 'latest' ? 1 : number), - ), - ); - // Returns blocks up until what was reported as the latest block (for uncheckpointed blocks) - blockSource.getBlocks.mockImplementation((from, limit) => - Promise.resolve(compactArray(times(limit, i => (from + i > latest ? undefined : makeBlock(from + i))))), + blockSource.getBlocks.mockImplementation((query: BlocksQuery) => + 'from' in query + ? Promise.resolve( + compactArray(times(query.limit, i => (query.from + i > latest ? undefined : makeBlock(query.from + i)))), + ) + : Promise.resolve([]), ); - // Returns checkpointed blocks (for blocks up to checkpointed tip) - blockSource.getCheckpointedBlocks.mockImplementation((from, limit) => - Promise.resolve( - compactArray( - times(limit, i => (from + i > checkpointed ? undefined : makeCheckpointedBlock(from + i, from + i))), - ), - ), - ); + // Returns block data for any known block that has not been pruned. + blockSource.getBlockData.mockImplementation(query => { + if (!('number' in query)) { + return Promise.resolve(undefined); + } + return Promise.resolve(query.number > latest ? undefined : makeBlockData(query.number, query.number)); + }); // Returns published checkpoints - each checkpoint contains just the one block for simplicity // Respects the limit parameter and returns up to `limit` checkpoints @@ -179,7 +182,7 @@ describe('L2BlockStream', () => { localData.proposed.number = BlockNumber(10); await blockStream.work(); - expect(blockSource.getBlocks).toHaveBeenCalledWith(BlockNumber(11), 5); + expect(blockSource.getBlocks).toHaveBeenCalledWith({ from: BlockNumber(11), limit: 5 }); expect(handler.events).toEqual([ { type: 'blocks-added', blocks: times(5, i => makeBlock(i + 11)) }, ] satisfies L2BlockStreamEvent[]); @@ -274,7 +277,8 @@ describe('L2BlockStream', () => { expectBlocksAdded([5]), expectCheckpointed(), ]); - expect(blockSource.getCheckpointedBlocks).toHaveBeenCalledTimes(1); + // 2 calls: one for block 0 in reorg detection (hash compare at genesis), one for block 1 in loop 2. + expect(blockSource.getBlockData).toHaveBeenCalledTimes(2); expect(blockSource.getBlocks).not.toHaveBeenCalled(); }); @@ -294,8 +298,9 @@ describe('L2BlockStream', () => { expectCheckpointed(), expectBlocksAdded([4, 5]), ]); - expect(blockSource.getCheckpointedBlocks).toHaveBeenCalledTimes(1); - expect(blockSource.getBlocks).toHaveBeenCalledWith(BlockNumber(4), 2); + // 2 calls: one for block 0 in reorg detection (hash compare at genesis), one for block 1 in loop 2. + expect(blockSource.getBlockData).toHaveBeenCalledTimes(2); + expect(blockSource.getBlocks).toHaveBeenCalledWith({ from: BlockNumber(4), limit: 2 }); }); it('handles reorg with uncheckpointed reason when pruned to checkpointed tip', async () => { @@ -317,6 +322,36 @@ describe('L2BlockStream', () => { checkpoint: makeCheckpointId(3), }); }); + + it('throws a meaningful error when local and source disagree on the genesis hash', async () => { + // Source advertises blocks 1-3 with the default mock genesis hash (Fr.ZERO). + setRemoteTips(3); + localData.proposed.number = BlockNumber(3); + // Local store disagrees at every height including block 0 (e.g. different genesisTimestamp). + localData.blockHashes[0] = `0xbad0`; + for (let i = 1; i <= 3; i++) { + localData.blockHashes[i] = `0xbad${i}`; + } + + // The reorg-search loop must NOT walk past block 0; it should throw a clear error + // pointing at the genesis-hash mismatch instead of cascading into "block hash not found + // for -1" further down. The error is caught and logged by `work` rather than rethrown, + // so we assert via the logged error and ensure no events were emitted. + const errorSpy = jest.spyOn( + (blockStream as unknown as { log: { error: (...args: any[]) => void } }).log, + 'error', + ); + + await blockStream.work(); + + expect(handler.events).toEqual([]); + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining('Error processing block stream'), + expect.objectContaining({ + message: expect.stringContaining('Genesis block hash mismatch'), + }), + ); + }); }); describe('with memory tips store', () => { @@ -376,17 +411,6 @@ describe('L2BlockStream', () => { /** Gets the last block number in a checkpoint */ const getLastBlockInCheckpoint = (checkpointNum: number) => checkpointNum * blocksPerCheckpoint; - /** Makes a block with correct checkpoint info */ - const makeBlockInCheckpoint = (blockNum: number) => { - const checkpointNum = getCheckpointForBlock(blockNum); - const firstBlockInCheckpoint = getFirstBlockInCheckpoint(checkpointNum); - return { - number: BlockNumber(blockNum), - checkpointNumber: CheckpointNumber(checkpointNum), - indexWithinCheckpoint: blockNum - firstBlockInCheckpoint, - } as L2Block; - }; - /** Makes a block with hash method (for use in mocks that need hash) */ const makeBlockInCheckpointWithHash = (blockNum: number) => { const checkpointNum = getCheckpointForBlock(blockNum); @@ -399,12 +423,13 @@ describe('L2BlockStream', () => { } as L2Block; }; - /** Makes a checkpointed block */ - const makeCheckpointedBlockInCheckpoint = (blockNum: number): CheckpointedL2Block => + /** Makes block data for a checkpointed block */ + const makeBlockDataInCheckpoint = (blockNum: number): BlockData => ({ - block: makeBlockInCheckpoint(blockNum), - checkpointNumber: getCheckpointForBlock(blockNum), - }) as CheckpointedL2Block; + header: makeHeader(blockNum), + checkpointNumber: CheckpointNumber(getCheckpointForBlock(blockNum)), + indexWithinCheckpoint: blockNum - getFirstBlockInCheckpoint(getCheckpointForBlock(blockNum)), + }) as unknown as BlockData; /** Sets the remote tips with correct checkpoint numbers for multi-block checkpoints. */ const setRemoteTipsMultiBlock = ( @@ -458,14 +483,13 @@ describe('L2BlockStream', () => { handler = new TestL2BlockStreamEventHandler(); blockStream = new TestL2BlockStream(blockSource, localData, handler, undefined, { batchSize: 10 }); - // Override the mocks to support multiple blocks per checkpoint - blockSource.getCheckpointedBlocks.mockImplementation((from, limit) => - Promise.resolve( - compactArray( - times(limit, i => (from + i > checkpointed ? undefined : makeCheckpointedBlockInCheckpoint(from + i))), - ), - ), - ); + // Override the mock to support multiple blocks per checkpoint + blockSource.getBlockData.mockImplementation(query => { + if (!('number' in query)) { + return Promise.resolve(undefined); + } + return Promise.resolve(query.number > latest ? undefined : makeBlockDataInCheckpoint(query.number)); + }); // Returns published checkpoints with multiple blocks each, respecting the limit parameter blockSource.getCheckpoints.mockImplementation((checkpointNumber: CheckpointNumber, limit: number) => { @@ -811,8 +835,30 @@ describe('L2BlockStream', () => { expect(checkpointEvents).toHaveLength(5); }); - it('does not call getCheckpointedBlocks(0) when startingBlock is 0', async () => { - // getCheckpointedBlocks rejects block 0 + it('skips Loop 1 entirely when startingBlock is past the checkpointed tip', async () => { + // proposed=15, checkpointed=9 (ckpt 3 covers blocks 7-9). startingBlock=12 is past the + // checkpointed tip, in the proposed range. Loop 1 must skip without an RPC for block 12. + setRemoteTipsMultiBlock(15, 9); + blockStream = new TestL2BlockStream(blockSource, localData, handler, undefined, { + batchSize: 10, + startingBlock: 12, + }); + + await blockStream.work(); + + // No chain-checkpointed events because startingBlock is past the checkpointed tip. + const checkpointEvents = handler.events.filter(e => e.type === 'chain-checkpointed'); + expect(checkpointEvents).toHaveLength(0); + // Loop 1 must not query block 12 — past-the-tip is decided from sourceTips alone. + const loop1Calls = blockSource.getBlockData.mock.calls.filter( + c => 'number' in c[0] && (c[0] as { number: number }).number === 12, + ); + expect(loop1Calls).toHaveLength(0); + }); + + it('calls getBlockData for block 0 only for reorg detection, not checkpoint lookup, when startingBlock is 0', async () => { + // With startingBlock=0, the stream skips the checkpoint-number lookup (line 121 path) + // so getBlockData is called for block 0 only once: for the genesis reorg detection. setRemoteTipsMultiBlock(15, 15); blockStream = new TestL2BlockStream(blockSource, localData, handler, undefined, { batchSize: 10, @@ -821,8 +867,10 @@ describe('L2BlockStream', () => { await blockStream.work(); - const calls = blockSource.getCheckpointedBlocks.mock.calls; - expect(calls.every(([blockNum]) => blockNum >= 1)).toBe(true); + const calls = blockSource.getBlockData.mock.calls; + const block0Calls = calls.filter(c => 'number' in c[0] && (c[0] as { number: number }).number === 0); + // Only the genesis reorg-detection call — not an additional checkpoint-lookup call. + expect(block0Calls).toHaveLength(1); }); }); @@ -1759,6 +1807,12 @@ class TestL2BlockStream extends L2BlockStream { } class TestL2TipsMemoryStore extends L2TipsMemoryStore { + constructor() { + // initialBlockHash must match the test mock's genesis hash (new Fr(0)) so that + // areBlockHashesEqualAt(0) compares matching values and finds no reorg at genesis. + super(new BlockHash(new Fr(0))); + } + protected override computeBlockHash(block: L2Block): Promise<`0x${string}`> { return Promise.resolve(new Fr(block.number).toString()); } diff --git a/yarn-project/stdlib/src/block/l2_block_stream/l2_block_stream.ts b/yarn-project/stdlib/src/block/l2_block_stream/l2_block_stream.ts index 9aac6c45b143..85bf1961bb77 100644 --- a/yarn-project/stdlib/src/block/l2_block_stream/l2_block_stream.ts +++ b/yarn-project/stdlib/src/block/l2_block_stream/l2_block_stream.ts @@ -17,10 +17,7 @@ export class L2BlockStream { private hasStarted = false; constructor( - private l2BlockSource: Pick< - L2BlockSource, - 'getBlocks' | 'getBlockHeader' | 'getL2Tips' | 'getCheckpoints' | 'getCheckpointedBlocks' - >, + private l2BlockSource: Pick, private localData: L2BlockStreamLocalDataProvider, private handler: L2BlockStreamEventHandler, private readonly log = createLogger('types:block_stream'), @@ -77,6 +74,22 @@ export class L2BlockStream { let latestBlockNumber = localTips.proposed.number; const sourceCache = new BlockHashCache([sourceTips.proposed]); while (!(await this.areBlockHashesEqualAt(latestBlockNumber, { sourceCache }))) { + if (latestBlockNumber === 0) { + // We walked all the way back to genesis and the hashes still differ. This means the + // local store and the source disagree on the genesis block itself — typically because + // they were configured with different `genesisTimestamp`/prefilled state. Continuing + // would underflow into negative block numbers and surface as "block hash not found + // for -1" further down. Fail loudly with a meaningful error instead. + this.log.error(`Genesis block hash mismatch between local store and source`, { + localBlockHash: await this.localData.getL2BlockHash(BlockNumber.ZERO), + sourceBlockHash: sourceCache.get(0) ?? (await this.getBlockHashFromSource(BlockNumber.ZERO)), + }); + throw new Error( + 'Genesis block hash mismatch between local store and source: refusing to walk past block 0. ' + + 'This usually indicates the two sides were configured with different genesis values ' + + '(e.g. genesisTimestamp or prefilled public data).', + ); + } latestBlockNumber--; } @@ -97,8 +110,9 @@ export class L2BlockStream { } // If we are just starting, use the starting block number from the options. - if (latestBlockNumber === 0 && this.opts.startingBlock !== undefined) { - latestBlockNumber = BlockNumber(Math.max(this.opts.startingBlock - 1, 0)); + const startingBlock = this.opts.startingBlock !== undefined ? BlockNumber(this.opts.startingBlock) : undefined; + if (latestBlockNumber === 0 && startingBlock !== undefined) { + latestBlockNumber = BlockNumber(Math.max(startingBlock - 1, 0)); } // Only log this entry once (for sanity) @@ -112,21 +126,18 @@ export class L2BlockStream { // When startingBlock is set, also skip ahead for checkpoints. if ( - this.opts.startingBlock !== undefined && - this.opts.startingBlock >= 1 && + startingBlock !== undefined && + startingBlock >= 1 && nextCheckpointToEmit <= sourceTips.checkpointed.checkpoint.number ) { - const startingBlockCheckpoints = await this.l2BlockSource.getCheckpointedBlocks( - BlockNumber(this.opts.startingBlock), - 1, - ); - if (startingBlockCheckpoints.length > 0) { - nextCheckpointToEmit = CheckpointNumber( - Math.max(nextCheckpointToEmit, startingBlockCheckpoints[0].checkpointNumber), - ); - } else { + if (startingBlock > sourceTips.checkpointed.block.number) { // startingBlock is past all checkpointed blocks; skip Loop 1 entirely. nextCheckpointToEmit = CheckpointNumber(sourceTips.checkpointed.checkpoint.number + 1); + } else { + const startingBlockData = await this.l2BlockSource.getBlockData({ number: startingBlock }); + if (startingBlockData) { + nextCheckpointToEmit = CheckpointNumber(Math.max(nextCheckpointToEmit, startingBlockData.checkpointNumber)); + } } } @@ -184,9 +195,9 @@ export class L2BlockStream { // Find the starting checkpoint number if (nextBlockNumber <= sourceTips.checkpointed.block.number) { - const blocks = await this.l2BlockSource.getCheckpointedBlocks(BlockNumber(nextBlockNumber), 1); - if (blocks.length > 0) { - nextCheckpointNumber = blocks[0].checkpointNumber; + const blockData = await this.l2BlockSource.getBlockData({ number: BlockNumber(nextBlockNumber) }); + if (blockData) { + nextCheckpointNumber = blockData.checkpointNumber; } } @@ -234,7 +245,7 @@ export class L2BlockStream { while (nextBlockNumber <= sourceTips.proposed.number) { const limit = Math.min(this.opts.batchSize ?? 50, sourceTips.proposed.number - nextBlockNumber + 1); this.log.trace(`Requesting blocks from ${nextBlockNumber} limit ${limit}`); - const blocks = await this.l2BlockSource.getBlocks(BlockNumber(nextBlockNumber), BlockNumber(limit)); + const blocks = await this.l2BlockSource.getBlocks({ from: BlockNumber(nextBlockNumber), limit }); if (blocks.length === 0) { break; } @@ -266,9 +277,6 @@ export class L2BlockStream { * @param args - A cache of data already requested from source, to avoid re-requesting it. */ private async areBlockHashesEqualAt(blockNumber: BlockNumber, args: { sourceCache: BlockHashCache }) { - if (blockNumber === 0) { - return true; - } const localBlockHash = await this.localData.getL2BlockHash(blockNumber); if (!localBlockHash && this.opts.skipFinalized) { // Failing to find a block hash when skipping finalized blocks can be highly problematic as we'd potentially need @@ -291,8 +299,8 @@ export class L2BlockStream { private getBlockHashFromSource(blockNumber: BlockNumber) { return this.l2BlockSource - .getBlockHeader(blockNumber) - .then(h => h?.hash()) + .getBlockData({ number: blockNumber }) + .then(d => d?.header.hash()) .then(hash => hash?.toString()); } diff --git a/yarn-project/stdlib/src/block/l2_block_stream/l2_tips_memory_store.ts b/yarn-project/stdlib/src/block/l2_block_stream/l2_tips_memory_store.ts index 81a316b5be58..ab5f9e5f5752 100644 --- a/yarn-project/stdlib/src/block/l2_block_stream/l2_tips_memory_store.ts +++ b/yarn-project/stdlib/src/block/l2_block_stream/l2_tips_memory_store.ts @@ -1,6 +1,7 @@ import { BlockNumber, CheckpointNumber } from '@aztec/foundation/branded-types'; import type { PublishedCheckpoint } from '../../checkpoint/published_checkpoint.js'; +import type { BlockHash } from '../block_hash.js'; import type { L2BlockTag } from '../l2_block_source.js'; import { L2TipsStoreBase } from './l2_tips_store_base.js'; @@ -9,6 +10,10 @@ import { L2TipsStoreBase } from './l2_tips_store_base.js'; * @dev Tests in kv-store/src/stores/l2_tips_memory_store.test.ts */ export class L2TipsMemoryStore extends L2TipsStoreBase { + constructor(initialBlockHash: BlockHash) { + super(initialBlockHash); + } + private readonly tips = new Map(); private readonly blockHashes = new Map(); private readonly blockToCheckpoint = new Map(); diff --git a/yarn-project/stdlib/src/block/l2_block_stream/l2_tips_store_base.ts b/yarn-project/stdlib/src/block/l2_block_stream/l2_tips_store_base.ts index 0289670d056e..676e732b665b 100644 --- a/yarn-project/stdlib/src/block/l2_block_stream/l2_tips_store_base.ts +++ b/yarn-project/stdlib/src/block/l2_block_stream/l2_tips_store_base.ts @@ -1,7 +1,7 @@ import { BlockNumber, CheckpointNumber } from '@aztec/foundation/branded-types'; import type { PublishedCheckpoint } from '../../checkpoint/published_checkpoint.js'; -import { GENESIS_BLOCK_HEADER_HASH } from '../block_hash.js'; +import type { BlockHash } from '../block_hash.js'; import type { L2Block } from '../l2_block.js'; import { type CheckpointId, @@ -17,6 +17,7 @@ import type { L2BlockStreamEvent, L2BlockStreamEventHandler, L2BlockStreamLocalD * while delegating storage operations to subclasses. */ export abstract class L2TipsStoreBase implements L2BlockStreamEventHandler, L2BlockStreamLocalDataProvider { + constructor(protected readonly initialBlockHash: BlockHash) {} // Abstract storage primitives - subclasses implement these based on their backing store /** Gets the block number for a given tag. */ @@ -60,7 +61,10 @@ export abstract class L2TipsStoreBase implements L2BlockStreamEventHandler, L2Bl // Public interface implementation - public getL2BlockHash(number: BlockNumber): Promise { + public async getL2BlockHash(number: BlockNumber): Promise { + if (number === 0) { + return (await this.getStoredBlockHash(number)) ?? this.initialBlockHash.toString(); + } return this.getStoredBlockHash(number); } @@ -123,7 +127,7 @@ export abstract class L2TipsStoreBase implements L2BlockStreamEventHandler, L2Bl private async getBlockId(tag: L2BlockTag): Promise { const blockNumber = await this.getTip(tag); if (blockNumber === undefined || blockNumber === 0) { - return { number: BlockNumber.ZERO, hash: GENESIS_BLOCK_HEADER_HASH.toString() }; + return { number: BlockNumber.ZERO, hash: this.initialBlockHash.toString() }; } const blockHash = await this.getStoredBlockHash(blockNumber); if (!blockHash) { @@ -139,7 +143,6 @@ export abstract class L2TipsStoreBase implements L2BlockStreamEventHandler, L2Bl } const checkpointNumber = await this.getCheckpointNumberForBlock(blockNumber); if (checkpointNumber === undefined) { - // No checkpoint associated with this block yet return { number: CheckpointNumber.ZERO, hash: GENESIS_CHECKPOINT_HEADER_HASH.toString() }; } const checkpoint = await this.getCheckpoint(checkpointNumber); diff --git a/yarn-project/stdlib/src/interfaces/archiver.test.ts b/yarn-project/stdlib/src/interfaces/archiver.test.ts index be161a9cfdd4..16df1aa6f3e0 100644 --- a/yarn-project/stdlib/src/interfaces/archiver.test.ts +++ b/yarn-project/stdlib/src/interfaces/archiver.test.ts @@ -9,9 +9,14 @@ import omit from 'lodash.omit'; import type { ContractArtifact } from '../abi/abi.js'; import { FunctionSelector } from '../abi/function_selector.js'; import { AztecAddress } from '../aztec-address/index.js'; -import { CheckpointedL2Block } from '../block/checkpointed_l2_block.js'; import { type BlockData, BlockHash, CommitteeAttestation, L2Block } from '../block/index.js'; -import type { L2Tips } from '../block/l2_block_source.js'; +import { + type BlockQuery, + BlockQuerySchema, + type BlocksQuery, + BlocksQuerySchema, + type L2Tips, +} from '../block/l2_block_source.js'; import type { ValidateCheckpointResult } from '../block/validate_block_result.js'; import { Checkpoint } from '../checkpoint/checkpoint.js'; import type { CheckpointData, ProposedCheckpointData } from '../checkpoint/checkpoint_data.js'; @@ -34,7 +39,6 @@ import { CheckpointHeader } from '../rollup/checkpoint_header.js'; import { randomTxScopedPrivateL2Log } from '../tests/factories.js'; import { getTokenContractArtifact } from '../tests/fixtures.js'; import { AppendOnlyTreeSnapshot } from '../trees/append_only_tree_snapshot.js'; -import { BlockHeader } from '../tx/block_header.js'; import type { IndexedTxEffect } from '../tx/indexed_tx_effect.js'; import { TxEffect } from '../tx/tx_effect.js'; import { TxHash } from '../tx/tx_hash.js'; @@ -103,76 +107,6 @@ describe('ArchiverApiSchema', () => { expect(result).toEqual(BlockNumber(0)); }); - it('getBlock', async () => { - const result = await context.client.getBlock(BlockNumber(1)); - expect(result).toBeInstanceOf(L2Block); - }); - - it('getBlockHeader', async () => { - const result = await context.client.getBlockHeader(BlockNumber(1)); - expect(result).toBeInstanceOf(BlockHeader); - }); - - it('getBlockHeaderByArchive', async () => { - const result = await context.client.getBlockHeaderByArchive(Fr.random()); - expect(result).toBeInstanceOf(BlockHeader); - }); - - it('getBlockData', async () => { - const result = await context.client.getBlockData(BlockNumber(1)); - expect(result).toBeUndefined(); - }); - - it('getBlockDataByArchive', async () => { - const result = await context.client.getBlockDataByArchive(Fr.random()); - expect(result).toBeUndefined(); - }); - - it('getBlockDataWithCheckpointContext', async () => { - const result = await context.client.getBlockDataWithCheckpointContext(BlockNumber(1)); - expect(result).toBeUndefined(); - }); - - it('getCheckpointData', async () => { - const result = await context.client.getCheckpointData(CheckpointNumber(1)); - expect(result).toBeUndefined(); - }); - - it('getCheckpointDataRange', async () => { - const result = await context.client.getCheckpointDataRange(CheckpointNumber(1), 1); - expect(result).toEqual([]); - }); - - it('getCheckpointNumberBySlot', async () => { - const result = await context.client.getCheckpointNumberBySlot(SlotNumber(1)); - expect(result).toBeUndefined(); - }); - - it('getBlockHeaderByHash', async () => { - const result = await context.client.getBlockHeaderByHash(BlockHash.random()); - expect(result).toBeInstanceOf(BlockHeader); - }); - - it('getL2Block', async () => { - const result = await context.client.getL2Block(BlockNumber(1)); - expect(result).toBeInstanceOf(L2Block); - }); - - it('getL2BlockByHash', async () => { - const result = await context.client.getL2BlockByHash(BlockHash.random()); - expect(result).toBeInstanceOf(L2Block); - }); - - it('getL2BlockByArchive', async () => { - const result = await context.client.getL2BlockByArchive(Fr.random()); - expect(result).toBeInstanceOf(L2Block); - }); - - it('getBlocks', async () => { - const result = await context.client.getBlocks(BlockNumber(1), BlockNumber(1)); - expect(result).toEqual([expect.any(L2Block)]); - }); - it('getCheckpoints', async () => { const response = await context.client.getCheckpoints(CheckpointNumber(1), BlockNumber(1)); expect(response).toHaveLength(1); @@ -181,22 +115,6 @@ describe('ArchiverApiSchema', () => { expect(response[0].l1).toBeDefined(); }); - it('getCheckpointedBlockByArchive', async () => { - const result = await context.client.getCheckpointedBlockByArchive(Fr.random()); - expect(result).toBeDefined(); - expect(result!.block.constructor.name).toEqual('L2Block'); - expect(result!.attestations[0]).toBeInstanceOf(CommitteeAttestation); - expect(result!.l1).toBeDefined(); - }); - - it('getCheckpointedBlockByHash', async () => { - const result = await context.client.getCheckpointedBlockByHash(BlockHash.random()); - expect(result).toBeDefined(); - expect(result!.block.constructor.name).toEqual('L2Block'); - expect(result!.attestations[0]).toBeInstanceOf(CommitteeAttestation); - expect(result!.l1).toBeDefined(); - }); - it('getTxEffect', async () => { const result = await context.client.getTxEffect(TxHash.fromBuffer(Buffer.alloc(32, BlockNumber(1)))); expect(result!.data).toBeInstanceOf(TxEffect); @@ -230,38 +148,11 @@ describe('ArchiverApiSchema', () => { expect(result[0].attestations[0]).toBeInstanceOf(CommitteeAttestation); }); - it('getCheckpointedBlock', async () => { - const result = await context.client.getCheckpointedBlock(BlockNumber(1)); - expect(result).toBeDefined(); - expect(result!.block.constructor.name).toEqual('L2Block'); - expect(result!.attestations[0]).toBeInstanceOf(CommitteeAttestation); - expect(result!.l1).toBeDefined(); - }); - - it('getCheckpointedBlocks', async () => { - const result = await context.client.getCheckpointedBlocks(BlockNumber(1), 10); - expect(result).toHaveLength(1); - expect(result[0].block.constructor.name).toEqual('L2Block'); - expect(result[0].attestations[0]).toBeInstanceOf(CommitteeAttestation); - expect(result[0].l1).toBeDefined(); - }); - - it('getCheckpointedBlocksForEpoch', async () => { - const result = await context.client.getCheckpointedBlocksForEpoch(EpochNumber(1)); - expect(result).toHaveLength(1); - expect(result[0]).toBeInstanceOf(CheckpointedL2Block); - }); - it('getBlocksForSlot', async () => { const result = await context.client.getBlocksForSlot(SlotNumber(1)); expect(result).toEqual([expect.any(L2Block)]); }); - it('getCheckpointedBlockHeadersForEpoch', async () => { - const result = await context.client.getCheckpointedBlockHeadersForEpoch(EpochNumber(1)); - expect(result).toEqual([expect.any(BlockHeader)]); - }); - it('isEpochComplete', async () => { const result = await context.client.isEpochComplete(EpochNumber(1)); expect(result).toBe(true); @@ -416,14 +307,94 @@ describe('ArchiverApiSchema', () => { expect(result).toBe(false); }); + it('getCheckpointData', async () => { + const result = await context.client.getCheckpointData(CheckpointNumber(1)); + expect(result).toBeUndefined(); + }); + + it('getCheckpointDataRange', async () => { + const result = await context.client.getCheckpointDataRange(CheckpointNumber(1), 10); + expect(result).toEqual([]); + }); + + it('getCheckpointNumberBySlot', async () => { + const result = await context.client.getCheckpointNumberBySlot(SlotNumber(1)); + expect(result).toBeUndefined(); + }); + it('getGenesisValues', async () => { const result = await context.client.getGenesisValues(); expect(result).toEqual({ genesisArchiveRoot: expect.any(Fr) }); }); - it('getL2Block', async () => { - const result = await context.client.getL2Block(BlockNumber(1)); - expect(result).toEqual(expect.any(L2Block)); + it('getBlock', async () => { + const result = await context.client.getBlock({ number: BlockNumber(1) }); + expect(result).toBeInstanceOf(L2Block); + }); + + it('getBlocks', async () => { + const result = await context.client.getBlocks({ from: BlockNumber(1), limit: 1 }); + expect(result).toEqual([expect.any(L2Block)]); + }); + + it('getBlockData', async () => { + const result = await context.client.getBlockData({ number: BlockNumber(1) }); + expect(result).toBeUndefined(); + }); + + it('getBlocksData', async () => { + const result = await context.client.getBlocksData({ from: BlockNumber(1), limit: 1 }); + expect(result).toEqual([]); + }); +}); + +describe('BlockQuerySchema', () => { + it.each<[string, BlockQuery]>([ + ['{ number }', { number: BlockNumber(1) }], + ['{ hash }', { hash: BlockHash.fromBuffer(Buffer.alloc(32, 1)) }], + ['{ archive }', { archive: new Fr(123) }], + ['{ tag: proposed }', { tag: 'proposed' }], + ['{ tag: checkpointed }', { tag: 'checkpointed' }], + ['{ tag: proven }', { tag: 'proven' }], + ['{ tag: finalized }', { tag: 'finalized' }], + ])('roundtrips %s', (_, query) => { + const json = JSON.parse(JSON.stringify(query)); + const parsed = BlockQuerySchema.parse(json); + expect(parsed).toEqual(query); + }); + + it('rejects mixed-key inputs', () => { + expect(BlockQuerySchema.safeParse({ number: 1, tag: 'proven' }).success).toBe(false); + expect(BlockQuerySchema.safeParse({ hash: '0x1', archive: '0x2' }).success).toBe(false); + }); + + it('rejects extra keys (onlyCheckpointed is plural-only)', () => { + expect(BlockQuerySchema.safeParse({ number: 1, onlyCheckpointed: true }).success).toBe(false); + expect(BlockQuerySchema.safeParse({ tag: 'checkpointed', onlyCheckpointed: true }).success).toBe(false); + }); +}); + +describe('BlocksQuerySchema', () => { + it.each<[string, BlocksQuery]>([ + ['{ from, limit }', { from: BlockNumber(1), limit: 10 }], + ['{ from, limit, onlyCheckpointed }', { from: BlockNumber(1), limit: 10, onlyCheckpointed: true }], + ['{ epoch, onlyCheckpointed: true }', { epoch: EpochNumber(5), onlyCheckpointed: true }], + ])('roundtrips %s', (_, query) => { + const json = JSON.parse(JSON.stringify(query)); + const parsed = BlocksQuerySchema.parse(json); + expect(parsed).toEqual(query); + }); + + it('rejects mixed-key inputs', () => { + expect(BlocksQuerySchema.safeParse({ from: 0, limit: 10, epoch: 5 }).success).toBe(false); + }); + + it('rejects epoch query without onlyCheckpointed', () => { + expect(BlocksQuerySchema.safeParse({ epoch: 1 }).success).toBe(false); + }); + + it('rejects epoch query with onlyCheckpointed: false', () => { + expect(BlocksQuerySchema.safeParse({ epoch: 1, onlyCheckpointed: false }).success).toBe(false); }); }); @@ -463,7 +434,9 @@ class MockArchiver implements ArchiverApi { getRegistryAddress(): Promise { return Promise.resolve(EthAddress.random()); } - getBlockNumber(): Promise { + getBlockNumber(): Promise; + getBlockNumber(query: BlockQuery): Promise; + getBlockNumber(_query?: BlockQuery): Promise { return Promise.resolve(BlockNumber(1)); } getProvenBlockNumber(): Promise { @@ -478,34 +451,17 @@ class MockArchiver implements ArchiverApi { getFinalizedL2BlockNumber(): Promise { return Promise.resolve(BlockNumber(0)); } - getBlock(number: BlockNumber): Promise { - return L2Block.random(number); - } - getBlockHeader(_number: BlockNumber | 'latest'): Promise { - return Promise.resolve(BlockHeader.empty()); + getBlock(_query: BlockQuery): Promise { + return L2Block.random(BlockNumber(1)); } - async getCheckpointedBlock(number: BlockNumber): Promise { - return Promise.resolve( - CheckpointedL2Block.fromFields({ - checkpointNumber: CheckpointNumber(1), - block: await L2Block.random(number), - attestations: [CommitteeAttestation.random()], - l1: new L1PublishedData(1n, 0n, `0x`), - }), - ); + async getBlocks(_query: BlocksQuery): Promise { + return [await L2Block.random(BlockNumber(1))]; } - async getCheckpointedBlocks(from: BlockNumber, _limit: number): Promise { - return [ - CheckpointedL2Block.fromFields({ - checkpointNumber: CheckpointNumber(1), - block: await L2Block.random(from), - attestations: [CommitteeAttestation.random()], - l1: new L1PublishedData(1n, 0n, `0x`), - }), - ]; + getBlockData(_query: BlockQuery): Promise { + return Promise.resolve(undefined); } - async getBlocks(from: BlockNumber, _limit: number): Promise { - return [await L2Block.random(from)]; + getBlocksData(_query: BlocksQuery): Promise { + return Promise.resolve([]); } async getCheckpoints(from: CheckpointNumber, _limit: number): Promise { return [ @@ -516,47 +472,6 @@ class MockArchiver implements ArchiverApi { }), ]; } - getCheckpointByArchive(_archive: Fr): Promise { - return Promise.resolve(Checkpoint.random()); - } - - async getCheckpointedBlockByHash(_blockHash: BlockHash): Promise { - return CheckpointedL2Block.fromFields({ - checkpointNumber: CheckpointNumber(1), - block: await L2Block.random(BlockNumber(1)), - attestations: [CommitteeAttestation.random()], - l1: new L1PublishedData(1n, 0n, `0x`), - }); - } - async getCheckpointedBlockByArchive(_archive: Fr): Promise { - return CheckpointedL2Block.fromFields({ - checkpointNumber: CheckpointNumber(1), - block: await L2Block.random(BlockNumber(1)), - attestations: [CommitteeAttestation.random()], - l1: new L1PublishedData(1n, 0n, `0x`), - }); - } - getBlockHeaderByHash(_blockHash: BlockHash): Promise { - return Promise.resolve(BlockHeader.empty()); - } - getBlockHeaderByArchive(_archive: Fr): Promise { - return Promise.resolve(BlockHeader.empty()); - } - getBlockData(_number: BlockNumber): Promise { - return Promise.resolve(undefined); - } - getBlockDataByArchive(_archive: Fr): Promise { - return Promise.resolve(undefined); - } - getL2Block(number: BlockNumber): Promise { - return L2Block.random(number); - } - getL2BlockByHash(_blockHash: BlockHash): Promise { - return L2Block.random(BlockNumber(1)); - } - getL2BlockByArchive(_archive: Fr): Promise { - return L2Block.random(BlockNumber(1)); - } async getTxEffect(_txHash: TxHash): Promise { expect(_txHash).toBeInstanceOf(TxHash); return { @@ -606,30 +521,10 @@ class MockArchiver implements ArchiverApi { getCheckpointNumberBySlot(_slot: SlotNumber): Promise { return Promise.resolve(undefined); } - getBlockDataWithCheckpointContext(_n: BlockNumber) { - return Promise.resolve(undefined); - } - async getCheckpointedBlocksForEpoch(epochNumber: EpochNumber): Promise { - expect(epochNumber).toEqual(EpochNumber(1)); - const block = await L2Block.random(BlockNumber(Number(epochNumber))); - return [ - CheckpointedL2Block.fromFields({ - checkpointNumber: CheckpointNumber(1), - block, - l1: new L1PublishedData(1n, 1n, `0x01`), - attestations: [CommitteeAttestation.random()], - }), - ]; - } async getBlocksForSlot(slotNumber: SlotNumber): Promise { expect(slotNumber).toEqual(SlotNumber(1)); return [await L2Block.random(BlockNumber(Number(slotNumber)))]; } - async getCheckpointedBlockHeadersForEpoch(epochNumber: EpochNumber): Promise { - expect(epochNumber).toEqual(EpochNumber(1)); - const block = await L2Block.random(BlockNumber(Number(epochNumber))); - return [block.header]; - } isEpochComplete(epochNumber: EpochNumber): Promise { expect(epochNumber).toEqual(EpochNumber(1)); return Promise.resolve(true); diff --git a/yarn-project/stdlib/src/interfaces/archiver.ts b/yarn-project/stdlib/src/interfaces/archiver.ts index 2fb4049ca4da..9809a2d1af15 100644 --- a/yarn-project/stdlib/src/interfaces/archiver.ts +++ b/yarn-project/stdlib/src/interfaces/archiver.ts @@ -4,11 +4,9 @@ import type { ApiSchemaFor } from '@aztec/foundation/schemas'; import { z } from 'zod'; -import { BlockDataSchema, BlockDataWithCheckpointContextSchema } from '../block/block_data.js'; -import { BlockHash } from '../block/block_hash.js'; -import { CheckpointedL2Block } from '../block/checkpointed_l2_block.js'; +import { BlockDataSchema } from '../block/block_data.js'; import { L2Block } from '../block/l2_block.js'; -import { type L2BlockSource, L2TipsSchema } from '../block/l2_block_source.js'; +import { BlockQuerySchema, BlocksQuerySchema, type L2BlockSource, L2TipsSchema } from '../block/l2_block_source.js'; import { ValidateCheckpointResultSchema } from '../block/validate_block_result.js'; import { Checkpoint } from '../checkpoint/checkpoint.js'; import { CheckpointDataSchema, ProposedCheckpointDataSchema } from '../checkpoint/checkpoint_data.js'; @@ -25,7 +23,6 @@ import { Tag } from '../logs/tag.js'; import { TxScopedL2Log } from '../logs/tx_scoped_l2_log.js'; import type { L1ToL2MessageSource } from '../messaging/l1_to_l2_message_source.js'; import { optional, schemas } from '../schemas/schemas.js'; -import { BlockHeader } from '../tx/block_header.js'; import { indexedTxSchema } from '../tx/indexed_tx_effect.js'; import { TxHash } from '../tx/tx_hash.js'; import { TxReceipt } from '../tx/tx_receipt.js'; @@ -85,60 +82,34 @@ export const ArchiverSpecificConfigSchema = z.object({ export type ArchiverApi = Omit< L2BlockSource & L2LogsSource & ContractDataSource & L1ToL2MessageSource, - 'start' | 'stop' + 'start' | 'stop' | 'getGenesisBlockHash' >; export const ArchiverApiSchema: ApiSchemaFor = { getRollupAddress: z.function().args().returns(schemas.EthAddress), getRegistryAddress: z.function().args().returns(schemas.EthAddress), - getBlockNumber: z.function().args().returns(BlockNumberSchema), + getBlockNumber: z.function().args(optional(BlockQuerySchema)).returns(BlockNumberSchema.optional()), getProvenBlockNumber: z.function().args().returns(BlockNumberSchema), getCheckpointedL2BlockNumber: z.function().args().returns(BlockNumberSchema), getCheckpointNumber: z.function().args().returns(CheckpointNumberSchema), getFinalizedL2BlockNumber: z.function().args().returns(BlockNumberSchema), - getBlock: z.function().args(BlockNumberSchema).returns(L2Block.schema.optional()), - getBlockHeader: z - .function() - .args(z.union([BlockNumberSchema, z.literal('latest')])) - .returns(BlockHeader.schema.optional()), - getCheckpointedBlock: z.function().args(BlockNumberSchema).returns(CheckpointedL2Block.schema.optional()), - getCheckpointedBlocks: z - .function() - .args(BlockNumberSchema, schemas.Integer) - .returns(z.array(CheckpointedL2Block.schema)), - getBlocks: z.function().args(BlockNumberSchema, schemas.Integer).returns(z.array(L2Block.schema)), getCheckpoints: z .function() .args(CheckpointNumberSchema, schemas.Integer) .returns(z.array(PublishedCheckpoint.schema)), - getCheckpointedBlockByHash: z.function().args(BlockHash.schema).returns(CheckpointedL2Block.schema.optional()), - getCheckpointedBlockByArchive: z.function().args(schemas.Fr).returns(CheckpointedL2Block.schema.optional()), - getBlockHeaderByHash: z.function().args(BlockHash.schema).returns(BlockHeader.schema.optional()), - getBlockHeaderByArchive: z.function().args(schemas.Fr).returns(BlockHeader.schema.optional()), - getBlockData: z.function().args(BlockNumberSchema).returns(BlockDataSchema.optional()), - getBlockDataByArchive: z.function().args(schemas.Fr).returns(BlockDataSchema.optional()), - getBlockDataWithCheckpointContext: z - .function() - .args(BlockNumberSchema) - .returns(BlockDataWithCheckpointContextSchema.optional()), getCheckpointData: z.function().args(CheckpointNumberSchema).returns(CheckpointDataSchema.optional()), getCheckpointDataRange: z .function() .args(CheckpointNumberSchema, schemas.Integer) .returns(z.array(CheckpointDataSchema)), getCheckpointNumberBySlot: z.function().args(schemas.SlotNumber).returns(CheckpointNumberSchema.optional()), - getL2Block: z.function().args(BlockNumberSchema).returns(L2Block.schema.optional()), - getL2BlockByHash: z.function().args(BlockHash.schema).returns(L2Block.schema.optional()), - getL2BlockByArchive: z.function().args(schemas.Fr).returns(L2Block.schema.optional()), getTxEffect: z.function().args(TxHash.schema).returns(indexedTxSchema().optional()), getSettledTxReceipt: z.function().args(TxHash.schema).returns(TxReceipt.schema.optional()), getSyncedL2SlotNumber: z.function().args().returns(schemas.SlotNumber.optional()), getSyncedL2EpochNumber: z.function().args().returns(EpochNumberSchema.optional()), getCheckpointsForEpoch: z.function().args(EpochNumberSchema).returns(z.array(Checkpoint.schema)), getCheckpointsDataForEpoch: z.function().args(EpochNumberSchema).returns(z.array(CheckpointDataSchema)), - getCheckpointedBlocksForEpoch: z.function().args(EpochNumberSchema).returns(z.array(CheckpointedL2Block.schema)), getBlocksForSlot: z.function().args(schemas.SlotNumber).returns(z.array(L2Block.schema)), - getCheckpointedBlockHeadersForEpoch: z.function().args(EpochNumberSchema).returns(z.array(BlockHeader.schema)), isEpochComplete: z.function().args(EpochNumberSchema).returns(z.boolean()), getL2Tips: z.function().args().returns(L2TipsSchema), getPrivateLogsByTags: z @@ -173,4 +144,8 @@ export const ArchiverApiSchema: ApiSchemaFor = { syncImmediate: z.function().args().returns(z.void()), isPendingChainInvalid: z.function().args().returns(z.boolean()), getPendingChainValidationStatus: z.function().args().returns(ValidateCheckpointResultSchema), + getBlock: z.function().args(BlockQuerySchema).returns(L2Block.schema.optional()), + getBlocks: z.function().args(BlocksQuerySchema).returns(z.array(L2Block.schema)), + getBlockData: z.function().args(BlockQuerySchema).returns(BlockDataSchema.optional()), + getBlocksData: z.function().args(BlocksQuerySchema).returns(z.array(BlockDataSchema)), }; diff --git a/yarn-project/stdlib/src/interfaces/aztec-node.test.ts b/yarn-project/stdlib/src/interfaces/aztec-node.test.ts index f648ae991c47..dd3cdc3fab00 100644 --- a/yarn-project/stdlib/src/interfaces/aztec-node.test.ts +++ b/yarn-project/stdlib/src/interfaces/aztec-node.test.ts @@ -14,7 +14,7 @@ import times from 'lodash.times'; import type { ContractArtifact } from '../abi/abi.js'; import { AztecAddress } from '../aztec-address/index.js'; import type { DataInBlock } from '../block/in_block.js'; -import { BlockHash, type BlockParameter, type CheckpointedL2Block } from '../block/index.js'; +import { BlockHash, type BlockParameter } from '../block/index.js'; import type { L2Tips } from '../block/l2_block_source.js'; import { type ContractClassPublic, @@ -75,7 +75,7 @@ describe('AztecNodeApiSchema', () => { }); afterEach(() => { - tested.add(/^AztecNodeApiSchema\s+([^(]+)/.exec(expect.getState().currentTestName!)![1]); + tested.add(/^AztecNodeApiSchema\s+([^(]+?)\s*(\(|$)/.exec(expect.getState().currentTestName!)![1]); context.httpServer.close(); }); @@ -98,6 +98,21 @@ describe('AztecNodeApiSchema', () => { }); }); + it('getL2Tips', async () => { + const result = await context.client.getL2Tips(); + const expectedTipId = { + block: { number: 1, hash: `0x01` }, + checkpoint: { number: 1, hash: `0x01` }, + }; + expect(result).toEqual({ + proposed: { number: 1, hash: `0x01` }, + checkpointed: expectedTipId, + proposedCheckpoint: expectedTipId, + proven: expectedTipId, + finalized: expectedTipId, + }); + }); + it('findLeavesIndexes', async () => { const response = await context.client.findLeavesIndexes(BlockNumber(1), MerkleTreeId.ARCHIVE, [ Fr.random(), @@ -166,29 +181,14 @@ describe('AztecNodeApiSchema', () => { it('getBlockHeader', async () => { const response = await context.client.getBlockHeader(BlockNumber(1)); - expect(response).toEqual(BlockHeader.empty()); + expect(response).toBeInstanceOf(BlockHeader); }); it('getCheckpointedBlocks', async () => { - const response = await context.client.getCheckpointedBlocks(BlockNumber(1), 1); + const response = await context.client.getCheckpointedBlocks(BlockNumber(1), 10); expect(response).toEqual([]); }); - it('getL2Tips', async () => { - const response = await context.client.getL2Tips(); - const tipId = { - block: { number: 1, hash: `0x01` }, - checkpoint: { number: 1, hash: `0x01` }, - }; - expect(response).toEqual({ - proposed: { number: 1, hash: `0x01` }, - checkpointed: tipId, - proposedCheckpoint: tipId, - proven: tipId, - finalized: tipId, - }); - }); - it('getCheckpoint', async () => { const response = await context.client.getCheckpoint(CheckpointNumber(1)); expect(response).toBeUndefined(); @@ -347,6 +347,25 @@ describe('AztecNodeApiSchema', () => { expect(response).toBeInstanceOf(Fr); }); + it.each<[string, BlockParameter]>([ + ['BlockNumber', BlockNumber(7)], + ['BlockHash', new BlockHash(new Fr(0x1234))], + ['{ archive }', { archive: new Fr(0x5678) }], + ['tag latest', 'latest'], + ['tag proven', 'proven'], + ])('getPublicStorageAt (round-trips %s)', async (_, block) => { + handler.lastReferenceBlock = undefined; + await context.client.getPublicStorageAt(block, await AztecAddress.random(), Fr.random()); + if (typeof block === 'object' && 'archive' in block) { + expect(handler.lastReferenceBlock).toEqual({ archive: expect.any(Fr) }); + } else if (BlockHash.isBlockHash(block)) { + expect(BlockHash.isBlockHash(handler.lastReferenceBlock)).toBe(true); + expect((handler.lastReferenceBlock as unknown as BlockHash).toString()).toEqual(block.toString()); + } else { + expect(handler.lastReferenceBlock).toEqual(block); + } + }); + it('getValidatorsStats', async () => { handler.validatorStats = { stats: { @@ -502,6 +521,7 @@ describe('AztecNodeApiSchema', () => { class MockAztecNode implements AztecNode { public validatorStats: ValidatorsStats | undefined; public singleValidatorStats: SingleValidatorStats | undefined; + public lastReferenceBlock: BlockParameter | undefined; constructor(private artifact: ContractArtifact) {} @@ -580,7 +600,7 @@ class MockAztecNode implements AztecNode { return Promise.resolve(BlockHeader.empty()); } - getCheckpointedBlocks(_from: BlockNumber, _limit: number): Promise { + getCheckpointedBlocks(_from: BlockNumber, _limit: number): Promise { return Promise.resolve([]); } @@ -790,9 +810,15 @@ class MockAztecNode implements AztecNode { return Promise.resolve([Tx.random()]); } getPublicStorageAt(block: BlockParameter, contract: AztecAddress, slot: Fr): Promise { - expect(block === 'latest' || block instanceof Fr || typeof block === 'number').toBe(true); + expect( + typeof block === 'number' || + typeof block === 'string' || + BlockHash.isBlockHash(block) || + (typeof block === 'object' && block !== null), + ).toBe(true); expect(contract).toBeInstanceOf(AztecAddress); expect(slot).toBeInstanceOf(Fr); + this.lastReferenceBlock = block; return Promise.resolve(Fr.random()); } getValidatorsStats(): Promise { diff --git a/yarn-project/stdlib/src/interfaces/aztec-node.ts b/yarn-project/stdlib/src/interfaces/aztec-node.ts index 2d0660fe3183..4e71f392d04a 100644 --- a/yarn-project/stdlib/src/interfaces/aztec-node.ts +++ b/yarn-project/stdlib/src/interfaces/aztec-node.ts @@ -21,7 +21,6 @@ import { z } from 'zod'; import type { AztecAddress } from '../aztec-address/index.js'; import { BlockHash } from '../block/block_hash.js'; import { type BlockParameter, BlockParameterSchema } from '../block/block_parameter.js'; -import { CheckpointedL2Block } from '../block/checkpointed_l2_block.js'; import { type DataInBlock, dataInBlockSchemaFor } from '../block/in_block.js'; import { type L2Tips, L2TipsSchema } from '../block/l2_block_source.js'; import { type CheckpointData, CheckpointDataSchema } from '../checkpoint/checkpoint_data.js'; @@ -227,7 +226,7 @@ export interface AztecNode { /** @deprecated Scheduled for removal; use `getBlock(param).then(r => r?.header)`. */ getBlockHeader(number: BlockNumber | 'latest'): Promise; /** @deprecated Scheduled for removal; use `getBlocks(from, limit, { includeL1PublishInfo: true, includeAttestations: true })`. */ - getCheckpointedBlocks(from: BlockNumber, limit: number): Promise; + getCheckpointedBlocks(from: BlockNumber, limit: number): Promise; /** @deprecated Scheduled for removal; use `getCheckpoints(from, limit)` over an explicit checkpoint range. */ getCheckpointsDataForEpoch(epoch: EpochNumber): Promise; @@ -568,7 +567,7 @@ export const AztecNodeApiSchema: ApiSchemaFor = { getCheckpointedBlocks: z .function() .args(BlockNumberPositiveSchema, z.number().gt(0).lte(MAX_RPC_BLOCKS_LEN)) - .returns(z.array(CheckpointedL2Block.schema)), + .returns(z.array(BlockResponseSchema)), getCheckpointsDataForEpoch: z.function().args(EpochNumberSchema).returns(z.array(CheckpointDataSchema)), diff --git a/yarn-project/stdlib/src/interfaces/checkpoint_parameter.ts b/yarn-project/stdlib/src/interfaces/checkpoint_parameter.ts index d6244456febe..36725a304ecc 100644 --- a/yarn-project/stdlib/src/interfaces/checkpoint_parameter.ts +++ b/yarn-project/stdlib/src/interfaces/checkpoint_parameter.ts @@ -12,11 +12,11 @@ import { ChainTipSchema } from './chain_tips.js'; * means the most recent confirmed checkpoint). */ export const CheckpointParameterSchema = z.union([ - CheckpointNumberSchema, + z.object({ number: CheckpointNumberSchema }).strict(), + z.object({ slot: SlotNumberSchema }).strict(), ChainTipSchema, z.literal('latest'), - z.object({ number: CheckpointNumberSchema }), - z.object({ slot: SlotNumberSchema }), + CheckpointNumberSchema, ]); export type CheckpointParameter = z.infer; diff --git a/yarn-project/stdlib/src/interfaces/p2p.test.ts b/yarn-project/stdlib/src/interfaces/p2p.test.ts index 3b03eb9158a9..37c64471ed15 100644 --- a/yarn-project/stdlib/src/interfaces/p2p.test.ts +++ b/yarn-project/stdlib/src/interfaces/p2p.test.ts @@ -1,4 +1,4 @@ -import { SlotNumber } from '@aztec/foundation/branded-types'; +import { CheckpointProposalHash, SlotNumber } from '@aztec/foundation/branded-types'; import { type JsonRpcTestContext, createJsonRpcTestSetup } from '@aztec/foundation/json-rpc/test'; import { CheckpointAttestation } from '../p2p/checkpoint_attestation.js'; @@ -27,7 +27,10 @@ describe('P2PApiSchema', () => { }); it('getCheckpointAttestationsForSlot', async () => { - const attestations = await context.client.getCheckpointAttestationsForSlot(SlotNumber(1), 'proposalId'); + const attestations = await context.client.getCheckpointAttestationsForSlot( + SlotNumber(1), + CheckpointProposalHash('0xdeadbeef'), + ); expect(attestations).toEqual([CheckpointAttestation.empty()]); expect(attestations[0]).toBeInstanceOf(CheckpointAttestation); }); @@ -65,9 +68,12 @@ const peers: PeerInfo[] = [ ]; class MockP2P implements P2PApi { - getCheckpointAttestationsForSlot(slot: SlotNumber, proposalId?: string): Promise { + getCheckpointAttestationsForSlot( + slot: SlotNumber, + proposalId?: CheckpointProposalHash, + ): Promise { expect(slot).toEqual(SlotNumber(1)); - expect(proposalId).toEqual('proposalId'); + expect(proposalId).toEqual(CheckpointProposalHash('0xdeadbeef')); return Promise.resolve([CheckpointAttestation.empty()]); } diff --git a/yarn-project/stdlib/src/interfaces/p2p.ts b/yarn-project/stdlib/src/interfaces/p2p.ts index 301cbb4f9fee..a729ecf22938 100644 --- a/yarn-project/stdlib/src/interfaces/p2p.ts +++ b/yarn-project/stdlib/src/interfaces/p2p.ts @@ -1,4 +1,4 @@ -import type { SlotNumber } from '@aztec/foundation/branded-types'; +import type { CheckpointProposalHash, SlotNumber } from '@aztec/foundation/branded-types'; import { z } from 'zod'; @@ -49,13 +49,19 @@ export interface P2PApi { getPeers(includePending?: boolean): Promise; /** - * Queries the Attestation pool for checkpoint attestations for the given slot + * Queries the Attestation pool for checkpoint attestations for the given slot. * * @param slot - the slot to query - * @param proposalId - the proposal id to query, or undefined to query all proposals for the slot + * @param proposalPayloadHash - hex-encoded keccak256 of the target proposal's signed + * payload (`CheckpointProposal.getPayloadHash()`). When provided, only + * attestations whose own signed payload hashes to the same value are returned. + * When omitted, all attestations for the slot are returned. * @returns CheckpointAttestations */ - getCheckpointAttestationsForSlot(slot: SlotNumber, proposalId?: string): Promise; + getCheckpointAttestationsForSlot( + slot: SlotNumber, + proposalPayloadHash?: CheckpointProposalHash, + ): Promise; } export interface P2PClient extends P2PApi { @@ -66,7 +72,10 @@ export interface P2PClient extends P2PApi { export const P2PApiSchema: ApiSchemaFor = { getCheckpointAttestationsForSlot: z .function() - .args(schemas.SlotNumber, optional(z.string())) + .args( + schemas.SlotNumber, + optional(z.string().regex(/^0x[0-9a-fA-F]+$/) as unknown as z.ZodType), + ) .returns(z.array(CheckpointAttestation.schema)), getPendingTxs: z .function() diff --git a/yarn-project/stdlib/src/p2p/block_proposal.ts b/yarn-project/stdlib/src/p2p/block_proposal.ts index de5a36e2ca64..1d29fa05ff99 100644 --- a/yarn-project/stdlib/src/p2p/block_proposal.ts +++ b/yarn-project/stdlib/src/p2p/block_proposal.ts @@ -5,7 +5,7 @@ import { IndexWithinCheckpoint, SlotNumber, } from '@aztec/foundation/branded-types'; -import type { BaseBuffer32 } from '@aztec/foundation/buffer'; +import { type BaseBuffer32, Buffer32 } from '@aztec/foundation/buffer'; import { keccak256 } from '@aztec/foundation/crypto/keccak'; import { Fr } from '@aztec/foundation/curves/bn254'; import type { EthAddress } from '@aztec/foundation/eth-address'; @@ -92,7 +92,7 @@ export class BlockProposal extends Gossipable implements Signable { } override generateP2PMessageIdentifier(): Promise { - return Promise.resolve(BlockProposalHash.fromBuffer(keccak256(this.signature.toBuffer()))); + return Promise.resolve(new Buffer32(this.getPayloadHashBuffer())); } get archive(): Fr { @@ -137,6 +137,21 @@ export class BlockProposal extends Gossipable implements Signable { ]); } + private getPayloadHashBuffer(): Buffer { + return keccak256(this.getPayloadToSign()); + } + + /** + * Returns a keccak256 hash of the signed payload. + * Used by the attestation pool to dedup distinct signed payloads at the same + * (slot, indexWithinCheckpoint) regardless of archive collisions. + * The hash deliberately excludes the signature so non-deterministic ECDSA + * re-signs of the same payload do not look like equivocation. + */ + getPayloadHash(): BlockProposalHash { + return BlockProposalHash.fromBuffer(this.getPayloadHashBuffer()); + } + static async createProposalFromSigner( blockHeader: BlockHeader, checkpointNumber: CheckpointNumber, diff --git a/yarn-project/stdlib/src/p2p/checkpoint_attestation.ts b/yarn-project/stdlib/src/p2p/checkpoint_attestation.ts index f3550d638e20..c054a6c5dc40 100644 --- a/yarn-project/stdlib/src/p2p/checkpoint_attestation.ts +++ b/yarn-project/stdlib/src/p2p/checkpoint_attestation.ts @@ -1,6 +1,5 @@ -import { CheckpointAttestationHash, SlotNumber } from '@aztec/foundation/branded-types'; -import type { BaseBuffer32 } from '@aztec/foundation/buffer'; -import { keccak256 } from '@aztec/foundation/crypto/keccak'; +import { CheckpointProposalHash, type SlotNumber } from '@aztec/foundation/branded-types'; +import { type BaseBuffer32, Buffer32 } from '@aztec/foundation/buffer'; import type { Fr } from '@aztec/foundation/curves/bn254'; import type { EthAddress } from '@aztec/foundation/eth-address'; import { Signature } from '@aztec/foundation/eth-signature'; @@ -15,8 +14,6 @@ import { Gossipable } from './gossipable.js'; import { type CoordinationSignatureContext, recoverCoordinationSigner } from './signature_utils.js'; import { TopicType } from './topic_type.js'; -export type { CheckpointAttestationHash } from '@aztec/foundation/branded-types'; - /** * CheckpointAttestation * @@ -53,7 +50,7 @@ export class CheckpointAttestation extends Gossipable { } override generateP2PMessageIdentifier(): Promise { - return Promise.resolve(CheckpointAttestationHash.fromBuffer(keccak256(this.signature.toBuffer()))); + return Promise.resolve(new Buffer32(this.payload.getPayloadHash())); } get archive(): Fr { @@ -104,6 +101,14 @@ export class CheckpointAttestation extends Gossipable { return this.payload.getPayloadToSign(); } + /** + * Returns a keccak256 hash of the signed consensus payload. + * Used to dedup distinct signed payloads. Returns same hash than the corresponding proposal. + */ + getPayloadHash(): CheckpointProposalHash { + return CheckpointProposalHash.fromBuffer(this.payload.getPayloadHash()); + } + toBuffer(): Buffer { return serializeToBuffer([this.payload, this.signature, this.proposerSignature]); } diff --git a/yarn-project/stdlib/src/p2p/checkpoint_proposal.ts b/yarn-project/stdlib/src/p2p/checkpoint_proposal.ts index dc832cda5138..2595068e7f6d 100644 --- a/yarn-project/stdlib/src/p2p/checkpoint_proposal.ts +++ b/yarn-project/stdlib/src/p2p/checkpoint_proposal.ts @@ -4,8 +4,7 @@ import { IndexWithinCheckpoint, SlotNumber, } from '@aztec/foundation/branded-types'; -import type { BaseBuffer32 } from '@aztec/foundation/buffer'; -import { keccak256 } from '@aztec/foundation/crypto/keccak'; +import { type BaseBuffer32, Buffer32 } from '@aztec/foundation/buffer'; import { Fr } from '@aztec/foundation/curves/bn254'; import type { EthAddress } from '@aztec/foundation/eth-address'; import { Signature } from '@aztec/foundation/eth-signature'; @@ -21,6 +20,7 @@ import { BlockHeader } from '../tx/block_header.js'; import { TxHash } from '../tx/index.js'; import type { Tx } from '../tx/tx.js'; import { BlockProposal } from './block_proposal.js'; +import { ConsensusPayload } from './consensus_payload.js'; import { Gossipable } from './gossipable.js'; import { type CoordinationSignatureContext, @@ -105,7 +105,7 @@ export class CheckpointProposal extends Gossipable implements Signable { } override generateP2PMessageIdentifier(): Promise { - return Promise.resolve(CheckpointProposalHash.fromBuffer(keccak256(this.signature.toBuffer()))); + return Promise.resolve(new Buffer32(this.toConsensusPayload().getPayloadHash())); } get slotNumber(): SlotNumber { @@ -164,6 +164,24 @@ export class CheckpointProposal extends Gossipable implements Signable { return serializeToBuffer([this.checkpointHeader, this.archive, serializeSignedBigInt(this.feeAssetPriceModifier)]); } + /** + * Returns a content-addressed keccak256 hash of the consensus payload + * (header + archive + feeAssetPriceModifier + signatureContext). + * + * Used by the attestation pool to dedup distinct signed payloads at the same slot + * regardless of archive/header collisions on `feeAssetPriceModifier` variants. + * The hash deliberately excludes the signature so non-deterministic ECDSA + * re-signs of the same payload do not look like equivocation. + */ + getPayloadHash(): CheckpointProposalHash { + return CheckpointProposalHash.fromBuffer(this.toConsensusPayload().getPayloadHash()); + } + + /** Returns the ConsensusPayload that an attester would sign for this proposal. */ + toConsensusPayload(): ConsensusPayload { + return new ConsensusPayload(this.checkpointHeader, this.archive, this.feeAssetPriceModifier, this.signatureContext); + } + static async createProposalFromSigner( checkpointHeader: CheckpointHeader, archiveRoot: Fr, diff --git a/yarn-project/stdlib/src/p2p/consensus_payload.ts b/yarn-project/stdlib/src/p2p/consensus_payload.ts index 3054f02d5724..17b3267f2194 100644 --- a/yarn-project/stdlib/src/p2p/consensus_payload.ts +++ b/yarn-project/stdlib/src/p2p/consensus_payload.ts @@ -1,3 +1,4 @@ +import { keccak256 } from '@aztec/foundation/crypto/keccak'; import { Fr } from '@aztec/foundation/curves/bn254'; import { schemas } from '@aztec/foundation/schemas'; import { BufferReader, serializeSignedBigInt, serializeToBuffer } from '@aztec/foundation/serialize'; @@ -69,6 +70,14 @@ export class ConsensusPayload implements Signable { return hexToBuffer(encodedData); } + /** + * Returns a keccak256 hash of the signed payload (header + archive + feeAssetPriceModifier). + * Used by the attestation pool to dedup distinct signed payloads. + */ + getPayloadHash(): Buffer { + return keccak256(this.getPayloadToSign()); + } + toBuffer(): Buffer { return serializeToBuffer([ this.header, diff --git a/yarn-project/stdlib/src/tests/mocks.ts b/yarn-project/stdlib/src/tests/mocks.ts index 39439d46f136..0c80d6bc6d1e 100644 --- a/yarn-project/stdlib/src/tests/mocks.ts +++ b/yarn-project/stdlib/src/tests/mocks.ts @@ -26,10 +26,9 @@ import { AvmCircuitPublicInputs } from '../avm/avm_circuit_public_inputs.js'; import { PublicDataWrite } from '../avm/public_data_write.js'; import { RevertCode } from '../avm/revert_code.js'; import { AztecAddress } from '../aztec-address/index.js'; -import { CheckpointedL2Block, CommitteeAttestation, L2Block } from '../block/index.js'; +import { L2Block } from '../block/index.js'; import type { CommitteeAttestationsAndSigners } from '../block/proposal/attestations_and_signers.js'; import { Checkpoint } from '../checkpoint/checkpoint.js'; -import { L1PublishedData } from '../checkpoint/published_checkpoint.js'; import { computeContractAddressFromInstance } from '../contract/contract_address.js'; import { getContractClassFromArtifact } from '../contract/contract_class.js'; import { SerializableContractInstance } from '../contract/contract_instance.js'; @@ -746,32 +745,3 @@ export const makeCheckpointAttestationFromBlock = ( return makeCheckpointAttestation({ header, archive, attesterSigner, proposerSigner }); }; - -export async function randomPublishedL2Block( - l2BlockNumber: number, - opts: { signers?: Secp256k1Signer[] } = {}, -): Promise { - const block = await L2Block.random(BlockNumber(l2BlockNumber)); - const l1 = L1PublishedData.fromFields({ - blockNumber: BigInt(block.number), - timestamp: block.header.globalVariables.timestamp, - blockHash: Buffer32.random().toString(), - }); - - const signers = opts.signers ?? times(3, () => Secp256k1Signer.random()); - const checkpoint = await Checkpoint.random(CheckpointNumber.fromBlockNumber(BlockNumber(l2BlockNumber)), { - numBlocks: 0, - }); - checkpoint.blocks = [block]; - const atts = signers.map(signer => - makeCheckpointAttestation({ - signer, - archive: block.archive.root, - header: checkpoint.header, - }), - ); - const attestations = atts.map( - (attestation, i) => new CommitteeAttestation(signers[i].address, attestation.signature), - ); - return new CheckpointedL2Block(CheckpointNumber.fromBlockNumber(BlockNumber(l2BlockNumber)), block, l1, attestations); -} diff --git a/yarn-project/stdlib/src/world-state/genesis_data.ts b/yarn-project/stdlib/src/world-state/genesis_data.ts index 83042b957786..2efc5c900296 100644 --- a/yarn-project/stdlib/src/world-state/genesis_data.ts +++ b/yarn-project/stdlib/src/world-state/genesis_data.ts @@ -13,3 +13,15 @@ export const EMPTY_GENESIS_DATA: GenesisData = { prefilledPublicData: [], genesisTimestamp: 0n, }; + +/** Returns if an object looks like genesis data */ +export function isGenesisData(obj: any): obj is GenesisData { + return ( + obj && + typeof obj === 'object' && + 'prefilledPublicData' in obj && + Array.isArray(obj.prefilledPublicData) && + 'genesisTimestamp' in obj && + typeof obj.genesisTimestamp === 'bigint' + ); +} diff --git a/yarn-project/telemetry-client/src/wrappers/l2_block_stream.ts b/yarn-project/telemetry-client/src/wrappers/l2_block_stream.ts index 665b8f83329b..90b58b6a5a62 100644 --- a/yarn-project/telemetry-client/src/wrappers/l2_block_stream.ts +++ b/yarn-project/telemetry-client/src/wrappers/l2_block_stream.ts @@ -11,10 +11,7 @@ import { type Traceable, type Tracer, trackSpan } from '@aztec/telemetry-client' /** Extends an L2BlockStream with a tracer to create a new trace per iteration. */ export class TraceableL2BlockStream extends L2BlockStream implements Traceable { constructor( - l2BlockSource: Pick< - L2BlockSource, - 'getBlocks' | 'getBlockHeader' | 'getL2Tips' | 'getCheckpoints' | 'getCheckpointedBlocks' - >, + l2BlockSource: Pick, localData: L2BlockStreamLocalDataProvider, handler: L2BlockStreamEventHandler, public readonly tracer: Tracer, diff --git a/yarn-project/txe/src/oracle/txe_oracle_top_level_context.ts b/yarn-project/txe/src/oracle/txe_oracle_top_level_context.ts index fc8e72b231ea..dd9a63f8e36a 100644 --- a/yarn-project/txe/src/oracle/txe_oracle_top_level_context.ts +++ b/yarn-project/txe/src/oracle/txe_oracle_top_level_context.ts @@ -175,7 +175,7 @@ export class TXEOracleTopLevelContext implements IMiscOracle, ITxeExecutionOracl async getLastTxEffects() { const latestBlockNumber = await this.stateMachine.archiver.getBlockNumber(); - const block = await this.stateMachine.archiver.getBlock(latestBlockNumber); + const block = await this.stateMachine.archiver.getBlock({ number: latestBlockNumber }); if (block!.body.txEffects.length != 1) { // Note that calls like env.mine() will result in blocks with no transactions, hitting this diff --git a/yarn-project/txe/src/state_machine/archiver.ts b/yarn-project/txe/src/state_machine/archiver.ts index 5a8cc437f9c0..ec153566daa1 100644 --- a/yarn-project/txe/src/state_machine/archiver.ts +++ b/yarn-project/txe/src/state_machine/archiver.ts @@ -4,9 +4,17 @@ import { CheckpointNumber, type EpochNumber, type SlotNumber } from '@aztec/foun import { Fr } from '@aztec/foundation/curves/bn254'; import type { EthAddress } from '@aztec/foundation/eth-address'; import type { AztecAsyncKVStore } from '@aztec/kv-store'; -import type { CheckpointId, L2BlockId, L2TipId, L2Tips, ValidateCheckpointResult } from '@aztec/stdlib/block'; +import { + type CheckpointId, + GENESIS_BLOCK_HEADER_HASH, + type L2BlockId, + type L2TipId, + type L2Tips, + type ValidateCheckpointResult, +} from '@aztec/stdlib/block'; import type { PublishedCheckpoint } from '@aztec/stdlib/checkpoint'; import type { L1RollupConstants } from '@aztec/stdlib/epoch-helpers'; +import { BlockHeader } from '@aztec/stdlib/tx'; /** * TXE Archiver implementation. @@ -17,7 +25,13 @@ export class TXEArchiver extends ArchiverDataSourceBase { private readonly updater = new ArchiverDataStoreUpdater(this.stores); constructor(db: AztecAsyncKVStore) { - super(createArchiverDataStores(db, { logsMaxPageSize: 9999 })); + super( + createArchiverDataStores(db, { logsMaxPageSize: 9999 }), + undefined, + BlockHeader.empty(), + GENESIS_BLOCK_HEADER_HASH, + new Fr(GENESIS_ARCHIVE_ROOT), + ); } public async addCheckpoints(checkpoints: PublishedCheckpoint[], result?: ValidateCheckpointResult): Promise { @@ -47,17 +61,18 @@ export class TXEArchiver extends ArchiverDataSourceBase { public async getL2Tips(): Promise { // In TXE there is no possibility of reorgs and no blocks are ever getting proven so we just set 'latest', 'proven' // and 'finalized' to the latest block. - const blockHeader = await this.getBlockHeader('latest'); - if (!blockHeader) { + const latestBlockNumber = await this.stores.blocks.getLatestL2BlockNumber(); + if (latestBlockNumber === 0) { + throw new Error('L2Tips requested from TXE Archiver but no block found'); + } + const latestBlockData = await this.stores.blocks.getBlockData({ number: latestBlockNumber }); + if (!latestBlockData) { throw new Error('L2Tips requested from TXE Archiver but no block header found'); } - const number = blockHeader.globalVariables.blockNumber; - const hash = (await blockHeader.hash()).toString(); - const checkpointedBlock = await this.getCheckpointedBlock(number); - if (!checkpointedBlock) { - throw new Error(`L2Tips requested from TXE Archiver but no checkpointed block found for block number ${number}`); - } + const number = latestBlockData.header.globalVariables.blockNumber; + const hash = latestBlockData.blockHash.toString(); + // TXE uses 1-block-per-checkpoint for testing simplicity, so we can use block number as checkpoint number. // This uses the deprecated fromBlockNumber method intentionally for the TXE testing environment. const checkpoint = await this.stores.blocks.getRangeOfCheckpoints(CheckpointNumber.fromBlockNumber(number), 1); diff --git a/yarn-project/txe/src/state_machine/dummy_p2p_client.ts b/yarn-project/txe/src/state_machine/dummy_p2p_client.ts index 28e9564f1a79..3990a17d12d3 100644 --- a/yarn-project/txe/src/state_machine/dummy_p2p_client.ts +++ b/yarn-project/txe/src/state_machine/dummy_p2p_client.ts @@ -1,4 +1,4 @@ -import type { SlotNumber } from '@aztec/foundation/branded-types'; +import type { CheckpointProposalHash, SlotNumber } from '@aztec/foundation/branded-types'; import type { AuthRequest, ENR, @@ -147,7 +147,10 @@ export class DummyP2P implements P2P { throw new Error('DummyP2P does not implement "getTxsByHash"'); } - public getCheckpointAttestationsForSlot(_slot: SlotNumber, _proposalId?: string): Promise { + public getCheckpointAttestationsForSlot( + _slot: SlotNumber, + _proposalPayloadHash?: CheckpointProposalHash, + ): Promise { throw new Error('DummyP2P does not implement "getCheckpointAttestationsForSlot"'); } diff --git a/yarn-project/validator-client/src/proposal_handler.test.ts b/yarn-project/validator-client/src/proposal_handler.test.ts index 2dcb6512b34a..44a28f3d0cb4 100644 --- a/yarn-project/validator-client/src/proposal_handler.test.ts +++ b/yarn-project/validator-client/src/proposal_handler.test.ts @@ -5,11 +5,12 @@ import { MAX_FEE_ASSET_PRICE_MODIFIER_BPS } from '@aztec/ethereum/contracts'; import { CheckpointNumber, SlotNumber } from '@aztec/foundation/branded-types'; import { Fr } from '@aztec/foundation/curves/bn254'; import { TestDateProvider } from '@aztec/foundation/timer'; -import type { FieldsOf } from '@aztec/foundation/types'; +import { type FieldsOf, unfreeze } from '@aztec/foundation/types'; import type { P2P } from '@aztec/p2p'; import type { BlockProposalValidator } from '@aztec/p2p/msg_validators'; -import type { L2Block, L2BlockSink, L2BlockSource } from '@aztec/stdlib/block'; +import type { BlockData, L2Block, L2BlockSink, L2BlockSource } from '@aztec/stdlib/block'; import type { Checkpoint } from '@aztec/stdlib/checkpoint'; +import type { L1RollupConstants } from '@aztec/stdlib/epoch-helpers'; import type { ITxProvider, ValidatorClientFullConfig, WorldStateSynchronizer } from '@aztec/stdlib/interfaces/server'; import type { L1ToL2MessageSource } from '@aztec/stdlib/messaging'; import { accumulateCheckpointOutHashes } from '@aztec/stdlib/messaging'; @@ -75,7 +76,7 @@ describe('ProposalHandler checkpoint validation', () => { l1GenesisTime: 0n, slotDuration: 24, epochDuration: 8, - } as any); + } as L1RollupConstants); epochCache.isProposerPipeliningEnabled.mockReturnValue(true); epochCache.pipeliningOffset.mockReturnValue(1); @@ -126,14 +127,14 @@ describe('ProposalHandler checkpoint validation', () => { }); it('returns last_block_not_found when block is not found before timeout', async () => { - blockSource.getBlockHeaderByArchive.mockResolvedValue(undefined); + blockSource.getBlockData.mockResolvedValue(undefined); const result = await handler.handleCheckpointProposal(await makeProposal(), proposalInfo); expect(result).toEqual({ isValid: false, reason: 'last_block_not_found' }); }); it('returns no_blocks_for_slot when no blocks exist for the slot', async () => { - blockSource.getBlockHeaderByArchive.mockResolvedValue(makeBlockHeader()); + blockSource.getBlockData.mockResolvedValue({ header: makeBlockHeader() } as BlockData); blockSource.getBlocksForSlot.mockResolvedValue([]); const result = await handler.handleCheckpointProposal(await makeProposal(), proposalInfo); @@ -141,7 +142,7 @@ describe('ProposalHandler checkpoint validation', () => { }); it('returns last_block_archive_mismatch when last block archive does not match', async () => { - blockSource.getBlockHeaderByArchive.mockResolvedValue(makeBlockHeader()); + blockSource.getBlockData.mockResolvedValue({ header: makeBlockHeader() } as BlockData); const blocks = [ { archive: new AppendOnlyTreeSnapshot(Fr.random(), 1), number: 1 }, { archive: new AppendOnlyTreeSnapshot(Fr.random(), 2), number: 2 }, @@ -171,7 +172,7 @@ describe('ProposalHandler checkpoint validation', () => { ); const archiveRoot = Fr.random(); - blockSource.getBlockHeaderByArchive.mockResolvedValue(makeBlockHeader()); + blockSource.getBlockData.mockResolvedValue({ header: makeBlockHeader() } as BlockData); const blocks = [ { archive: new AppendOnlyTreeSnapshot(Fr.random(), 1), number: 1 }, { archive: new AppendOnlyTreeSnapshot(Fr.random(), 2), number: 2 }, @@ -185,14 +186,14 @@ describe('ProposalHandler checkpoint validation', () => { }); it('caches validation result and returns it on second call', async () => { - blockSource.getBlockHeaderByArchive.mockResolvedValue(undefined); + blockSource.getBlockData.mockResolvedValue(undefined); const proposal = await makeProposal(); const result1 = await handler.handleCheckpointProposal(proposal, proposalInfo); expect(result1.isValid).toBe(false); // Reset mocks to verify they're NOT called again - blockSource.getBlockHeaderByArchive.mockClear(); + blockSource.getBlockData.mockClear(); blockSource.syncImmediate.mockClear(); const result2 = await handler.handleCheckpointProposal(proposal, proposalInfo); @@ -201,7 +202,7 @@ describe('ProposalHandler checkpoint validation', () => { }); it('does not use cache for a different proposal', async () => { - blockSource.getBlockHeaderByArchive.mockResolvedValue(undefined); + blockSource.getBlockData.mockResolvedValue(undefined); await handler.handleCheckpointProposal(await makeProposal({ archiveRoot: Fr.random() }), proposalInfo); blockSource.syncImmediate.mockClear(); @@ -210,8 +211,32 @@ describe('ProposalHandler checkpoint validation', () => { expect(blockSource.syncImmediate).toHaveBeenCalled(); }); - it('returns block_fetch_error when getBlockHeaderByArchive throws', async () => { - blockSource.getBlockHeaderByArchive.mockRejectedValue(new Error('db connection failed')); + // Regression for A-1013: cache used to key by (archive, slot) which let two proposals at the + // same slot+archive but with a different feeAssetPriceModifier share the same cache entry. + it('does not cache across proposals that share archive and slot but differ in feeAssetPriceModifier', async () => { + blockSource.getBlockData.mockResolvedValue(undefined); + const sharedHeader = makeCheckpointHeader(0, { slotNumber: SlotNumber(1) }); + const sharedArchive = Fr.random(); + + const proposalA = await makeProposal({ + checkpointHeader: sharedHeader, + archiveRoot: sharedArchive, + feeAssetPriceModifier: 50n, + }); + await handler.handleCheckpointProposal(proposalA, proposalInfo); + blockSource.syncImmediate.mockClear(); + + const proposalB = await makeProposal({ + checkpointHeader: sharedHeader, + archiveRoot: sharedArchive, + feeAssetPriceModifier: -50n, + }); + await handler.handleCheckpointProposal(proposalB, proposalInfo); + expect(blockSource.syncImmediate).toHaveBeenCalled(); + }); + + it('returns block_fetch_error when getBlockData throws', async () => { + blockSource.getBlockData.mockRejectedValue(new Error('db connection failed')); const result = await handler.handleCheckpointProposal(await makeProposal(), proposalInfo); expect(result).toEqual({ isValid: false, reason: 'block_fetch_error' }); @@ -234,8 +259,8 @@ describe('ProposalHandler checkpoint validation', () => { checkpointNumber: CheckpointNumber(3), header: { getBlockNumber: () => 9 }, indexWithinCheckpoint: 2, - } as any; - blockSource.getBlockDataByArchive.mockResolvedValue(blockData); + } as BlockData; + blockSource.getBlockData.mockResolvedValue(blockData); jest .spyOn(handler, 'handleCheckpointProposal') @@ -265,7 +290,7 @@ describe('ProposalHandler checkpoint validation', () => { header: { globalVariables: GlobalVariables.empty({ slotNumber: SlotNumber(1) }) }, } as unknown as L2Block; - blockSource.getBlockHeaderByArchive.mockResolvedValue(makeBlockHeader()); + blockSource.getBlockData.mockResolvedValue({ header: makeBlockHeader() } as BlockData); blockSource.getBlocksForSlot.mockResolvedValue([block]); mockDispose = jest.fn(); @@ -360,7 +385,7 @@ describe('ProposalHandler checkpoint validation', () => { gasFees: header.gasFees, timestamp: header.timestamp, }); - (blockHeader as any).lastArchive = new AppendOnlyTreeSnapshot(lastArchiveRoot, 0); + unfreeze(blockHeader).lastArchive = new AppendOnlyTreeSnapshot(lastArchiveRoot, 0); const minimalBlock = { archive: new AppendOnlyTreeSnapshot(archiveRoot, 1), diff --git a/yarn-project/validator-client/src/proposal_handler.ts b/yarn-project/validator-client/src/proposal_handler.ts index c1b5026a3c2a..cdf951a68b34 100644 --- a/yarn-project/validator-client/src/proposal_handler.ts +++ b/yarn-project/validator-client/src/proposal_handler.ts @@ -4,7 +4,12 @@ import { type Blob, encodeCheckpointBlobDataFromBlocks, getBlobsPerL1Block } fro import { INITIAL_L2_BLOCK_NUM } from '@aztec/constants'; import type { EpochCache } from '@aztec/epoch-cache'; import { validateFeeAssetPriceModifier } from '@aztec/ethereum/contracts'; -import { BlockNumber, CheckpointNumber, SlotNumber } from '@aztec/foundation/branded-types'; +import { + BlockNumber, + CheckpointNumber, + type CheckpointProposalHash, + SlotNumber, +} from '@aztec/foundation/branded-types'; import { pick } from '@aztec/foundation/collection'; import { Fr } from '@aztec/foundation/curves/bn254'; import { TimeoutError } from '@aztec/foundation/error'; @@ -88,10 +93,11 @@ type CheckpointComputationResult = export class ProposalHandler { public readonly tracer: Tracer; - /** Cached last checkpoint validation result to avoid double-validation on validator nodes. */ + /** Cached last checkpoint validation result to avoid double-validation on validator nodes. + * Keyed by signed-payload hash so two proposals at the same (slot, archive) but with a + * different `feeAssetPriceModifier` (or any other signed field) are validated independently. */ private lastCheckpointValidationResult?: { - archive: Fr; - slotNumber: SlotNumber; + payloadHash: CheckpointProposalHash; result: CheckpointProposalValidationResult; }; @@ -292,7 +298,7 @@ export class ProposalHandler { proposalInfo.blockNumber = blockNumber; // Check that this block number does not exist already - const existingBlock = await this.blockSource.getBlockHeader(blockNumber); + const existingBlock = await this.blockSource.getBlockData({ number: blockNumber }); if (existingBlock) { this.log.warn(`Block number ${blockNumber} already exists, skipping processing`, proposalInfo); return { isValid: false, blockNumber, reason: 'block_number_already_exists' }; @@ -393,11 +399,12 @@ export class ProposalHandler { try { return ( - (await this.blockSource.getBlockDataByArchive(parentArchive)) ?? + (await this.blockSource.getBlockData({ archive: parentArchive })) ?? (timeoutDurationMs <= 0 ? undefined : await retryUntil( - () => this.blockSource.syncImmediate().then(() => this.blockSource.getBlockDataByArchive(parentArchive)), + () => + this.blockSource.syncImmediate().then(() => this.blockSource.getBlockData({ archive: parentArchive })), 'force archiver sync', timeoutDurationMs / 1000, 0.5, @@ -734,13 +741,10 @@ export class ProposalHandler { proposalInfo: LogData, ): Promise { const slot = proposal.slotNumber; + const payloadHash = proposal.getPayloadHash(); - // Check cache: same archive+slot means we already validated this proposal - if ( - this.lastCheckpointValidationResult && - this.lastCheckpointValidationResult.archive.equals(proposal.archive) && - this.lastCheckpointValidationResult.slotNumber === slot - ) { + // Check cache: same signed-payload hash means we already validated this exact proposal. + if (this.lastCheckpointValidationResult && this.lastCheckpointValidationResult.payloadHash === payloadHash) { this.log.debug(`Returning cached validation result for checkpoint proposal at slot ${slot}`, proposalInfo); return this.lastCheckpointValidationResult.result; } @@ -749,7 +753,7 @@ export class ProposalHandler { if (!proposer) { this.log.warn(`Received checkpoint proposal with invalid signature for slot ${proposal.slotNumber}`); const result: CheckpointProposalValidationResult = { isValid: false, reason: 'invalid_signature' }; - this.lastCheckpointValidationResult = { archive: proposal.archive, slotNumber: slot, result }; + this.lastCheckpointValidationResult = { payloadHash, result }; return result; } @@ -758,12 +762,12 @@ export class ProposalHandler { `Received checkpoint proposal with invalid feeAssetPriceModifier ${proposal.feeAssetPriceModifier} for slot ${proposal.slotNumber}`, ); const result: CheckpointProposalValidationResult = { isValid: false, reason: 'invalid_fee_asset_price_modifier' }; - this.lastCheckpointValidationResult = { archive: proposal.archive, slotNumber: slot, result }; + this.lastCheckpointValidationResult = { payloadHash, result }; return result; } const result = await this.validateCheckpointProposal(proposal, proposalInfo); - this.lastCheckpointValidationResult = { archive: proposal.archive, slotNumber: slot, result }; + this.lastCheckpointValidationResult = { payloadHash, result }; // Upload blobs to filestore if validation passed (fire and forget) if (result.isValid) { @@ -794,7 +798,7 @@ export class ProposalHandler { lastBlockHeader = await retryUntil( async () => { await this.blockSource.syncImmediate(); - return this.blockSource.getBlockHeaderByArchive(proposal.archive); + return (await this.blockSource.getBlockData({ archive: proposal.archive }))?.header; }, `waiting for block with archive ${proposal.archive.toString()} for slot ${slot}`, timeoutSeconds, @@ -953,7 +957,7 @@ export class ProposalHandler { /** Uploads blobs for a checkpoint to the filestore. */ protected async uploadBlobsForCheckpoint(proposal: CheckpointProposalCore, proposalInfo: LogData): Promise { try { - const lastBlockHeader = await this.blockSource.getBlockHeaderByArchive(proposal.archive); + const lastBlockHeader = (await this.blockSource.getBlockData({ archive: proposal.archive }))?.header; if (!lastBlockHeader) { this.log.warn(`Failed to get last block header for blob upload`, proposalInfo); return; @@ -987,7 +991,7 @@ export class ProposalHandler { if (!this.archiver) { return false; } - const blockData = await this.blockSource.getBlockDataByArchive(proposal.archive); + const blockData = await this.blockSource.getBlockData({ archive: proposal.archive }); if (!blockData) { this.log.debug(`Block data not found for checkpoint proposal archive, cannot set proposed checkpoint`, { archive: proposal.archive.toString(), @@ -1015,7 +1019,7 @@ export class ProposalHandler { if (!this.archiver) { return false; } - let blockData = await this.blockSource.getBlockDataByArchive(proposal.archive); + let blockData = await this.blockSource.getBlockData({ archive: proposal.archive }); if (!blockData) { // The checkpoint proposal often arrives before the last block finishes re-execution. @@ -1025,7 +1029,7 @@ export class ProposalHandler { const timeoutSeconds = Math.max(1, Number(timeOfNextSlot) - Math.floor(this.dateProvider.now() / 1000)); blockData = await retryUntil( - () => this.blockSource.getBlockDataByArchive(proposal.archive), + () => this.blockSource.getBlockData({ archive: proposal.archive }), 'block data for own checkpoint proposal', timeoutSeconds, 0.25, diff --git a/yarn-project/validator-client/src/validator.integration.test.ts b/yarn-project/validator-client/src/validator.integration.test.ts index bf61ac2e3566..249d6e026043 100644 --- a/yarn-project/validator-client/src/validator.integration.test.ts +++ b/yarn-project/validator-client/src/validator.integration.test.ts @@ -104,10 +104,10 @@ describe('ValidatorClient Integration', () => { dataStoreMapSizeKb: 1024 * 1024, }); await registerProtocolContracts(archiverStore); - const archiver = await createNoopL1Archiver(archiverStore, { ...l1Constants, genesisArchiveRoot }); - await archiver.start(); - // Create world state synchronizer + // Construct world-state first so we can pass its initial header to the archiver, mirroring + // production wiring (see aztec-node/server.ts). Both sides must agree on the genesis hash for + // L2BlockStream's `areBlockHashesEqualAt` check to succeed at block 0. const wsConfig = { l1Contracts: { rollupAddress }, worldStateBlockCheckIntervalMS: 20, @@ -116,6 +116,14 @@ describe('ValidatorClient Integration', () => { worldStateCheckpointHistory: 0, }; const worldStateDb = await NativeWorldStateService.tmp(rollupAddress, true, genesis); + const archiver = await createNoopL1Archiver( + archiverStore, + { ...l1Constants, genesisArchiveRoot }, + undefined, + worldStateDb.getInitialHeader(), + ); + await archiver.start(); + const synchronizer = new ServerWorldStateSynchronizer(worldStateDb, archiver, wsConfig); await synchronizer.start(); @@ -406,7 +414,7 @@ describe('ValidatorClient Integration', () => { // Verify blocks are in archiver and hashes match await attestor.archiver.syncImmediate(); - const attestorBlocks = await attestor.archiver.getBlocks(BlockNumber(1), 3); + const attestorBlocks = await attestor.archiver.getBlocks({ from: BlockNumber(1), limit: 3 }); expect(attestorBlocks.length).toBe(3); const attestorBlockHashes = await Promise.all(attestorBlocks.map(b => b.header.hash())); @@ -441,7 +449,7 @@ describe('ValidatorClient Integration', () => { // Verify blocks are in archiver and hashes match await attestor.archiver.syncImmediate(); - const attestorBlocks = await attestor.archiver.getBlocks(BlockNumber(1), 3); + const attestorBlocks = await attestor.archiver.getBlocks({ from: BlockNumber(1), limit: 3 }); expect(attestorBlocks.length).toBe(3); const attestorBlockHashes = await Promise.all(attestorBlocks.map(b => b.header.hash())); @@ -497,7 +505,7 @@ describe('ValidatorClient Integration', () => { // Verify all blocks are in archiver await attestor.archiver.syncImmediate(); - const attestorBlocks = await attestor.archiver.getBlocks(BlockNumber(1), 4); + const attestorBlocks = await attestor.archiver.getBlocks({ from: BlockNumber(1), limit: 4 }); expect(attestorBlocks.length).toBe(4); const attestorBlockHashes = await Promise.all(attestorBlocks.map(b => b.header.hash())); diff --git a/yarn-project/validator-client/src/validator.test.ts b/yarn-project/validator-client/src/validator.test.ts index f3d66d088920..d1286c4d086e 100644 --- a/yarn-project/validator-client/src/validator.test.ts +++ b/yarn-project/validator-client/src/validator.test.ts @@ -151,7 +151,7 @@ describe('ValidatorClient', () => { }); blockSource = mock(); - blockSource.getCheckpointedBlocksForEpoch.mockResolvedValue([]); + blockSource.getBlocks.mockResolvedValue([]); blockSource.getCheckpointsDataForEpoch.mockResolvedValue([]); blockSource.getBlocksForSlot.mockResolvedValue([]); blockSource.getSyncedL2SlotNumber.mockResolvedValue(SlotNumber(Number.MAX_SAFE_INTEGER)); @@ -261,8 +261,9 @@ describe('ValidatorClient', () => { makeCheckpointAttestation({ signer: attestor1, archive, header: proposal.checkpointHeader }), makeCheckpointAttestation({ signer: attestor2, archive, header: proposal.checkpointHeader }), ]; - p2pClient.getCheckpointAttestationsForSlot.mockImplementation((slot, proposalId) => { - if (proposal.slotNumber === slot && proposalId === proposal.archive.toString()) { + const expectedPayloadHash = proposal.getPayloadHash(); + p2pClient.getCheckpointAttestationsForSlot.mockImplementation((slot, proposalPayloadHash) => { + if (proposal.slotNumber === slot && proposalPayloadHash === expectedPayloadHash) { return Promise.resolve(expectedAttestations); } return Promise.resolve([]); @@ -294,41 +295,36 @@ describe('ValidatorClient', () => { expect(addCheckpointAttestationsSpy.mock.calls[0][0]).toHaveLength(2); }); - it('should filter out attestations with mismatched payload', async () => { + it('forwards the proposal payload hash to the pool so mismatched attestations are filtered out', async () => { const signer = Secp256k1Signer.random(); const attestor1 = Secp256k1Signer.random(); - const attestor2 = Secp256k1Signer.random(); const archive = Fr.random(); const txHashes = [0, 1, 2, 3, 4, 5].map(() => TxHash.random()); const proposal = await makeCheckpointProposal({ signer, archiveRoot: archive, lastBlock: { txHashes } }); - // Create attestations - one with matching payload, one with mismatched + // The pool is responsible for filtering by payload hash; the validator just forwards it. + // We mock the pool to return the matching attestation only when queried with the right hash. const validAttestation = makeCheckpointAttestation({ signer: attestor1, archive, header: proposal.checkpointHeader, }); - const invalidAttestation = makeCheckpointAttestation({ - signer: attestor2, - archive: Fr.random(), - header: proposal.checkpointHeader, - }); - p2pClient.getCheckpointAttestationsForSlot.mockImplementation((slot, proposalId) => - proposal.slotNumber === slot && proposalId === proposal.archive.toString() - ? Promise.resolve([validAttestation, invalidAttestation]) + const expectedPayloadHash = proposal.getPayloadHash(); + p2pClient.getCheckpointAttestationsForSlot.mockImplementation((slot, proposalPayloadHash) => + proposal.slotNumber === slot && proposalPayloadHash === expectedPayloadHash + ? Promise.resolve([validAttestation]) : Promise.resolve([]), ); - // Perform the query - should timeout but we're testing the filtering behavior + // Only one matching attestation is returned, but the validator needs 2 -> times out. await expect( validatorClient.collectAttestations(proposal, 2, new Date(dateProvider.now() + 1000), CheckpointNumber(1)), ).rejects.toThrow(AttestationTimeoutError); - // Verify that getCheckpointAttestationsForSlot was called (meaning the loop ran) - expect(p2pClient.getCheckpointAttestationsForSlot).toHaveBeenCalled(); + expect(p2pClient.getCheckpointAttestationsForSlot).toHaveBeenCalledWith(proposal.slotNumber, expectedPayloadHash); }); }); @@ -338,6 +334,7 @@ describe('ValidatorClient', () => { let sender: PeerId; let blockBuildResult: BuildBlockInCheckpointResult; let mockCheckpointBuilder: MockProxy; + let parentBlockData: BlockData; const makeTxFromHash = (txHash: TxHash) => ({ getTxHash: () => txHash, txHash }) as Tx; const getExpectedWallClockDeadline = (currentSlot: SlotNumber) => @@ -393,9 +390,10 @@ describe('ValidatorClient', () => { epochCache.filterInCommittee.mockResolvedValue([EthAddress.fromString(validatorAccounts[0].address)]); epochCache.isEscapeHatchOpenAtSlot.mockResolvedValue(false); - // Return parent block data when requested (includes checkpoint info, avoids loading full L2Block) + // Return parent block data when requested by archive root (parent block lookup). + // Return undefined for number-based queries (existence check — the proposed block must not exist yet). const parentSlot = SlotNumber(Number(blockHeader.globalVariables.slotNumber) - 1); - blockSource.getBlockDataByArchive.mockResolvedValue({ + parentBlockData = { header: { getBlockNumber: () => blockNumber - 1, getSlot: () => parentSlot, @@ -405,7 +403,10 @@ describe('ValidatorClient', () => { blockHash: BlockHash.random(), checkpointNumber: CheckpointNumber(1), indexWithinCheckpoint: IndexWithinCheckpoint(0), - } as unknown as BlockData); + } as unknown as BlockData; + blockSource.getBlockData.mockImplementation(query => + Promise.resolve('number' in query ? undefined : parentBlockData), + ); blockSource.getGenesisValues.mockResolvedValue({ genesisArchiveRoot: new Fr(GENESIS_ARCHIVE_ROOT) }); blockSource.syncImmediate.mockImplementation(() => Promise.resolve()); @@ -636,8 +637,8 @@ describe('ValidatorClient', () => { }, }); - // Mock getBlockHeaderByArchive to return a header so retryUntil succeeds - blockSource.getBlockHeaderByArchive.mockResolvedValue(makeBlockHeader()); + // Mock getBlockData to return block data so retryUntil succeeds + blockSource.getBlockData.mockResolvedValue({ header: makeBlockHeader() } as any); blockSource.getBlocksForSlot.mockResolvedValue(blocks); // Checkpoint validation should fail: proposal points to block 2 but last block in slot is block 3 @@ -648,12 +649,13 @@ describe('ValidatorClient', () => { it('should wait for previous block to sync', async () => { epochCache.filterInCommittee.mockResolvedValue([EthAddress.fromString(validatorAccounts[0].address)]); - blockSource.getBlockDataByArchive.mockResolvedValueOnce(undefined); - blockSource.getBlockDataByArchive.mockResolvedValueOnce(undefined); - blockSource.getBlockDataByArchive.mockResolvedValueOnce(undefined); + blockSource.getBlockData.mockResolvedValueOnce(undefined); + blockSource.getBlockData.mockResolvedValueOnce(undefined); + blockSource.getBlockData.mockResolvedValueOnce(undefined); const isValid = await validatorClient.validateBlockProposal(proposal, sender); - // Direct call returns undefined, then retryUntil: 2 undefined + 1 success = 4 total - expect(blockSource.getBlockDataByArchive).toHaveBeenCalledTimes(4); + // Archive lookups: 1 direct + 2 retryUntil (undefined) + 1 retryUntil (success) = 4 + // Plus 1 number-based existence check after parent block is found = 5 total + expect(blockSource.getBlockData).toHaveBeenCalledTimes(5); expect(isValid).toBe(true); }); @@ -694,10 +696,13 @@ describe('ValidatorClient', () => { }); it('should not validate proposal if the proposed block number is taken', async () => { - blockSource.getBlockHeader.mockResolvedValue({} as BlockHeader); + // Parent block lookup (by archive) returns valid data; existence check (by number) also returns data → block taken. + blockSource.getBlockData.mockImplementation(query => + Promise.resolve('number' in query ? ({ header: {} as BlockHeader } as any) : parentBlockData), + ); const isValid = await validatorClient.validateBlockProposal(proposal, sender); expect(isValid).toBe(false); - expect(blockSource.getBlockHeader).toHaveBeenCalledWith(blockNumber); + expect(blockSource.getBlockData).toHaveBeenCalledWith({ number: blockNumber }); }); it('should not emit WANT_TO_SLASH_EVENT if slashing is disabled', async () => { @@ -889,8 +894,8 @@ describe('ValidatorClient', () => { nowSeconds: 0n, }); - // Mock parent block data returned by getBlockDataByArchive - blockSource.getBlockDataByArchive.mockResolvedValue({ + // Mock parent block data returned by getBlockData + blockSource.getBlockData.mockResolvedValue({ header: { getBlockNumber: () => BlockNumber(parentBlockNumber), getSlot: () => SlotNumber(parentSlotNumber), @@ -1025,7 +1030,7 @@ describe('ValidatorClient', () => { it('should send blobs from blocks in the slot to filestore', async () => { const mockBlock = L2Block.empty(); - blockSource.getBlockHeaderByArchive.mockResolvedValue(makeBlockHeader()); + blockSource.getBlockData.mockResolvedValue({ header: makeBlockHeader() } as any); blockSource.getBlocksForSlot.mockResolvedValue([mockBlock]); const proposal = await makeCheckpointProposal({ lastBlock: {} }); @@ -1039,7 +1044,7 @@ describe('ValidatorClient', () => { }); it('should not upload if last block header is not found', async () => { - blockSource.getBlockHeaderByArchive.mockResolvedValue(undefined); + blockSource.getBlockData.mockResolvedValue(undefined); const proposal = await makeCheckpointProposal({ lastBlock: {} }); await (validatorClient.getProposalHandler() as TestProposalHandler).uploadBlobsForCheckpoint( @@ -1052,7 +1057,7 @@ describe('ValidatorClient', () => { it('should not throw when blob upload fails', async () => { const mockBlock = L2Block.empty(); - blockSource.getBlockHeaderByArchive.mockResolvedValue(makeBlockHeader()); + blockSource.getBlockData.mockResolvedValue({ header: makeBlockHeader() } as any); blockSource.getBlocksForSlot.mockResolvedValue([mockBlock]); blobClient.sendBlobsToFilestore.mockRejectedValue(new Error('upload failed')); diff --git a/yarn-project/validator-client/src/validator.ts b/yarn-project/validator-client/src/validator.ts index eaebe669fa47..d92a51717233 100644 --- a/yarn-project/validator-client/src/validator.ts +++ b/yarn-project/validator-client/src/validator.ts @@ -645,7 +645,7 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter) */ protected async uploadBlobsForCheckpoint(proposal: CheckpointProposalCore, proposalInfo: LogData): Promise { try { - const lastBlockHeader = await this.blockSource.getBlockHeaderByArchive(proposal.archive); + const lastBlockHeader = (await this.blockSource.getBlockData({ archive: proposal.archive }))?.header; if (!lastBlockHeader) { this.log.warn(`Failed to get last block header for blob upload`, proposalInfo); return; @@ -881,33 +881,21 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter) await this.collectOwnAttestations(proposal, checkpointNumber); - const proposalId = proposal.archive.toString(); + const proposalPayloadHash = proposal.getPayloadHash(); const myAddresses = this.getValidatorAddresses(); let attestations: CheckpointAttestation[] = []; while (true) { - // Filter out attestations with a mismatching archive. This should NOT happen since we have verified - // the proposer signature (ie our own) before accepting the attestation into the pool via the p2p client. - const collectedAttestations = (await this.p2pClient.getCheckpointAttestationsForSlot(slot, proposalId)).filter( - attestation => { - if (!attestation.archive.equals(proposal.archive)) { - this.log.warn( - `Received attestation for slot ${slot} with mismatched archive from ${attestation - .getSender() - ?.toString()}`, - { attestationArchive: attestation.archive.toString(), proposalArchive: proposal.archive.toString() }, - ); - return false; - } - return true; - }, - ); + // The pool already filters by proposal payload hash; if any attestation slips through with a + // mismatched payload hash, drop it defensively. Equivocations are emitted as separate slash + // events from libp2p_service. + const collectedAttestations = await this.p2pClient.getCheckpointAttestationsForSlot(slot, proposalPayloadHash); // Log new attestations we collected const oldSenders = attestations.map(attestation => attestation.getSender()); for (const collected of collectedAttestations) { const collectedSender = collected.getSender(); - // Skip attestations with invalid signatures + // Skip attestations with invalid signatures. Should not happen as we don't add invalid attestations to our pool. if (!collectedSender) { this.log.warn(`Skipping attestation with invalid signature for slot ${slot}`); continue; diff --git a/yarn-project/world-state/src/synchronizer/factory.ts b/yarn-project/world-state/src/synchronizer/factory.ts index 47eb9d601339..5f8c2a5f52f9 100644 --- a/yarn-project/world-state/src/synchronizer/factory.ts +++ b/yarn-project/world-state/src/synchronizer/factory.ts @@ -2,7 +2,7 @@ import type { LoggerBindings } from '@aztec/foundation/log'; import type { L2BlockSource } from '@aztec/stdlib/block'; import type { DataStoreConfig } from '@aztec/stdlib/kv-store'; import type { L1ToL2MessageSource } from '@aztec/stdlib/messaging'; -import { EMPTY_GENESIS_DATA, type GenesisData } from '@aztec/stdlib/world-state'; +import { EMPTY_GENESIS_DATA, type GenesisData, isGenesisData } from '@aztec/stdlib/world-state'; import { type TelemetryClient, getTelemetryClient } from '@aztec/telemetry-client'; import { WorldStateInstrumentation } from '../instrumentation/instrumentation.js'; @@ -21,12 +21,14 @@ export interface WorldStateTreeMapSizes { export async function createWorldStateSynchronizer( config: WorldStateConfig & DataStoreConfig, l2BlockSource: L2BlockSource & L1ToL2MessageSource, - genesis: GenesisData = EMPTY_GENESIS_DATA, + genesisOrNativeWorldState: GenesisData | NativeWorldStateService, client: TelemetryClient = getTelemetryClient(), bindings?: LoggerBindings, ) { const instrumentation = new WorldStateInstrumentation(client); - const merkleTrees = await createWorldState(config, genesis, instrumentation, bindings); + const merkleTrees = isGenesisData(genesisOrNativeWorldState) + ? await createWorldState(config, genesisOrNativeWorldState, instrumentation, bindings) + : genesisOrNativeWorldState; return new ServerWorldStateSynchronizer(merkleTrees, l2BlockSource, config, instrumentation); } diff --git a/yarn-project/world-state/src/synchronizer/server_world_state_synchronizer.test.ts b/yarn-project/world-state/src/synchronizer/server_world_state_synchronizer.test.ts index fec1b9eca5f0..42f5471d609c 100644 --- a/yarn-project/world-state/src/synchronizer/server_world_state_synchronizer.test.ts +++ b/yarn-project/world-state/src/synchronizer/server_world_state_synchronizer.test.ts @@ -2,13 +2,7 @@ import { BlockNumber, CheckpointNumber } from '@aztec/foundation/branded-types'; import { timesParallel } from '@aztec/foundation/collection'; import { Fr } from '@aztec/foundation/curves/bn254'; import { type Logger, createLogger } from '@aztec/foundation/log'; -import { - BlockHash, - GENESIS_BLOCK_HEADER_HASH, - L2Block, - type L2BlockSource, - type L2BlockStream, -} from '@aztec/stdlib/block'; +import { BlockHash, L2Block, type L2BlockSource, type L2BlockStream } from '@aztec/stdlib/block'; import type { Checkpoint } from '@aztec/stdlib/checkpoint'; import { type MerkleTreeReadOperations, WorldStateRunningState } from '@aztec/stdlib/interfaces/server'; import type { L1ToL2MessageSource } from '@aztec/stdlib/messaging'; @@ -285,9 +279,9 @@ describe('ServerWorldStateSynchronizer', () => { }); class TestWorldStateSynchronizer extends ServerWorldStateSynchronizer { - public latest = { number: BlockNumber.ZERO, hash: GENESIS_BLOCK_HEADER_HASH.toString() }; - public finalized = { number: BlockNumber.ZERO, hash: GENESIS_BLOCK_HEADER_HASH.toString() }; - public proven = { number: BlockNumber.ZERO, hash: GENESIS_BLOCK_HEADER_HASH.toString() }; + public latest = { number: BlockNumber.ZERO, hash: BlockHash.random().toString() }; + public finalized = { number: BlockNumber.ZERO, hash: BlockHash.random().toString() }; + public proven = { number: BlockNumber.ZERO, hash: BlockHash.random().toString() }; constructor( merkleTrees: MerkleTreeAdminDatabase, diff --git a/yarn-project/world-state/src/synchronizer/server_world_state_synchronizer.ts b/yarn-project/world-state/src/synchronizer/server_world_state_synchronizer.ts index eaf2666336de..d8e939fba8ae 100644 --- a/yarn-project/world-state/src/synchronizer/server_world_state_synchronizer.ts +++ b/yarn-project/world-state/src/synchronizer/server_world_state_synchronizer.ts @@ -1,4 +1,4 @@ -import { INITIAL_CHECKPOINT_NUMBER, INITIAL_L2_BLOCK_NUM } from '@aztec/constants'; +import { INITIAL_CHECKPOINT_NUMBER } from '@aztec/constants'; import { BlockNumber, CheckpointNumber } from '@aztec/foundation/branded-types'; import type { Fr } from '@aztec/foundation/curves/bn254'; import { type Logger, createLogger } from '@aztec/foundation/log'; @@ -6,7 +6,6 @@ import { promiseWithResolvers } from '@aztec/foundation/promise'; import { elapsed } from '@aztec/foundation/timer'; import { type BlockHash, - GENESIS_BLOCK_HEADER_HASH, GENESIS_CHECKPOINT_HEADER_HASH, type L2Block, type L2BlockId, @@ -288,14 +287,15 @@ export class ServerWorldStateSynchronizer // but we use a block stream so we need to provide 'local' L2Tips. // We configure the block stream to ignore checkpoints and set checkpoint values to genesis here. const genesisCheckpointHeaderHash = GENESIS_CHECKPOINT_HEADER_HASH.toString(); + const initialBlockHash = (await this.merkleTreeCommitted.getInitialHeader().hash()).toString(); return { proposed: latestBlockId, checkpointed: { - block: { number: INITIAL_L2_BLOCK_NUM, hash: GENESIS_BLOCK_HEADER_HASH.toString() }, + block: { number: BlockNumber.ZERO, hash: initialBlockHash }, checkpoint: { number: INITIAL_CHECKPOINT_NUMBER, hash: genesisCheckpointHeaderHash }, }, proposedCheckpoint: { - block: { number: INITIAL_L2_BLOCK_NUM, hash: GENESIS_BLOCK_HEADER_HASH.toString() }, + block: { number: BlockNumber.ZERO, hash: initialBlockHash }, checkpoint: { number: INITIAL_CHECKPOINT_NUMBER, hash: genesisCheckpointHeaderHash }, }, finalized: { @@ -409,16 +409,15 @@ export class ServerWorldStateSynchronizer if (this.historyToKeep === undefined) { return; } - // Get the checkpointed block for the finalized block number - const finalisedCheckpoint = await this.l2BlockSource.getCheckpointedBlock(summary.finalizedBlockNumber); - if (finalisedCheckpoint === undefined) { + const finalisedBlockData = await this.l2BlockSource.getBlockData({ number: summary.finalizedBlockNumber }); + if (finalisedBlockData === undefined) { this.log.warn( `Failed to retrieve checkpointed block for finalized block number: ${summary.finalizedBlockNumber}`, ); return; } // Compute the required historic checkpoint number - const newHistoricCheckpointNumber = finalisedCheckpoint.checkpointNumber - this.historyToKeep + 1; + const newHistoricCheckpointNumber = finalisedBlockData.checkpointNumber - this.historyToKeep + 1; if (newHistoricCheckpointNumber <= 1) { return; } diff --git a/yarn-project/world-state/src/test/integration.test.ts b/yarn-project/world-state/src/test/integration.test.ts index 893a50c8746d..a594ac0bf229 100644 --- a/yarn-project/world-state/src/test/integration.test.ts +++ b/yarn-project/world-state/src/test/integration.test.ts @@ -1,7 +1,8 @@ import { MockPrefilledArchiver } from '@aztec/archiver/test'; +import { GENESIS_ARCHIVE_ROOT } from '@aztec/constants'; import { BlockNumber, CheckpointNumber } from '@aztec/foundation/branded-types'; import { timesAsync } from '@aztec/foundation/collection'; -import type { Fr } from '@aztec/foundation/curves/bn254'; +import { Fr } from '@aztec/foundation/curves/bn254'; import { EthAddress } from '@aztec/foundation/eth-address'; import { type Logger, createLogger } from '@aztec/foundation/log'; import { sleep } from '@aztec/foundation/sleep'; @@ -58,6 +59,8 @@ describe('world-state integration', () => { archiver = new MockPrefilledArchiver(checkpoints); db = (await createWorldState(config)) as NativeWorldStateService; + await archiver.setInitialHeader(db.getInitialHeader()); + archiver.setGenesisArchiveRoot(new Fr(GENESIS_ARCHIVE_ROOT)); synchronizer = new TestWorldStateSynchronizer(db, archiver, config); log.info(`Created synchronizer`); }, 30_000); @@ -94,7 +97,7 @@ describe('world-state integration', () => { const expectSynchedBlockHashMatches = async (number: number) => { const syncedBlockHash = await db.getCommitted().getLeafValue(MerkleTreeId.ARCHIVE, BigInt(number)); - const archiverBlockHash = await (await archiver.getBlockHeader(number))?.hash(); + const archiverBlockHash = await (await archiver.getBlockData({ number: BlockNumber(number) }))?.header.hash(); expect(syncedBlockHash).toEqual(archiverBlockHash); }; @@ -143,8 +146,6 @@ describe('world-state integration', () => { }); it('syncs from latest block when restarting', async () => { - const getBlocksSpy = jest.spyOn(archiver, 'getBlocks'); - await synchronizer.start(); await archiver.createBlocks(5); await awaitSync(5); @@ -160,10 +161,6 @@ describe('world-state integration', () => { await archiver.createBlocks(4); await awaitSync(12); await expectSynchedToBlock(12); - - expect(getBlocksSpy).toHaveBeenCalledWith(1, 5); - expect(getBlocksSpy).toHaveBeenCalledWith(6, 3); - expect(getBlocksSpy).toHaveBeenCalledWith(9, 4); }); });