@aztec/archiver 0.0.1-commit.7b97ef96e → 0.0.1-commit.7cbc774

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 (128) hide show
  1. package/README.md +19 -11
  2. package/dest/archiver.d.ts +36 -17
  3. package/dest/archiver.d.ts.map +1 -1
  4. package/dest/archiver.js +257 -75
  5. package/dest/config.d.ts +6 -3
  6. package/dest/config.d.ts.map +1 -1
  7. package/dest/config.js +23 -15
  8. package/dest/errors.d.ts +55 -9
  9. package/dest/errors.d.ts.map +1 -1
  10. package/dest/errors.js +81 -14
  11. package/dest/factory.d.ts +13 -9
  12. package/dest/factory.d.ts.map +1 -1
  13. package/dest/factory.js +48 -39
  14. package/dest/index.d.ts +11 -3
  15. package/dest/index.d.ts.map +1 -1
  16. package/dest/index.js +10 -2
  17. package/dest/l1/bin/retrieve-calldata.js +32 -28
  18. package/dest/l1/calldata_retriever.d.ts +71 -53
  19. package/dest/l1/calldata_retriever.d.ts.map +1 -1
  20. package/dest/l1/calldata_retriever.js +190 -262
  21. package/dest/l1/data_retrieval.d.ts +24 -16
  22. package/dest/l1/data_retrieval.d.ts.map +1 -1
  23. package/dest/l1/data_retrieval.js +39 -45
  24. package/dest/l1/spire_proposer.d.ts +5 -5
  25. package/dest/l1/spire_proposer.d.ts.map +1 -1
  26. package/dest/l1/spire_proposer.js +9 -17
  27. package/dest/l1/trace_tx.d.ts +12 -66
  28. package/dest/l1/trace_tx.d.ts.map +1 -1
  29. package/dest/l1/validate_historical_logs.d.ts +23 -0
  30. package/dest/l1/validate_historical_logs.d.ts.map +1 -0
  31. package/dest/l1/validate_historical_logs.js +108 -0
  32. package/dest/modules/contract_data_source_adapter.d.ts +25 -0
  33. package/dest/modules/contract_data_source_adapter.d.ts.map +1 -0
  34. package/dest/modules/contract_data_source_adapter.js +40 -0
  35. package/dest/modules/data_source_base.d.ts +70 -46
  36. package/dest/modules/data_source_base.d.ts.map +1 -1
  37. package/dest/modules/data_source_base.js +270 -135
  38. package/dest/modules/data_store_updater.d.ts +42 -17
  39. package/dest/modules/data_store_updater.d.ts.map +1 -1
  40. package/dest/modules/data_store_updater.js +191 -122
  41. package/dest/modules/instrumentation.d.ts +18 -2
  42. package/dest/modules/instrumentation.d.ts.map +1 -1
  43. package/dest/modules/instrumentation.js +35 -7
  44. package/dest/modules/l1_synchronizer.d.ts +12 -11
  45. package/dest/modules/l1_synchronizer.d.ts.map +1 -1
  46. package/dest/modules/l1_synchronizer.js +439 -207
  47. package/dest/modules/validation.d.ts +4 -3
  48. package/dest/modules/validation.d.ts.map +1 -1
  49. package/dest/modules/validation.js +6 -6
  50. package/dest/store/block_store.d.ts +174 -70
  51. package/dest/store/block_store.d.ts.map +1 -1
  52. package/dest/store/block_store.js +696 -250
  53. package/dest/store/contract_class_store.d.ts +17 -4
  54. package/dest/store/contract_class_store.d.ts.map +1 -1
  55. package/dest/store/contract_class_store.js +24 -68
  56. package/dest/store/contract_instance_store.d.ts +28 -1
  57. package/dest/store/contract_instance_store.d.ts.map +1 -1
  58. package/dest/store/contract_instance_store.js +37 -2
  59. package/dest/store/data_stores.d.ts +68 -0
  60. package/dest/store/data_stores.d.ts.map +1 -0
  61. package/dest/store/data_stores.js +54 -0
  62. package/dest/store/function_names_cache.d.ts +17 -0
  63. package/dest/store/function_names_cache.d.ts.map +1 -0
  64. package/dest/store/function_names_cache.js +30 -0
  65. package/dest/store/l2_tips_cache.d.ts +13 -7
  66. package/dest/store/l2_tips_cache.d.ts.map +1 -1
  67. package/dest/store/l2_tips_cache.js +13 -76
  68. package/dest/store/log_store.d.ts +42 -37
  69. package/dest/store/log_store.d.ts.map +1 -1
  70. package/dest/store/log_store.js +262 -408
  71. package/dest/store/log_store_codec.d.ts +70 -0
  72. package/dest/store/log_store_codec.d.ts.map +1 -0
  73. package/dest/store/log_store_codec.js +101 -0
  74. package/dest/store/message_store.d.ts +11 -1
  75. package/dest/store/message_store.d.ts.map +1 -1
  76. package/dest/store/message_store.js +51 -9
  77. package/dest/test/fake_l1_state.d.ts +22 -1
  78. package/dest/test/fake_l1_state.d.ts.map +1 -1
  79. package/dest/test/fake_l1_state.js +152 -24
  80. package/dest/test/mock_l1_to_l2_message_source.d.ts +1 -1
  81. package/dest/test/mock_l1_to_l2_message_source.d.ts.map +1 -1
  82. package/dest/test/mock_l1_to_l2_message_source.js +2 -1
  83. package/dest/test/mock_l2_block_source.d.ts +52 -46
  84. package/dest/test/mock_l2_block_source.d.ts.map +1 -1
  85. package/dest/test/mock_l2_block_source.js +246 -170
  86. package/dest/test/mock_structs.d.ts +4 -1
  87. package/dest/test/mock_structs.d.ts.map +1 -1
  88. package/dest/test/mock_structs.js +13 -1
  89. package/dest/test/noop_l1_archiver.d.ts +12 -6
  90. package/dest/test/noop_l1_archiver.d.ts.map +1 -1
  91. package/dest/test/noop_l1_archiver.js +26 -9
  92. package/package.json +14 -14
  93. package/src/archiver.ts +313 -75
  94. package/src/config.ts +32 -12
  95. package/src/errors.ts +122 -21
  96. package/src/factory.ts +54 -30
  97. package/src/index.ts +18 -2
  98. package/src/l1/README.md +25 -68
  99. package/src/l1/bin/retrieve-calldata.ts +40 -27
  100. package/src/l1/calldata_retriever.ts +243 -384
  101. package/src/l1/data_retrieval.ts +55 -69
  102. package/src/l1/spire_proposer.ts +7 -15
  103. package/src/l1/validate_historical_logs.ts +140 -0
  104. package/src/modules/contract_data_source_adapter.ts +55 -0
  105. package/src/modules/data_source_base.ts +336 -171
  106. package/src/modules/data_store_updater.ts +224 -154
  107. package/src/modules/instrumentation.ts +48 -8
  108. package/src/modules/l1_synchronizer.ts +579 -254
  109. package/src/modules/validation.ts +10 -9
  110. package/src/store/block_store.ts +865 -290
  111. package/src/store/contract_class_store.ts +31 -103
  112. package/src/store/contract_instance_store.ts +51 -5
  113. package/src/store/data_stores.ts +104 -0
  114. package/src/store/function_names_cache.ts +37 -0
  115. package/src/store/l2_tips_cache.ts +16 -70
  116. package/src/store/log_store.ts +301 -559
  117. package/src/store/log_store_codec.ts +132 -0
  118. package/src/store/message_store.ts +60 -10
  119. package/src/structs/inbox_message.ts +1 -1
  120. package/src/test/fake_l1_state.ts +198 -35
  121. package/src/test/mock_l1_to_l2_message_source.ts +1 -0
  122. package/src/test/mock_l2_block_source.ts +309 -205
  123. package/src/test/mock_structs.ts +20 -6
  124. package/src/test/noop_l1_archiver.ts +39 -9
  125. package/dest/store/kv_archiver_store.d.ts +0 -354
  126. package/dest/store/kv_archiver_store.d.ts.map +0 -1
  127. package/dest/store/kv_archiver_store.js +0 -464
  128. package/src/store/kv_archiver_store.ts +0 -671
