@aztec-labs/archiver 6.0.0-rc.1 → 6.0.0-rc.2

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 (42) hide show
  1. package/README.md +7 -1
  2. package/dest/errors.d.ts +20 -1
  3. package/dest/errors.d.ts.map +1 -1
  4. package/dest/errors.js +22 -0
  5. package/dest/l1/bin/retrieve-calldata.js +14 -4
  6. package/dest/l1/calldata_retriever.d.ts +15 -17
  7. package/dest/l1/calldata_retriever.d.ts.map +1 -1
  8. package/dest/l1/calldata_retriever.js +2 -9
  9. package/dest/l1/data_retrieval.d.ts +7 -3
  10. package/dest/l1/data_retrieval.d.ts.map +1 -1
  11. package/dest/l1/data_retrieval.js +14 -9
  12. package/dest/modules/data_store_updater.js +5 -5
  13. package/dest/modules/l1_synchronizer.d.ts +1 -1
  14. package/dest/modules/l1_synchronizer.d.ts.map +1 -1
  15. package/dest/modules/l1_synchronizer.js +228 -191
  16. package/dest/modules/validation.d.ts +14 -8
  17. package/dest/modules/validation.d.ts.map +1 -1
  18. package/dest/modules/validation.js +72 -22
  19. package/dest/store/block_store.d.ts +2 -1
  20. package/dest/store/block_store.d.ts.map +1 -1
  21. package/dest/store/block_store.js +34 -14
  22. package/dest/store/contract_class_store.d.ts +1 -1
  23. package/dest/store/contract_class_store.d.ts.map +1 -1
  24. package/dest/store/contract_class_store.js +4 -2
  25. package/dest/store/contract_instance_store.d.ts +5 -4
  26. package/dest/store/contract_instance_store.d.ts.map +1 -1
  27. package/dest/store/contract_instance_store.js +20 -9
  28. package/dest/test/fake_l1_state.d.ts +3 -1
  29. package/dest/test/fake_l1_state.d.ts.map +1 -1
  30. package/dest/test/fake_l1_state.js +4 -4
  31. package/package.json +13 -13
  32. package/src/errors.ts +32 -0
  33. package/src/l1/bin/retrieve-calldata.ts +15 -4
  34. package/src/l1/calldata_retriever.ts +14 -20
  35. package/src/l1/data_retrieval.ts +22 -14
  36. package/src/modules/data_store_updater.ts +4 -4
  37. package/src/modules/l1_synchronizer.ts +294 -261
  38. package/src/modules/validation.ts +87 -28
  39. package/src/store/block_store.ts +41 -13
  40. package/src/store/contract_class_store.ts +4 -4
  41. package/src/store/contract_instance_store.ts +24 -17
  42. package/src/test/fake_l1_state.ts +9 -3
@@ -3,10 +3,11 @@ import type { ViemCommitteeAttestations } from '@aztec-labs/ethereum/contracts';
3
3
  import { type CheckpointNumber, EpochNumber } from '@aztec-labs/foundation/branded-types';
4
4
  import { compactArray } from '@aztec-labs/foundation/collection';
5
5
  import type { Fr } from '@aztec-labs/foundation/curves/bn254';
6
+ import type { EthAddress } from '@aztec-labs/foundation/eth-address';
6
7
  import type { Logger } from '@aztec-labs/foundation/log';
7
8
  import {
8
9
  type AttestationInfo,
9
- type CommitteeAttestation,
10
+ CommitteeAttestation,
10
11
  type ValidateCheckpointNegativeResult,
11
12
  type ValidateCheckpointResult,
12
13
  getAttestationInfoFromPayload,
@@ -16,6 +17,8 @@ import { type L1RollupConstants, computeQuorum, getEpochAtSlot } from '@aztec-la
16
17
  import { ConsensusPayload, type CoordinationSignatureContext } from '@aztec-labs/stdlib/p2p';
17
18
  import type { CheckpointHeader } from '@aztec-labs/stdlib/rollup';
18
19
 
20
+ import { CheckpointAttestationsDecodeError } from '../errors.js';
21
+
19
22
  export type { ValidateCheckpointResult };
20
23
 
21
24
  /**
@@ -36,24 +39,57 @@ export type CalldataCheckpointForAttestations = {
36
39
  archiveRoot: Fr;
37
40
  feeAssetPriceModifier: bigint;
38
41
  header: CheckpointHeader;
39
- attestations: CommitteeAttestation[];
40
42
  /** The exact packed attestations tuple from L1 calldata, carried verbatim for byte-faithful invalidation. */
41
43
  verbatimAttestations: ViemCommitteeAttestations;
42
44
  };
