@aztec/archiver 0.0.1-commit.f650c0a5c → 0.0.1-commit.f7ea82942

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/dest/archiver.d.ts +4 -2
  2. package/dest/archiver.d.ts.map +1 -1
  3. package/dest/archiver.js +12 -1
  4. package/dest/config.d.ts +3 -1
  5. package/dest/config.d.ts.map +1 -1
  6. package/dest/config.js +13 -2
  7. package/dest/errors.d.ts +17 -1
  8. package/dest/errors.d.ts.map +1 -1
  9. package/dest/errors.js +22 -0
  10. package/dest/factory.d.ts +1 -1
  11. package/dest/factory.d.ts.map +1 -1
  12. package/dest/factory.js +2 -1
  13. package/dest/index.d.ts +3 -2
  14. package/dest/index.d.ts.map +1 -1
  15. package/dest/index.js +2 -1
  16. package/dest/l1/data_retrieval.d.ts +18 -9
  17. package/dest/l1/data_retrieval.d.ts.map +1 -1
  18. package/dest/l1/data_retrieval.js +13 -19
  19. package/dest/l1/validate_historical_logs.d.ts +23 -0
  20. package/dest/l1/validate_historical_logs.d.ts.map +1 -0
  21. package/dest/l1/validate_historical_logs.js +108 -0
  22. package/dest/modules/data_store_updater.d.ts +12 -5
  23. package/dest/modules/data_store_updater.d.ts.map +1 -1
  24. package/dest/modules/data_store_updater.js +13 -3
  25. package/dest/modules/instrumentation.d.ts +7 -2
  26. package/dest/modules/instrumentation.d.ts.map +1 -1
  27. package/dest/modules/instrumentation.js +22 -6
  28. package/dest/modules/l1_synchronizer.d.ts +4 -1
  29. package/dest/modules/l1_synchronizer.d.ts.map +1 -1
  30. package/dest/modules/l1_synchronizer.js +114 -25
  31. package/dest/store/block_store.d.ts +10 -3
  32. package/dest/store/block_store.d.ts.map +1 -1
  33. package/dest/store/block_store.js +41 -3
  34. package/dest/store/kv_archiver_store.d.ts +9 -3
  35. package/dest/store/kv_archiver_store.d.ts.map +1 -1
  36. package/dest/store/kv_archiver_store.js +7 -0
  37. package/dest/store/l2_tips_cache.d.ts +1 -1
  38. package/dest/store/l2_tips_cache.d.ts.map +1 -1
  39. package/dest/store/l2_tips_cache.js +2 -2
  40. package/dest/store/log_store.d.ts +1 -1
  41. package/dest/store/log_store.d.ts.map +1 -1
  42. package/dest/store/log_store.js +2 -4
  43. package/dest/test/fake_l1_state.d.ts +6 -3
  44. package/dest/test/fake_l1_state.d.ts.map +1 -1
  45. package/dest/test/fake_l1_state.js +17 -7
  46. package/dest/test/noop_l1_archiver.d.ts +1 -1
  47. package/dest/test/noop_l1_archiver.d.ts.map +1 -1
  48. package/dest/test/noop_l1_archiver.js +4 -1
  49. package/package.json +13 -13
  50. package/src/archiver.ts +23 -3
  51. package/src/config.ts +14 -1
  52. package/src/errors.ts +34 -0
  53. package/src/factory.ts +1 -0
  54. package/src/index.ts +2 -1
  55. package/src/l1/data_retrieval.ts +30 -35
  56. package/src/l1/validate_historical_logs.ts +140 -0
  57. package/src/modules/data_store_updater.ts +27 -3
  58. package/src/modules/instrumentation.ts +27 -7
  59. package/src/modules/l1_synchronizer.ts +168 -27
  60. package/src/store/block_store.ts +53 -1
  61. package/src/store/kv_archiver_store.ts +15 -0
  62. package/src/store/l2_tips_cache.ts +8 -2
  63. package/src/store/log_store.ts +2 -5
  64. package/src/test/fake_l1_state.ts +23 -10
  65. package/src/test/noop_l1_archiver.ts +3 -0
@@ -2,12 +2,13 @@ import type { BlobClientInterface } from '@aztec/blob-client/client';
2
2
  import { EpochCache } from '@aztec/epoch-cache';
3
3
  import { InboxContract, type InboxContractState, RollupContract } from '@aztec/ethereum/contracts';
4
4
  import type { L1BlockId } from '@aztec/ethereum/l1-types';
5
+ import { getFinalizedL1Block } from '@aztec/ethereum/queries';
5
6
  import type { ViemPublicClient, ViemPublicDebugClient } from '@aztec/ethereum/types';
6
7
  import { asyncPool } from '@aztec/foundation/async-pool';