@@ -1,19 +1,13 @@
1
1
  import { MULTI_CALL_3_ADDRESS, type ViemCommitteeAttestations, type ViemHeader } from '@aztec/ethereum/contracts';
2
2
  import type { ViemPublicClient, ViemPublicDebugClient } from '@aztec/ethereum/types';
3
3
  import { CheckpointNumber } from '@aztec/foundation/branded-types';
4
+ import { LruSet } from '@aztec/foundation/collection';
4
5
  import { Fr } from '@aztec/foundation/curves/bn254';
5
6
  import { EthAddress } from '@aztec/foundation/eth-address';
6
- import type { ViemSignature } from '@aztec/foundation/eth-signature';
7
7
  import type { Logger } from '@aztec/foundation/log';
8
- import {
9
- EmpireSlashingProposerAbi,
10
- GovernanceProposerAbi,
11
- RollupAbi,
12
- SlashFactoryAbi,
13
- TallySlashingProposerAbi,
14
- } from '@aztec/l1-artifacts';
8
+ import { RollupAbi } from '@aztec/l1-artifacts';
15
9
  import { CommitteeAttestation } from '@aztec/stdlib/block';
16
- import { ConsensusPayload, SignatureDomainSeparator } from '@aztec/stdlib/p2p';
10
+ import { computeCheckpointPayloadDigest } from '@aztec/stdlib/checkpoint';
17
11
  import { CheckpointHeader } from '@aztec/stdlib/rollup';
18
12
 