43
45
 
46
+ /** A checkpoint's attestations decoded for its epoch committee, and their validation result. */
47
+ export type ResolvedCheckpointAttestations = {
48
+ /** Empty if the epoch has an open escape hatch or no committee. */
49
+ attestations: CommitteeAttestation[];
50
+ validationResult: ValidateCheckpointResult;
51
+ };
52
+
44
53
  /**
45
- * Validates the attestations of a checkpoint from L1 calldata only, without fetching or decoding its blobs.
46
- * The signed consensus payload (header, archive root, fee asset price modifier) is fully available from
47
- * calldata, so an invalid-attestation checkpoint can be rejected before any (possibly malformed) blob is
48
- * fetched and decoded.
54
+ * Decodes and validates the attestations of a checkpoint from L1 calldata only, so an invalid checkpoint can be
55
+ * rejected before its blobs are fetched. The tuple is decoded against the epoch's committee size, and not at all
56
+ * during an escape hatch, where the proposer may post an arbitrary tuple.
57
+ *
58
+ * @throws CheckpointAttestationsDecodeError if the tuple does not decode for the epoch committee.
49
59
  */
50
- export function validateCheckpointAttestationsFromCalldata(
60
+ export async function resolveCheckpointAttestationsFromCalldata(
51
61
  checkpoint: CalldataCheckpointForAttestations,
52
62
  epochCache: EpochCache,
53
63
  constants: Pick<L1RollupConstants, 'epochDuration'>,
54
64
  signatureContext: CoordinationSignatureContext,
55
65
  logger?: Logger,
56
- ): Promise<ValidateCheckpointResult> {
66
+ ): Promise<ResolvedCheckpointAttestations> {
67
+ const slot = checkpoint.header.slotNumber;
68
+ const epoch: EpochNumber = getEpochAtSlot(slot, constants);
69
+ // Read the hatch flag from the same cache entry as the committee, so both come from one snapshot.
70
+ const { committee, seed, isEscapeHatchOpen } = await epochCache.getCommitteeForEpoch(epoch);
71
+
72
+ if (isEscapeHatchOpen) {
73
+ logger?.warn(
74
+ `Escape hatch open for epoch ${epoch} at slot ${slot}, skipping attestations of checkpoint ${checkpoint.checkpointNumber}`,
75
+ );
76
+ return { attestations: [], validationResult: { valid: true } };
77
+ }
78
+
79
+ if (!committee || committee.length === 0) {
80
+ logger?.warn(
81
+ `No committee found for epoch ${epoch} at slot ${slot}. Accepting checkpoint ${checkpoint.checkpointNumber} without validation.`,
82
+ );
83
+ return { attestations: [], validationResult: { valid: true } };
84
+ }
85
+
86
+ let attestations: CommitteeAttestation[];
87
+ try {
88
+ attestations = CommitteeAttestation.fromPacked(checkpoint.verbatimAttestations, committee.length);
89
+ } catch (err) {
90
+ throw new CheckpointAttestationsDecodeError(checkpoint.checkpointNumber, epoch, committee.length, err);
91
+ }
92
+
57
93
  const payload = new ConsensusPayload(
58
94
  checkpoint.header,
59
95
  checkpoint.archiveRoot,
@@ -67,15 +103,16 @@ export function validateCheckpointAttestationsFromCalldata(
67
103
  checkpointNumber: checkpoint.checkpointNumber,
68
104
  timestamp: checkpoint.header.timestamp,
69
105
  };
70
- return validateAttestations(
106
+
107
+ const validationResult = validateAttestationsAgainstCommittee(
71
108
  payload,
72
- checkpoint.attestations,
109
+ attestations,
73
110
  checkpoint.verbatimAttestations,
74
111
  checkpointInfo,
75
- epochCache,
76
- constants,
112
+ { committee, seed, epoch },
77
113
  logger,
78
114
  );
115
+ return { attestations, validationResult };
79
116
  }
80
117
 
81
118
  /**
@@ -92,36 +129,58 @@ export async function validateAttestations(
92
129
  constants: Pick<L1RollupConstants, 'epochDuration'>,
93
130
  logger?: Logger,
94
131
  ): Promise<ValidateCheckpointResult> {
132
+ const slot = payload.header.slotNumber;
133
+ const epoch: EpochNumber = getEpochAtSlot(slot, constants);
134
+ const { committee, seed, isEscapeHatchOpen } = await epochCache.getCommitteeForEpoch(epoch);
135
+
136
+ if (isEscapeHatchOpen) {
137
+ logger?.warn(`Escape hatch open for epoch ${epoch} at slot ${slot}, skipping checkpoint validation`);
138
+ return { valid: true };
139
+ }
140
+
141
+ if (!committee || committee.length === 0) {
142
+ logger?.warn(`No committee found for epoch ${epoch} at slot ${slot}. Accepting checkpoint without validation.`, {
143
+ checkpointNumber: checkpointInfo.checkpointNumber,
144
+ slot,
145
+ epoch,
146
+ });
147
+ return { valid: true };
148
+ }
149
+
150
+ return validateAttestationsAgainstCommittee(
151
+ payload,
152
+ attestations,
153
+ verbatimAttestations,
154
+ checkpointInfo,
155
+ { committee, seed, epoch },
156
+ logger,
157
+ );
158
+ }
159
+
160
+ /** Validates decoded attestations against a known, non-empty epoch committee. */
161
+ function validateAttestationsAgainstCommittee(
162
+ payload: ConsensusPayload,
163
+ attestations: CommitteeAttestation[],
164
+ verbatimAttestations: ViemCommitteeAttestations,
165
+ checkpointInfo: CheckpointInfo,
166
+ { committee, seed, epoch }: { committee: EthAddress[]; seed: bigint; epoch: EpochNumber },
167
+ logger?: Logger,
168
+ ): ValidateCheckpointResult {
95
169
  const attestorInfos = getAttestationInfoFromPayload(payload, attestations);
96
170
  const attestors = compactArray(attestorInfos.map(info => ('address' in info ? info.address : undefined)));
97
171
  const headerHash = payload.header.hash();
98
172
  const archiveRoot = payload.archive.toString();
99
173
  const slot = payload.header.slotNumber;
100
174
  const checkpointNumber = checkpointInfo.checkpointNumber;
101
- const epoch: EpochNumber = getEpochAtSlot(slot, constants);
102
- const { committee, seed } = await epochCache.getCommitteeForEpoch(epoch);
103
175
  const logData = { checkpointNumber, slot, epoch, headerHash, archiveRoot };
104
176
 
105
177
  logger?.debug(`Validating attestations for checkpoint ${checkpointNumber} at slot ${slot} in epoch ${epoch}`, {
106
- committee: (committee ?? []).map(member => member.toString()),
178
+ committee: committee.map(member => member.toString()),
107
179
  recoveredAttestors: attestorInfos,
108
180
  postedAttestations: attestations.map(a => (a.address.isZero() ? a.signature : a.address).toString()),
109
181
  ...logData,
110
182
  });
111
183
 
112
- if (!committee || committee.length === 0) {
113
- logger?.warn(
114
- `No committee found for epoch ${epoch} at slot ${slot}. Accepting checkpoint without validation.`,
115
- logData,
116
- );
117
- return { valid: true };
118
- }
119
-
120
- if (await epochCache.isEscapeHatchOpen(epoch)) {
121
- logger?.warn(`Escape hatch open for epoch ${epoch} at slot ${slot}, skipping checkpoint validation`);
122
- return { valid: true };
123
- }
124
-
125
184
  const requiredAttestationCount = computeQuorum(committee.length);
126
185
 
127
186
  const failedValidationResult = <TReason extends ValidateCheckpointNegativeResult['reason']>(reason: TReason) => ({
@@ -51,6 +51,7 @@ import {
51
51
  CannotOverwriteCheckpointedBlockError,
52
52
  CheckpointNotFoundError,
53
53
  CheckpointNumberNotSequentialError,
54
+ DuplicateTxHashError,
54
55
  InitialCheckpointNumberNotSequentialError,
55
56
  NoProposedCheckpointToPromoteError,
56
57
  ProposedCheckpointArchiveRootMismatchError,
@@ -569,6 +570,10 @@ export class BlockStore {
569
570
  indexWithinCheckpoint,
570
571
  });
571
572
 
573
+ await this.assertTxsNotInAncestors(
574
+ block.body.txEffects.map(tx => tx.txHash),
575
+ block.number,
576
+ );
572
577
  for (let i = 0; i < block.body.txEffects.length; i++) {
573
578
  const txEffect: IndexedTxEffect = {
574
579
  data: block.body.txEffects[i],
@@ -587,6 +592,28 @@ export class BlockStore {
587
592
  await this.#blockArchiveIndex.set(block.archive.root.toString(), block.number);
588
593
  }
589
594
 
595
+ /**
596
+ * Throws if any of the txs is already owned by a stored block below `blockNumber`. Stored blocks below the one being
597
+ * inserted are its ancestors (archive chaining is enforced on insert), so the tx would be included twice and the new
598
+ * block would take over the ancestor's tx effect entry. An owner at the same or a higher height is a block being
599
+ * replaced (a losing local proposal, a re-presented checkpoint), so its entry is overwritten. This relies on replaced
600
+ * blocks at lower heights having been removed via deleteBlock first (as the updater does before inserting
601
+ * checkpoints), which also deletes their tx effect entries.
602
+ */
603
+ private async assertTxsNotInAncestors(txHashes: TxHash[], blockNumber: BlockNumber): Promise<void> {
604
+ const existing = await this.#txEffects.getManyAsync(txHashes.map(txHash => txHash.toString()));
605
+ for (let i = 0; i < txHashes.length; i++) {
606
+ const stored = existing[i];
607
+ if (stored === undefined) {
608
+ continue;
609
+ }
610
+ const owner = readTxLocation(stored).blockNumber;
611
+ if (owner < blockNumber) {
612
+ throw new DuplicateTxHashError(txHashes[i], blockNumber, owner);
613
+ }
614
+ }
615
+ }
616
+
590
617
  /** Deletes a block and all associated data (tx effects, indices). */
591
618
  private async deleteBlock(blockNumber: number, blockStorage: BlockStorage): Promise<void> {
592
619
  // Delete the block from the main blocks map
@@ -1283,19 +1310,7 @@ export class BlockStore {
1283
1310
  */
1284
1311
  public async getTxLocation(txHash: TxHash): Promise<TxLocation | undefined> {
1285
1312
  const txEffect = await this.#txEffects.getAsync(txHash.toString());
1286
- if (!txEffect) {
1287
- return undefined;
1288
- }
1289
- // Read only the IndexedTxEffect header (`blockHash(32) + l2BlockNumber(4) + txIndexInBlock(4)`); the
1290
- // large tail (the full TxEffect with logs etc.) is irrelevant here.
1291
- const view = Buffer.from(txEffect.buffer, txEffect.byteOffset, txEffect.byteLength);
1292
- const l2BlockNumber = view.readUInt32BE(32);
1293
- const txIndexInBlock = view.readUInt32BE(36);
1294
- return {
1295
- blockNumber: BlockNumber(l2BlockNumber),
1296
- blockHash: BlockHash.fromBuffer(view.subarray(0, 32)),
1297
- txIndexInBlock,
1298
- };
1313
+ return txEffect ? readTxLocation(txEffect) : undefined;
1299
1314
  }
1300
1315
 
1301
1316
  /**
@@ -1714,3 +1729,16 @@ export class BlockStore {
1714
1729
  };
1715
1730
  }
1716
1731
  }
1732
+
1733
+ /**
1734
+ * Reads only the header of a serialized IndexedTxEffect (`blockHash(32) + l2BlockNumber(4) + txIndexInBlock(4)`),
1735
+ * skipping the large tail (the full TxEffect with logs etc.).
1736
+ */
1737
+ function readTxLocation(txEffect: Buffer): TxLocation {
1738
+ const view = Buffer.from(txEffect.buffer, txEffect.byteOffset, txEffect.byteLength);
1739
+ return {
1740
+ blockNumber: BlockNumber(view.readUInt32BE(32)),
1741
+ blockHash: BlockHash.fromBuffer(view.subarray(0, 32)),
1742
+ txIndexInBlock: view.readUInt32BE(36),
1743
+ };
1744
+ }
@@ -28,9 +28,8 @@ export class ContractClassStore {
28
28
  * @returns True if every insert succeeded.
29
29
  */
30
30
  async addContractClasses(data: ContractClassPublicWithCommitment[], blockNumber: number): Promise<boolean> {
31
- return (await Promise.all(data.map(c => this.addContractClass(c, c.publicBytecodeCommitment, blockNumber)))).every(
32
- Boolean,
33
- );
31
+ await Promise.all(data.map(c => this.addContractClass(c, c.publicBytecodeCommitment, blockNumber)));
32
+ return true;
34
33
  }
35
34
 
36
35
  /**
@@ -40,7 +39,8 @@ export class ContractClassStore {
40
39
  * @returns True if every delete succeeded.
41
40
  */
42
41
  async deleteContractClasses(data: ContractClassPublic[], blockNumber: number): Promise<boolean> {
43
- return (await Promise.all(data.map(c => this.deleteContractClass(c, blockNumber)))).every(Boolean);
42
+ await Promise.all(data.map(c => this.deleteContractClass(c, blockNumber)));
43
+ return true;
44
44
  }
45
45
 
46
46
  async addContractClass(
@@ -45,16 +45,19 @@ export class ContractInstanceStore {
45
45
  * @returns True if every insert succeeded.
46
46
  */
47
47
  async addContractInstances(data: ContractInstanceWithAddress[], blockNumber: number): Promise<boolean> {
48
- return (await Promise.all(data.map(c => this.addContractInstance(c, blockNumber)))).every(Boolean);
48
+ await Promise.all(data.map(c => this.addContractInstance(c, blockNumber)));
49
+ return true;
49
50
  }
50
51
 
51
52
  /**
52
- * Removes multiple contract instances from the store.
53
+ * Removes multiple contract instances from the store, but only those that were published at the given block.
53
54
  * @param data - Contract instances to delete.
55
+ * @param blockNumber - L2 block being unwound, which must be the one that published each deleted instance.
54
56
  * @returns True if every delete succeeded.
55
57
  */
56
- async deleteContractInstances(data: ContractInstanceWithAddress[]): Promise<boolean> {
57
- return (await Promise.all(data.map(c => this.deleteContractInstance(c)))).every(Boolean);
58
+ async deleteContractInstances(data: ContractInstanceWithAddress[], blockNumber: number): Promise<boolean> {
59
+ await Promise.all(data.map(c => this.deleteContractInstance(c, blockNumber)));
60
+ return true;
58
61
  }
59
62
 
60
63
  /**
@@ -69,11 +72,10 @@ export class ContractInstanceStore {
69
72
  timestamp: UInt64,
70
73
  blockNumber: BlockNumber,
71
74
  ): Promise<boolean> {
72
- return (
73
- await Promise.all(
74
- data.map((update, logIndex) => this.addContractInstanceUpdate(update, timestamp, blockNumber, logIndex)),
75
- )
76
- ).every(Boolean);
75
+ await Promise.all(
76
+ data.map((update, logIndex) => this.addContractInstanceUpdate(update, timestamp, blockNumber, logIndex)),
77
+ );
78
+ return true;
77
79
  }
78
80
 
79
81
  /**
@@ -88,11 +90,10 @@ export class ContractInstanceStore {
88
90
  timestamp: UInt64,
89
91
  blockNumber: BlockNumber,
90
92
  ): Promise<boolean> {
91
- return (
92
- await Promise.all(
93
- data.map((update, logIndex) => this.deleteContractInstanceUpdate(update, timestamp, blockNumber, logIndex)),
94
- )
95
- ).every(Boolean);
93
+ await Promise.all(
94
+ data.map((update, logIndex) => this.deleteContractInstanceUpdate(update, timestamp, blockNumber, logIndex)),
95
+ );
96
+ return true;
96
97
  }
97
98
 
98
99
  addContractInstance(contractInstance: ContractInstanceWithAddress, blockNumber: number): Promise<void> {
@@ -119,15 +120,21 @@ export class ContractInstanceStore {
119
120
  });
120
121
  }
121
122
 
122
- deleteContractInstance(contractInstance: ContractInstanceWithAddress): Promise<void> {
123
+ deleteContractInstance(contractInstance: ContractInstanceWithAddress, blockNumber: number): Promise<void> {
123
124
  // Protocol contracts are preloaded at block 0 and must never be deleted, even when the block that
124
125
  // (re-)published them on-chain is unwound by a reorg.
125
126
  if (isProtocolContract(contractInstance.address)) {
126
127
  return Promise.resolve();
127
128
  }
128
129
  return this.db.transactionAsync(async () => {
129
- await this.#contractInstances.delete(contractInstance.address.toString());
130
- await this.#contractInstancePublishedAt.delete(contractInstance.address.toString());
130
+ const key = contractInstance.address.toString();
131
+ // An unwound block can carry a log naming an address first published by a surviving block (a forged log in an
132
+ // unexecuted checkpoint, which the store path skipped). Deleting by address alone would erase that instance.
133
+ if ((await this.#contractInstancePublishedAt.getAsync(key)) !== blockNumber) {
134
+ return;
135
+ }
136
+ await this.#contractInstances.delete(key);
137
+ await this.#contractInstancePublishedAt.delete(key);
131
138
  });
132
139
  }
133
140
 
@@ -80,6 +80,8 @@ type AddCheckpointOptions = {
80
80
  * `attestationsHash` the rollup stores at propose time covers.
81
81
  */
82
82
  spareAttestationsBitmapBit?: boolean;
83
+ /** Overrides the posted attestations tuple, e.g. to model an escape-hatch proposer's arbitrary tuple. */
84
+ verbatimAttestations?: ViemCommitteeAttestations;
83
85
  };
84
86
 
85
87
  /** Result from adding a checkpoint. */
@@ -263,6 +265,7 @@ export class FakeL1State {
263
265
  checkpoint,
264
266
  signers,
265
267
  options.spareAttestationsBitmapBit ?? false,
268
+ options.verbatimAttestations,
266
269
  );
267
270
  const blobHashes = await this.makeVersionedBlobHashes(checkpoint);
268
271
  const blobs = await this.makeBlobsFromCheckpoint(checkpoint);
@@ -776,6 +779,7 @@ export class FakeL1State {
776
779
  checkpoint: Checkpoint,
777
780
  signers: Secp256k1Signer[],
778
781
  spareAttestationsBitmapBit: boolean,
782
+ verbatimAttestationsOverride?: ViemCommitteeAttestations,
779
783
  ): Promise<{
780
784
  tx: Transaction;
781
785
  attestationsHash: Buffer32;
@@ -806,9 +810,11 @@ export class FakeL1State {
806
810
  );
807
811
 
808
812
  const packedAttestations = attestationsAndSigners.getPackedAttestations();
809
- const verbatimAttestations = spareAttestationsBitmapBit
810
- ? { ...packedAttestations, signatureIndices: setLastBitmapBit(packedAttestations.signatureIndices) }
811
- : packedAttestations;
813
+ const verbatimAttestations =
814
+ verbatimAttestationsOverride ??
815
+ (spareAttestationsBitmapBit
816
+ ? { ...packedAttestations, signatureIndices: setLastBitmapBit(packedAttestations.signatureIndices) }
817
+ : packedAttestations);
812
818
 
813
819
  const rollupInput = encodeFunctionData({
814
820
  abi: RollupAbi,