7
8
  import { maxBigint } from '@aztec/foundation/bigint';
8
9
  import { BlockNumber, CheckpointNumber, EpochNumber } from '@aztec/foundation/branded-types';
9
10
  import { Buffer16, Buffer32 } from '@aztec/foundation/buffer';
10
- import { pick } from '@aztec/foundation/collection';
11
+ import { partition, pick } from '@aztec/foundation/collection';
11
12
  import { Fr } from '@aztec/foundation/curves/bn254';
12
13
  import { type Logger, createLogger } from '@aztec/foundation/log';
13
14
  import { retryTimes } from '@aztec/foundation/retry';
@@ -15,14 +16,16 @@ import { count } from '@aztec/foundation/string';
15
16
  import { DateProvider, Timer, elapsed } from '@aztec/foundation/timer';
16
17
  import { isDefined, isErrorClass } from '@aztec/foundation/types';
17
18
  import { type ArchiverEmitter, L2BlockSourceEvents, type ValidateCheckpointResult } from '@aztec/stdlib/block';
18
- import { PublishedCheckpoint } from '@aztec/stdlib/checkpoint';
19
+ import { Checkpoint, PublishedCheckpoint } from '@aztec/stdlib/checkpoint';
19
20
  import { type L1RollupConstants, getEpochAtSlot, getSlotAtNextL1Block } from '@aztec/stdlib/epoch-helpers';
20
21
  import { computeInHashFromL1ToL2Messages } from '@aztec/stdlib/messaging';
21
22
  import { type Traceable, type Tracer, execInSpan, trackSpan } from '@aztec/telemetry-client';
22
23
 
23
24
  import { InitialCheckpointNumberNotSequentialError } from '../errors.js';