19
13
  import {
@@ -30,43 +24,48 @@ import {
30
24
 
31
25
  import type { ArchiverInstrumentation } from '../modules/instrumentation.js';
32
26
  import { getSuccessfulCallsFromDebug } from './debug_tx.js';
33
- import { getCallFromSpireProposer } from './spire_proposer.js';
27
+ import { getCallsFromSpireProposer } from './spire_proposer.js';
34
28
  import { getSuccessfulCallsFromTrace } from './trace_tx.js';
35
29
  import type { CallInfo } from './types.js';
36
30
 
31
+ /** Decoded checkpoint data from a propose calldata. */
32
+ type CheckpointData = {
33
+ checkpointNumber: CheckpointNumber;
34
+ archiveRoot: Fr;
35
+ header: CheckpointHeader;
36
+ attestations: CommitteeAttestation[];
37
+ blockHash: string;
38
+ feeAssetPriceModifier: bigint;
39
+ };
40
+
37
41
  /**
38
42
  * Extracts calldata to the `propose` method of the rollup contract from an L1 transaction
39
- * in order to reconstruct an L2 block header.
43
+ * in order to reconstruct an L2 block header. Uses hash matching against expected hashes
44
+ * from the CheckpointProposed event to verify the correct propose calldata.
40
45
  */
41
46
  export class CalldataRetriever {
42
47
  /** Tx hashes we've already logged for trace+debug failure (log once per tx per process). */
43
- private static readonly traceFailureWarnedTxHashes = new Set<string>();
48
+ private static readonly traceFailureWarnedTxHashes = new LruSet<string>(1000);
44
49
 
45
50
  /** Clears the trace-failure warned set. For testing only. */
46
51
  static resetTraceFailureWarnedForTesting(): void {
47
52
  CalldataRetriever.traceFailureWarnedTxHashes.clear();
48
53
  }
49
54
 
50
- /** Pre-computed valid contract calls for validation */
51
- private readonly validContractCalls: ValidContractCall[];
52
-
53
- private readonly rollupAddress: EthAddress;
54
-
55
55
  constructor(
56
56
  private readonly publicClient: ViemPublicClient,
57
57
  private readonly debugClient: ViemPublicDebugClient,
58
58
  private readonly targetCommitteeSize: number,
59
59
  private readonly instrumentation: ArchiverInstrumentation | undefined,
60
60
  private readonly logger: Logger,
61
- contractAddresses: {
62
- rollupAddress: EthAddress;
63
- governanceProposerAddress: EthAddress;
64
- slashingProposerAddress: EthAddress;
65
- slashFactoryAddress?: EthAddress;
66
- },
67
- ) {
68
- this.rollupAddress = contractAddresses.rollupAddress;
69
- this.validContractCalls = computeValidContractCalls(contractAddresses);
61
+ private readonly rollupAddress: EthAddress,
62
+ ) {}
63
+
64
+ private getSignatureContext() {
65
+ return {
66
+ chainId: this.publicClient.chain.id,
67
+ rollupAddress: this.rollupAddress,
68
+ };
70
69
  }
71
70
 
72
71
  /**
@@ -75,7 +74,7 @@ export class CalldataRetriever {
75
74
  * @param txHash - Hash of the tx that published it.
76
75
  * @param blobHashes - Blob hashes for the checkpoint.
77
76
  * @param checkpointNumber - Checkpoint number.
78
- * @param expectedHashes - Optional expected hashes from the CheckpointProposed event for validation
77
+ * @param expectedHashes - Expected hashes from the CheckpointProposed event for validation
79
78
  * @returns Checkpoint header and metadata from the calldata, deserialized
80
79
  */
81
80
  async getCheckpointFromRollupTx(
@@ -83,51 +82,43 @@ export class CalldataRetriever {
83
82
  _blobHashes: Buffer[],
84
83
  checkpointNumber: CheckpointNumber,
85
84
  expectedHashes: {
86
- attestationsHash?: Hex;
87
- payloadDigest?: Hex;
85
+ attestationsHash: Hex;
86
+ payloadDigest: Hex;
88
87
  },
89
- ): Promise<{
90
- checkpointNumber: CheckpointNumber;
91
- archiveRoot: Fr;
92
- header: CheckpointHeader;
93
- attestations: CommitteeAttestation[];
94
- blockHash: string;
95
- feeAssetPriceModifier: bigint;
96
- }> {
97
- this.logger.trace(`Fetching checkpoint ${checkpointNumber} from rollup tx ${txHash}`, {
98
- willValidateHashes: !!expectedHashes.attestationsHash || !!expectedHashes.payloadDigest,
99
- hasAttestationsHash: !!expectedHashes.attestationsHash,
100
- hasPayloadDigest: !!expectedHashes.payloadDigest,
101
- });
88
+ ): Promise<CheckpointData> {
89
+ this.logger.trace(`Fetching checkpoint ${checkpointNumber} from rollup tx ${txHash}`);
102
90
  const tx = await this.publicClient.getTransaction({ hash: txHash });
103
- const proposeCalldata = await this.getProposeCallData(tx, checkpointNumber);
104
- return this.decodeAndBuildCheckpoint(proposeCalldata, tx.blockHash!, checkpointNumber, expectedHashes);
91
+ return this.getCheckpointFromTx(tx, checkpointNumber, expectedHashes);
105
92
  }
106
93
 
107
- /** Gets rollup propose calldata from a transaction */
108
- protected async getProposeCallData(tx: Transaction, checkpointNumber: CheckpointNumber): Promise<Hex> {
109
- // Try to decode as multicall3 with validation
110
- const proposeCalldata = this.tryDecodeMulticall3(tx);
111
- if (proposeCalldata) {
94
+ /** Gets checkpoint data from a transaction by trying decode strategies then falling back to trace. */
95
+ protected async getCheckpointFromTx(
96
+ tx: Transaction,
97
+ checkpointNumber: CheckpointNumber,
98
+ expectedHashes: { attestationsHash: Hex; payloadDigest: Hex },
99
+ ): Promise<CheckpointData> {
100
+ // Try to decode as multicall3 with hash-verified matching
101
+ const multicall3Result = this.tryDecodeMulticall3(tx, expectedHashes, checkpointNumber, tx.blockHash!);
102
+ if (multicall3Result) {
112
103
  this.logger.trace(`Decoded propose calldata from multicall3 for tx ${tx.hash}`);
113
104
  this.instrumentation?.recordBlockProposalTxTarget(tx.to!, false);
114
- return proposeCalldata;
105
+ return multicall3Result;
115
106
  }
116
107
 
117
108
  // Try to decode as direct propose call
118
- const directProposeCalldata = this.tryDecodeDirectPropose(tx);
119
- if (directProposeCalldata) {
109
+ const directResult = this.tryDecodeDirectPropose(tx, expectedHashes, checkpointNumber, tx.blockHash!);
110
+ if (directResult) {
120
111
  this.logger.trace(`Decoded propose calldata from direct call for tx ${tx.hash}`);
121
112
  this.instrumentation?.recordBlockProposalTxTarget(tx.to!, false);
122
- return directProposeCalldata;
113
+ return directResult;
123
114
  }
124
115
 
125
116
  // Try to decode as Spire Proposer multicall wrapper
126
- const spireProposeCalldata = await this.tryDecodeSpireProposer(tx);
127
- if (spireProposeCalldata) {
117
+ const spireResult = await this.tryDecodeSpireProposer(tx, expectedHashes, checkpointNumber, tx.blockHash!);
118
+ if (spireResult) {
128
119
  this.logger.trace(`Decoded propose calldata from Spire Proposer for tx ${tx.hash}`);
129
120
  this.instrumentation?.recordBlockProposalTxTarget(tx.to!, false);
130
- return spireProposeCalldata;
121
+ return spireResult;
131
122
  }
132
123
 
133
124
  // Fall back to trace-based extraction
@@ -135,52 +126,82 @@ export class CalldataRetriever {
135
126
  `Failed to decode multicall3, direct propose, or Spire proposer for L1 tx ${tx.hash}, falling back to trace for checkpoint ${checkpointNumber}`,
136
127
  );
137
128
  this.instrumentation?.recordBlockProposalTxTarget(tx.to ?? EthAddress.ZERO.toString(), true);
138
- return await this.extractCalldataViaTrace(tx.hash);
129
+ const tracedCalldata = await this.extractCalldataViaTrace(tx.hash);
130
+ const tracedResult = this.tryDecodeAndVerifyPropose(
131
+ tracedCalldata,
132
+ expectedHashes,
133
+ checkpointNumber,
134
+ tx.blockHash!,
135
+ );
136
+ if (!tracedResult) {
137
+ throw new Error(`Hash mismatch for traced propose calldata in tx ${tx.hash} for checkpoint ${checkpointNumber}`);
138
+ }
139
+ return tracedResult;
139
140
  }
140
141
 
141
142
  /**
142
143
  * Attempts to decode a transaction as a Spire Proposer multicall wrapper.
143
- * If successful, extracts the wrapped call and validates it as either multicall3 or direct propose.
144
+ * If successful, iterates all wrapped calls and validates each as either multicall3
145
+ * or direct propose, verifying against expected hashes.
144
146
  * @param tx - The transaction to decode
145
- * @returns The propose calldata if successfully decoded and validated, undefined otherwise
147
+ * @param expectedHashes - Expected hashes for hash-verified matching
148
+ * @param checkpointNumber - The checkpoint number
149
+ * @param blockHash - The L1 block hash
150
+ * @returns The checkpoint data if successfully decoded and validated, undefined otherwise
146
151
  */
147
- protected async tryDecodeSpireProposer(tx: Transaction): Promise<Hex | undefined> {
148
- // Try to decode as Spire Proposer multicall (extracts the wrapped call)
149
- const spireWrappedCall = await getCallFromSpireProposer(tx, this.publicClient, this.logger);
150
- if (!spireWrappedCall) {
152
+ protected async tryDecodeSpireProposer(
153
+ tx: Transaction,
154
+ expectedHashes: { attestationsHash: Hex; payloadDigest: Hex },
155
+ checkpointNumber: CheckpointNumber,
156
+ blockHash: Hex,
157
+ ): Promise<CheckpointData | undefined> {
158
+ // Try to decode as Spire Proposer multicall (extracts all wrapped calls)
159
+ const spireWrappedCalls = await getCallsFromSpireProposer(tx, this.publicClient, this.logger);
160
+ if (!spireWrappedCalls) {
151
161
  return undefined;
152
162
  }
153
163
 
154
- this.logger.trace(`Decoded Spire Proposer wrapping for tx ${tx.hash}, inner call to ${spireWrappedCall.to}`);
164
+ this.logger.trace(`Decoded Spire Proposer wrapping for tx ${tx.hash}, ${spireWrappedCalls.length} inner call(s)`);
155
165
 
156
- // Now try to decode the wrapped call as either multicall3 or direct propose
157
- const wrappedTx = { to: spireWrappedCall.to, input: spireWrappedCall.data, hash: tx.hash };
166
+ // Try each wrapped call as either multicall3 or direct propose
167
+ for (const spireWrappedCall of spireWrappedCalls) {
168
+ const wrappedTx = { to: spireWrappedCall.to, input: spireWrappedCall.data, hash: tx.hash };
158
169
 
159
- const multicall3Calldata = this.tryDecodeMulticall3(wrappedTx);
160
- if (multicall3Calldata) {
161
- this.logger.trace(`Decoded propose calldata from Spire Proposer to multicall3 for tx ${tx.hash}`);
162
- return multicall3Calldata;
163
- }
170
+ const multicall3Result = this.tryDecodeMulticall3(wrappedTx, expectedHashes, checkpointNumber, blockHash);
171
+ if (multicall3Result) {
172
+ this.logger.trace(`Decoded propose calldata from Spire Proposer to multicall3 for tx ${tx.hash}`);
173
+ return multicall3Result;
174
+ }
164
175
 
165
- const directProposeCalldata = this.tryDecodeDirectPropose(wrappedTx);
166
- if (directProposeCalldata) {
167
- this.logger.trace(`Decoded propose calldata from Spire Proposer to direct propose for tx ${tx.hash}`);
168
- return directProposeCalldata;
176
+ const directResult = this.tryDecodeDirectPropose(wrappedTx, expectedHashes, checkpointNumber, blockHash);
177
+ if (directResult) {
178
+ this.logger.trace(`Decoded propose calldata from Spire Proposer to direct propose for tx ${tx.hash}`);
179
+ return directResult;
180
+ }
169
181
  }
170
182
 
171
183
  this.logger.warn(
172
- `Spire Proposer wrapped call could not be decoded as multicall3 or direct propose for tx ${tx.hash}`,
184
+ `Spire Proposer wrapped calls could not be decoded as multicall3 or direct propose for tx ${tx.hash}`,
173
185
  );
174
186
  return undefined;
175
187
  }
176
188
 
177
189
  /**
178
190
  * Attempts to decode transaction input as multicall3 and extract propose calldata.
179
- * Returns undefined if validation fails.
191
+ * Finds all calls matching the rollup address and propose selector, then decodes
192
+ * and verifies each candidate against expected hashes from the CheckpointProposed event.
180
193
  * @param tx - The transaction-like object with to, input, and hash
181
- * @returns The propose calldata if successfully validated, undefined otherwise
194
+ * @param expectedHashes - Expected hashes from CheckpointProposed event
195
+ * @param checkpointNumber - The checkpoint number
196
+ * @param blockHash - The L1 block hash
197
+ * @returns The checkpoint data if successfully validated, undefined otherwise
182
198
  */
183
- protected tryDecodeMulticall3(tx: { to: Hex | null | undefined; input: Hex; hash: Hex }): Hex | undefined {
199
+ protected tryDecodeMulticall3(
200
+ tx: { to: Hex | null | undefined; input: Hex; hash: Hex },
201
+ expectedHashes: { attestationsHash: Hex; payloadDigest: Hex },
202
+ checkpointNumber: CheckpointNumber,
203
+ blockHash: Hex,
204
+ ): CheckpointData | undefined {
184
205
  const txHash = tx.hash;
185
206
 
186
207
  try {
@@ -209,59 +230,54 @@ export class CalldataRetriever {
209
230
 
210
231
  const [calls] = multicall3Args;
211
232
 
212
- // Validate all calls and find propose calls
233
+ // Find all calls matching rollup address + propose selector
213
234
  const rollupAddressLower = this.rollupAddress.toString().toLowerCase();
214
- const proposeCalls: Hex[] = [];
235
+ const proposeSelectorLower = PROPOSE_SELECTOR.toLowerCase();
236
+ const candidates: Hex[] = [];
215
237
 
216
- for (let i = 0; i < calls.length; i++) {
217
- const addr = calls[i].target.toLowerCase();
218
- const callData = calls[i].callData;
238
+ for (const call of calls) {
239
+ const addr = call.target.toLowerCase();
240
+ const callData = call.callData;
219
241
 
220
- // Extract function selector (first 4 bytes)
221
242
  if (callData.length < 10) {
222
- // "0x" + 8 hex chars = 10 chars minimum for a valid function call
223
- this.logger.warn(`Invalid calldata length at index ${i} (${callData.length})`, { txHash });
224
- return undefined;
243
+ continue;
225
244
  }
226
- const functionSelector = callData.slice(0, 10) as Hex;
227
245
 
228
- // Validate this call is allowed by searching through valid calls
229
- const validCall = this.validContractCalls.find(
230
- vc => vc.address === addr && vc.functionSelector === functionSelector,
231
- );
232
-
233
- if (!validCall) {
234
- this.logger.warn(`Invalid contract call detected in multicall3`, {
235
- index: i,
236
- targetAddress: addr,
237
- functionSelector,
238
- validCalls: this.validContractCalls.map(c => ({ address: c.address, selector: c.functionSelector })),
239
- txHash,
240
- });
241
- return undefined;
246
+ const selector = callData.slice(0, 10).toLowerCase();
247
+ if (addr === rollupAddressLower && selector === proposeSelectorLower) {
248
+ candidates.push(callData);
242
249
  }
250
+ }
243
251
 
244
- this.logger.trace(`Valid call found to ${addr}`, { validCall });
252
+ if (candidates.length === 0) {
253
+ this.logger.debug(`No propose candidates found in multicall3`, { txHash });
254
+ return undefined;
255
+ }
245
256
 
246
- // Collect propose calls specifically
247
- if (addr === rollupAddressLower && validCall.functionName === 'propose') {
248
- proposeCalls.push(callData);
257
+ // Decode, verify, and build for each candidate
258
+ const verified: CheckpointData[] = [];
259
+ for (const candidate of candidates) {
260
+ const result = this.tryDecodeAndVerifyPropose(candidate, expectedHashes, checkpointNumber, blockHash);
261
+ if (result) {
262
+ verified.push(result);
249
263
  }
250
264
  }
251
265
 
252
- // Validate exactly ONE propose call
253
- if (proposeCalls.length === 0) {
254
- this.logger.warn(`No propose calls found in multicall3`, { txHash });
255
- return undefined;
266
+ if (verified.length === 1) {
267
+ this.logger.trace(`Verified single propose candidate via hash matching`, { txHash });
268
+ return verified[0];
256
269
  }
257
270
 
258
- if (proposeCalls.length > 1) {
259
- this.logger.warn(`Multiple propose calls found in multicall3 (${proposeCalls.length})`, { txHash });
260
- return undefined;
271
+ if (verified.length > 1) {
272
+ this.logger.warn(
273
+ `Multiple propose candidates verified (${verified.length}), returning first (identical data)`,
274
+ { txHash },
275
+ );
276
+ return verified[0];
261
277
  }
262
278
 
263
- // Successfully extracted single propose call
264
- return proposeCalls[0];
279
+ this.logger.debug(`No candidates verified against expected hashes`, { txHash });
280
+ return undefined;
265
281
  } catch (err) {
266
282
  // Any decoding error triggers fallback to trace
267
283
  this.logger.warn(`Failed to decode multicall3: ${err}`, { txHash });
@@ -271,11 +287,19 @@ export class CalldataRetriever {
271
287
 
272
288
  /**
273
289
  * Attempts to decode transaction as a direct propose call to the rollup contract.
274
- * Returns undefined if validation fails.
290
+ * Decodes, verifies hashes, and builds checkpoint data in a single pass.
275
291
  * @param tx - The transaction-like object with to, input, and hash
276
- * @returns The propose calldata if successfully validated, undefined otherwise
292
+ * @param expectedHashes - Expected hashes from CheckpointProposed event
293
+ * @param checkpointNumber - The checkpoint number
294
+ * @param blockHash - The L1 block hash
295
+ * @returns The checkpoint data if successfully validated, undefined otherwise
277
296
  */
278
- protected tryDecodeDirectPropose(tx: { to: Hex | null | undefined; input: Hex; hash: Hex }): Hex | undefined {
297
+ protected tryDecodeDirectPropose(
298
+ tx: { to: Hex | null | undefined; input: Hex; hash: Hex },
299
+ expectedHashes: { attestationsHash: Hex; payloadDigest: Hex },
300
+ checkpointNumber: CheckpointNumber,
301
+ blockHash: Hex,
302
+ ): CheckpointData | undefined {
279
303
  const txHash = tx.hash;
280
304
  try {
281
305
  // Check if transaction is to the rollup address
@@ -284,18 +308,16 @@ export class CalldataRetriever {
284
308
  return undefined;
285
309
  }
286
310
 
287
- // Try to decode as propose call
311
+ // Validate it's a propose call before full decode+verify
288
312
  const { functionName } = decodeFunctionData({ abi: RollupAbi, data: tx.input });
289
-
290
- // If not propose, return undefined
291
313
  if (functionName !== 'propose') {
292
314
  this.logger.warn(`Transaction to rollup is not propose (got ${functionName})`, { txHash });
293
315
  return undefined;
294
316
  }
295
317
 
296
- // Successfully validated direct propose call
318
+ // Decode, verify hashes, and build checkpoint data
297
319
  this.logger.trace(`Validated direct propose call to rollup`, { txHash });
298
- return tx.input;
320
+ return this.tryDecodeAndVerifyPropose(tx.input, expectedHashes, checkpointNumber, blockHash);
299
321
  } catch (err) {
300
322
  // Any decoding error means it's not a valid propose call
301
323
  this.logger.warn(`Failed to decode as direct propose: ${err}`, { txHash });
@@ -363,10 +385,105 @@ export class CalldataRetriever {
363
385
  return calls[0].input;
364
386
  }
365
387
 
388
+ /**
389
+ * Decodes propose calldata, verifies against expected hashes, and builds checkpoint data.
390
+ * Returns undefined on decode errors or hash mismatches (soft failure for try-based callers).
391
+ * @param proposeCalldata - The propose function calldata
392
+ * @param expectedHashes - Expected hashes from the CheckpointProposed event
393
+ * @param checkpointNumber - The checkpoint number
394
+ * @param blockHash - The L1 block hash
395
+ * @returns The decoded checkpoint data, or undefined on failure
396
+ */
397
+ protected tryDecodeAndVerifyPropose(
398
+ proposeCalldata: Hex,
399
+ expectedHashes: { attestationsHash: Hex; payloadDigest: Hex },
400
+ checkpointNumber: CheckpointNumber,
401
+ blockHash: Hex,
402
+ ): CheckpointData | undefined {
403
+ try {
404
+ const { functionName, args } = decodeFunctionData({ abi: RollupAbi, data: proposeCalldata });
405
+ if (functionName !== 'propose') {
406
+ return undefined;
407
+ }
408
+
409
+ const [decodedArgs, packedAttestations] = args! as readonly [
410
+ { archive: Hex; oracleInput: { feeAssetPriceModifier: bigint }; header: ViemHeader },
411
+ ViemCommitteeAttestations,
412
+ ...unknown[],
413
+ ];
414
+
415
+ // Verify attestationsHash
416
+ const computedAttestationsHash = this.computeAttestationsHash(packedAttestations);
417
+ if (
418
+ !Buffer.from(hexToBytes(computedAttestationsHash)).equals(
419
+ Buffer.from(hexToBytes(expectedHashes.attestationsHash)),
420
+ )
421
+ ) {
422
+ this.logger.warn(`Attestations hash mismatch during verification`, {
423
+ computed: computedAttestationsHash,
424
+ expected: expectedHashes.attestationsHash,
425
+ });
426
+ return undefined;
427
+ }
428
+
429
+ // Verify payloadDigest
430
+ const header = CheckpointHeader.fromViem(decodedArgs.header);
431
+ const archiveRoot = new Fr(Buffer.from(hexToBytes(decodedArgs.archive)));
432
+ const feeAssetPriceModifier = decodedArgs.oracleInput.feeAssetPriceModifier;
433
+ const computedPayloadDigest = this.computePayloadDigest(header, archiveRoot, feeAssetPriceModifier);
434
+ if (
435
+ !Buffer.from(hexToBytes(computedPayloadDigest)).equals(Buffer.from(hexToBytes(expectedHashes.payloadDigest)))
436
+ ) {
437
+ this.logger.warn(`Payload digest mismatch during verification`, {
438
+ computed: computedPayloadDigest,
439
+ expected: expectedHashes.payloadDigest,
440
+ });
441
+ return undefined;
442
+ }
443
+
444
+ const attestations = CommitteeAttestation.fromPacked(packedAttestations, this.targetCommitteeSize);
445
+
446
+ this.logger.trace(`Validated and decoded propose calldata for checkpoint ${checkpointNumber}`, {
447
+ checkpointNumber,
448
+ archive: decodedArgs.archive,
449
+ header: decodedArgs.header,
450
+ l1BlockHash: blockHash,
451
+ attestations,
452
+ packedAttestations,
453
+ targetCommitteeSize: this.targetCommitteeSize,
454
+ });
455
+
456
+ return {
457
+ checkpointNumber,
458
+ archiveRoot,
459
+ header,
460
+ attestations,
461
+ blockHash,
462
+ feeAssetPriceModifier,
463
+ };
464
+ } catch {
465
+ return undefined;
466
+ }
467
+ }
468
+
469
+ /** Computes the keccak256 hash of ABI-encoded CommitteeAttestations. */
470
+ private computeAttestationsHash(packedAttestations: ViemCommitteeAttestations): Hex {
471
+ return keccak256(encodeAbiParameters([this.getCommitteeAttestationsStructDef()], [packedAttestations]));
472
+ }
473
+
474
+ /** Computes the keccak256 payload digest from the checkpoint header, archive root, and fee asset price modifier. */
475
+ private computePayloadDigest(header: CheckpointHeader, archiveRoot: Fr, feeAssetPriceModifier: bigint): Hex {
476
+ return computeCheckpointPayloadDigest({
477
+ header,
478
+ archiveRoot,
479
+ feeAssetPriceModifier,
480
+ signatureContext: this.getSignatureContext(),
481
+ }).toString();
482
+ }
483
+
366
484
  /**
367
485
  * Extracts the CommitteeAttestations struct definition from RollupAbi.
368
486
  * Finds the _attestations parameter by name in the propose function.
369
- * Lazy-loaded to avoid issues during module initialization.
370
487
  */
371
488
  private getCommitteeAttestationsStructDef(): AbiParameter {
372
489
  const proposeFunction = RollupAbi.find(item => item.type === 'function' && item.name === 'propose') as
@@ -399,265 +516,7 @@ export class CalldataRetriever {
399
516
  components: tupleParam.components || [],
400
517
  } as AbiParameter;
401
518
  }
402
-
403
- /**
404
- * Decodes propose calldata and builds the checkpoint header structure.
405
- * @param proposeCalldata - The propose function calldata
406
- * @param blockHash - The L1 block hash containing this transaction
407
- * @param checkpointNumber - The checkpoint number
408
- * @param expectedHashes - Optional expected hashes from the CheckpointProposed event for validation
409
- * @returns The decoded checkpoint header and metadata
410
- */
411
- protected decodeAndBuildCheckpoint(
412
- proposeCalldata: Hex,
413
- blockHash: Hex,
414
- checkpointNumber: CheckpointNumber,
415
- expectedHashes: {
416
- attestationsHash?: Hex;
417
- payloadDigest?: Hex;
418
- },
419
- ): {
420
- checkpointNumber: CheckpointNumber;
421
- archiveRoot: Fr;
422
- header: CheckpointHeader;
423
- attestations: CommitteeAttestation[];
424
- blockHash: string;
425
- feeAssetPriceModifier: bigint;
426
- } {
427
- const { functionName: rollupFunctionName, args: rollupArgs } = decodeFunctionData({
428
- abi: RollupAbi,
429
- data: proposeCalldata,
430
- });
431
-
432
- if (rollupFunctionName !== 'propose') {
433
- throw new Error(`Unexpected rollup method called ${rollupFunctionName}`);
434
- }
435
-
436
- const [decodedArgs, packedAttestations, _signers, _attestationsAndSignersSignature, _blobInput] =
437
- rollupArgs! as readonly [
438
- {
439
- archive: Hex;
440
- oracleInput: { feeAssetPriceModifier: bigint };
441
- header: ViemHeader;
442
- },
443
- ViemCommitteeAttestations,
444
- Hex[],
445
- ViemSignature,
446
- Hex,
447
- ];
448
-
449
- const attestations = CommitteeAttestation.fromPacked(packedAttestations, this.targetCommitteeSize);
450
- const header = CheckpointHeader.fromViem(decodedArgs.header);
451
- const archiveRoot = new Fr(Buffer.from(hexToBytes(decodedArgs.archive)));
452
-
453
- // Validate attestationsHash if provided (skip for backwards compatibility with older events)
454
- if (expectedHashes.attestationsHash) {
455
- // Compute attestationsHash: keccak256(abi.encode(CommitteeAttestations))
456
- const computedAttestationsHash = keccak256(
457
- encodeAbiParameters([this.getCommitteeAttestationsStructDef()], [packedAttestations]),
458
- );
459
-
460
- // Compare as buffers to avoid case-sensitivity and string comparison issues
461
- const computedBuffer = Buffer.from(hexToBytes(computedAttestationsHash));
462
- const expectedBuffer = Buffer.from(hexToBytes(expectedHashes.attestationsHash));
463
-
464
- if (!computedBuffer.equals(expectedBuffer)) {
465
- throw new Error(
466
- `Attestations hash mismatch for checkpoint ${checkpointNumber}: ` +
467
- `computed=${computedAttestationsHash}, expected=${expectedHashes.attestationsHash}`,
468
- );
469
- }
470
-
471
- this.logger.trace(`Validated attestationsHash for checkpoint ${checkpointNumber}`, {
472
- computedAttestationsHash,
473
- expectedAttestationsHash: expectedHashes.attestationsHash,
474
- });
475
- }
476
-
477
- // Validate payloadDigest if provided (skip for backwards compatibility with older events)
478
- if (expectedHashes.payloadDigest) {
479
- // Use ConsensusPayload to compute the digest - this ensures we match the exact logic
480
- // used by the network for signing and verification
481
- const feeAssetPriceModifier = decodedArgs.oracleInput.feeAssetPriceModifier;
482
- const consensusPayload = new ConsensusPayload(header, archiveRoot, feeAssetPriceModifier);
483
- const payloadToSign = consensusPayload.getPayloadToSign(SignatureDomainSeparator.checkpointAttestation);
484
- const computedPayloadDigest = keccak256(payloadToSign);
485
-
486
- // Compare as buffers to avoid case-sensitivity and string comparison issues
487
- const computedBuffer = Buffer.from(hexToBytes(computedPayloadDigest));
488
- const expectedBuffer = Buffer.from(hexToBytes(expectedHashes.payloadDigest));
489
-
490
- if (!computedBuffer.equals(expectedBuffer)) {
491
- throw new Error(
492
- `Payload digest mismatch for checkpoint ${checkpointNumber}: ` +
493
- `computed=${computedPayloadDigest}, expected=${expectedHashes.payloadDigest}`,
494
- );
495
- }
496
-
497
- this.logger.trace(`Validated payloadDigest for checkpoint ${checkpointNumber}`, {
498
- computedPayloadDigest,
499
- expectedPayloadDigest: expectedHashes.payloadDigest,
500
- });
501
- }
502
-
503
- this.logger.trace(`Decoded propose calldata`, {
504
- checkpointNumber,
505
- archive: decodedArgs.archive,
506
- header: decodedArgs.header,
507
- l1BlockHash: blockHash,
508
- attestations,
509
- packedAttestations,
510
- targetCommitteeSize: this.targetCommitteeSize,
511
- });
512
-
513
- return {
514
- checkpointNumber,
515
- archiveRoot,
516
- header,
517
- attestations,
518
- blockHash,
519
- feeAssetPriceModifier: decodedArgs.oracleInput.feeAssetPriceModifier,
520
- };
521
- }
522
519
  }
523
520
 
524
- /**
525
- * Pre-computed function selectors for all valid contract calls.
526
- * These are computed once at module load time from the ABIs.
527
- * Based on analysis of sequencer-client/src/publisher/sequencer-publisher.ts
528
- */
529
-
530
- // Rollup contract function selectors (always valid)
521
+ /** Function selector for the `propose` method of the rollup contract. */
531
522
  const PROPOSE_SELECTOR = toFunctionSelector(RollupAbi.find(x => x.type === 'function' && x.name === 'propose')!);
532
- const INVALIDATE_BAD_ATTESTATION_SELECTOR = toFunctionSelector(
533
- RollupAbi.find(x => x.type === 'function' && x.name === 'invalidateBadAttestation')!,
534
- );
535
- const INVALIDATE_INSUFFICIENT_ATTESTATIONS_SELECTOR = toFunctionSelector(
536
- RollupAbi.find(x => x.type === 'function' && x.name === 'invalidateInsufficientAttestations')!,
537
- );
538
-
539
- // Governance proposer function selectors
540
- const GOVERNANCE_SIGNAL_WITH_SIG_SELECTOR = toFunctionSelector(
541
- GovernanceProposerAbi.find(x => x.type === 'function' && x.name === 'signalWithSig')!,
542
- );
543
-
544
- // Slash factory function selectors
545
- const CREATE_SLASH_PAYLOAD_SELECTOR = toFunctionSelector(
546
- SlashFactoryAbi.find(x => x.type === 'function' && x.name === 'createSlashPayload')!,
547
- );
548
-
549
- // Empire slashing proposer function selectors
550
- const EMPIRE_SIGNAL_WITH_SIG_SELECTOR = toFunctionSelector(
551
- EmpireSlashingProposerAbi.find(x => x.type === 'function' && x.name === 'signalWithSig')!,
552
- );
553
- const EMPIRE_SUBMIT_ROUND_WINNER_SELECTOR = toFunctionSelector(
554
- EmpireSlashingProposerAbi.find(x => x.type === 'function' && x.name === 'submitRoundWinner')!,
555
- );
556
-
557
- // Tally slashing proposer function selectors
558
- const TALLY_VOTE_SELECTOR = toFunctionSelector(
559
- TallySlashingProposerAbi.find(x => x.type === 'function' && x.name === 'vote')!,
560
- );
561
- const TALLY_EXECUTE_ROUND_SELECTOR = toFunctionSelector(
562
- TallySlashingProposerAbi.find(x => x.type === 'function' && x.name === 'executeRound')!,
563
- );
564
-
565
- /**
566
- * Defines a valid contract call that can appear in a sequencer publisher transaction
567
- */
568
- interface ValidContractCall {
569
- /** Contract address (lowercase for comparison) */
570
- address: string;
571
- /** Function selector (4 bytes) */
572
- functionSelector: Hex;
573
- /** Human-readable function name for logging */
574
- functionName: string;
575
- }
576
-
577
- /**
578
- * All valid contract calls that the sequencer publisher can make.
579
- * Builds the list of valid (address, selector) pairs for validation.
580
- *
581
- * Alternatively, if we are absolutely sure that no code path from any of these
582
- * contracts can eventually land on another call to `propose`, we can remove the
583
- * function selectors.
584
- */
585
- function computeValidContractCalls(addresses: {
586
- rollupAddress: EthAddress;
587
- governanceProposerAddress?: EthAddress;
588
- slashFactoryAddress?: EthAddress;
589
- slashingProposerAddress?: EthAddress;
590
- }): ValidContractCall[] {
591
- const { rollupAddress, governanceProposerAddress, slashFactoryAddress, slashingProposerAddress } = addresses;
592
- const calls: ValidContractCall[] = [];
593
-
594
- // Rollup contract calls (always present)
595
- calls.push(
596
- {
597
- address: rollupAddress.toString().toLowerCase(),
598
- functionSelector: PROPOSE_SELECTOR,
599
- functionName: 'propose',
600
- },
601
- {
602
- address: rollupAddress.toString().toLowerCase(),
603
- functionSelector: INVALIDATE_BAD_ATTESTATION_SELECTOR,
604
- functionName: 'invalidateBadAttestation',
605
- },
606
- {
607
- address: rollupAddress.toString().toLowerCase(),
608
- functionSelector: INVALIDATE_INSUFFICIENT_ATTESTATIONS_SELECTOR,
609
- functionName: 'invalidateInsufficientAttestations',
610
- },
611
- );
612
-
613
- // Governance proposer calls (optional)
614
- if (governanceProposerAddress && !governanceProposerAddress.isZero()) {
615
- calls.push({
616
- address: governanceProposerAddress.toString().toLowerCase(),
617
- functionSelector: GOVERNANCE_SIGNAL_WITH_SIG_SELECTOR,
618
- functionName: 'signalWithSig',
619
- });
620
- }
621
-
622
- // Slash factory calls (optional)
623
- if (slashFactoryAddress && !slashFactoryAddress.isZero()) {
624
- calls.push({
625
- address: slashFactoryAddress.toString().toLowerCase(),
626
- functionSelector: CREATE_SLASH_PAYLOAD_SELECTOR,
627
- functionName: 'createSlashPayload',
628
- });
629
- }
630
-
631
- // Slashing proposer calls (optional, can be either Empire or Tally)
632
- if (slashingProposerAddress && !slashingProposerAddress.isZero()) {
633
- // Empire calls
634
- calls.push(
635
- {
636
- address: slashingProposerAddress.toString().toLowerCase(),
637
- functionSelector: EMPIRE_SIGNAL_WITH_SIG_SELECTOR,
638
- functionName: 'signalWithSig (empire)',
639
- },
640
- {
641
- address: slashingProposerAddress.toString().toLowerCase(),
642
- functionSelector: EMPIRE_SUBMIT_ROUND_WINNER_SELECTOR,
643
- functionName: 'submitRoundWinner',
644
- },
645
- );
646
-
647
- // Tally calls
648
- calls.push(
649
- {
650
- address: slashingProposerAddress.toString().toLowerCase(),
651
- functionSelector: TALLY_VOTE_SELECTOR,
652
- functionName: 'vote',
653
- },
654
- {
655
- address: slashingProposerAddress.toString().toLowerCase(),
656
- functionSelector: TALLY_EXECUTE_ROUND_SELECTOR,
657
- functionName: 'executeRound',
658
- },
659
- );
660
- }
661
-
662
- return calls;
663
- }