@aztec/archiver 0.0.1-commit.1bb068fb5 → 0.0.1-commit.1dcfe2301

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