@aztec/archiver 0.0.1-commit.f2ce05ee → 0.0.1-commit.f5a9928

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