24
25
  import {
25
- retrieveCheckpointsFromRollup,
26
+ type RetrievedCheckpointFromCalldata,
27
+ getCheckpointBlobDataFromBlobs,
28
+ retrieveCheckpointCalldataFromRollup,
26
29
  retrieveL1ToL2Message,
27
30
  retrieveL1ToL2Messages,
28
31
  retrievedToPublishedCheckpoint,
@@ -66,6 +69,7 @@ export class ArchiverL1Synchronizer implements Traceable {
66
69
  private config: {
67
70
  batchSize: number;
68
71
  skipValidateCheckpointAttestations?: boolean;
72
+ skipPromoteProposedCheckpointDuringL1Sync?: boolean;
69
73
  maxAllowedEthClientDriftSeconds: number;
70
74
  },
71
75
  private readonly blobClient: BlobClientInterface,
@@ -91,6 +95,7 @@ export class ArchiverL1Synchronizer implements Traceable {
91
95
  public setConfig(newConfig: {
92
96
  batchSize: number;
93
97
  skipValidateCheckpointAttestations?: boolean;
98
+ skipPromoteProposedCheckpointDuringL1Sync?: boolean;
94
99
  maxAllowedEthClientDriftSeconds: number;
95
100
  }) {
96
101
  this.config = newConfig;
@@ -215,7 +220,11 @@ export class ArchiverL1Synchronizer implements Traceable {
215
220
  /** Query L1 for its finalized block and update the finalized checkpoint accordingly. */
216
221
  private async updateFinalizedCheckpoint(): Promise<void> {
217
222
  try {
218
- const finalizedL1Block = await this.publicClient.getBlock({ blockTag: 'finalized', includeTransactions: false });
223
+ const finalizedL1Block = await getFinalizedL1Block(this.publicClient);
224
+ if (!finalizedL1Block) {
225
+ this.log.trace(`Skipping finalized checkpoint update: L1 has no finalized block yet.`);
226
+ return;
227
+ }
219
228
  const finalizedL1BlockNumber = finalizedL1Block.number;
220
229
  const finalizedCheckpointNumber = await this.rollup.getProvenCheckpointNumber({
221
230
  blockNumber: finalizedL1BlockNumber,
@@ -405,7 +414,7 @@ export class ArchiverL1Synchronizer implements Traceable {
405
414
  `Failed to store L1 to L2 messages retrieved from L1: ${error.message}. Rolling back syncpoint to retry.`,
406
415
  { inboxMessage: error.inboxMessage },
407
416
  );
408
- await this.rollbackL1ToL2Messages(remoteMessagesState.treeInProgress);
417
+ await this.rollbackL1ToL2Messages(remoteMessagesState);
409
418
  return false;
410
419
  }
411
420
  throw error;
@@ -420,7 +429,7 @@ export class ArchiverL1Synchronizer implements Traceable {
420
429
  `Local L1 to L2 messages state does not match remote after sync attempt. Rolling back syncpoint to retry.`,
421
430
  { localLastMessageAfterSync, remoteMessagesState },
422
431
  );
423
- await this.rollbackL1ToL2Messages(remoteMessagesState.treeInProgress);
432
+ await this.rollbackL1ToL2Messages(remoteMessagesState);
424
433
  return false;
425
434
  }
426
435
 
@@ -475,18 +484,39 @@ export class ArchiverL1Synchronizer implements Traceable {
475
484
  * Rolls back local L1 to L2 messages to the last common message with L1, and updates the syncpoint to the L1 block of that message.
476
485
  * If no common message is found, rolls back all messages and sets the syncpoint to the start block.
477
486
  */
478
- private async rollbackL1ToL2Messages(remoteTreeInProgress: bigint): Promise<L1BlockId> {
479
- // Slowly go back through our messages until we find the last common message.
480
- // We could query the logs in batch as an optimization, but the depth of the reorg should not be deep, and this
481
- // is a very rare case, so it's fine to query one log at a time.
487
+ private async rollbackL1ToL2Messages(remoteMessagesState: InboxContractState): Promise<L1BlockId> {
488
+ const { treeInProgress: remoteTreeInProgress, messagesRollingHash: remoteRollingHash } = remoteMessagesState;
489
+
490
+ // Slowly go back through our messages until we find the last common message. We could query the logs in
491
+ // batch as an optimization, but the depth of the reorg should not be deep, and this is a very rare case,
492
+ // so it's fine to query one log at a time.
482
493
  let commonMsg: undefined | InboxMessage;
483
494
  let messagesToDelete = 0;
484
495
  this.log.verbose(`Searching most recent common L1 to L2 message`);
485
496
  for await (const localMsg of this.store.iterateL1ToL2Messages({ reverse: true })) {
497
+ const logCtx = { remoteMsg: undefined as InboxMessage | undefined, localMsg, remoteMessagesState };
498
+
499
+ // First check if the local message rolling hash matches the current rolling hash of the inbox contract,
500
+ // which means we just need to rollback some local messages and we should be back in sync. This means there
501
+ // was an L1 reorg that removed some of the messages we had, but no new messages were added compared.
502
+ if (localMsg.rollingHash.equals(remoteRollingHash)) {
503
+ this.log.info(
504
+ `Found common L1 to L2 message at index ${localMsg.index} on L1 block ${localMsg.l1BlockNumber} matching current remote state`,
505
+ logCtx,
506
+ );
507
+ commonMsg = localMsg;
508
+ break;
509
+ }
510
+
511
+ // If there's no match with the current remote state, check if the message exists on the inbox contract at all
512
+ // by looking at the inbox events. If the L1 reorg *added* new messages in addition to deleting existing ones,
513
+ // then the current remote state's rolling hash will not match anything we have locally, so we need to check existence
514
+ // of individual messages via logs. Note we use logs and not historical queries so we don't have to depend on
515
+ // an archival rpc node, since the message could be from a long time ago if we're catching up with syncing.
486
516
  const remoteMsg = await retrieveL1ToL2Message(this.inbox, localMsg);
487
- const logCtx = { remoteMsg, localMsg: localMsg };
517
+ logCtx.remoteMsg = remoteMsg;
488
518
  if (remoteMsg && remoteMsg.rollingHash.equals(localMsg.rollingHash)) {
489
- this.log.verbose(
519
+ this.log.info(
490
520
  `Found most recent common L1 to L2 message at index ${localMsg.index} on L1 block ${localMsg.l1BlockNumber}`,
491
521
  logCtx,
492
522
  );
@@ -718,22 +748,20 @@ export class ArchiverL1Synchronizer implements Traceable {
718
748
 
719
749
  this.log.trace(`Retrieving checkpoints from L1 block ${searchStartBlock} to ${searchEndBlock}`);
720
750
 
721
- // TODO(md): Retrieve from blob client then from consensus client, then from peers
722
- const retrievedCheckpoints = await execInSpan(this.tracer, 'Archiver.retrieveCheckpointsFromRollup', () =>
723
- retrieveCheckpointsFromRollup(
751
+ // First fetch calldata only, no blobs yet, since we may be able to just get that data out of the proposed chain
752
+ const calldataCheckpoints = await execInSpan(this.tracer, 'Archiver.retrieveCheckpointCalldataFromRollup', () =>
753
+ retrieveCheckpointCalldataFromRollup(
724
754
  this.rollup,
725
755
  this.publicClient,
726
756
  this.debugClient,
727
- this.blobClient,
728
757
  searchStartBlock, // TODO(palla/reorg): If the L2 reorg was due to an L1 reorg, we need to start search earlier
729
758
  searchEndBlock,
730
759
  this.instrumentation,
731
760
  this.log,
732
- !initialSyncComplete, // isHistoricalSync
733
761
  ),
734
762
  );
735
763
 
736
- if (retrievedCheckpoints.length === 0) {
764
+ if (calldataCheckpoints.length === 0) {
737
765
  // We are not calling `setBlockSynchedL1BlockNumber` because it may cause sync issues if based off infura.
738
766
  // See further details in earlier comments.
739
767
  this.log.trace(`Retrieved no new checkpoints from L1 block ${searchStartBlock} to ${searchEndBlock}`);
@@ -741,17 +769,43 @@ export class ArchiverL1Synchronizer implements Traceable {
741
769
  }
742
770
 
743
771
  this.log.debug(
744
- `Retrieved ${retrievedCheckpoints.length} new checkpoints between L1 blocks ${searchStartBlock} and ${searchEndBlock}`,
772
+ `Retrieved ${calldataCheckpoints.length} new checkpoint calldata between L1 blocks ${searchStartBlock} and ${searchEndBlock}`,
745
773
  {
746
- lastProcessedCheckpoint: retrievedCheckpoints[retrievedCheckpoints.length - 1].l1,
774
+ lastProcessedCheckpoint: calldataCheckpoints[calldataCheckpoints.length - 1].l1,
747
775
  searchStartBlock,
748
776
  searchEndBlock,
749
777
  },
750
778
  );
751
779
 
752
- const publishedCheckpoints = await Promise.all(retrievedCheckpoints.map(b => retrievedToPublishedCheckpoint(b)));
780
+ // Check if the last checkpoint matches the proposed one (so we can skip blob fetch).
781
+ // We only check the last one because the proposed checkpoint is always the most recent one,
782
+ // and if it's in a multi-checkpoint batch it will always be last (sorted by L1 block number).
783
+ const lastCalldataCheckpoint = calldataCheckpoints[calldataCheckpoints.length - 1];
784
+ const checkpointToPromote = await this.tryBuildPublishedCheckpointFromProposed(lastCalldataCheckpoint);
785
+
786
+ // Then fetch blobs in parallel and build the full published checkpoints
787
+ const toFetchBlobs = checkpointToPromote ? calldataCheckpoints.slice(0, -1) : calldataCheckpoints;
788
+ const blobFetched = await asyncPool(10, toFetchBlobs, async checkpoint =>
789
+ retrievedToPublishedCheckpoint({
790
+ ...checkpoint,
791
+ checkpointBlobData: await getCheckpointBlobDataFromBlobs(
792
+ this.blobClient,
793
+ checkpoint.l1.blockHash,
794
+ checkpoint.blobHashes,
795
+ checkpoint.checkpointNumber,
796
+ this.log,
797
+ !initialSyncComplete,
798
+ checkpoint.parentBeaconBlockRoot,
799
+ checkpoint.l1.timestamp,
800
+ ),
801
+ }),
802
+ );
803
+
804
+ // And add the promoted checkpoint to the list of all checkpoints
805
+ const publishedCheckpoints = checkpointToPromote ? [...blobFetched, checkpointToPromote] : blobFetched;
753
806
  const validCheckpoints: PublishedCheckpoint[] = [];
754
807
 
808
+ // Now loop through all checkpoints and validate their attestations
755
809
  for (const published of publishedCheckpoints) {
756
810
  const validationResult = this.config.skipValidateCheckpointAttestations
757
811
  ? { valid: true as const }
@@ -832,15 +886,34 @@ export class ArchiverL1Synchronizer implements Traceable {
832
886
  try {
833
887
  const updatedValidationResult =
834
888
  rollupStatus.validationResult === initialValidationResult ? undefined : rollupStatus.validationResult;
889
+
890
+ // Split valid checkpoints: the promoted one (if any) is persisted via the proposed-promotion path,
891
+ // the rest via addCheckpoints. Both paths run within the same store transaction for atomicity.
892
+ const [[maybeValidCheckpointToPromote], checkpointsToAdd] = partition(
893
+ validCheckpoints,
894
+ c => c.checkpoint.number === checkpointToPromote?.checkpoint.number,
895
+ );
896
+
835
897
  const [processDuration, result] = await elapsed(() =>
836
898
  execInSpan(this.tracer, 'Archiver.addCheckpoints', () =>
837
- this.updater.addCheckpoints(validCheckpoints, updatedValidationResult),
899
+ this.updater.addCheckpoints(
900
+ checkpointsToAdd,
901
+ updatedValidationResult,
902
+ maybeValidCheckpointToPromote && {
903
+ l1: lastCalldataCheckpoint.l1,
904
+ attestations: lastCalldataCheckpoint.attestations,
905
+ checkpoint: maybeValidCheckpointToPromote,
906
+ },
907
+ ),
838
908
  ),
839
909
  );
840
- this.instrumentation.processNewBlocks(
841
- processDuration / validCheckpoints.length,
842
- validCheckpoints.flatMap(c => c.checkpoint.blocks),
843
- );
910
+
911
+ if (checkpointsToAdd.length > 0) {
912
+ this.instrumentation.processNewCheckpointedBlocks(
913
+ processDuration / checkpointsToAdd.length,
914
+ checkpointsToAdd.flatMap(c => c.checkpoint.blocks),
915
+ );
916
+ }
844
917
 
845
918
  // If blocks were pruned due to conflict with L1 checkpoints, emit event
846
919
  if (result.prunedBlocks && result.prunedBlocks.length > 0) {
@@ -893,7 +966,7 @@ export class ArchiverL1Synchronizer implements Traceable {
893
966
  });
894
967
  }
895
968
  lastRetrievedCheckpoint = validCheckpoints.at(-1) ?? lastRetrievedCheckpoint;
896
- lastL1BlockWithCheckpoint = retrievedCheckpoints.at(-1)?.l1.blockNumber ?? lastL1BlockWithCheckpoint;
969
+ lastL1BlockWithCheckpoint = calldataCheckpoints.at(-1)?.l1.blockNumber ?? lastL1BlockWithCheckpoint;
897
970
  } while (searchEndBlock < currentL1BlockNumber);
898
971
 
899
972
  // Important that we update AFTER inserting the blocks.
@@ -902,6 +975,74 @@ export class ArchiverL1Synchronizer implements Traceable {
902
975
  return { ...rollupStatus, lastRetrievedCheckpoint, lastL1BlockWithCheckpoint };
903
976
  }
904
977
 
978
+ /** Checks if this checkpoint matches the local proposed one, and if so, loads local data to build a synthetic published checkpoint. */
979
+ private async tryBuildPublishedCheckpointFromProposed(
980
+ calldataCheckpoint: RetrievedCheckpointFromCalldata | undefined,
981
+ ): Promise<PublishedCheckpoint | undefined> {
982
+ const proposed = await this.store.getProposedCheckpointOnly();
983
+ if (
984
+ this.config.skipPromoteProposedCheckpointDuringL1Sync ||
985
+ !proposed ||
986
+ !calldataCheckpoint ||
987
+ proposed.checkpointNumber !== calldataCheckpoint.checkpointNumber
988
+ ) {
989
+ return undefined;
990
+ }
991
+
992
+ if (
993
+ !proposed.header.equals(calldataCheckpoint.header) ||
994
+ !proposed.archive.root.equals(calldataCheckpoint.archiveRoot)
995
+ ) {
996
+ this.log.warn(
997
+ `Local proposed checkpoint ${proposed.checkpointNumber} does not match checkpoint retrieved from L1, overriding with L1 data`,
998
+ {
999
+ proposedCheckpointNumber: proposed.checkpointNumber,
1000
+ proposedHeader: proposed.header.toInspect(),
1001
+ proposedArchiveRoot: proposed.archive.root.toString(),
1002
+ calldataCheckpointNumber: calldataCheckpoint.checkpointNumber,
1003
+ calldataHeader: calldataCheckpoint.header.toInspect(),
1004
+ calldataArchiveRoot: calldataCheckpoint.archiveRoot.toString(),
1005
+ },
1006
+ );
1007
+ return undefined;
1008
+ }
1009
+
1010
+ this.log.debug(
1011
+ `Building published checkpoint from proposed ${calldataCheckpoint.checkpointNumber} (skipping blob fetch)`,
1012
+ { proposedHeader: proposed.header.toInspect(), proposedArchiveRoot: proposed.archive.root.toString() },
1013
+ );
1014
+
1015
+ const blocks = await this.store.getBlocks(BlockNumber(proposed.startBlock), proposed.blockCount);
1016
+ if (blocks.length !== proposed.blockCount) {
1017
+ this.log.warn(
1018
+ `Local proposed checkpoint ${proposed.checkpointNumber} has wrong block count (expected ${proposed.blockCount} blocks starting at ${proposed.startBlock} but got ${blocks.length})`,
1019
+ {
1020
+ proposedCheckpointNumber: proposed.checkpointNumber,
1021
+ proposedStartBlock: proposed.startBlock,
1022
+ proposedBlockCount: proposed.blockCount,
1023
+ retrievedBlocks: blocks.map(b => b.number),
1024
+ },
1025
+ );
1026
+ return undefined;
1027
+ }
1028
+
1029
+ const checkpoint = Checkpoint.from({
1030
+ archive: proposed.archive,
1031
+ header: proposed.header,
1032
+ blocks,
1033
+ number: proposed.checkpointNumber,
1034
+ feeAssetPriceModifier: proposed.feeAssetPriceModifier,
1035
+ });
1036
+ const promotedCheckpoint = PublishedCheckpoint.from({
1037
+ checkpoint,
1038
+ l1: calldataCheckpoint.l1,
1039
+ attestations: calldataCheckpoint.attestations,
1040
+ });
1041
+ this.instrumentation.processCheckpointPromoted();
1042
+
1043
+ return promotedCheckpoint;
1044
+ }
1045
+
905
1046
  private async checkForNewCheckpointsBeforeL1SyncPoint(
906
1047
  status: RollupStatus,
907
1048
  blocksSynchedTo: bigint,
@@ -52,7 +52,10 @@ import {
52
52
  CheckpointNotFoundError,
53
53
  CheckpointNumberNotSequentialError,
54
54
  InitialCheckpointNumberNotSequentialError,
55
+ NoProposedCheckpointToPromoteError,
56
+ ProposedCheckpointArchiveRootMismatchError,
55
57
  ProposedCheckpointNotSequentialError,
58
+ ProposedCheckpointPromotionNotSequentialError,
56
59
  ProposedCheckpointStaleError,
57
60
  } from '../errors.js';
58
61
 
@@ -694,6 +697,55 @@ export class BlockStore {
694
697
  await this.#proposedCheckpoint.delete();
695
698
  }
696
699
 
700
+ /**
701
+ * Promotes the proposed checkpoint singleton to a confirmed checkpoint entry.
702
+ * This persists the checkpoint to the store, clears the proposed singleton, and updates the L1 sync point.
703
+ * Should only be called after the checkpoint has been validated.
704
+ * @param expectedArchiveRoot - The archive root to match against the proposed checkpoint, to guard against races.
705
+ */
706
+ async promoteProposedToCheckpointed(
707
+ l1: L1PublishedData,
708
+ attestations: CommitteeAttestation[],
709
+ expectedArchiveRoot: Fr,
710
+ ): Promise<void> {
711
+ return await this.db.transactionAsync(async () => {
712
+ const proposed = await this.getProposedCheckpointOnly();
713
+ if (!proposed) {
714
+ throw new NoProposedCheckpointToPromoteError();
715
+ }
716
+ if (!proposed.archive.root.equals(expectedArchiveRoot)) {
717
+ throw new ProposedCheckpointArchiveRootMismatchError(expectedArchiveRoot, proposed.archive.root);
718
+ }
719
+
720
+ // Verify sequentiality: promoted checkpoint must follow the latest confirmed one
721
+ const latestCheckpointNumber = await this.getLatestCheckpointNumber();
722
+ if (latestCheckpointNumber !== proposed.checkpointNumber - 1) {
723
+ throw new ProposedCheckpointPromotionNotSequentialError(proposed.checkpointNumber, latestCheckpointNumber);
724
+ }
725
+
726
+ // Write the checkpoint entry
727
+ await this.#checkpoints.set(proposed.checkpointNumber, {
728
+ header: proposed.header.toBuffer(),
729
+ archive: proposed.archive.toBuffer(),
730
+ checkpointOutHash: proposed.checkpointOutHash.toBuffer(),
731
+ l1: l1.toBuffer(),
732
+ attestations: attestations.map(attestation => attestation.toBuffer()),
733
+ checkpointNumber: proposed.checkpointNumber,
734
+ startBlock: proposed.startBlock,
735
+ blockCount: proposed.blockCount,
736
+ });
737
+
738
+ // Update the slot-to-checkpoint index
739
+ await this.#slotToCheckpoint.set(proposed.header.slotNumber, proposed.checkpointNumber);
740
+
741
+ // Clear the proposed checkpoint singleton
742
+ await this.#proposedCheckpoint.delete();
743
+
744
+ // Update the last synced L1 block
745
+ await this.#lastSynchedL1Block.set(l1.blockNumber);
746
+ });
747
+ }
748
+
697
749
  /** Clears the proposed checkpoint if the given confirmed checkpoint number supersedes it. */
698
750
  async clearProposedCheckpointIfSuperseded(confirmedCheckpointNumber: CheckpointNumber): Promise<void> {
699
751
  const proposedCheckpointNumber = await this.getProposedCheckpointNumber();
@@ -976,7 +1028,7 @@ export class BlockStore {
976
1028
  return {
977
1029
  header: BlockHeader.fromBuffer(blockStorage.header),
978
1030
  archive: AppendOnlyTreeSnapshot.fromBuffer(blockStorage.archive),
979
- blockHash: Fr.fromBuffer(blockStorage.blockHash),
1031
+ blockHash: BlockHash.fromBuffer(blockStorage.blockHash),
980
1032
  checkpointNumber: CheckpointNumber(blockStorage.checkpointNumber),
981
1033
  indexWithinCheckpoint: IndexWithinCheckpoint(blockStorage.indexWithinCheckpoint),
982
1034
  };
@@ -10,12 +10,14 @@ import {
10
10
  type BlockData,
11
11
  BlockHash,
12
12
  CheckpointedL2Block,
13
+ type CommitteeAttestation,
13
14
  L2Block,
14
15
  type ValidateCheckpointResult,
15
16
  } from '@aztec/stdlib/block';
16
17
  import type {
17
18
  CheckpointData,
18
19
  CommonCheckpointData,
20
+ L1PublishedData,
19
21
  ProposedCheckpointData,
20
22
  ProposedCheckpointInput,
21
23
  PublishedCheckpoint,
@@ -647,6 +649,19 @@ export class KVArchiverDataStore implements ContractDataSource {
647
649
  return this.#blockStore.deleteProposedCheckpoint();
648
650
  }
649
651
 
652
+ /**
653
+ * Promotes the proposed checkpoint to a confirmed checkpoint entry.
654
+ * Should only be called after the checkpoint has been validated.
655
+ * @param expectedArchiveRoot - The archive root to match against the proposed checkpoint, to guard against races.
656
+ */
657
+ public promoteProposedToCheckpointed(
658
+ l1: L1PublishedData,
659
+ attestations: CommitteeAttestation[],
660
+ expectedArchiveRoot: Fr,
661
+ ): Promise<void> {
662
+ return this.#blockStore.promoteProposedToCheckpointed(l1, attestations, expectedArchiveRoot);
663
+ }
664
+
650
665
  /**
651
666
  * Gets the number of the latest L2 block processed.
652
667
  * @returns The number of the latest L2 block processed.
@@ -1,6 +1,12 @@
1
- import { GENESIS_BLOCK_HEADER_HASH, INITIAL_L2_BLOCK_NUM } from '@aztec/constants';
1
+ import { INITIAL_L2_BLOCK_NUM } from '@aztec/constants';
2
2
  import { BlockNumber, CheckpointNumber } from '@aztec/foundation/branded-types';
3
- import { type BlockData, type CheckpointId, GENESIS_CHECKPOINT_HEADER_HASH, type L2Tips } from '@aztec/stdlib/block';
3
+ import {
4
+ type BlockData,
5
+ type CheckpointId,
6
+ GENESIS_BLOCK_HEADER_HASH,
7
+ GENESIS_CHECKPOINT_HEADER_HASH,
8
+ type L2Tips,
9
+ } from '@aztec/stdlib/block';
4
10
 
5
11
  import type { BlockStore } from './block_store.js';
6
12
 
@@ -1,7 +1,6 @@
1
1
  import { INITIAL_L2_BLOCK_NUM } from '@aztec/constants';
2
2
  import { BlockNumber } from '@aztec/foundation/branded-types';
3
3
  import { compactArray, filterAsync } from '@aztec/foundation/collection';
4
- import { Fr } from '@aztec/foundation/curves/bn254';
5
4
  import { createLogger } from '@aztec/foundation/log';
6
5
  import { BufferReader, numToUInt32BE } from '@aztec/foundation/serialize';
7
6
  import type { AztecAsyncKVStore, AztecAsyncMap } from '@aztec/kv-store';
@@ -302,13 +301,11 @@ export class LogStore {
302
301
  }
303
302
 
304
303
  #unpackBlockHash(reader: BufferReader): BlockHash {
305
- const blockHash = reader.remainingBytes() > 0 ? reader.readObject(Fr) : undefined;
306
-
307
- if (!blockHash) {
304
+ if (reader.remainingBytes() === 0) {
308
305
  throw new Error('Failed to read block hash from log entry buffer');
309
306
  }
310
307
 
311
- return new BlockHash(blockHash);
308
+ return BlockHash.fromBuffer(reader);
312
309
  }
313
310
 
314
311
  deleteLogs(blocks: L2Block[]): Promise<boolean> {
@@ -151,8 +151,10 @@ export class FakeL1State {
151
151
  // Computed from checkpoints based on L1 block visibility
152
152
  private pendingCheckpointNumber: CheckpointNumber = CheckpointNumber(0);
153
153
 
154
- // The L1 block number reported as "finalized" (defaults to the start block)
155
- private finalizedL1BlockNumber: bigint;
154
+ // The L1 block number reported as "finalized" (defaults to the start block).
155
+ // `undefined` simulates the startup window on a fresh devnet where
156
+ // `getBlock({ blockTag: 'finalized' })` fails with "finalized block not found".
157
+ private finalizedL1BlockNumber: bigint | undefined;
156
158
 
157
159
  constructor(private readonly config: FakeL1StateConfig) {
158
160
  this.l1BlockNumber = config.l1StartBlock;
@@ -288,8 +290,11 @@ export class FakeL1State {
288
290
  this.updatePendingCheckpointNumber();
289
291
  }
290
292
 
291
- /** Sets the L1 block number that will be reported as "finalized". */
292
- setFinalizedL1BlockNumber(blockNumber: bigint): void {
293
+ /**
294
+ * Sets the L1 block number that will be reported as "finalized". Pass `undefined` to
295
+ * simulate a chain that does not yet have a finalized block (devnet startup).
296
+ */
297
+ setFinalizedL1BlockNumber(blockNumber: bigint | undefined): void {
293
298
  this.finalizedL1BlockNumber = blockNumber;
294
299
  }
295
300
 
@@ -502,8 +507,8 @@ export class FakeL1State {
502
507
  Promise.resolve(this.getMessageSentLogs(fromBlock, toBlock)),
503
508
  );
504
509
 
505
- mockInbox.getMessageSentEventByHash.mockImplementation((msgHash: string, l1BlockHash: string) =>
506
- Promise.resolve(this.getMessageSentLogByHash(msgHash, l1BlockHash) as MessageSentLog),
510
+ mockInbox.getMessageSentEventByHash.mockImplementation((msgHash: string, aroundL1BlockNumber: bigint) =>
511
+ Promise.resolve(this.getMessageSentLogByHash(msgHash, aroundL1BlockNumber) as MessageSentLog),
507
512
  );
508
513
 
509
514
  return mockInbox;
@@ -519,6 +524,11 @@ export class FakeL1State {
519
524
  publicClient.getBlock.mockImplementation((async (args: { blockNumber?: bigint; blockTag?: string } = {}) => {
520
525
  let blockNum: bigint;
521
526
  if (args.blockTag === 'finalized') {
527
+ if (this.finalizedL1BlockNumber === undefined) {
528
+ throw Object.assign(new Error('finalized block not found'), {
529
+ details: 'finalized block not found',
530
+ });
531
+ }
522
532
  blockNum = this.finalizedL1BlockNumber;
523
533
  } else {
524
534
  blockNum = args.blockNumber ?? (await publicClient.getBlockNumber());
@@ -544,10 +554,10 @@ export class FakeL1State {
544
554
  createMockBlobClient(): MockProxy<BlobClientInterface> {
545
555
  const blobClient = mock<BlobClientInterface>();
546
556
 
547
- // The blockId is the transaction's blockHash, which we set to the checkpoint's archive root
557
+ // The blockId is the L1 block hash, which we derive from the L1 block number
548
558
  blobClient.getBlobSidecar.mockImplementation((blockId: `0x${string}`) =>
549
559
  Promise.resolve(
550
- this.checkpoints.find(cpData => cpData.checkpoint.archive.root.toString() === blockId)?.blobs ?? [],
560
+ this.checkpoints.find(cpData => Buffer32.fromBigInt(cpData.l1BlockNumber).toString() === blockId)?.blobs ?? [],
551
561
  ),
552
562
  );
553
563
 
@@ -623,9 +633,12 @@ export class FakeL1State {
623
633
  }));
624
634
  }
625
635
 
626
- private getMessageSentLogByHash(msgHash: string, l1BlockHash: string): MessageSentLog | undefined {
636
+ private getMessageSentLogByHash(msgHash: string, aroundL1BlockNumber: bigint): MessageSentLog | undefined {
627
637
  const msg = this.messages.find(
628
- msg => msg.leaf.toString() === msgHash && Buffer32.fromBigInt(msg.l1BlockNumber).toString() === l1BlockHash,
638
+ msg =>
639
+ msg.leaf.toString() === msgHash &&
640
+ msg.l1BlockNumber >= aroundL1BlockNumber - 5n &&
641
+ msg.l1BlockNumber <= aroundL1BlockNumber + 5n,
629
642
  );
630
643
  if (!msg) {
631
644
  return undefined;
@@ -70,7 +70,9 @@ export class NoopL1Archiver extends Archiver {
70
70
  debugClient,
71
71
  rollup,
72
72
  {
73
+ rollupAddress: EthAddress.ZERO,
73
74
  registryAddress: EthAddress.ZERO,
75
+ inboxAddress: EthAddress.ZERO,
74
76
  governanceProposerAddress: EthAddress.ZERO,
75
77
  slashingProposerAddress: EthAddress.ZERO,
76
78
  },
@@ -81,6 +83,7 @@ export class NoopL1Archiver extends Archiver {
81
83
  skipValidateCheckpointAttestations: true,
82
84
  maxAllowedEthClientDriftSeconds: 300,
83
85
  ethereumAllowNoDebugHosts: true, // Skip trace validation
86
+ skipHistoricalLogsCheck: true, // Skip historical logs validation
84
87
  },
85
88
  blobClient,
86
89
  instrumentation,