diff --git a/spartan/metrics/grafana/dashboards/aztec_network.json b/spartan/metrics/grafana/dashboards/aztec_network.json index 7b037701c3a8..8f0399e66798 100644 --- a/spartan/metrics/grafana/dashboards/aztec_network.json +++ b/spartan/metrics/grafana/dashboards/aztec_network.json @@ -2676,6 +2676,504 @@ ], "title": "Archiver Sync Duration (P95)", "type": "timeseries" + }, + { + "datasource": { + "default": true, + "type": "prometheus", + "uid": "${data_source}" + }, + "description": "Distribution of checkpoint L1 inclusion delays by time bucket (seconds into L2 slot)", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 80, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 0, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "percent" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + }, + "unit": "percentunit" + }, + "overrides": [ + { + "matcher": { + "id": "byRegexp", + "options": ".*0-5s.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "dark-green", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": ".*5-10s.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "green", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": ".*10-15s.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "semi-dark-yellow", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": ".*15-20s.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "yellow", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": ".*20-30s.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "orange", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": ".*30\\+s.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "dark-red", + "mode": "fixed" + } + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 64 + }, + "id": 40, + "interval": "1m", + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "multi", + "sort": "none" + } + }, + "pluginVersion": "12.3.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${data_source}" + }, + "editorMode": "code", + "expr": "(\n sum(rate(aztec_archiver_checkpoint_l1_inclusion_delay_bucket{k8s_namespace_name=~\"$namespace\", le=\"5\"}[$__rate_interval])) by (le)\n - ignoring(le) sum(rate(aztec_archiver_checkpoint_l1_inclusion_delay_bucket{k8s_namespace_name=~\"$namespace\", le=\"0\"}[$__rate_interval]))\n)\n/\nsum(rate(aztec_archiver_checkpoint_l1_inclusion_delay_count{k8s_namespace_name=~\"$namespace\"}[$__rate_interval]))", + "legendFormat": "0-5s", + "range": true, + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${data_source}" + }, + "editorMode": "code", + "expr": "(\n sum(rate(aztec_archiver_checkpoint_l1_inclusion_delay_bucket{k8s_namespace_name=~\"$namespace\", le=\"10\"}[$__rate_interval])) by (le)\n - ignoring(le) sum(rate(aztec_archiver_checkpoint_l1_inclusion_delay_bucket{k8s_namespace_name=~\"$namespace\", le=\"5\"}[$__rate_interval]))\n)\n/\nsum(rate(aztec_archiver_checkpoint_l1_inclusion_delay_count{k8s_namespace_name=~\"$namespace\"}[$__rate_interval]))", + "hide": false, + "legendFormat": "5-10s", + "range": true, + "refId": "B" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${data_source}" + }, + "editorMode": "code", + "expr": "(\n sum(rate(aztec_archiver_checkpoint_l1_inclusion_delay_bucket{k8s_namespace_name=~\"$namespace\", le=\"15\"}[$__rate_interval])) by (le)\n - ignoring(le) sum(rate(aztec_archiver_checkpoint_l1_inclusion_delay_bucket{k8s_namespace_name=~\"$namespace\", le=\"10\"}[$__rate_interval]))\n)\n/\nsum(rate(aztec_archiver_checkpoint_l1_inclusion_delay_count{k8s_namespace_name=~\"$namespace\"}[$__rate_interval]))", + "hide": false, + "legendFormat": "10-15s", + "range": true, + "refId": "C" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${data_source}" + }, + "editorMode": "code", + "expr": "(\n sum(rate(aztec_archiver_checkpoint_l1_inclusion_delay_bucket{k8s_namespace_name=~\"$namespace\", le=\"20\"}[$__rate_interval])) by (le)\n - ignoring(le) sum(rate(aztec_archiver_checkpoint_l1_inclusion_delay_bucket{k8s_namespace_name=~\"$namespace\", le=\"15\"}[$__rate_interval]))\n)\n/\nsum(rate(aztec_archiver_checkpoint_l1_inclusion_delay_count{k8s_namespace_name=~\"$namespace\"}[$__rate_interval]))", + "hide": false, + "legendFormat": "15-20s", + "range": true, + "refId": "D" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${data_source}" + }, + "editorMode": "code", + "expr": "(\n sum(rate(aztec_archiver_checkpoint_l1_inclusion_delay_bucket{k8s_namespace_name=~\"$namespace\", le=\"30\"}[$__rate_interval])) by (le)\n - ignoring(le) sum(rate(aztec_archiver_checkpoint_l1_inclusion_delay_bucket{k8s_namespace_name=~\"$namespace\", le=\"20\"}[$__rate_interval]))\n)\n/\nsum(rate(aztec_archiver_checkpoint_l1_inclusion_delay_count{k8s_namespace_name=~\"$namespace\"}[$__rate_interval]))", + "hide": false, + "legendFormat": "20-30s", + "range": true, + "refId": "E" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${data_source}" + }, + "editorMode": "code", + "expr": "(\n sum(rate(aztec_archiver_checkpoint_l1_inclusion_delay_count{k8s_namespace_name=~\"$namespace\"}[$__rate_interval]))\n - sum(rate(aztec_archiver_checkpoint_l1_inclusion_delay_bucket{k8s_namespace_name=~\"$namespace\", le=\"30\"}[$__rate_interval]))\n)\n/\nsum(rate(aztec_archiver_checkpoint_l1_inclusion_delay_count{k8s_namespace_name=~\"$namespace\"}[$__rate_interval]))", + "hide": false, + "legendFormat": "30+s", + "range": true, + "refId": "F" + } + ], + "title": "Checkpoint L1 Inclusion Delay Distribution", + "type": "timeseries" + }, + { + "datasource": { + "default": true, + "type": "prometheus", + "uid": "${data_source}" + }, + "description": "Absolute count of checkpoints by L1 inclusion delay bucket", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 60, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 0, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "normal" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + }, + "unit": "cps" + }, + "overrides": [ + { + "matcher": { + "id": "byRegexp", + "options": ".*0-5s.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "dark-green", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": ".*5-10s.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "green", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": ".*10-15s.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "semi-dark-yellow", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": ".*15-20s.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "yellow", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": ".*20-30s.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "orange", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": ".*30\\+s.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "dark-red", + "mode": "fixed" + } + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 72 + }, + "id": 41, + "interval": "1m", + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "multi", + "sort": "none" + } + }, + "pluginVersion": "12.3.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${data_source}" + }, + "editorMode": "code", + "expr": "sum(rate(aztec_archiver_checkpoint_l1_inclusion_delay_bucket{k8s_namespace_name=~\"$namespace\", le=\"5\"}[$__rate_interval])) by (le)\n- ignoring(le) sum(rate(aztec_archiver_checkpoint_l1_inclusion_delay_bucket{k8s_namespace_name=~\"$namespace\", le=\"0\"}[$__rate_interval]))", + "legendFormat": "0-5s", + "range": true, + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${data_source}" + }, + "editorMode": "code", + "expr": "sum(rate(aztec_archiver_checkpoint_l1_inclusion_delay_bucket{k8s_namespace_name=~\"$namespace\", le=\"10\"}[$__rate_interval])) by (le)\n- ignoring(le) sum(rate(aztec_archiver_checkpoint_l1_inclusion_delay_bucket{k8s_namespace_name=~\"$namespace\", le=\"5\"}[$__rate_interval]))", + "hide": false, + "legendFormat": "5-10s", + "range": true, + "refId": "B" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${data_source}" + }, + "editorMode": "code", + "expr": "sum(rate(aztec_archiver_checkpoint_l1_inclusion_delay_bucket{k8s_namespace_name=~\"$namespace\", le=\"15\"}[$__rate_interval])) by (le)\n- ignoring(le) sum(rate(aztec_archiver_checkpoint_l1_inclusion_delay_bucket{k8s_namespace_name=~\"$namespace\", le=\"10\"}[$__rate_interval]))", + "hide": false, + "legendFormat": "10-15s", + "range": true, + "refId": "C" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${data_source}" + }, + "editorMode": "code", + "expr": "sum(rate(aztec_archiver_checkpoint_l1_inclusion_delay_bucket{k8s_namespace_name=~\"$namespace\", le=\"20\"}[$__rate_interval])) by (le)\n- ignoring(le) sum(rate(aztec_archiver_checkpoint_l1_inclusion_delay_bucket{k8s_namespace_name=~\"$namespace\", le=\"15\"}[$__rate_interval]))", + "hide": false, + "legendFormat": "15-20s", + "range": true, + "refId": "D" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${data_source}" + }, + "editorMode": "code", + "expr": "sum(rate(aztec_archiver_checkpoint_l1_inclusion_delay_bucket{k8s_namespace_name=~\"$namespace\", le=\"30\"}[$__rate_interval])) by (le)\n- ignoring(le) sum(rate(aztec_archiver_checkpoint_l1_inclusion_delay_bucket{k8s_namespace_name=~\"$namespace\", le=\"20\"}[$__rate_interval]))", + "hide": false, + "legendFormat": "20-30s", + "range": true, + "refId": "E" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${data_source}" + }, + "editorMode": "code", + "expr": "sum(rate(aztec_archiver_checkpoint_l1_inclusion_delay_count{k8s_namespace_name=~\"$namespace\"}[$__rate_interval]))\n- sum(rate(aztec_archiver_checkpoint_l1_inclusion_delay_bucket{k8s_namespace_name=~\"$namespace\", le=\"30\"}[$__rate_interval]))", + "hide": false, + "legendFormat": "30+s", + "range": true, + "refId": "F" + } + ], + "title": "Checkpoint L1 Inclusion Delay Count", + "type": "timeseries" } ], "preload": false, diff --git a/yarn-project/archiver/src/archiver-sync.test.ts b/yarn-project/archiver/src/archiver-sync.test.ts index bbe5f3aa236e..1779d1362a3e 100644 --- a/yarn-project/archiver/src/archiver-sync.test.ts +++ b/yarn-project/archiver/src/archiver-sync.test.ts @@ -126,7 +126,6 @@ describe('Archiver Sync', () => { publicClient, rollupContract, inboxContract, - contractAddresses, archiverStore, config, blobClient, diff --git a/yarn-project/archiver/src/factory.ts b/yarn-project/archiver/src/factory.ts index dc0ca5552d85..ca4d60f8a780 100644 --- a/yarn-project/archiver/src/factory.ts +++ b/yarn-project/archiver/src/factory.ts @@ -138,7 +138,6 @@ export async function createArchiver( debugClient, rollup, inbox, - { ...config.l1Contracts, slashingProposerAddress }, archiverStore, archiverConfig, deps.blobClient, diff --git a/yarn-project/archiver/src/l1/README.md b/yarn-project/archiver/src/l1/README.md index c1cb4ecdab8c..2076d0e61bbc 100644 --- a/yarn-project/archiver/src/l1/README.md +++ b/yarn-project/archiver/src/l1/README.md @@ -5,29 +5,27 @@ Modules and classes to handle data retrieval from L1 for the archiver. ## Calldata Retriever The sequencer publisher bundles multiple operations into a single multicall3 transaction for gas -efficiency. A typical transaction includes: +efficiency. The archiver needs to extract the `propose` calldata from these bundled transactions +to reconstruct L2 blocks. -1. Attestation invalidations (if needed): `invalidateBadAttestation`, `invalidateInsufficientAttestations` -2. Block proposal: `propose` (exactly one per transaction to the rollup contract) -3. Governance and slashing (if needed): votes, payload creation/execution +The retriever uses hash matching against `attestationsHash` and `payloadDigest` from the +`CheckpointProposed` L1 event to verify it has found the correct propose calldata. These hashes +are always required. -The archiver needs to extract the `propose` calldata from these bundled transactions to reconstruct -L2 blocks. This class needs to handle scenarios where the transaction was submitted via multicall3, -as well as alternative ways for submitting the `propose` call that other clients might use. +### Multicall3 Decoding with Hash Matching -### Multicall3 Validation and Decoding - -First attempt to decode the transaction as a multicall3 `aggregate3` call with validation: +First attempt to decode the transaction as a multicall3 `aggregate3` call: - Check if transaction is to multicall3 address (`0xcA11bde05977b3631167028862bE2a173976CA11`) - Decode as `aggregate3(Call3[] calldata calls)` -- Allow calls to known addresses and methods (rollup, governance, slashing contracts, etc.) -- Find the single `propose` call to the rollup contract -- Verify exactly one `propose` call exists -- Extract and return the propose calldata +- Find all calls matching the rollup contract address and the `propose` function selector +- Verify each candidate by computing `attestationsHash` (keccak256 of ABI-encoded attestations) + and `payloadDigest` (keccak256 of the consensus payload signing hash) and comparing against + expected values from the `CheckpointProposed` event +- Return the verified candidate (if multiple verify, return the first with a warning) -This step handles the common case efficiently without requiring expensive trace or debug RPC calls. -Any validation failure triggers fallback to the next step. +This approach works regardless of what other calls are in the multicall3 bundle, because hash +matching identifies the correct propose call without needing an allowlist. ### Direct Propose Call @@ -35,64 +33,23 @@ Second attempt to decode the transaction as a direct `propose` call to the rollu - Check if transaction is to the rollup address - Decode as `propose` function call -- Verify the function is indeed `propose` +- Verify against expected hashes - Return the transaction input as the propose calldata -This handles scenarios where clients submit transactions directly to the rollup contract without -using multicall3 for bundling. Any validation failure triggers fallback to the next step. - ### Spire Proposer Call -Given existing attempts to route the call via the Spire proposer, we also check if the tx is `to` the -proposer known address, and if so, we try decoding it as either a multicall3 or a direct call to the -rollup contract. - -Similar as with the multicall3 check, we check that there are no other calls in the Spire proposer, so -we are absolutely sure that the only call is the successful one to the rollup. Any extraneous call would -imply an unexpected path to calling `propose` in the rollup contract, and since we cannot verify if the -calldata arguments we extracted are the correct ones (see the section below), we cannot know for sure which -one is the call that succeeded, so we don't know which calldata to process. - -Furthermore, since the Spire proposer is upgradeable, we check if the implementation has not changed in -order to decode. As usual, any validation failure triggers fallback to the next step. - -### Verifying Multicall3 Arguments - -**This is NOT implemented for simplicity's sake** - -If the checks above don't hold, such as when there are multiple calls to `propose`, then we cannot -reliably extract the `propose` calldata from the multicall3 arguments alone. We can try a best-effort -where we try all `propose` calls we see and validate them against on-chain data. Note that we can use these -same strategies if we were to obtain the calldata from another source. - -#### TempBlockLog Verification - -Read the stored `TempBlockLog` for the L2 block number from L1 and verify it matches our decoded header hash, -since the `TempBlockLog` stores the hash of the proposed block header, the payload commitment, and the attestations. - -However, `TempBlockLog` is only stored temporarily and deleted after proven, so this method only works for recent -blocks, not for historical data syncing. - -#### Archive Verification - -Verify that the archive root in the decoded propose is correct with regard to the block header. This requires -hashing the block header we have retrieved, inserting it into the archive tree, and checking the resulting root -against the one we got from L1. - -However, this requires that the archive keeps a reference to world-state, which is not the case in the current -system. - -#### Emit Commitments in Rollup Contract - -Modify rollup contract to emit commitments to the block header in the `L2BlockProposed` event, allowing us to easily -verify the calldata we obtained vs the emitted event. +Given existing attempts to route the call via the Spire proposer, we also check if the tx is +`to` the proposer known address. If so, we extract all wrapped calls and try each as either +a multicall3 or direct propose call, using hash matching to find and verify the correct one. -However, modifying the rollup contract is out of scope for this change. But we can implement this approach in `v2`. +Since the Spire proposer is upgradeable, we check that the implementation has not changed in +order to decode. Any validation failure triggers fallback to the next step. ### Debug and Trace Transaction Fallback -Last, we use L1 node's trace/debug RPC methods to definitively identify the one successful `propose` call within the tx. -We can then extract the exact calldata that hit the `propose` function in the rollup contract. +Last, we use L1 node's trace/debug RPC methods to definitively identify the one successful +`propose` call within the tx. We can then extract the exact calldata that hit the `propose` +function in the rollup contract. -This approach requires access to a debug-enabled L1 node, which may be more resource-intensive, so we only -use it as a fallback when the first step fails, which should be rare in practice. \ No newline at end of file +This approach requires access to a debug-enabled L1 node, which may be more resource-intensive, +so we only use it as a fallback when earlier steps fail, which should be rare in practice. diff --git a/yarn-project/archiver/src/l1/bin/retrieve-calldata.ts b/yarn-project/archiver/src/l1/bin/retrieve-calldata.ts index 6be4953275e4..e81e02e86547 100644 --- a/yarn-project/archiver/src/l1/bin/retrieve-calldata.ts +++ b/yarn-project/archiver/src/l1/bin/retrieve-calldata.ts @@ -5,7 +5,7 @@ import { EthAddress } from '@aztec/foundation/eth-address'; import { createLogger } from '@aztec/foundation/log'; import { RollupAbi } from '@aztec/l1-artifacts/RollupAbi'; -import { type Hex, createPublicClient, getAbiItem, http, toEventSelector } from 'viem'; +import { type Hex, createPublicClient, decodeEventLog, getAbiItem, http, toEventSelector } from 'viem'; import { mainnet } from 'viem/chains'; import { CalldataRetriever } from '../calldata_retriever.js'; @@ -89,14 +89,6 @@ async function main() { logger.info(`Transaction found in block ${tx.blockNumber}`); - // For simplicity, use zero addresses for optional contract addresses - // In production, these would be fetched from the rollup contract or configuration - const slashingProposerAddress = EthAddress.ZERO; - const governanceProposerAddress = EthAddress.ZERO; - const slashFactoryAddress = undefined; - - logger.info('Using zero addresses for governance/slashing (can be configured if needed)'); - // Create CalldataRetriever const retriever = new CalldataRetriever( publicClient as unknown as ViemPublicClient, @@ -104,46 +96,67 @@ async function main() { targetCommitteeSize, undefined, logger, - { - rollupAddress, - governanceProposerAddress, - slashingProposerAddress, - slashFactoryAddress, - }, + rollupAddress, ); - // Extract checkpoint number from transaction logs - logger.info('Decoding transaction to extract checkpoint number...'); + // Extract checkpoint number and hashes from transaction logs + logger.info('Decoding transaction to extract checkpoint number and hashes...'); const receipt = await publicClient.getTransactionReceipt({ hash: txHash }); - // Look for CheckpointProposed event (emitted when a checkpoint is proposed to the rollup) - // Event signature: CheckpointProposed(uint256 indexed checkpointNumber, bytes32 indexed archive, bytes32[], bytes32, bytes32) - // Hash: keccak256("CheckpointProposed(uint256,bytes32,bytes32[],bytes32,bytes32)") - const checkpointProposedEvent = receipt.logs.find(log => { + // Look for CheckpointProposed event + const checkpointProposedEventAbi = getAbiItem({ abi: RollupAbi, name: 'CheckpointProposed' }); + const checkpointProposedLog = receipt.logs.find(log => { try { return ( log.address.toLowerCase() === rollupAddress.toString().toLowerCase() && - log.topics[0] === toEventSelector(getAbiItem({ abi: RollupAbi, name: 'CheckpointProposed' })) + log.topics[0] === toEventSelector(checkpointProposedEventAbi) ); } catch { return false; } }); - if (!checkpointProposedEvent || checkpointProposedEvent.topics[1] === undefined) { + if (!checkpointProposedLog || checkpointProposedLog.topics[1] === undefined) { throw new Error(`Checkpoint proposed event not found`); } - const checkpointNumber = CheckpointNumber.fromBigInt(BigInt(checkpointProposedEvent.topics[1])); + const checkpointNumber = CheckpointNumber.fromBigInt(BigInt(checkpointProposedLog.topics[1])); + + // Decode the full event to extract attestationsHash and payloadDigest + const decodedEvent = decodeEventLog({ + abi: RollupAbi, + data: checkpointProposedLog.data, + topics: checkpointProposedLog.topics, + }); + + const eventArgs = decodedEvent.args as { + checkpointNumber: bigint; + archive: Hex; + versionedBlobHashes: Hex[]; + attestationsHash: Hex; + payloadDigest: Hex; + }; + + if (!eventArgs.attestationsHash || !eventArgs.payloadDigest) { + throw new Error(`CheckpointProposed event missing attestationsHash or payloadDigest`); + } + + const expectedHashes = { + attestationsHash: eventArgs.attestationsHash, + payloadDigest: eventArgs.payloadDigest, + }; + + logger.info(`Checkpoint Number: ${checkpointNumber}`); + logger.info(`Attestations Hash: ${expectedHashes.attestationsHash}`); + logger.info(`Payload Digest: ${expectedHashes.payloadDigest}`); logger.info(''); logger.info('Retrieving checkpoint from rollup transaction...'); logger.info(''); - // For this script, we don't have blob hashes or expected hashes, so pass empty arrays/objects - const result = await retriever.getCheckpointFromRollupTx(txHash, [], checkpointNumber, {}); + const result = await retriever.getCheckpointFromRollupTx(txHash, [], checkpointNumber, expectedHashes); - logger.info(' Successfully retrieved block header!'); + logger.info(' Successfully retrieved block header!'); logger.info(''); logger.info('Block Header Details:'); logger.info('===================='); diff --git a/yarn-project/archiver/src/l1/calldata_retriever.test.ts b/yarn-project/archiver/src/l1/calldata_retriever.test.ts index 45f0a81d9db1..0d1c78ef707c 100644 --- a/yarn-project/archiver/src/l1/calldata_retriever.test.ts +++ b/yarn-project/archiver/src/l1/calldata_retriever.test.ts @@ -32,7 +32,7 @@ import { EIP1967_IMPLEMENTATION_SLOT, SPIRE_PROPOSER_ADDRESS, SPIRE_PROPOSER_EXPECTED_IMPLEMENTATION, - getCallFromSpireProposer, + getCallsFromSpireProposer, verifyProxyImplementation, } from './spire_proposer.js'; @@ -40,25 +40,35 @@ import { * Test class that exposes protected methods for testing */ class TestCalldataRetriever extends CalldataRetriever { - public override tryDecodeMulticall3(tx: Transaction): Hex | undefined { - return super.tryDecodeMulticall3(tx); + public override tryDecodeMulticall3( + tx: Transaction, + expectedHashes: { attestationsHash: Hex; payloadDigest: Hex }, + checkpointNumber: CheckpointNumber, + blockHash: Hex, + ) { + return super.tryDecodeMulticall3(tx, expectedHashes, checkpointNumber, blockHash); } - public override tryDecodeDirectPropose(tx: Transaction): Hex | undefined { - return super.tryDecodeDirectPropose(tx); + public override tryDecodeDirectPropose( + tx: Transaction, + expectedHashes: { attestationsHash: Hex; payloadDigest: Hex }, + checkpointNumber: CheckpointNumber, + blockHash: Hex, + ) { + return super.tryDecodeDirectPropose(tx, expectedHashes, checkpointNumber, blockHash); } public override async extractCalldataViaTrace(txHash: Hex): Promise { return await super.extractCalldataViaTrace(txHash); } - public override decodeAndBuildCheckpoint( + public override tryDecodeAndVerifyPropose( proposeCalldata: Hex, - blockHash: Hex, + expectedHashes: { attestationsHash: Hex; payloadDigest: Hex }, checkpointNumber: CheckpointNumber, - expectedHashes: { attestationsHash?: Hex; payloadDigest?: Hex }, + blockHash: Hex, ) { - return super.decodeAndBuildCheckpoint(proposeCalldata, blockHash, checkpointNumber, expectedHashes); + return super.tryDecodeAndVerifyPropose(proposeCalldata, expectedHashes, checkpointNumber, blockHash); } } @@ -72,10 +82,8 @@ describe('CalldataRetriever', () => { const TARGET_COMMITTEE_SIZE = 5; const rollupAddress = EthAddress.random(); - const governanceProposerAddress = EthAddress.random(); - const slashFactoryAddress = EthAddress.random(); - const slashingProposerAddress = EthAddress.random(); const blockHash = Buffer32.random().toString(); + const checkpointNumber = CheckpointNumber(42); beforeEach(() => { txHash = '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef'; @@ -84,12 +92,14 @@ describe('CalldataRetriever', () => { logger = createLogger('test:calldata_retriever'); instrumentation = mock(); - retriever = new TestCalldataRetriever(publicClient, debugClient, TARGET_COMMITTEE_SIZE, instrumentation, logger, { + retriever = new TestCalldataRetriever( + publicClient, + debugClient, + TARGET_COMMITTEE_SIZE, + instrumentation, + logger, rollupAddress, - governanceProposerAddress, - slashFactoryAddress, - slashingProposerAddress, - }); + ); }); function makeViemHeader(): ViemHeader { @@ -136,6 +146,19 @@ describe('CalldataRetriever', () => { }); } + /** + * Sets up mocks for the hash computation methods to return specific test hashes. + * This allows us to test validation logic without recomputing hashes (which would duplicate production logic). + */ + function mockHashComputation( + attestationsHash: Hex = '0x1111111111111111111111111111111111111111111111111111111111111111', + payloadDigest: Hex = '0x2222222222222222222222222222222222222222222222222222222222222222', + ): { attestationsHash: Hex; payloadDigest: Hex } { + jest.spyOn(retriever as any, 'computeAttestationsHash').mockReturnValue(attestationsHash); + jest.spyOn(retriever as any, 'computePayloadDigest').mockReturnValue(payloadDigest); + return { attestationsHash, payloadDigest }; + } + function makeMulticall3Transaction(calls: { target: Hex; callData: Hex }[]): Transaction { const multicall3Data = encodeFunctionData({ abi: multicall3Abi, @@ -151,15 +174,14 @@ describe('CalldataRetriever', () => { } describe('getCheckpointFromRollupTx', () => { - const checkpointNumber = CheckpointNumber(42); - it('should successfully decode valid multicall3 transaction', async () => { const proposeCalldata = makeProposeCalldata(); const tx = makeMulticall3Transaction([{ target: rollupAddress.toString(), callData: proposeCalldata }]); + const hashes = mockHashComputation(); publicClient.getTransaction.mockResolvedValue(tx); - const result = await retriever.getCheckpointFromRollupTx(txHash, [], checkpointNumber, {}); + const result = await retriever.getCheckpointFromRollupTx(txHash, [], checkpointNumber, hashes); expect(result.checkpointNumber).toBe(checkpointNumber); expect(result.header).toBeInstanceOf(CheckpointHeader); @@ -171,6 +193,7 @@ describe('CalldataRetriever', () => { it('should fall back to direct propose when multicall3 decoding fails', async () => { const proposeCalldata = makeProposeCalldata(); + const hashes = mockHashComputation(); // Transaction that's not multicall3 but is a direct propose call const tx = { @@ -182,7 +205,7 @@ describe('CalldataRetriever', () => { publicClient.getTransaction.mockResolvedValue(tx); - const result = await retriever.getCheckpointFromRollupTx(txHash, [], checkpointNumber, {}); + const result = await retriever.getCheckpointFromRollupTx(txHash, [], checkpointNumber, hashes); expect(result.checkpointNumber).toBe(checkpointNumber); expect(result.header).toBeInstanceOf(CheckpointHeader); @@ -191,6 +214,7 @@ describe('CalldataRetriever', () => { it('should fall back to trace when both multicall3 and direct propose fail', async () => { const proposeCalldata = makeProposeCalldata(); + const hashes = mockHashComputation(); // Transaction that's neither multicall3 nor direct propose (wrong address) const wrongAddress = EthAddress.random(); @@ -224,7 +248,7 @@ describe('CalldataRetriever', () => { }, ]); - const result = await retriever.getCheckpointFromRollupTx(txHash, [], checkpointNumber, {}); + const result = await retriever.getCheckpointFromRollupTx(txHash, [], checkpointNumber, hashes); expect(result.checkpointNumber).toBe(checkpointNumber); expect(debugClient.request).toHaveBeenCalledWith({ method: 'trace_transaction', params: [txHash] }); @@ -233,6 +257,7 @@ describe('CalldataRetriever', () => { it('should throw when tracing fails', async () => { const proposeCalldata = makeProposeCalldata(); + const hashes = mockHashComputation(); // Transaction that's neither multicall3 nor direct propose (wrong address) const wrongAddress = EthAddress.random(); @@ -248,20 +273,21 @@ describe('CalldataRetriever', () => { // Mock both trace methods to fail debugClient.request.mockRejectedValue(new Error(`Method not available`)); - await expect(retriever.getCheckpointFromRollupTx(txHash, [], checkpointNumber, {})).rejects.toThrow( + await expect(retriever.getCheckpointFromRollupTx(txHash, [], checkpointNumber, hashes)).rejects.toThrow( 'Failed to trace transaction', ); }); it('should throw when transaction retrieval fails', async () => { + const hashes = mockHashComputation(); publicClient.getTransaction.mockRejectedValue(new Error('Transaction not found')); - await expect(retriever.getCheckpointFromRollupTx(txHash, [], checkpointNumber, {})).rejects.toThrow( + await expect(retriever.getCheckpointFromRollupTx(txHash, [], checkpointNumber, hashes)).rejects.toThrow( 'Transaction not found', ); }); - it('should validate attestationsHash when provided', async () => { + it('should validate attestationsHash', async () => { const attestations = makeViemCommitteeAttestations(); const proposeCalldata = makeProposeCalldata(undefined, attestations); const tx = makeMulticall3Transaction([{ target: rollupAddress.toString(), callData: proposeCalldata }]); @@ -289,8 +315,14 @@ describe('CalldataRetriever', () => { ), ); + // Mock only payloadDigest computation; use real attestationsHash + jest + .spyOn(retriever as any, 'computePayloadDigest') + .mockReturnValue('0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' as Hex); + const result = await retriever.getCheckpointFromRollupTx(txHash, [], checkpointNumber, { attestationsHash: expectedAttestationsHash, + payloadDigest: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' as Hex, }); expect(result.checkpointNumber).toBe(checkpointNumber); @@ -301,34 +333,23 @@ describe('CalldataRetriever', () => { const attestations = makeViemCommitteeAttestations(); const proposeCalldata = makeProposeCalldata(undefined, attestations); const tx = makeMulticall3Transaction([{ target: rollupAddress.toString(), callData: proposeCalldata }]); + const hashes = mockHashComputation(); publicClient.getTransaction.mockResolvedValue(tx); - // Use a different (wrong) attestationsHash + // Use a different (wrong) attestationsHash — hash mismatch causes tryDecodeMulticall3 to + // return undefined, falling through to trace which fails in tests const wrongAttestationsHash = '0x0000000000000000000000000000000000000000000000000000000000000001' as Hex; await expect( retriever.getCheckpointFromRollupTx(txHash, [], checkpointNumber, { attestationsHash: wrongAttestationsHash, + payloadDigest: hashes.payloadDigest, }), - ).rejects.toThrow('Attestations hash mismatch'); - }); - - it('should work with empty expectedHashes for backwards compatibility', async () => { - const proposeCalldata = makeProposeCalldata(); - const tx = makeMulticall3Transaction([{ target: rollupAddress.toString(), callData: proposeCalldata }]); - - publicClient.getTransaction.mockResolvedValue(tx); - - // Call with empty expectedHashes (simulating old event format without hash fields) - const result = await retriever.getCheckpointFromRollupTx(txHash, [], checkpointNumber, {}); - - expect(result.checkpointNumber).toBe(checkpointNumber); - expect(result.header).toBeInstanceOf(CheckpointHeader); - // Should succeed without validation when hashes are not provided + ).rejects.toThrow('Failed to trace'); }); - it('should validate payloadDigest when provided', async () => { + it('should validate payloadDigest', async () => { const header = makeViemHeader(); const attestations = makeViemCommitteeAttestations(); const archiveRoot = Fr.random(); @@ -362,7 +383,13 @@ describe('CalldataRetriever', () => { const payloadToSign = consensusPayload.getPayloadToSign(SignatureDomainSeparator.checkpointAttestation); const expectedPayloadDigest = keccak256(payloadToSign); + // Mock only attestationsHash computation; use real payloadDigest + jest + .spyOn(retriever as any, 'computeAttestationsHash') + .mockReturnValue('0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' as Hex); + const result = await retriever.getCheckpointFromRollupTx(txHash, [], checkpointNumber, { + attestationsHash: '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' as Hex, payloadDigest: expectedPayloadDigest, }); @@ -373,132 +400,180 @@ describe('CalldataRetriever', () => { it('should throw when payloadDigest does not match', async () => { const proposeCalldata = makeProposeCalldata(); const tx = makeMulticall3Transaction([{ target: rollupAddress.toString(), callData: proposeCalldata }]); + const hashes = mockHashComputation(); publicClient.getTransaction.mockResolvedValue(tx); - // Use a different (wrong) payloadDigest + // Use a different (wrong) payloadDigest — hash mismatch causes tryDecodeMulticall3 to + // return undefined, falling through to trace which fails in tests const wrongPayloadDigest = '0x0000000000000000000000000000000000000000000000000000000000000002' as Hex; await expect( retriever.getCheckpointFromRollupTx(txHash, [], checkpointNumber, { + attestationsHash: hashes.attestationsHash, payloadDigest: wrongPayloadDigest, }), - ).rejects.toThrow('Payload digest mismatch'); + ).rejects.toThrow('Failed to trace'); }); }); + describe('tryDecodeMulticall3', () => { - it('should decode simple multicall3 with single propose call', () => { + it('should decode multicall3 with single verified propose call', () => { const proposeCalldata = makeProposeCalldata(); + const hashes = mockHashComputation(); const tx = makeMulticall3Transaction([{ target: rollupAddress.toString() as Hex, callData: proposeCalldata }]); - const result = retriever.tryDecodeMulticall3(tx); + const result = retriever.tryDecodeMulticall3(tx, hashes, checkpointNumber, blockHash as Hex); - expect(result).toBe(proposeCalldata); + expect(result).toBeDefined(); + expect(result!.header).toBeInstanceOf(CheckpointHeader); + expect(result!.archiveRoot).toBeInstanceOf(Fr); + expect(result!.checkpointNumber).toBe(checkpointNumber); }); - it('should decode multicall3 with propose and other rollup calls', () => { + it('should decode multicall3 with propose and other calls (hash matching ignores non-propose)', () => { const proposeCalldata = makeProposeCalldata(); - // Use the actual selector for these functions + const hashes = mockHashComputation(); const invalidateBadSelector = toFunctionSelector( RollupAbi.find(x => x.type === 'function' && x.name === 'invalidateBadAttestation')!, ); - const invalidateBadCalldata = (invalidateBadSelector + '0'.repeat(120)) as Hex; // Minimal valid calldata + const invalidateBadCalldata = (invalidateBadSelector + '0'.repeat(120)) as Hex; const tx = makeMulticall3Transaction([ { target: rollupAddress.toString() as Hex, callData: invalidateBadCalldata }, { target: rollupAddress.toString() as Hex, callData: proposeCalldata }, ]); - const result = retriever.tryDecodeMulticall3(tx); + const result = retriever.tryDecodeMulticall3(tx, hashes, checkpointNumber, blockHash as Hex); - expect(result).toBe(proposeCalldata); + expect(result).toBeDefined(); + expect(result!.header).toBeInstanceOf(CheckpointHeader); }); - it('should decode multicall3 with mixed valid calls', () => { + it('should decode multicall3 with unknown calls when propose is hash-verified', () => { const proposeCalldata = makeProposeCalldata(); - const invalidateBadSelector = toFunctionSelector( - RollupAbi.find(x => x.type === 'function' && x.name === 'invalidateBadAttestation')!, - ); - const invalidateBadCalldata = (invalidateBadSelector + '0'.repeat(120)) as Hex; + const hashes = mockHashComputation(); + const unknownAddress = EthAddress.random(); const tx = makeMulticall3Transaction([ - { target: rollupAddress.toString() as Hex, callData: invalidateBadCalldata }, + { target: unknownAddress.toString() as Hex, callData: '0x12345678' as Hex }, { target: rollupAddress.toString() as Hex, callData: proposeCalldata }, ]); - const result = retriever.tryDecodeMulticall3(tx); + const result = retriever.tryDecodeMulticall3(tx, hashes, checkpointNumber, blockHash as Hex); + expect(result).toBeDefined(); + expect(result!.header).toBeInstanceOf(CheckpointHeader); + }); + + it('should return first when multiple propose candidates all verify (with warning)', () => { + const proposeCalldata = makeProposeCalldata(); + const hashes = mockHashComputation(); - expect(result).toBe(proposeCalldata); + // Same calldata twice -> both verify + const tx = makeMulticall3Transaction([ + { target: rollupAddress.toString() as Hex, callData: proposeCalldata }, + { target: rollupAddress.toString() as Hex, callData: proposeCalldata }, + ]); + + const warnSpy = jest.spyOn(logger, 'warn'); + const result = retriever.tryDecodeMulticall3(tx, hashes, checkpointNumber, blockHash as Hex); + expect(result).toBeDefined(); + expect(result!.header).toBeInstanceOf(CheckpointHeader); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('Multiple propose candidates verified'), + expect.any(Object), + ); + warnSpy.mockRestore(); + }); + + it('should return the verified candidate when only one of multiple candidates verifies', () => { + const proposeCalldata1 = makeProposeCalldata(); + const proposeCalldata2 = makeProposeCalldata(); + + const hashes = mockHashComputation(); + + // Mock tryDecodeAndVerifyPropose to be selective - only first calldata verifies + jest.spyOn(retriever, 'tryDecodeAndVerifyPropose').mockImplementation((calldata, _hashes) => { + if (calldata === proposeCalldata1) { + return { + checkpointNumber, + archiveRoot: Fr.random(), + header: CheckpointHeader.random(), + attestations: [], + blockHash, + feeAssetPriceModifier: 0n, + }; + } + return undefined; + }); + + const tx = makeMulticall3Transaction([ + { target: rollupAddress.toString() as Hex, callData: proposeCalldata1 }, + { target: rollupAddress.toString() as Hex, callData: proposeCalldata2 }, + ]); + + const result = retriever.tryDecodeMulticall3(tx, hashes, checkpointNumber, blockHash as Hex); + expect(result).toBeDefined(); + expect(result!.checkpointNumber).toBe(checkpointNumber); }); it('should return undefined when not to multicall3 address', () => { const proposeCalldata = makeProposeCalldata(); + const hashes = mockHashComputation(); const tx = { input: proposeCalldata, to: rollupAddress.toString() as Hex, hash: '0x123' as Hex, } as Transaction; - const result = retriever.tryDecodeMulticall3(tx); + const result = retriever.tryDecodeMulticall3(tx, hashes, checkpointNumber, blockHash as Hex); expect(result).toBeUndefined(); }); it('should return undefined when to is null', () => { const proposeCalldata = makeProposeCalldata(); + const hashes = mockHashComputation(); const tx = { input: proposeCalldata, to: null, hash: '0x123' as Hex, } as Transaction; - const result = retriever.tryDecodeMulticall3(tx); + const result = retriever.tryDecodeMulticall3(tx, hashes, checkpointNumber, blockHash as Hex); expect(result).toBeUndefined(); }); it('should return undefined when not multicall3 aggregate3', () => { const proposeCalldata = makeProposeCalldata(); + const hashes = mockHashComputation(); const tx = { input: proposeCalldata, to: MULTI_CALL_3_ADDRESS as Hex, hash: '0x123' as Hex, } as Transaction; - const result = retriever.tryDecodeMulticall3(tx); + const result = retriever.tryDecodeMulticall3(tx, hashes, checkpointNumber, blockHash as Hex); expect(result).toBeUndefined(); }); - it('should return undefined when call to unknown address', () => { + it('should return undefined when propose call to wrong address', () => { const proposeCalldata = makeProposeCalldata(); - const unknownAddress = EthAddress.random(); + const hashes = mockHashComputation(); + const wrongRollupAddress = EthAddress.random(); const tx = makeMulticall3Transaction([ - { target: rollupAddress.toString() as Hex, callData: proposeCalldata }, - { target: unknownAddress.toString() as Hex, callData: '0x12345678' as Hex }, + { target: wrongRollupAddress.toString() as Hex, callData: proposeCalldata }, ]); - const result = retriever.tryDecodeMulticall3(tx); - - expect(result).toBeUndefined(); - }); - - it('should return undefined when unknown function selector on rollup', () => { - const proposeCalldata = makeProposeCalldata(); - const invalidCalldata = '0x99999999' as Hex; // Unknown selector - - const tx = makeMulticall3Transaction([ - { target: rollupAddress.toString() as Hex, callData: proposeCalldata }, - { target: rollupAddress.toString() as Hex, callData: invalidCalldata }, - ]); - - const result = retriever.tryDecodeMulticall3(tx); - + const result = retriever.tryDecodeMulticall3(tx, hashes, checkpointNumber, blockHash as Hex); expect(result).toBeUndefined(); }); it('should return undefined when no propose calls found', () => { + const hashes = mockHashComputation(); const invalidateBadSelector = toFunctionSelector( RollupAbi.find(x => x.type === 'function' && x.name === 'invalidateBadAttestation')!, ); @@ -508,49 +583,53 @@ describe('CalldataRetriever', () => { { target: rollupAddress.toString() as Hex, callData: invalidateBadCalldata }, ]); - const result = retriever.tryDecodeMulticall3(tx); + const result = retriever.tryDecodeMulticall3(tx, hashes, checkpointNumber, blockHash as Hex); expect(result).toBeUndefined(); }); - it('should return undefined when multiple propose calls', () => { - const proposeCalldata1 = makeProposeCalldata(); - const proposeCalldata2 = makeProposeCalldata(); - - const tx = makeMulticall3Transaction([ - { target: rollupAddress.toString() as Hex, callData: proposeCalldata1 }, - { target: rollupAddress.toString() as Hex, callData: proposeCalldata2 }, - ]); + it('should return undefined when empty calls array', () => { + const hashes = mockHashComputation(); + const tx = makeMulticall3Transaction([]); - const result = retriever.tryDecodeMulticall3(tx); + const result = retriever.tryDecodeMulticall3(tx, hashes, checkpointNumber, blockHash as Hex); expect(result).toBeUndefined(); }); - it('should return undefined when calldata too short', () => { - const tx = makeMulticall3Transaction([{ target: rollupAddress.toString() as Hex, callData: '0x123' as Hex }]); - - const result = retriever.tryDecodeMulticall3(tx); - - expect(result).toBeUndefined(); - }); + it('should return undefined when hashes do not match', () => { + const proposeCalldata = makeProposeCalldata(); - it('should return undefined when empty calls array', () => { - const tx = makeMulticall3Transaction([]); + // Mock to return different hashes than expected + mockHashComputation( + '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' as Hex, + '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' as Hex, + ); - const result = retriever.tryDecodeMulticall3(tx); + const tx = makeMulticall3Transaction([{ target: rollupAddress.toString() as Hex, callData: proposeCalldata }]); + // Pass different hashes - validation will fail + const result = retriever.tryDecodeMulticall3( + tx, + { + attestationsHash: '0x0000000000000000000000000000000000000000000000000000000000000001' as Hex, + payloadDigest: '0x0000000000000000000000000000000000000000000000000000000000000002' as Hex, + }, + checkpointNumber, + blockHash as Hex, + ); expect(result).toBeUndefined(); }); it('should return undefined when decoding throws exception', () => { + const hashes = mockHashComputation(); const tx = { input: '0xinvalid' as Hex, to: MULTI_CALL_3_ADDRESS as Hex, hash: '0x123' as Hex, } as Transaction; - const result = retriever.tryDecodeMulticall3(tx); + const result = retriever.tryDecodeMulticall3(tx, hashes, checkpointNumber, blockHash as Hex); expect(result).toBeUndefined(); }); @@ -559,6 +638,7 @@ describe('CalldataRetriever', () => { describe('tryDecodeDirectPropose', () => { it('should decode direct propose call to rollup', () => { const proposeCalldata = makeProposeCalldata(); + const hashes = mockHashComputation(); const tx = { input: proposeCalldata, to: rollupAddress.toString() as Hex, @@ -566,13 +646,17 @@ describe('CalldataRetriever', () => { blockHash: Buffer32.random().toString() as Hex, } as Transaction; - const result = retriever.tryDecodeDirectPropose(tx); + const result = retriever.tryDecodeDirectPropose(tx, hashes, checkpointNumber, blockHash as Hex); - expect(result).toBe(proposeCalldata); + expect(result).toBeDefined(); + expect(result!.header).toBeInstanceOf(CheckpointHeader); + expect(result!.archiveRoot).toBeInstanceOf(Fr); + expect(result!.checkpointNumber).toBe(checkpointNumber); }); it('should return undefined when not to rollup address', () => { const proposeCalldata = makeProposeCalldata(); + const hashes = mockHashComputation(); const wrongAddress = EthAddress.random(); const tx = { input: proposeCalldata, @@ -580,25 +664,27 @@ describe('CalldataRetriever', () => { hash: '0x123' as Hex, } as Transaction; - const result = retriever.tryDecodeDirectPropose(tx); + const result = retriever.tryDecodeDirectPropose(tx, hashes, checkpointNumber, blockHash as Hex); expect(result).toBeUndefined(); }); it('should return undefined when to is null', () => { const proposeCalldata = makeProposeCalldata(); + const hashes = mockHashComputation(); const tx = { input: proposeCalldata, to: null, hash: '0x123' as Hex, } as Transaction; - const result = retriever.tryDecodeDirectPropose(tx); + const result = retriever.tryDecodeDirectPropose(tx, hashes, checkpointNumber, blockHash as Hex); expect(result).toBeUndefined(); }); it('should return undefined when function is not propose', () => { + const hashes = mockHashComputation(); const invalidateBadSelector = toFunctionSelector( RollupAbi.find(x => x.type === 'function' && x.name === 'invalidateBadAttestation')!, ); @@ -610,26 +696,55 @@ describe('CalldataRetriever', () => { hash: '0x123' as Hex, } as Transaction; - const result = retriever.tryDecodeDirectPropose(tx); + const result = retriever.tryDecodeDirectPropose(tx, hashes, checkpointNumber, blockHash as Hex); expect(result).toBeUndefined(); }); it('should return undefined when input cannot be decoded', () => { + const hashes = mockHashComputation(); const tx = { input: '0xinvalid' as Hex, to: rollupAddress.toString() as Hex, hash: '0x123' as Hex, } as Transaction; - const result = retriever.tryDecodeDirectPropose(tx); + const result = retriever.tryDecodeDirectPropose(tx, hashes, checkpointNumber, blockHash as Hex); + + expect(result).toBeUndefined(); + }); + + it('should return undefined when hashes do not match', () => { + const proposeCalldata = makeProposeCalldata(); + + // Mock to return different hashes than expected + mockHashComputation( + '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' as Hex, + '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' as Hex, + ); + + const tx = { + input: proposeCalldata, + to: rollupAddress.toString() as Hex, + hash: '0x123' as Hex, + } as Transaction; + + const result = retriever.tryDecodeDirectPropose( + tx, + { + attestationsHash: '0x0000000000000000000000000000000000000000000000000000000000000001' as Hex, + payloadDigest: '0x0000000000000000000000000000000000000000000000000000000000000002' as Hex, + }, + checkpointNumber, + blockHash as Hex, + ); expect(result).toBeUndefined(); }); }); describe('tryDecodeSpireProposer', () => { - function makeSpireProposerMulticallTransaction(call: { target: Hex; data: Hex }): Transaction { + function makeSpireProposerMulticallTransaction(calls: { target: Hex; data: Hex }[]): Transaction { const spireMulticallData = encodeFunctionData({ abi: [ { @@ -655,15 +770,13 @@ describe('CalldataRetriever', () => { ] as const, functionName: 'multicall', args: [ - [ - { - proposer: EthAddress.random().toString() as Hex, - target: call.target, - data: call.data, - value: 0n, - gasLimit: 1000000n, - }, - ], + calls.map(call => ({ + proposer: EthAddress.random().toString() as Hex, + target: call.target, + data: call.data, + value: 0n, + gasLimit: 1000000n, + })), ], }); @@ -677,21 +790,21 @@ describe('CalldataRetriever', () => { it('should decode Spire Proposer with direct propose call', async () => { const proposeCalldata = makeProposeCalldata(); - const tx = makeSpireProposerMulticallTransaction({ - target: rollupAddress.toString() as Hex, - data: proposeCalldata, - }); + const tx = makeSpireProposerMulticallTransaction([ + { target: rollupAddress.toString() as Hex, data: proposeCalldata }, + ]); // Mock the proxy implementation verification publicClient.getStorageAt.mockResolvedValue( ('0x000000000000000000000000' + SPIRE_PROPOSER_EXPECTED_IMPLEMENTATION.slice(2)) as Hex, ); - const result = await getCallFromSpireProposer(tx, publicClient, logger); + const result = await getCallsFromSpireProposer(tx, publicClient, logger); expect(result).toBeDefined(); - expect(result?.to.toLowerCase()).toBe(rollupAddress.toString().toLowerCase()); - expect(result?.data).toBe(proposeCalldata); + expect(result).toHaveLength(1); + expect(result![0].to.toLowerCase()).toBe(rollupAddress.toString().toLowerCase()); + expect(result![0].data).toBe(proposeCalldata); expect(publicClient.getStorageAt).toHaveBeenCalledWith({ address: SPIRE_PROPOSER_ADDRESS, slot: EIP1967_IMPLEMENTATION_SLOT, @@ -706,21 +819,37 @@ describe('CalldataRetriever', () => { args: [[{ target: rollupAddress.toString() as Hex, allowFailure: false, callData: proposeCalldata }]], }); - const tx = makeSpireProposerMulticallTransaction({ - target: MULTI_CALL_3_ADDRESS as Hex, - data: multicall3Data, - }); + const tx = makeSpireProposerMulticallTransaction([{ target: MULTI_CALL_3_ADDRESS as Hex, data: multicall3Data }]); // Mock the proxy implementation verification publicClient.getStorageAt.mockResolvedValue( ('0x000000000000000000000000' + SPIRE_PROPOSER_EXPECTED_IMPLEMENTATION.slice(2)) as Hex, ); - const result = await getCallFromSpireProposer(tx, publicClient, logger); + const result = await getCallsFromSpireProposer(tx, publicClient, logger); expect(result).toBeDefined(); - expect(result?.to).toBe(MULTI_CALL_3_ADDRESS); - expect(result?.data).toBe(multicall3Data); + expect(result).toHaveLength(1); + expect(result![0].to).toBe(MULTI_CALL_3_ADDRESS); + expect(result![0].data).toBe(multicall3Data); + }); + + it('should return all calls when Spire Proposer contains multiple calls', async () => { + const proposeCalldata = makeProposeCalldata(); + const tx = makeSpireProposerMulticallTransaction([ + { target: rollupAddress.toString() as Hex, data: proposeCalldata }, + { target: rollupAddress.toString() as Hex, data: proposeCalldata }, + ]); + + // Mock the proxy implementation verification + publicClient.getStorageAt.mockResolvedValue( + ('0x000000000000000000000000' + SPIRE_PROPOSER_EXPECTED_IMPLEMENTATION.slice(2)) as Hex, + ); + + const result = await getCallsFromSpireProposer(tx, publicClient, logger); + + expect(result).toBeDefined(); + expect(result).toHaveLength(2); }); it('should return undefined when not to Spire Proposer address', async () => { @@ -731,7 +860,7 @@ describe('CalldataRetriever', () => { hash: txHash, } as Transaction; - const result = await getCallFromSpireProposer(tx, publicClient, logger); + const result = await getCallsFromSpireProposer(tx, publicClient, logger); expect(result).toBeUndefined(); expect(publicClient.getStorageAt).not.toHaveBeenCalled(); @@ -739,135 +868,36 @@ describe('CalldataRetriever', () => { it('should return undefined when proxy implementation verification fails', async () => { const proposeCalldata = makeProposeCalldata(); - const tx = makeSpireProposerMulticallTransaction({ - target: rollupAddress.toString() as Hex, - data: proposeCalldata, - }); + const tx = makeSpireProposerMulticallTransaction([ + { target: rollupAddress.toString() as Hex, data: proposeCalldata }, + ]); // Mock the proxy pointing to wrong implementation publicClient.getStorageAt.mockResolvedValue('0x000000000000000000000000wrongimplementation0000000000' as Hex); - const result = await getCallFromSpireProposer(tx, publicClient, logger); - - expect(result).toBeUndefined(); - }); - - it('should return undefined when Spire Proposer contains multiple calls', async () => { - const proposeCalldata = makeProposeCalldata(); - const spireMulticallData = encodeFunctionData({ - abi: [ - { - inputs: [ - { - components: [ - { internalType: 'address', name: 'proposer', type: 'address' }, - { internalType: 'address', name: 'target', type: 'address' }, - { internalType: 'bytes', name: 'data', type: 'bytes' }, - { internalType: 'uint256', name: 'value', type: 'uint256' }, - { internalType: 'uint256', name: 'gasLimit', type: 'uint256' }, - ], - internalType: 'struct IProposerMulticall.Call[]', - name: '_calls', - type: 'tuple[]', - }, - ], - name: 'multicall', - outputs: [], - stateMutability: 'nonpayable', - type: 'function', - }, - ] as const, - functionName: 'multicall', - args: [ - [ - { - proposer: EthAddress.random().toString() as Hex, - target: rollupAddress.toString() as Hex, - data: proposeCalldata, - value: 0n, - gasLimit: 1000000n, - }, - { - proposer: EthAddress.random().toString() as Hex, - target: rollupAddress.toString() as Hex, - data: proposeCalldata, - value: 0n, - gasLimit: 1000000n, - }, - ], - ], - }); - - const tx = { - input: spireMulticallData, - blockHash, - to: SPIRE_PROPOSER_ADDRESS as Hex, - hash: txHash, - } as Transaction; - - // Mock the proxy implementation verification - publicClient.getStorageAt.mockResolvedValue( - ('0x000000000000000000000000' + SPIRE_PROPOSER_EXPECTED_IMPLEMENTATION.slice(2)) as Hex, - ); - - const result = await getCallFromSpireProposer(tx, publicClient, logger); + const result = await getCallsFromSpireProposer(tx, publicClient, logger); expect(result).toBeUndefined(); }); it('should extract call even if target is unknown (validation happens in next step)', async () => { const unknownAddress = EthAddress.random(); - const tx = makeSpireProposerMulticallTransaction({ - target: unknownAddress.toString() as Hex, - data: '0x12345678' as Hex, - }); + const tx = makeSpireProposerMulticallTransaction([ + { target: unknownAddress.toString() as Hex, data: '0x12345678' as Hex }, + ]); // Mock the proxy implementation verification publicClient.getStorageAt.mockResolvedValue( ('0x000000000000000000000000' + SPIRE_PROPOSER_EXPECTED_IMPLEMENTATION.slice(2)) as Hex, ); - const result = await getCallFromSpireProposer(tx, publicClient, logger); + const result = await getCallsFromSpireProposer(tx, publicClient, logger); // Spire proposer should successfully extract the call, even if target is unknown - // The validation of the target happens in the next step (tryDecodeMulticall3 or tryDecodeDirectPropose) expect(result).toBeDefined(); - expect(result?.to.toLowerCase()).toBe(unknownAddress.toString().toLowerCase()); - expect(result?.data).toBe('0x12345678'); - }); - - it('should extract multicall3 call (validation of inner calls happens in next step)', async () => { - const proposeCalldata = makeProposeCalldata(); - const invalidCalldata = '0x99999999' as Hex; // Unknown selector - - const multicall3Data = encodeFunctionData({ - abi: multicall3Abi, - functionName: 'aggregate3', - args: [ - [ - { target: rollupAddress.toString() as Hex, allowFailure: false, callData: proposeCalldata }, - { target: rollupAddress.toString() as Hex, allowFailure: false, callData: invalidCalldata }, - ], - ], - }); - - const tx = makeSpireProposerMulticallTransaction({ - target: MULTI_CALL_3_ADDRESS as Hex, - data: multicall3Data, - }); - - // Mock the proxy implementation verification - publicClient.getStorageAt.mockResolvedValue( - ('0x000000000000000000000000' + SPIRE_PROPOSER_EXPECTED_IMPLEMENTATION.slice(2)) as Hex, - ); - - const result = await getCallFromSpireProposer(tx, publicClient, logger); - - // Spire proposer should successfully extract the multicall3 call - // Validation of the inner calls happens in tryDecodeMulticall3 - expect(result).toBeDefined(); - expect(result?.to).toBe(MULTI_CALL_3_ADDRESS); - expect(result?.data).toBe(multicall3Data); + expect(result).toHaveLength(1); + expect(result![0].to.toLowerCase()).toBe(unknownAddress.toString().toLowerCase()); + expect(result![0].data).toBe('0x12345678'); }); }); @@ -1102,57 +1132,103 @@ describe('CalldataRetriever', () => { }); }); - describe('decodeAndBuildCheckpoint', () => { - const blockHash = Fr.random().toString() as Hex; - const checkpointNumber = CheckpointNumber(42); - - it('should correctly decode propose calldata and build checkpoint', () => { + describe('tryDecodeAndVerifyPropose', () => { + it('should decode and verify propose calldata successfully', () => { const proposeCalldata = makeProposeCalldata(); + const hashes = mockHashComputation(); - const result = retriever.decodeAndBuildCheckpoint(proposeCalldata, blockHash, checkpointNumber, {}); + const result = retriever.tryDecodeAndVerifyPropose(proposeCalldata, hashes, checkpointNumber, blockHash as Hex); - expect(result.checkpointNumber).toBe(checkpointNumber); - expect(result.header).toBeInstanceOf(CheckpointHeader); - expect(result.archiveRoot).toBeInstanceOf(Fr); - expect(Array.isArray(result.attestations)).toBe(true); - expect(result.blockHash).toBe(blockHash); + expect(result).toBeDefined(); + expect(result!.checkpointNumber).toBe(checkpointNumber); + expect(result!.header).toBeInstanceOf(CheckpointHeader); + expect(result!.archiveRoot).toBeInstanceOf(Fr); + expect(Array.isArray(result!.attestations)).toBe(true); + expect(result!.blockHash).toBe(blockHash); }); it('should handle attestations correctly', () => { const attestations = makeViemCommitteeAttestations(); const proposeCalldata = makeProposeCalldata(undefined, attestations); + const hashes = mockHashComputation(); - const result = retriever.decodeAndBuildCheckpoint(proposeCalldata, blockHash, checkpointNumber, {}); + const result = retriever.tryDecodeAndVerifyPropose(proposeCalldata, hashes, checkpointNumber, blockHash as Hex); - expect(result.attestations).toHaveLength(TARGET_COMMITTEE_SIZE); + expect(result).toBeDefined(); + expect(result!.attestations).toHaveLength(TARGET_COMMITTEE_SIZE); }); - it('should throw when calldata is not for propose function', () => { + it('should return undefined when calldata is not for propose function', () => { const invalidateBadSelector = toFunctionSelector( RollupAbi.find(x => x.type === 'function' && x.name === 'invalidateBadAttestation')!, ); const invalidCalldata = (invalidateBadSelector + '0'.repeat(120)) as Hex; + const hashes = mockHashComputation(); + + const result = retriever.tryDecodeAndVerifyPropose(invalidCalldata, hashes, checkpointNumber, blockHash as Hex); - expect(() => retriever.decodeAndBuildCheckpoint(invalidCalldata, blockHash, checkpointNumber, {})).toThrow(); + expect(result).toBeUndefined(); }); - it('should throw when calldata is malformed', () => { + it('should return undefined when calldata is malformed', () => { const malformedCalldata = '0xinvalid' as Hex; + const hashes = mockHashComputation(); + + const result = retriever.tryDecodeAndVerifyPropose(malformedCalldata, hashes, checkpointNumber, blockHash as Hex); + + expect(result).toBeUndefined(); + }); + + it('should return undefined when attestationsHash does not match', () => { + const proposeCalldata = makeProposeCalldata(); + mockHashComputation( + '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' as Hex, + '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' as Hex, + ); + + const result = retriever.tryDecodeAndVerifyPropose( + proposeCalldata, + { + attestationsHash: '0x0000000000000000000000000000000000000000000000000000000000000001' as Hex, + payloadDigest: '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' as Hex, + }, + checkpointNumber, + blockHash as Hex, + ); - expect(() => retriever.decodeAndBuildCheckpoint(malformedCalldata, blockHash, checkpointNumber, {})).toThrow(); + expect(result).toBeUndefined(); + }); + + it('should return undefined when payloadDigest does not match', () => { + const proposeCalldata = makeProposeCalldata(); + mockHashComputation( + '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' as Hex, + '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' as Hex, + ); + + const result = retriever.tryDecodeAndVerifyPropose( + proposeCalldata, + { + attestationsHash: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' as Hex, + payloadDigest: '0x0000000000000000000000000000000000000000000000000000000000000002' as Hex, + }, + checkpointNumber, + blockHash as Hex, + ); + + expect(result).toBeUndefined(); }); }); describe('integration', () => { - const checkpointNumber = CheckpointNumber(42); - it('should complete full flow from tx hash to checkpoint via multicall3', async () => { const proposeCalldata = makeProposeCalldata(); + const hashes = mockHashComputation(); const tx = makeMulticall3Transaction([{ target: rollupAddress.toString() as Hex, callData: proposeCalldata }]); publicClient.getTransaction.mockResolvedValue(tx); - const result = await retriever.getCheckpointFromRollupTx(txHash, [], checkpointNumber, {}); + const result = await retriever.getCheckpointFromRollupTx(txHash, [], checkpointNumber, hashes); expect(result).toBeDefined(); expect(result.checkpointNumber).toBe(checkpointNumber); @@ -1174,6 +1250,7 @@ describe('CalldataRetriever', () => { const SPIRE_PROPOSER_EXPECTED_IMPLEMENTATION = '0x7d38d47e7c82195e6e607d3b0f1c20c615c7bf42'; const proposeCalldata = makeProposeCalldata(); + const hashes = mockHashComputation(); // Create Spire Proposer multicall transaction const spireMulticallData = encodeFunctionData({ @@ -1225,7 +1302,7 @@ describe('CalldataRetriever', () => { ('0x000000000000000000000000' + SPIRE_PROPOSER_EXPECTED_IMPLEMENTATION.slice(2)) as Hex, ); - const result = await retriever.getCheckpointFromRollupTx(txHash, [], checkpointNumber, {}); + const result = await retriever.getCheckpointFromRollupTx(txHash, [], checkpointNumber, hashes); expect(result).toBeDefined(); expect(result.checkpointNumber).toBe(checkpointNumber); @@ -1244,5 +1321,141 @@ describe('CalldataRetriever', () => { // Verify instrumentation was called with Spire Proposer address expect(instrumentation.recordBlockProposalTxTarget).toHaveBeenCalledWith(SPIRE_PROPOSER_ADDRESS, false); }); + + it('should succeed via hash matching when multicall3 has unknown calls', async () => { + const proposeCalldata = makeProposeCalldata(); + const hashes = mockHashComputation( + '0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890' as Hex, + '0x0fedcba987654321fedcba987654321fedcba987654321fedcba987654321fed' as Hex, + ); + const unknownAddress = EthAddress.random(); + + const tx = makeMulticall3Transaction([ + { target: unknownAddress.toString() as Hex, callData: '0x12345678' as Hex }, + { target: rollupAddress.toString() as Hex, callData: proposeCalldata }, + ]); + + publicClient.getTransaction.mockResolvedValue(tx); + + const result = await retriever.getCheckpointFromRollupTx(txHash, [], checkpointNumber, hashes); + + expect(result.checkpointNumber).toBe(checkpointNumber); + expect(result.header).toBeInstanceOf(CheckpointHeader); + expect(result.archiveRoot).toBeInstanceOf(Fr); + expect(instrumentation.recordBlockProposalTxTarget).toHaveBeenCalledWith(MULTI_CALL_3_ADDRESS, false); + }); + + it('should succeed via Spire-wrapped multicall3 with unknown calls', async () => { + const proposeCalldata = makeProposeCalldata(); + const hashes = mockHashComputation( + '0x9876543210fedcba9876543210fedcba9876543210fedcba9876543210fedcba' as Hex, + '0x0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef' as Hex, + ); + const unknownAddress = EthAddress.random(); + + const multicall3Data = encodeFunctionData({ + abi: multicall3Abi, + functionName: 'aggregate3', + args: [ + [ + { target: unknownAddress.toString() as Hex, allowFailure: false, callData: '0x12345678' as Hex }, + { target: rollupAddress.toString() as Hex, allowFailure: false, callData: proposeCalldata }, + ], + ], + }); + + const spireMulticallData = encodeFunctionData({ + abi: [ + { + inputs: [ + { + components: [ + { internalType: 'address', name: 'proposer', type: 'address' }, + { internalType: 'address', name: 'target', type: 'address' }, + { internalType: 'bytes', name: 'data', type: 'bytes' }, + { internalType: 'uint256', name: 'value', type: 'uint256' }, + { internalType: 'uint256', name: 'gasLimit', type: 'uint256' }, + ], + internalType: 'struct IProposerMulticall.Call[]', + name: '_calls', + type: 'tuple[]', + }, + ], + name: 'multicall', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + ] as const, + functionName: 'multicall', + args: [ + [ + { + proposer: EthAddress.random().toString() as Hex, + target: MULTI_CALL_3_ADDRESS as Hex, + data: multicall3Data, + value: 0n, + gasLimit: 1000000n, + }, + ], + ], + }); + + const tx = { + input: spireMulticallData, + blockHash, + to: SPIRE_PROPOSER_ADDRESS as Hex, + hash: txHash, + } as Transaction; + + publicClient.getTransaction.mockResolvedValue(tx); + publicClient.getStorageAt.mockResolvedValue( + ('0x000000000000000000000000' + SPIRE_PROPOSER_EXPECTED_IMPLEMENTATION.slice(2)) as Hex, + ); + + const result = await retriever.getCheckpointFromRollupTx(txHash, [], checkpointNumber, hashes); + + expect(result.checkpointNumber).toBe(checkpointNumber); + expect(result.header).toBeInstanceOf(CheckpointHeader); + expect(instrumentation.recordBlockProposalTxTarget).toHaveBeenCalledWith(SPIRE_PROPOSER_ADDRESS, false); + }); + + it('should fall back to trace with wrong hashes and final decode throws mismatch', async () => { + const proposeCalldata = makeProposeCalldata(); + const wrongHashes = { + attestationsHash: '0x0000000000000000000000000000000000000000000000000000000000000001' as Hex, + payloadDigest: '0x0000000000000000000000000000000000000000000000000000000000000002' as Hex, + }; + const unknownAddress = EthAddress.random(); + + const tx = makeMulticall3Transaction([ + { target: unknownAddress.toString() as Hex, callData: '0x12345678' as Hex }, + { target: rollupAddress.toString() as Hex, callData: proposeCalldata }, + ]); + + publicClient.getTransaction.mockResolvedValue(tx); + + // Mock trace to return the propose calldata (trace succeeds but final hash validation fails) + debugClient.request.mockResolvedValueOnce([ + { + type: 'call', + action: { + from: EthAddress.random().toString(), + to: rollupAddress.toString(), + callType: 'call', + input: proposeCalldata, + value: '0x0', + gas: '0x5208', + }, + result: { output: '0x', gasUsed: '0x5208' }, + subtraces: 0, + traceAddress: [], + }, + ]); + + await expect(retriever.getCheckpointFromRollupTx(txHash, [], checkpointNumber, wrongHashes)).rejects.toThrow( + /hash mismatch/i, + ); + }); }); }); diff --git a/yarn-project/archiver/src/l1/calldata_retriever.ts b/yarn-project/archiver/src/l1/calldata_retriever.ts index f023bfbac865..e21f499b9e90 100644 --- a/yarn-project/archiver/src/l1/calldata_retriever.ts +++ b/yarn-project/archiver/src/l1/calldata_retriever.ts @@ -3,15 +3,8 @@ import type { ViemPublicClient, ViemPublicDebugClient } from '@aztec/ethereum/ty import { CheckpointNumber } from '@aztec/foundation/branded-types'; import { Fr } from '@aztec/foundation/curves/bn254'; import { EthAddress } from '@aztec/foundation/eth-address'; -import type { ViemSignature } from '@aztec/foundation/eth-signature'; import type { Logger } from '@aztec/foundation/log'; -import { - EmpireSlashingProposerAbi, - GovernanceProposerAbi, - RollupAbi, - SlashFactoryAbi, - TallySlashingProposerAbi, -} from '@aztec/l1-artifacts'; +import { RollupAbi } from '@aztec/l1-artifacts'; import { CommitteeAttestation } from '@aztec/stdlib/block'; import { ConsensusPayload, SignatureDomainSeparator } from '@aztec/stdlib/p2p'; import { CheckpointHeader } from '@aztec/stdlib/rollup'; @@ -30,13 +23,24 @@ import { import type { ArchiverInstrumentation } from '../modules/instrumentation.js'; import { getSuccessfulCallsFromDebug } from './debug_tx.js'; -import { getCallFromSpireProposer } from './spire_proposer.js'; +import { getCallsFromSpireProposer } from './spire_proposer.js'; import { getSuccessfulCallsFromTrace } from './trace_tx.js'; import type { CallInfo } from './types.js'; +/** Decoded checkpoint data from a propose calldata. */ +type CheckpointData = { + checkpointNumber: CheckpointNumber; + archiveRoot: Fr; + header: CheckpointHeader; + attestations: CommitteeAttestation[]; + blockHash: string; + feeAssetPriceModifier: bigint; +}; + /** * Extracts calldata to the `propose` method of the rollup contract from an L1 transaction - * in order to reconstruct an L2 block header. + * in order to reconstruct an L2 block header. Uses hash matching against expected hashes + * from the CheckpointProposed event to verify the correct propose calldata. */ export class CalldataRetriever { /** Tx hashes we've already logged for trace+debug failure (log once per tx per process). */ @@ -47,27 +51,14 @@ export class CalldataRetriever { CalldataRetriever.traceFailureWarnedTxHashes.clear(); } - /** Pre-computed valid contract calls for validation */ - private readonly validContractCalls: ValidContractCall[]; - - private readonly rollupAddress: EthAddress; - constructor( private readonly publicClient: ViemPublicClient, private readonly debugClient: ViemPublicDebugClient, private readonly targetCommitteeSize: number, private readonly instrumentation: ArchiverInstrumentation | undefined, private readonly logger: Logger, - contractAddresses: { - rollupAddress: EthAddress; - governanceProposerAddress: EthAddress; - slashingProposerAddress: EthAddress; - slashFactoryAddress?: EthAddress; - }, - ) { - this.rollupAddress = contractAddresses.rollupAddress; - this.validContractCalls = computeValidContractCalls(contractAddresses); - } + private readonly rollupAddress: EthAddress, + ) {} /** * Gets checkpoint header and metadata from the calldata of an L1 transaction. @@ -75,7 +66,7 @@ export class CalldataRetriever { * @param txHash - Hash of the tx that published it. * @param blobHashes - Blob hashes for the checkpoint. * @param checkpointNumber - Checkpoint number. - * @param expectedHashes - Optional expected hashes from the CheckpointProposed event for validation + * @param expectedHashes - Expected hashes from the CheckpointProposed event for validation * @returns Checkpoint header and metadata from the calldata, deserialized */ async getCheckpointFromRollupTx( @@ -83,51 +74,43 @@ export class CalldataRetriever { _blobHashes: Buffer[], checkpointNumber: CheckpointNumber, expectedHashes: { - attestationsHash?: Hex; - payloadDigest?: Hex; + attestationsHash: Hex; + payloadDigest: Hex; }, - ): Promise<{ - checkpointNumber: CheckpointNumber; - archiveRoot: Fr; - header: CheckpointHeader; - attestations: CommitteeAttestation[]; - blockHash: string; - feeAssetPriceModifier: bigint; - }> { - this.logger.trace(`Fetching checkpoint ${checkpointNumber} from rollup tx ${txHash}`, { - willValidateHashes: !!expectedHashes.attestationsHash || !!expectedHashes.payloadDigest, - hasAttestationsHash: !!expectedHashes.attestationsHash, - hasPayloadDigest: !!expectedHashes.payloadDigest, - }); + ): Promise { + this.logger.trace(`Fetching checkpoint ${checkpointNumber} from rollup tx ${txHash}`); const tx = await this.publicClient.getTransaction({ hash: txHash }); - const proposeCalldata = await this.getProposeCallData(tx, checkpointNumber); - return this.decodeAndBuildCheckpoint(proposeCalldata, tx.blockHash!, checkpointNumber, expectedHashes); + return this.getCheckpointFromTx(tx, checkpointNumber, expectedHashes); } - /** Gets rollup propose calldata from a transaction */ - protected async getProposeCallData(tx: Transaction, checkpointNumber: CheckpointNumber): Promise { - // Try to decode as multicall3 with validation - const proposeCalldata = this.tryDecodeMulticall3(tx); - if (proposeCalldata) { + /** Gets checkpoint data from a transaction by trying decode strategies then falling back to trace. */ + protected async getCheckpointFromTx( + tx: Transaction, + checkpointNumber: CheckpointNumber, + expectedHashes: { attestationsHash: Hex; payloadDigest: Hex }, + ): Promise { + // Try to decode as multicall3 with hash-verified matching + const multicall3Result = this.tryDecodeMulticall3(tx, expectedHashes, checkpointNumber, tx.blockHash!); + if (multicall3Result) { this.logger.trace(`Decoded propose calldata from multicall3 for tx ${tx.hash}`); this.instrumentation?.recordBlockProposalTxTarget(tx.to!, false); - return proposeCalldata; + return multicall3Result; } // Try to decode as direct propose call - const directProposeCalldata = this.tryDecodeDirectPropose(tx); - if (directProposeCalldata) { + const directResult = this.tryDecodeDirectPropose(tx, expectedHashes, checkpointNumber, tx.blockHash!); + if (directResult) { this.logger.trace(`Decoded propose calldata from direct call for tx ${tx.hash}`); this.instrumentation?.recordBlockProposalTxTarget(tx.to!, false); - return directProposeCalldata; + return directResult; } // Try to decode as Spire Proposer multicall wrapper - const spireProposeCalldata = await this.tryDecodeSpireProposer(tx); - if (spireProposeCalldata) { + const spireResult = await this.tryDecodeSpireProposer(tx, expectedHashes, checkpointNumber, tx.blockHash!); + if (spireResult) { this.logger.trace(`Decoded propose calldata from Spire Proposer for tx ${tx.hash}`); this.instrumentation?.recordBlockProposalTxTarget(tx.to!, false); - return spireProposeCalldata; + return spireResult; } // Fall back to trace-based extraction @@ -135,52 +118,82 @@ export class CalldataRetriever { `Failed to decode multicall3, direct propose, or Spire proposer for L1 tx ${tx.hash}, falling back to trace for checkpoint ${checkpointNumber}`, ); this.instrumentation?.recordBlockProposalTxTarget(tx.to ?? EthAddress.ZERO.toString(), true); - return await this.extractCalldataViaTrace(tx.hash); + const tracedCalldata = await this.extractCalldataViaTrace(tx.hash); + const tracedResult = this.tryDecodeAndVerifyPropose( + tracedCalldata, + expectedHashes, + checkpointNumber, + tx.blockHash!, + ); + if (!tracedResult) { + throw new Error(`Hash mismatch for traced propose calldata in tx ${tx.hash} for checkpoint ${checkpointNumber}`); + } + return tracedResult; } /** * Attempts to decode a transaction as a Spire Proposer multicall wrapper. - * If successful, extracts the wrapped call and validates it as either multicall3 or direct propose. + * If successful, iterates all wrapped calls and validates each as either multicall3 + * or direct propose, verifying against expected hashes. * @param tx - The transaction to decode - * @returns The propose calldata if successfully decoded and validated, undefined otherwise + * @param expectedHashes - Expected hashes for hash-verified matching + * @param checkpointNumber - The checkpoint number + * @param blockHash - The L1 block hash + * @returns The checkpoint data if successfully decoded and validated, undefined otherwise */ - protected async tryDecodeSpireProposer(tx: Transaction): Promise { - // Try to decode as Spire Proposer multicall (extracts the wrapped call) - const spireWrappedCall = await getCallFromSpireProposer(tx, this.publicClient, this.logger); - if (!spireWrappedCall) { + protected async tryDecodeSpireProposer( + tx: Transaction, + expectedHashes: { attestationsHash: Hex; payloadDigest: Hex }, + checkpointNumber: CheckpointNumber, + blockHash: Hex, + ): Promise { + // Try to decode as Spire Proposer multicall (extracts all wrapped calls) + const spireWrappedCalls = await getCallsFromSpireProposer(tx, this.publicClient, this.logger); + if (!spireWrappedCalls) { return undefined; } - this.logger.trace(`Decoded Spire Proposer wrapping for tx ${tx.hash}, inner call to ${spireWrappedCall.to}`); + this.logger.trace(`Decoded Spire Proposer wrapping for tx ${tx.hash}, ${spireWrappedCalls.length} inner call(s)`); - // Now try to decode the wrapped call as either multicall3 or direct propose - const wrappedTx = { to: spireWrappedCall.to, input: spireWrappedCall.data, hash: tx.hash }; + // Try each wrapped call as either multicall3 or direct propose + for (const spireWrappedCall of spireWrappedCalls) { + const wrappedTx = { to: spireWrappedCall.to, input: spireWrappedCall.data, hash: tx.hash }; - const multicall3Calldata = this.tryDecodeMulticall3(wrappedTx); - if (multicall3Calldata) { - this.logger.trace(`Decoded propose calldata from Spire Proposer to multicall3 for tx ${tx.hash}`); - return multicall3Calldata; - } + const multicall3Result = this.tryDecodeMulticall3(wrappedTx, expectedHashes, checkpointNumber, blockHash); + if (multicall3Result) { + this.logger.trace(`Decoded propose calldata from Spire Proposer to multicall3 for tx ${tx.hash}`); + return multicall3Result; + } - const directProposeCalldata = this.tryDecodeDirectPropose(wrappedTx); - if (directProposeCalldata) { - this.logger.trace(`Decoded propose calldata from Spire Proposer to direct propose for tx ${tx.hash}`); - return directProposeCalldata; + const directResult = this.tryDecodeDirectPropose(wrappedTx, expectedHashes, checkpointNumber, blockHash); + if (directResult) { + this.logger.trace(`Decoded propose calldata from Spire Proposer to direct propose for tx ${tx.hash}`); + return directResult; + } } this.logger.warn( - `Spire Proposer wrapped call could not be decoded as multicall3 or direct propose for tx ${tx.hash}`, + `Spire Proposer wrapped calls could not be decoded as multicall3 or direct propose for tx ${tx.hash}`, ); return undefined; } /** * Attempts to decode transaction input as multicall3 and extract propose calldata. - * Returns undefined if validation fails. + * Finds all calls matching the rollup address and propose selector, then decodes + * and verifies each candidate against expected hashes from the CheckpointProposed event. * @param tx - The transaction-like object with to, input, and hash - * @returns The propose calldata if successfully validated, undefined otherwise + * @param expectedHashes - Expected hashes from CheckpointProposed event + * @param checkpointNumber - The checkpoint number + * @param blockHash - The L1 block hash + * @returns The checkpoint data if successfully validated, undefined otherwise */ - protected tryDecodeMulticall3(tx: { to: Hex | null | undefined; input: Hex; hash: Hex }): Hex | undefined { + protected tryDecodeMulticall3( + tx: { to: Hex | null | undefined; input: Hex; hash: Hex }, + expectedHashes: { attestationsHash: Hex; payloadDigest: Hex }, + checkpointNumber: CheckpointNumber, + blockHash: Hex, + ): CheckpointData | undefined { const txHash = tx.hash; try { @@ -209,59 +222,54 @@ export class CalldataRetriever { const [calls] = multicall3Args; - // Validate all calls and find propose calls + // Find all calls matching rollup address + propose selector const rollupAddressLower = this.rollupAddress.toString().toLowerCase(); - const proposeCalls: Hex[] = []; + const proposeSelectorLower = PROPOSE_SELECTOR.toLowerCase(); + const candidates: Hex[] = []; - for (let i = 0; i < calls.length; i++) { - const addr = calls[i].target.toLowerCase(); - const callData = calls[i].callData; + for (const call of calls) { + const addr = call.target.toLowerCase(); + const callData = call.callData; - // Extract function selector (first 4 bytes) if (callData.length < 10) { - // "0x" + 8 hex chars = 10 chars minimum for a valid function call - this.logger.warn(`Invalid calldata length at index ${i} (${callData.length})`, { txHash }); - return undefined; + continue; } - const functionSelector = callData.slice(0, 10) as Hex; - - // Validate this call is allowed by searching through valid calls - const validCall = this.validContractCalls.find( - vc => vc.address === addr && vc.functionSelector === functionSelector, - ); - if (!validCall) { - this.logger.warn(`Invalid contract call detected in multicall3`, { - index: i, - targetAddress: addr, - functionSelector, - validCalls: this.validContractCalls.map(c => ({ address: c.address, selector: c.functionSelector })), - txHash, - }); - return undefined; + const selector = callData.slice(0, 10).toLowerCase(); + if (addr === rollupAddressLower && selector === proposeSelectorLower) { + candidates.push(callData); } + } - this.logger.trace(`Valid call found to ${addr}`, { validCall }); + if (candidates.length === 0) { + this.logger.debug(`No propose candidates found in multicall3`, { txHash }); + return undefined; + } - // Collect propose calls specifically - if (addr === rollupAddressLower && validCall.functionName === 'propose') { - proposeCalls.push(callData); + // Decode, verify, and build for each candidate + const verified: CheckpointData[] = []; + for (const candidate of candidates) { + const result = this.tryDecodeAndVerifyPropose(candidate, expectedHashes, checkpointNumber, blockHash); + if (result) { + verified.push(result); } } - // Validate exactly ONE propose call - if (proposeCalls.length === 0) { - this.logger.warn(`No propose calls found in multicall3`, { txHash }); - return undefined; + if (verified.length === 1) { + this.logger.trace(`Verified single propose candidate via hash matching`, { txHash }); + return verified[0]; } - if (proposeCalls.length > 1) { - this.logger.warn(`Multiple propose calls found in multicall3 (${proposeCalls.length})`, { txHash }); - return undefined; + if (verified.length > 1) { + this.logger.warn( + `Multiple propose candidates verified (${verified.length}), returning first (identical data)`, + { txHash }, + ); + return verified[0]; } - // Successfully extracted single propose call - return proposeCalls[0]; + this.logger.debug(`No candidates verified against expected hashes`, { txHash }); + return undefined; } catch (err) { // Any decoding error triggers fallback to trace this.logger.warn(`Failed to decode multicall3: ${err}`, { txHash }); @@ -271,11 +279,19 @@ export class CalldataRetriever { /** * Attempts to decode transaction as a direct propose call to the rollup contract. - * Returns undefined if validation fails. + * Decodes, verifies hashes, and builds checkpoint data in a single pass. * @param tx - The transaction-like object with to, input, and hash - * @returns The propose calldata if successfully validated, undefined otherwise + * @param expectedHashes - Expected hashes from CheckpointProposed event + * @param checkpointNumber - The checkpoint number + * @param blockHash - The L1 block hash + * @returns The checkpoint data if successfully validated, undefined otherwise */ - protected tryDecodeDirectPropose(tx: { to: Hex | null | undefined; input: Hex; hash: Hex }): Hex | undefined { + protected tryDecodeDirectPropose( + tx: { to: Hex | null | undefined; input: Hex; hash: Hex }, + expectedHashes: { attestationsHash: Hex; payloadDigest: Hex }, + checkpointNumber: CheckpointNumber, + blockHash: Hex, + ): CheckpointData | undefined { const txHash = tx.hash; try { // Check if transaction is to the rollup address @@ -284,18 +300,16 @@ export class CalldataRetriever { return undefined; } - // Try to decode as propose call + // Validate it's a propose call before full decode+verify const { functionName } = decodeFunctionData({ abi: RollupAbi, data: tx.input }); - - // If not propose, return undefined if (functionName !== 'propose') { this.logger.warn(`Transaction to rollup is not propose (got ${functionName})`, { txHash }); return undefined; } - // Successfully validated direct propose call + // Decode, verify hashes, and build checkpoint data this.logger.trace(`Validated direct propose call to rollup`, { txHash }); - return tx.input; + return this.tryDecodeAndVerifyPropose(tx.input, expectedHashes, checkpointNumber, blockHash); } catch (err) { // Any decoding error means it's not a valid propose call this.logger.warn(`Failed to decode as direct propose: ${err}`, { txHash }); @@ -363,10 +377,102 @@ export class CalldataRetriever { return calls[0].input; } + /** + * Decodes propose calldata, verifies against expected hashes, and builds checkpoint data. + * Returns undefined on decode errors or hash mismatches (soft failure for try-based callers). + * @param proposeCalldata - The propose function calldata + * @param expectedHashes - Expected hashes from the CheckpointProposed event + * @param checkpointNumber - The checkpoint number + * @param blockHash - The L1 block hash + * @returns The decoded checkpoint data, or undefined on failure + */ + protected tryDecodeAndVerifyPropose( + proposeCalldata: Hex, + expectedHashes: { attestationsHash: Hex; payloadDigest: Hex }, + checkpointNumber: CheckpointNumber, + blockHash: Hex, + ): CheckpointData | undefined { + try { + const { functionName, args } = decodeFunctionData({ abi: RollupAbi, data: proposeCalldata }); + if (functionName !== 'propose') { + return undefined; + } + + const [decodedArgs, packedAttestations] = args! as readonly [ + { archive: Hex; oracleInput: { feeAssetPriceModifier: bigint }; header: ViemHeader }, + ViemCommitteeAttestations, + ...unknown[], + ]; + + // Verify attestationsHash + const computedAttestationsHash = this.computeAttestationsHash(packedAttestations); + if ( + !Buffer.from(hexToBytes(computedAttestationsHash)).equals( + Buffer.from(hexToBytes(expectedHashes.attestationsHash)), + ) + ) { + this.logger.warn(`Attestations hash mismatch during verification`, { + computed: computedAttestationsHash, + expected: expectedHashes.attestationsHash, + }); + return undefined; + } + + // Verify payloadDigest + const header = CheckpointHeader.fromViem(decodedArgs.header); + const archiveRoot = new Fr(Buffer.from(hexToBytes(decodedArgs.archive))); + const feeAssetPriceModifier = decodedArgs.oracleInput.feeAssetPriceModifier; + const computedPayloadDigest = this.computePayloadDigest(header, archiveRoot, feeAssetPriceModifier); + if ( + !Buffer.from(hexToBytes(computedPayloadDigest)).equals(Buffer.from(hexToBytes(expectedHashes.payloadDigest))) + ) { + this.logger.warn(`Payload digest mismatch during verification`, { + computed: computedPayloadDigest, + expected: expectedHashes.payloadDigest, + }); + return undefined; + } + + const attestations = CommitteeAttestation.fromPacked(packedAttestations, this.targetCommitteeSize); + + this.logger.trace(`Validated and decoded propose calldata for checkpoint ${checkpointNumber}`, { + checkpointNumber, + archive: decodedArgs.archive, + header: decodedArgs.header, + l1BlockHash: blockHash, + attestations, + packedAttestations, + targetCommitteeSize: this.targetCommitteeSize, + }); + + return { + checkpointNumber, + archiveRoot, + header, + attestations, + blockHash, + feeAssetPriceModifier, + }; + } catch { + return undefined; + } + } + + /** Computes the keccak256 hash of ABI-encoded CommitteeAttestations. */ + private computeAttestationsHash(packedAttestations: ViemCommitteeAttestations): Hex { + return keccak256(encodeAbiParameters([this.getCommitteeAttestationsStructDef()], [packedAttestations])); + } + + /** Computes the keccak256 payload digest from the checkpoint header, archive root, and fee asset price modifier. */ + private computePayloadDigest(header: CheckpointHeader, archiveRoot: Fr, feeAssetPriceModifier: bigint): Hex { + const consensusPayload = new ConsensusPayload(header, archiveRoot, feeAssetPriceModifier); + const payloadToSign = consensusPayload.getPayloadToSign(SignatureDomainSeparator.checkpointAttestation); + return keccak256(payloadToSign); + } + /** * Extracts the CommitteeAttestations struct definition from RollupAbi. * Finds the _attestations parameter by name in the propose function. - * Lazy-loaded to avoid issues during module initialization. */ private getCommitteeAttestationsStructDef(): AbiParameter { const proposeFunction = RollupAbi.find(item => item.type === 'function' && item.name === 'propose') as @@ -399,265 +505,7 @@ export class CalldataRetriever { components: tupleParam.components || [], } as AbiParameter; } - - /** - * Decodes propose calldata and builds the checkpoint header structure. - * @param proposeCalldata - The propose function calldata - * @param blockHash - The L1 block hash containing this transaction - * @param checkpointNumber - The checkpoint number - * @param expectedHashes - Optional expected hashes from the CheckpointProposed event for validation - * @returns The decoded checkpoint header and metadata - */ - protected decodeAndBuildCheckpoint( - proposeCalldata: Hex, - blockHash: Hex, - checkpointNumber: CheckpointNumber, - expectedHashes: { - attestationsHash?: Hex; - payloadDigest?: Hex; - }, - ): { - checkpointNumber: CheckpointNumber; - archiveRoot: Fr; - header: CheckpointHeader; - attestations: CommitteeAttestation[]; - blockHash: string; - feeAssetPriceModifier: bigint; - } { - const { functionName: rollupFunctionName, args: rollupArgs } = decodeFunctionData({ - abi: RollupAbi, - data: proposeCalldata, - }); - - if (rollupFunctionName !== 'propose') { - throw new Error(`Unexpected rollup method called ${rollupFunctionName}`); - } - - const [decodedArgs, packedAttestations, _signers, _attestationsAndSignersSignature, _blobInput] = - rollupArgs! as readonly [ - { - archive: Hex; - oracleInput: { feeAssetPriceModifier: bigint }; - header: ViemHeader; - }, - ViemCommitteeAttestations, - Hex[], - ViemSignature, - Hex, - ]; - - const attestations = CommitteeAttestation.fromPacked(packedAttestations, this.targetCommitteeSize); - const header = CheckpointHeader.fromViem(decodedArgs.header); - const archiveRoot = new Fr(Buffer.from(hexToBytes(decodedArgs.archive))); - - // Validate attestationsHash if provided (skip for backwards compatibility with older events) - if (expectedHashes.attestationsHash) { - // Compute attestationsHash: keccak256(abi.encode(CommitteeAttestations)) - const computedAttestationsHash = keccak256( - encodeAbiParameters([this.getCommitteeAttestationsStructDef()], [packedAttestations]), - ); - - // Compare as buffers to avoid case-sensitivity and string comparison issues - const computedBuffer = Buffer.from(hexToBytes(computedAttestationsHash)); - const expectedBuffer = Buffer.from(hexToBytes(expectedHashes.attestationsHash)); - - if (!computedBuffer.equals(expectedBuffer)) { - throw new Error( - `Attestations hash mismatch for checkpoint ${checkpointNumber}: ` + - `computed=${computedAttestationsHash}, expected=${expectedHashes.attestationsHash}`, - ); - } - - this.logger.trace(`Validated attestationsHash for checkpoint ${checkpointNumber}`, { - computedAttestationsHash, - expectedAttestationsHash: expectedHashes.attestationsHash, - }); - } - - // Validate payloadDigest if provided (skip for backwards compatibility with older events) - if (expectedHashes.payloadDigest) { - // Use ConsensusPayload to compute the digest - this ensures we match the exact logic - // used by the network for signing and verification - const feeAssetPriceModifier = decodedArgs.oracleInput.feeAssetPriceModifier; - const consensusPayload = new ConsensusPayload(header, archiveRoot, feeAssetPriceModifier); - const payloadToSign = consensusPayload.getPayloadToSign(SignatureDomainSeparator.checkpointAttestation); - const computedPayloadDigest = keccak256(payloadToSign); - - // Compare as buffers to avoid case-sensitivity and string comparison issues - const computedBuffer = Buffer.from(hexToBytes(computedPayloadDigest)); - const expectedBuffer = Buffer.from(hexToBytes(expectedHashes.payloadDigest)); - - if (!computedBuffer.equals(expectedBuffer)) { - throw new Error( - `Payload digest mismatch for checkpoint ${checkpointNumber}: ` + - `computed=${computedPayloadDigest}, expected=${expectedHashes.payloadDigest}`, - ); - } - - this.logger.trace(`Validated payloadDigest for checkpoint ${checkpointNumber}`, { - computedPayloadDigest, - expectedPayloadDigest: expectedHashes.payloadDigest, - }); - } - - this.logger.trace(`Decoded propose calldata`, { - checkpointNumber, - archive: decodedArgs.archive, - header: decodedArgs.header, - l1BlockHash: blockHash, - attestations, - packedAttestations, - targetCommitteeSize: this.targetCommitteeSize, - }); - - return { - checkpointNumber, - archiveRoot, - header, - attestations, - blockHash, - feeAssetPriceModifier: decodedArgs.oracleInput.feeAssetPriceModifier, - }; - } } -/** - * Pre-computed function selectors for all valid contract calls. - * These are computed once at module load time from the ABIs. - * Based on analysis of sequencer-client/src/publisher/sequencer-publisher.ts - */ - -// Rollup contract function selectors (always valid) +/** Function selector for the `propose` method of the rollup contract. */ const PROPOSE_SELECTOR = toFunctionSelector(RollupAbi.find(x => x.type === 'function' && x.name === 'propose')!); -const INVALIDATE_BAD_ATTESTATION_SELECTOR = toFunctionSelector( - RollupAbi.find(x => x.type === 'function' && x.name === 'invalidateBadAttestation')!, -); -const INVALIDATE_INSUFFICIENT_ATTESTATIONS_SELECTOR = toFunctionSelector( - RollupAbi.find(x => x.type === 'function' && x.name === 'invalidateInsufficientAttestations')!, -); - -// Governance proposer function selectors -const GOVERNANCE_SIGNAL_WITH_SIG_SELECTOR = toFunctionSelector( - GovernanceProposerAbi.find(x => x.type === 'function' && x.name === 'signalWithSig')!, -); - -// Slash factory function selectors -const CREATE_SLASH_PAYLOAD_SELECTOR = toFunctionSelector( - SlashFactoryAbi.find(x => x.type === 'function' && x.name === 'createSlashPayload')!, -); - -// Empire slashing proposer function selectors -const EMPIRE_SIGNAL_WITH_SIG_SELECTOR = toFunctionSelector( - EmpireSlashingProposerAbi.find(x => x.type === 'function' && x.name === 'signalWithSig')!, -); -const EMPIRE_SUBMIT_ROUND_WINNER_SELECTOR = toFunctionSelector( - EmpireSlashingProposerAbi.find(x => x.type === 'function' && x.name === 'submitRoundWinner')!, -); - -// Tally slashing proposer function selectors -const TALLY_VOTE_SELECTOR = toFunctionSelector( - TallySlashingProposerAbi.find(x => x.type === 'function' && x.name === 'vote')!, -); -const TALLY_EXECUTE_ROUND_SELECTOR = toFunctionSelector( - TallySlashingProposerAbi.find(x => x.type === 'function' && x.name === 'executeRound')!, -); - -/** - * Defines a valid contract call that can appear in a sequencer publisher transaction - */ -interface ValidContractCall { - /** Contract address (lowercase for comparison) */ - address: string; - /** Function selector (4 bytes) */ - functionSelector: Hex; - /** Human-readable function name for logging */ - functionName: string; -} - -/** - * All valid contract calls that the sequencer publisher can make. - * Builds the list of valid (address, selector) pairs for validation. - * - * Alternatively, if we are absolutely sure that no code path from any of these - * contracts can eventually land on another call to `propose`, we can remove the - * function selectors. - */ -function computeValidContractCalls(addresses: { - rollupAddress: EthAddress; - governanceProposerAddress?: EthAddress; - slashFactoryAddress?: EthAddress; - slashingProposerAddress?: EthAddress; -}): ValidContractCall[] { - const { rollupAddress, governanceProposerAddress, slashFactoryAddress, slashingProposerAddress } = addresses; - const calls: ValidContractCall[] = []; - - // Rollup contract calls (always present) - calls.push( - { - address: rollupAddress.toString().toLowerCase(), - functionSelector: PROPOSE_SELECTOR, - functionName: 'propose', - }, - { - address: rollupAddress.toString().toLowerCase(), - functionSelector: INVALIDATE_BAD_ATTESTATION_SELECTOR, - functionName: 'invalidateBadAttestation', - }, - { - address: rollupAddress.toString().toLowerCase(), - functionSelector: INVALIDATE_INSUFFICIENT_ATTESTATIONS_SELECTOR, - functionName: 'invalidateInsufficientAttestations', - }, - ); - - // Governance proposer calls (optional) - if (governanceProposerAddress && !governanceProposerAddress.isZero()) { - calls.push({ - address: governanceProposerAddress.toString().toLowerCase(), - functionSelector: GOVERNANCE_SIGNAL_WITH_SIG_SELECTOR, - functionName: 'signalWithSig', - }); - } - - // Slash factory calls (optional) - if (slashFactoryAddress && !slashFactoryAddress.isZero()) { - calls.push({ - address: slashFactoryAddress.toString().toLowerCase(), - functionSelector: CREATE_SLASH_PAYLOAD_SELECTOR, - functionName: 'createSlashPayload', - }); - } - - // Slashing proposer calls (optional, can be either Empire or Tally) - if (slashingProposerAddress && !slashingProposerAddress.isZero()) { - // Empire calls - calls.push( - { - address: slashingProposerAddress.toString().toLowerCase(), - functionSelector: EMPIRE_SIGNAL_WITH_SIG_SELECTOR, - functionName: 'signalWithSig (empire)', - }, - { - address: slashingProposerAddress.toString().toLowerCase(), - functionSelector: EMPIRE_SUBMIT_ROUND_WINNER_SELECTOR, - functionName: 'submitRoundWinner', - }, - ); - - // Tally calls - calls.push( - { - address: slashingProposerAddress.toString().toLowerCase(), - functionSelector: TALLY_VOTE_SELECTOR, - functionName: 'vote', - }, - { - address: slashingProposerAddress.toString().toLowerCase(), - functionSelector: TALLY_EXECUTE_ROUND_SELECTOR, - functionName: 'executeRound', - }, - ); - } - - return calls; -} diff --git a/yarn-project/archiver/src/l1/data_retrieval.ts b/yarn-project/archiver/src/l1/data_retrieval.ts index 54b6dd62207d..4f5a529f1aae 100644 --- a/yarn-project/archiver/src/l1/data_retrieval.ts +++ b/yarn-project/archiver/src/l1/data_retrieval.ts @@ -157,11 +157,6 @@ export async function retrieveCheckpointsFromRollup( blobClient: BlobClientInterface, searchStartBlock: bigint, searchEndBlock: bigint, - contractAddresses: { - governanceProposerAddress: EthAddress; - slashFactoryAddress?: EthAddress; - slashingProposerAddress: EthAddress; - }, instrumentation: ArchiverInstrumentation, logger: Logger = createLogger('archiver'), isHistoricalSync: boolean = false, @@ -205,7 +200,6 @@ export async function retrieveCheckpointsFromRollup( blobClient, checkpointProposedLogs, rollupConstants, - contractAddresses, instrumentation, logger, isHistoricalSync, @@ -226,7 +220,6 @@ export async function retrieveCheckpointsFromRollup( * @param blobClient - The blob client client for fetching blob data. * @param logs - CheckpointProposed logs. * @param rollupConstants - The rollup constants (chainId, version, targetCommitteeSize). - * @param contractAddresses - The contract addresses (governanceProposerAddress, slashFactoryAddress, slashingProposerAddress). * @param instrumentation - The archiver instrumentation instance. * @param logger - The logger instance. * @param isHistoricalSync - Whether this is a historical sync. @@ -239,11 +232,6 @@ async function processCheckpointProposedLogs( blobClient: BlobClientInterface, logs: CheckpointProposedLog[], { chainId, version, targetCommitteeSize }: { chainId: Fr; version: Fr; targetCommitteeSize: number }, - contractAddresses: { - governanceProposerAddress: EthAddress; - slashFactoryAddress?: EthAddress; - slashingProposerAddress: EthAddress; - }, instrumentation: ArchiverInstrumentation, logger: Logger, isHistoricalSync: boolean, @@ -255,7 +243,7 @@ async function processCheckpointProposedLogs( targetCommitteeSize, instrumentation, logger, - { ...contractAddresses, rollupAddress: EthAddress.fromString(rollup.address) }, + EthAddress.fromString(rollup.address), ); await asyncPool(10, logs, async log => { @@ -266,10 +254,9 @@ async function processCheckpointProposedLogs( // The value from the event and contract will match only if the checkpoint is in the chain. if (archive.equals(archiveFromChain)) { - // Build expected hashes object (fields may be undefined for backwards compatibility with older events) const expectedHashes = { - attestationsHash: log.args.attestationsHash?.toString(), - payloadDigest: log.args.payloadDigest?.toString(), + attestationsHash: log.args.attestationsHash.toString() as Hex, + payloadDigest: log.args.payloadDigest.toString() as Hex, }; const checkpoint = await calldataRetriever.getCheckpointFromRollupTx( diff --git a/yarn-project/archiver/src/l1/spire_proposer.test.ts b/yarn-project/archiver/src/l1/spire_proposer.test.ts index ed9148a950ee..3d1c85056222 100644 --- a/yarn-project/archiver/src/l1/spire_proposer.test.ts +++ b/yarn-project/archiver/src/l1/spire_proposer.test.ts @@ -9,7 +9,7 @@ import { SPIRE_PROPOSER_ADDRESS, SPIRE_PROPOSER_EXPECTED_IMPLEMENTATION, SpireProposerAbi, - getCallFromSpireProposer, + getCallsFromSpireProposer, verifyProxyImplementation, } from './spire_proposer.js'; @@ -102,21 +102,19 @@ describe('Spire Proposer', () => { }); }); - describe('getCallFromSpireProposer', () => { - function makeSpireProposerMulticallTransaction(call: { target: Hex; data: Hex }): Transaction { + describe('getCallsFromSpireProposer', () => { + function makeSpireProposerMulticallTransaction(...calls: { target: Hex; data: Hex }[]): Transaction { const spireMulticallData = encodeFunctionData({ abi: SpireProposerAbi, functionName: 'multicall', args: [ - [ - { - proposer: EthAddress.random().toString() as Hex, - target: call.target, - data: call.data, - value: 0n, - gasLimit: 1000000n, - }, - ], + calls.map(call => ({ + proposer: EthAddress.random().toString() as Hex, + target: call.target, + data: call.data, + value: 0n, + gasLimit: 1000000n, + })), ], }); @@ -143,11 +141,12 @@ describe('Spire Proposer', () => { data: calldata, }); - const result = await getCallFromSpireProposer(tx, publicClient, logger); + const result = await getCallsFromSpireProposer(tx, publicClient, logger); expect(result).toBeDefined(); - expect(result?.to.toLowerCase()).toBe(targetAddress.toLowerCase()); - expect(result?.data).toBe(calldata); + expect(result).toHaveLength(1); + expect(result![0].to.toLowerCase()).toBe(targetAddress.toLowerCase()); + expect(result![0].data).toBe(calldata); expect(publicClient.getStorageAt).toHaveBeenCalledWith({ address: SPIRE_PROPOSER_ADDRESS, slot: EIP1967_IMPLEMENTATION_SLOT, @@ -161,11 +160,12 @@ describe('Spire Proposer', () => { data: '0xabcdef' as Hex, }); - const result = await getCallFromSpireProposer(tx, publicClient, logger); + const result = await getCallsFromSpireProposer(tx, publicClient, logger); expect(result).toBeDefined(); - expect(result?.to.toLowerCase()).toBe(unknownAddress.toLowerCase()); - expect(result?.data).toBe('0xabcdef'); + expect(result).toHaveLength(1); + expect(result![0].to.toLowerCase()).toBe(unknownAddress.toLowerCase()); + expect(result![0].data).toBe('0xabcdef'); }); it('should preserve exact calldata bytes', async () => { @@ -176,10 +176,11 @@ describe('Spire Proposer', () => { data: complexCalldata, }); - const result = await getCallFromSpireProposer(tx, publicClient, logger); + const result = await getCallsFromSpireProposer(tx, publicClient, logger); expect(result).toBeDefined(); - expect(result?.data).toBe(complexCalldata); + expect(result).toHaveLength(1); + expect(result![0].data).toBe(complexCalldata); }); }); @@ -191,7 +192,7 @@ describe('Spire Proposer', () => { hash: txHash, } as Transaction; - const result = await getCallFromSpireProposer(tx, publicClient, logger); + const result = await getCallsFromSpireProposer(tx, publicClient, logger); expect(result).toBeUndefined(); expect(publicClient.getStorageAt).not.toHaveBeenCalled(); @@ -204,7 +205,7 @@ describe('Spire Proposer', () => { hash: txHash, } as Transaction; - const result = await getCallFromSpireProposer(tx, publicClient, logger); + const result = await getCallsFromSpireProposer(tx, publicClient, logger); expect(result).toBeUndefined(); expect(publicClient.getStorageAt).not.toHaveBeenCalled(); @@ -217,7 +218,7 @@ describe('Spire Proposer', () => { hash: txHash, } as unknown as Transaction; - const result = await getCallFromSpireProposer(tx, publicClient, logger); + const result = await getCallsFromSpireProposer(tx, publicClient, logger); expect(result).toBeUndefined(); }); @@ -231,7 +232,7 @@ describe('Spire Proposer', () => { // Mock the proxy pointing to wrong implementation publicClient.getStorageAt.mockResolvedValue('0x00000000000000000000000000000000000000000000000000bad' as Hex); - const result = await getCallFromSpireProposer(tx, publicClient, logger); + const result = await getCallsFromSpireProposer(tx, publicClient, logger); expect(result).toBeUndefined(); }); @@ -250,12 +251,12 @@ describe('Spire Proposer', () => { ('0x000000000000000000000000' + SPIRE_PROPOSER_EXPECTED_IMPLEMENTATION.slice(2)) as Hex, ); - const result = await getCallFromSpireProposer(tx, publicClient, logger); + const result = await getCallsFromSpireProposer(tx, publicClient, logger); expect(result).toBeUndefined(); }); - it('should return undefined when Spire Proposer contains zero calls', async () => { + it('should return empty array when Spire Proposer contains zero calls', async () => { const spireMulticallData = encodeFunctionData({ abi: SpireProposerAbi, functionName: 'multicall', @@ -272,48 +273,30 @@ describe('Spire Proposer', () => { ('0x000000000000000000000000' + SPIRE_PROPOSER_EXPECTED_IMPLEMENTATION.slice(2)) as Hex, ); - const result = await getCallFromSpireProposer(tx, publicClient, logger); + const result = await getCallsFromSpireProposer(tx, publicClient, logger); - expect(result).toBeUndefined(); + expect(result).toBeDefined(); + expect(result).toHaveLength(0); }); - it('should return undefined when Spire Proposer contains multiple calls', async () => { - const spireMulticallData = encodeFunctionData({ - abi: SpireProposerAbi, - functionName: 'multicall', - args: [ - [ - { - proposer: EthAddress.random().toString() as Hex, - target: EthAddress.random().toString() as Hex, - data: '0x12345678' as Hex, - value: 0n, - gasLimit: 1000000n, - }, - { - proposer: EthAddress.random().toString() as Hex, - target: EthAddress.random().toString() as Hex, - data: '0xabcdef' as Hex, - value: 0n, - gasLimit: 1000000n, - }, - ], - ], - }); - - const tx = { - input: spireMulticallData, - to: SPIRE_PROPOSER_ADDRESS as Hex, - hash: txHash, - } as Transaction; + it('should return all calls when Spire Proposer contains multiple calls', async () => { + const target1 = EthAddress.random().toString() as Hex; + const target2 = EthAddress.random().toString() as Hex; + const tx = makeSpireProposerMulticallTransaction( + { target: target1, data: '0x12345678' as Hex }, + { target: target2, data: '0xabcdef' as Hex }, + ); publicClient.getStorageAt.mockResolvedValue( ('0x000000000000000000000000' + SPIRE_PROPOSER_EXPECTED_IMPLEMENTATION.slice(2)) as Hex, ); - const result = await getCallFromSpireProposer(tx, publicClient, logger); + const result = await getCallsFromSpireProposer(tx, publicClient, logger); - expect(result).toBeUndefined(); + expect(result).toBeDefined(); + expect(result).toHaveLength(2); + expect(result![0].to.toLowerCase()).toBe(target1.toLowerCase()); + expect(result![1].to.toLowerCase()).toBe(target2.toLowerCase()); }); it('should return undefined when decoding throws exception', async () => { @@ -327,7 +310,7 @@ describe('Spire Proposer', () => { ('0x000000000000000000000000' + SPIRE_PROPOSER_EXPECTED_IMPLEMENTATION.slice(2)) as Hex, ); - const result = await getCallFromSpireProposer(tx, publicClient, logger); + const result = await getCallsFromSpireProposer(tx, publicClient, logger); expect(result).toBeUndefined(); }); @@ -366,11 +349,11 @@ describe('Spire Proposer', () => { hash: txHash, } as Transaction; - const result = await getCallFromSpireProposer(tx, publicClient, logger); + const result = await getCallsFromSpireProposer(tx, publicClient, logger); expect(result).toBeDefined(); - expect(result?.to.toLowerCase()).toBe(targetAddress.toLowerCase()); - expect(result?.data).toBe(calldata); + expect(result![0].to.toLowerCase()).toBe(targetAddress.toLowerCase()); + expect(result![0].data).toBe(calldata); }); }); }); diff --git a/yarn-project/archiver/src/l1/spire_proposer.ts b/yarn-project/archiver/src/l1/spire_proposer.ts index b328fa5a7fdb..3b7e64ebfd2a 100644 --- a/yarn-project/archiver/src/l1/spire_proposer.ts +++ b/yarn-project/archiver/src/l1/spire_proposer.ts @@ -87,17 +87,17 @@ export async function verifyProxyImplementation( /** * Attempts to decode transaction as a Spire Proposer Multicall. * Spire Proposer is a proxy contract that wraps multiple calls. - * Returns the target address and calldata of the wrapped call if validation succeeds and there is a single call. + * Returns all wrapped calls if validation succeeds (caller handles hash matching to find the propose call). * @param tx - The transaction to decode * @param publicClient - The viem public client for proxy verification * @param logger - Logger instance - * @returns Object with 'to' and 'data' of the wrapped call, or undefined if validation fails + * @returns Array of wrapped calls with 'to' and 'data', or undefined if not a valid Spire Proposer tx */ -export async function getCallFromSpireProposer( +export async function getCallsFromSpireProposer( tx: Transaction, publicClient: { getStorageAt: (params: { address: Hex; slot: Hex }) => Promise }, logger: Logger, -): Promise<{ to: Hex; data: Hex } | undefined> { +): Promise<{ to: Hex; data: Hex }[] | undefined> { const txHash = tx.hash; try { @@ -141,17 +141,9 @@ export async function getCallFromSpireProposer( const [calls] = spireArgs; - // Validate exactly ONE call (see ./README.md for rationale) - if (calls.length !== 1) { - logger.warn(`Spire Proposer multicall must contain exactly one call (got ${calls.length})`, { txHash }); - return undefined; - } - - const call = calls[0]; - - // Successfully extracted the single wrapped call - logger.trace(`Decoded Spire Proposer with single call to ${call.target}`, { txHash }); - return { to: call.target, data: call.data }; + // Return all wrapped calls (hash matching in the caller determines which is the propose call) + logger.trace(`Decoded Spire Proposer with ${calls.length} call(s)`, { txHash }); + return calls.map(call => ({ to: call.target, data: call.data })); } catch (err) { // Any decoding error triggers fallback to trace logger.warn(`Failed to decode Spire Proposer: ${err}`, { txHash }); diff --git a/yarn-project/archiver/src/modules/instrumentation.ts b/yarn-project/archiver/src/modules/instrumentation.ts index fbf91cf16a1a..8f08ddeb3541 100644 --- a/yarn-project/archiver/src/modules/instrumentation.ts +++ b/yarn-project/archiver/src/modules/instrumentation.ts @@ -1,6 +1,9 @@ +import type { SlotNumber } from '@aztec/foundation/branded-types'; import { createLogger } from '@aztec/foundation/log'; import type { L2Block } from '@aztec/stdlib/block'; import type { CheckpointData } from '@aztec/stdlib/checkpoint'; +import type { L1RollupConstants } from '@aztec/stdlib/epoch-helpers'; +import { getTimestampForSlot } from '@aztec/stdlib/epoch-helpers'; import { Attributes, type Gauge, @@ -38,6 +41,8 @@ export class ArchiverInstrumentation { private blockProposalTxTargetCount: UpDownCounter; + private checkpointL1InclusionDelay: Histogram; + private log = createLogger('archiver:instrumentation'); private constructor( @@ -85,6 +90,8 @@ export class ArchiverInstrumentation { }, ); + this.checkpointL1InclusionDelay = meter.createHistogram(Metrics.ARCHIVER_CHECKPOINT_L1_INCLUSION_DELAY); + this.dbMetrics = new LmdbMetrics( meter, { @@ -161,4 +168,17 @@ export class ArchiverInstrumentation { [Attributes.L1_BLOCK_PROPOSAL_USED_TRACE]: usedTrace, }); } + + /** + * Records L1 inclusion timing for a checkpoint observed on L1 (seconds into the L2 slot). + */ + public processCheckpointL1Timing(data: { + slotNumber: SlotNumber; + l1Timestamp: bigint; + l1Constants: Pick; + }): void { + const slotStartTs = getTimestampForSlot(data.slotNumber, data.l1Constants); + const inclusionDelaySeconds = Number(data.l1Timestamp - slotStartTs); + this.checkpointL1InclusionDelay.record(inclusionDelaySeconds); + } } diff --git a/yarn-project/archiver/src/modules/l1_synchronizer.ts b/yarn-project/archiver/src/modules/l1_synchronizer.ts index 22b1ed5aba29..640a10234127 100644 --- a/yarn-project/archiver/src/modules/l1_synchronizer.ts +++ b/yarn-project/archiver/src/modules/l1_synchronizer.ts @@ -1,7 +1,6 @@ import type { BlobClientInterface } from '@aztec/blob-client/client'; import { EpochCache } from '@aztec/epoch-cache'; import { InboxContract, RollupContract } from '@aztec/ethereum/contracts'; -import type { L1ContractAddresses } from '@aztec/ethereum/l1-contract-addresses'; import type { L1BlockId } from '@aztec/ethereum/l1-types'; import type { ViemPublicClient, ViemPublicDebugClient } from '@aztec/ethereum/types'; import { maxBigint } from '@aztec/foundation/bigint'; @@ -9,7 +8,6 @@ import { BlockNumber, CheckpointNumber, EpochNumber } from '@aztec/foundation/br import { Buffer32 } from '@aztec/foundation/buffer'; import { pick } from '@aztec/foundation/collection'; import { Fr } from '@aztec/foundation/curves/bn254'; -import { EthAddress } from '@aztec/foundation/eth-address'; import { type Logger, createLogger } from '@aztec/foundation/log'; import { count } from '@aztec/foundation/string'; import { DateProvider, Timer, elapsed } from '@aztec/foundation/timer'; @@ -61,10 +59,6 @@ export class ArchiverL1Synchronizer implements Traceable { private readonly debugClient: ViemPublicDebugClient, private readonly rollup: RollupContract, private readonly inbox: InboxContract, - private readonly l1Addresses: Pick< - L1ContractAddresses, - 'registryAddress' | 'governanceProposerAddress' | 'slashFactoryAddress' - > & { slashingProposerAddress: EthAddress }, private readonly store: KVArchiverDataStore, private config: { batchSize: number; @@ -708,7 +702,6 @@ export class ArchiverL1Synchronizer implements Traceable { this.blobClient, searchStartBlock, // TODO(palla/reorg): If the L2 reorg was due to an L1 reorg, we need to start search earlier searchEndBlock, - this.l1Addresses, this.instrumentation, this.log, !initialSyncComplete, // isHistoricalSync @@ -803,6 +796,14 @@ export class ArchiverL1Synchronizer implements Traceable { ); } + for (const published of validCheckpoints) { + this.instrumentation.processCheckpointL1Timing({ + slotNumber: published.checkpoint.header.slotNumber, + l1Timestamp: published.l1.timestamp, + l1Constants: this.l1Constants, + }); + } + try { const updatedValidationResult = rollupStatus.validationResult === initialValidationResult ? undefined : rollupStatus.validationResult; diff --git a/yarn-project/archiver/src/test/fake_l1_state.ts b/yarn-project/archiver/src/test/fake_l1_state.ts index e55a234b544b..b05fd2f8e505 100644 --- a/yarn-project/archiver/src/test/fake_l1_state.ts +++ b/yarn-project/archiver/src/test/fake_l1_state.ts @@ -14,6 +14,7 @@ import { CommitteeAttestation, CommitteeAttestationsAndSigners, L2Block } from ' import { Checkpoint } from '@aztec/stdlib/checkpoint'; import { getSlotAtTimestamp } from '@aztec/stdlib/epoch-helpers'; import { InboxLeaf } from '@aztec/stdlib/messaging'; +import { ConsensusPayload, SignatureDomainSeparator } from '@aztec/stdlib/p2p'; import { makeAndSignCommitteeAttestationsAndSigners, makeCheckpointAttestationFromCheckpoint, @@ -22,7 +23,16 @@ import { import { AppendOnlyTreeSnapshot } from '@aztec/stdlib/trees'; import { type MockProxy, mock } from 'jest-mock-extended'; -import { type FormattedBlock, type Transaction, encodeFunctionData, multicall3Abi, toHex } from 'viem'; +import { + type AbiParameter, + type FormattedBlock, + type Transaction, + encodeAbiParameters, + encodeFunctionData, + keccak256, + multicall3Abi, + toHex, +} from 'viem'; import { updateRollingHash } from '../structs/inbox_message.js'; @@ -87,6 +97,10 @@ type CheckpointData = { blobHashes: `0x${string}`[]; blobs: Blob[]; signers: Secp256k1Signer[]; + /** Hash of the packed attestations, matching what the L1 event emits. */ + attestationsHash: Buffer32; + /** Payload digest, matching what the L1 event emits. */ + payloadDigest: Buffer32; /** If true, archiveAt will ignore it */ pruned?: boolean; }; @@ -194,8 +208,8 @@ export class FakeL1State { // Store the messages internally so they match the checkpoint's inHash this.addMessages(checkpointNumber, messagesL1BlockNumber, messages); - // Create the transaction and blobs - const tx = await this.makeRollupTx(checkpoint, signers); + // Create the transaction, blobs, and event hashes + const { tx, attestationsHash, payloadDigest } = await this.makeRollupTx(checkpoint, signers); const blobHashes = await this.makeVersionedBlobHashes(checkpoint); const blobs = await this.makeBlobsFromCheckpoint(checkpoint); @@ -208,6 +222,8 @@ export class FakeL1State { blobHashes, blobs, signers, + attestationsHash, + payloadDigest, }); // Update last archive for auto-chaining @@ -510,10 +526,8 @@ export class FakeL1State { checkpointNumber: cpData.checkpointNumber, archive: cpData.checkpoint.archive.root, versionedBlobHashes: cpData.blobHashes.map(h => Buffer.from(h.slice(2), 'hex')), - // These are intentionally undefined to skip hash validation in the archiver - // (validation is skipped when these fields are falsy) - payloadDigest: undefined, - attestationsHash: undefined, + attestationsHash: cpData.attestationsHash, + payloadDigest: cpData.payloadDigest, }, })); } @@ -539,7 +553,10 @@ export class FakeL1State { })); } - private async makeRollupTx(checkpoint: Checkpoint, signers: Secp256k1Signer[]): Promise { + private async makeRollupTx( + checkpoint: Checkpoint, + signers: Secp256k1Signer[], + ): Promise<{ tx: Transaction; attestationsHash: Buffer32; payloadDigest: Buffer32 }> { const attestations = signers .map(signer => makeCheckpointAttestationFromCheckpoint(checkpoint, signer)) .map(attestation => CommitteeAttestation.fromSignature(attestation.signature)) @@ -557,6 +574,8 @@ export class FakeL1State { signers[0], ); + const packedAttestations = attestationsAndSigners.getPackedAttestations(); + const rollupInput = encodeFunctionData({ abi: RollupAbi, functionName: 'propose', @@ -566,7 +585,7 @@ export class FakeL1State { archive, oracleInput: { feeAssetPriceModifier: 0n }, }, - attestationsAndSigners.getPackedAttestations(), + packedAttestations, attestationsAndSigners.getSigners().map(signer => signer.toString()), attestationsAndSignersSignature.toViemSignature(), blobInput, @@ -587,12 +606,43 @@ export class FakeL1State { ], }); - return { + // Compute attestationsHash (same logic as CalldataRetriever) + const attestationsHash = Buffer32.fromString( + keccak256(encodeAbiParameters([this.getCommitteeAttestationsStructDef()], [packedAttestations])), + ); + + // Compute payloadDigest (same logic as CalldataRetriever) + const consensusPayload = ConsensusPayload.fromCheckpoint(checkpoint); + const payloadToSign = consensusPayload.getPayloadToSign(SignatureDomainSeparator.checkpointAttestation); + const payloadDigest = Buffer32.fromString(keccak256(payloadToSign)); + + const tx = { input: multiCallInput, hash: archive, blockHash: archive, to: MULTI_CALL_3_ADDRESS as `0x${string}`, } as Transaction; + + return { tx, attestationsHash, payloadDigest }; + } + + /** Extracts the CommitteeAttestations struct definition from RollupAbi for hash computation. */ + private getCommitteeAttestationsStructDef(): AbiParameter { + const proposeFunction = RollupAbi.find(item => item.type === 'function' && item.name === 'propose') as + | { type: 'function'; name: string; inputs: readonly AbiParameter[] } + | undefined; + + if (!proposeFunction) { + throw new Error('propose function not found in RollupAbi'); + } + + const attestationsParam = proposeFunction.inputs.find(param => param.name === '_attestations'); + if (!attestationsParam) { + throw new Error('_attestations parameter not found in propose function'); + } + + const tupleParam = attestationsParam as unknown as { type: 'tuple'; components?: readonly AbiParameter[] }; + return { type: 'tuple', components: tupleParam.components || [] } as AbiParameter; } private async makeVersionedBlobHashes(checkpoint: Checkpoint): Promise<`0x${string}`[]> { diff --git a/yarn-project/bb-prover/src/prover/client/bb_private_kernel_prover.ts b/yarn-project/bb-prover/src/prover/client/bb_private_kernel_prover.ts index 049815bd6567..70f691a6508c 100644 --- a/yarn-project/bb-prover/src/prover/client/bb_private_kernel_prover.ts +++ b/yarn-project/bb-prover/src/prover/client/bb_private_kernel_prover.ts @@ -278,7 +278,7 @@ export abstract class BBPrivateKernelProver implements PrivateKernelProver { this.log.info(`Generating ClientIVC proof...`); const barretenberg = await Barretenberg.initSingleton({ ...this.options, - logger: this.options.logger?.[(process.env.LOG_LEVEL as LogLevel) || 'verbose'], + logger: this.options.logger?.verbose, }); const backend = new AztecClientBackend( executionSteps.map(step => ungzip(step.bytecode)), diff --git a/yarn-project/end-to-end/src/composed/ha/e2e_ha_full.test.ts b/yarn-project/end-to-end/src/composed/ha/e2e_ha_full.test.ts index ab573ff23d7e..286d4f5c2271 100644 --- a/yarn-project/end-to-end/src/composed/ha/e2e_ha_full.test.ts +++ b/yarn-project/end-to-end/src/composed/ha/e2e_ha_full.test.ts @@ -383,9 +383,13 @@ describe('HA Full Setup', () => { logger.info(`Found ${checkpointProposalDuties.length} checkpoint proposal duty`); // Check attestation duties + // All validators attest (tracked in DB), but the checkpoint posted to L1 is trimmed to quorum. const attestationDuties = allDuties.filter(d => d.dutyType === 'ATTESTATION'); - expect(attestationDuties.length).toBe(attestations.length); - logger.info(`Found ${attestationDuties.length} attestation duties`); + expect(attestationDuties.length).toBe(VALIDATOR_COUNT); + expect(attestations.length).toBe(quorum); + logger.info( + `Found ${attestationDuties.length} attestation duties, ${attestations.length} in checkpoint (quorum: ${quorum})`, + ); // Verify no duplicate attestations per validator (HA protection ensures 1 per validator address) const dutiesByValidator = verifyNoDuplicateAttestations(attestationDuties, logger); @@ -403,8 +407,8 @@ describe('HA Full Setup', () => { const p2pAttestations = await p2p.getCheckpointAttestationsForSlot(slot); const p2pAttestationsWithSignatures = p2pAttestations.filter(a => !a.signature.isEmpty()); - // Extract validator addresses from P2P attestations using getSender() - expect(p2pAttestationsWithSignatures.length).toBe(attestations.length); + // P2P pool has attestations from all committee members; checkpoint on L1 is trimmed to quorum + expect(p2pAttestationsWithSignatures.length).toBe(COMMITTEE_SIZE); const p2pValidatorAddresses = new Map(); for (const attestation of p2pAttestationsWithSignatures) { const sender = attestation.getSender(); @@ -788,13 +792,14 @@ describe('HA Full Setup', () => { (info: AttestationInfo) => info.status === 'recovered-from-signature' && info.address !== undefined, ); - // Verify checkpoint has at least quorum attestations + // Verify checkpoint has exactly quorum attestations (trimmed to minimum required) const checkpointValidatorAddresses = new Set(validAttestations.map(info => info.address!.toString())); - expect(checkpointValidatorAddresses.size).toBeGreaterThanOrEqual(quorum); + expect(checkpointValidatorAddresses.size).toBe(quorum); - // Verify checkpoint attestations match database records (each validator in DB should appear in checkpoint) - for (const validatorAddress of dutiesByValidator.keys()) { - expect(checkpointValidatorAddresses.has(validatorAddress)).toBe(true); + // Verify every validator in the checkpoint has a corresponding DB duty record + // (checkpoint is trimmed to quorum, so it's a subset of DB records) + for (const validatorAddress of checkpointValidatorAddresses) { + expect(dutiesByValidator.has(validatorAddress)).toBe(true); } } }); diff --git a/yarn-project/end-to-end/src/spartan/upgrade_rollup_version.test.ts b/yarn-project/end-to-end/src/spartan/upgrade_rollup_version.test.ts index 02eaa828162a..71c5828b496e 100644 --- a/yarn-project/end-to-end/src/spartan/upgrade_rollup_version.test.ts +++ b/yarn-project/end-to-end/src/spartan/upgrade_rollup_version.test.ts @@ -591,7 +591,7 @@ describe('spartan_upgrade_rollup_version', () => { await waitForResourceByLabel({ resource: 'pods', namespace: config.NAMESPACE, - label: 'app.kubernetes.io/component=rpc', + label: 'app.kubernetes.io/component=rpc-node', timeout: '5m', }); } diff --git a/yarn-project/end-to-end/src/spartan/utils/nodes.ts b/yarn-project/end-to-end/src/spartan/utils/nodes.ts index 0a3166395afd..baba6c63de1a 100644 --- a/yarn-project/end-to-end/src/spartan/utils/nodes.ts +++ b/yarn-project/end-to-end/src/spartan/utils/nodes.ts @@ -363,16 +363,24 @@ export async function enableValidatorDynamicBootNode( */ export async function rollAztecPods(namespace: string, clearState: boolean = false) { // Pod components use 'validator', but StatefulSets and PVCs use 'sequencer-node' for validators + // RPC nodes have nodeType='rpc-node' in Helm values, so their component label is 'rpc-node' (not 'rpc') const podComponents = [ 'p2p-bootstrap', 'prover-node', 'prover-broker', 'prover-agent', 'sequencer-node', - 'rpc', + 'rpc-node', + 'validator-ha-db', + ]; + const pvcComponents = [ + 'p2p-bootstrap', + 'prover-node', + 'prover-broker', + 'sequencer-node', + 'rpc-node', 'validator-ha-db', ]; - const pvcComponents = ['p2p-bootstrap', 'prover-node', 'prover-broker', 'sequencer-node', 'rpc', 'validator-ha-db']; // StatefulSet components that need to be scaled down before PVC deletion // Note: validators use 'sequencer-node' as component label, not 'validator' const statefulSetComponents = [ @@ -380,7 +388,7 @@ export async function rollAztecPods(namespace: string, clearState: boolean = fal 'prover-node', 'prover-broker', 'sequencer-node', - 'rpc', + 'rpc-node', 'validator-ha-db', ]; diff --git a/yarn-project/end-to-end/src/test-wallet/wallet_worker_script.ts b/yarn-project/end-to-end/src/test-wallet/wallet_worker_script.ts index 820c1c402e95..a532bbabdbd1 100644 --- a/yarn-project/end-to-end/src/test-wallet/wallet_worker_script.ts +++ b/yarn-project/end-to-end/src/test-wallet/wallet_worker_script.ts @@ -1,43 +1,60 @@ import { createAztecNodeClient } from '@aztec/aztec.js/node'; +import type { SendOptions } from '@aztec/aztec.js/wallet'; import { jsonStringify } from '@aztec/foundation/json-rpc'; -import type { ApiSchema } from '@aztec/foundation/schemas'; +import { createLogger } from '@aztec/foundation/log'; +import type { ApiSchema, Fr } from '@aztec/foundation/schemas'; import { parseWithOptionals, schemaHasMethod } from '@aztec/foundation/schemas'; import { NodeListener, TransportServer } from '@aztec/foundation/transport'; +import { ExecutionPayload, Tx } from '@aztec/stdlib/tx'; import { workerData } from 'worker_threads'; import { TestWallet } from './test_wallet.js'; import { WorkerWalletSchema } from './worker_wallet_schema.js'; -const { nodeUrl, pxeConfig } = workerData as { nodeUrl: string; pxeConfig?: Record }; +const logger = createLogger('e2e:test-wallet:worker'); -const node = createAztecNodeClient(nodeUrl); -const wallet = await TestWallet.create(node, pxeConfig); +try { + const { nodeUrl, pxeConfig } = workerData as { nodeUrl: string; pxeConfig?: Record }; -/** Handlers for methods that need custom implementation (not direct wallet passthrough). */ -const handlers: Record Promise> = { - proveTx: async (exec, opts) => { - const provenTx = await wallet.proveTx(exec, opts); - // ProvenTx has non-serializable fields (node proxy, etc.) — extract only Tx-compatible fields - const { data, chonkProof, contractClassLogFields, publicFunctionCalldata } = provenTx; - return { data, chonkProof, contractClassLogFields, publicFunctionCalldata }; - }, - registerAccount: async (secret, salt) => { - const manager = await wallet.createSchnorrAccount(secret, salt); - return manager.address; - }, -}; + logger.info('Initializing worker wallet', { nodeUrl }); + const node = createAztecNodeClient(nodeUrl); + const wallet = await TestWallet.create(node, pxeConfig); + logger.info('Worker wallet initialized'); -const schema = WorkerWalletSchema as ApiSchema; -const listener = new NodeListener(); -const server = new TransportServer<{ fn: string; args: string }>(listener, async msg => { - if (!schemaHasMethod(schema, msg.fn)) { - throw new Error(`Unknown method: ${msg.fn}`); - } - const jsonParams = JSON.parse(msg.args) as unknown[]; - const args = await parseWithOptionals(jsonParams, schema[msg.fn].parameters()); - const handler = handlers[msg.fn]; - const result = handler ? await handler(...args) : await (wallet as any)[msg.fn](...args); - return jsonStringify(result); -}); -server.start(); + const customMethods = { + proveTx: async (exec: ExecutionPayload, opts: Omit) => { + const provenTx = await wallet.proveTx(exec, opts); + return new Tx( + provenTx.getTxHash(), + provenTx.data, + provenTx.chonkProof, + provenTx.contractClassLogFields, + provenTx.publicFunctionCalldata, + ); + }, + registerAccount: async (secret: Fr, salt: Fr) => { + const manager = await wallet.createSchnorrAccount(secret, salt); + return manager.address; + }, + }; + + const schema = WorkerWalletSchema as ApiSchema; + const listener = new NodeListener(); + const server = new TransportServer<{ fn: string; args: string }>(listener, async msg => { + if (!schemaHasMethod(schema, msg.fn)) { + throw new Error(`Unknown method: ${msg.fn}`); + } + const jsonParams = JSON.parse(msg.args) as unknown[]; + const args: any[] = await parseWithOptionals(jsonParams, schema[msg.fn].parameters()); + // we have to erase the fn type in order to be able to spread ...args + const handler: ((...args: any[]) => Promise) | undefined = + msg.fn in customMethods ? customMethods[msg.fn as keyof typeof customMethods] : undefined; + const result = handler ? await handler(...args) : await (wallet as any)[msg.fn](...args); + return jsonStringify(result); + }); + server.start(); +} catch (err: unknown) { + logger.error('Worker wallet initialization failed', { error: err instanceof Error ? err.stack : String(err) }); + process.exit(1); +} diff --git a/yarn-project/end-to-end/src/test-wallet/worker_wallet.ts b/yarn-project/end-to-end/src/test-wallet/worker_wallet.ts index 3296e6758816..ca4cd97644c1 100644 --- a/yarn-project/end-to-end/src/test-wallet/worker_wallet.ts +++ b/yarn-project/end-to-end/src/test-wallet/worker_wallet.ts @@ -19,7 +19,10 @@ import type { import type { ChainInfo } from '@aztec/entrypoints/interfaces'; import type { Fr } from '@aztec/foundation/curves/bn254'; import { jsonStringify } from '@aztec/foundation/json-rpc'; +import { createLogger } from '@aztec/foundation/log'; +import { promiseWithResolvers } from '@aztec/foundation/promise'; import type { ApiSchema } from '@aztec/foundation/schemas'; +import { sleep } from '@aztec/foundation/sleep'; import { NodeConnector, TransportClient } from '@aztec/foundation/transport'; import type { PXEConfig } from '@aztec/pxe/config'; import type { ContractArtifact, EventMetadataDefinition, FunctionCall } from '@aztec/stdlib/abi'; @@ -35,6 +38,10 @@ import { WorkerWalletSchema } from './worker_wallet_schema.js'; type WorkerMsg = { fn: string; args: string }; +const log = createLogger('e2e:test-wallet:worker-wallet'); + +const WORKER_READY_TIMEOUT_MS = 120_000; + /** * Wallet implementation that offloads all work to a worker thread. * Implements the Wallet interface by proxying calls over a transport layer @@ -53,8 +60,18 @@ export class WorkerWallet implements Wallet { * @returns A WorkerWallet ready to use. */ static async create(nodeUrl: string, pxeConfig?: Partial): Promise { - const worker = new Worker(new URL('./wallet_worker_script.js', import.meta.url), { + // replace stc/ with dest/ so the wallet works in Jest tests + const workerUrl = new URL('./wallet_worker_script.js', import.meta.url); + workerUrl.pathname = workerUrl.pathname.replace('/src/', '/dest/'); + // remove JEST_WORKER_ID so the worker uses pino-pretty transport instead of Jest's raw output. + const { JEST_WORKER_ID: _, ...parentEnv } = process.env; + const worker = new Worker(workerUrl, { workerData: { nodeUrl, pxeConfig }, + env: { + ...parentEnv, + ...(process.stderr.isTTY || process.env.FORCE_COLOR ? { FORCE_COLOR: '1' } : {}), + LOG_LEVEL: process.env.WORKER_LOG_LEVEL ?? 'warning', + }, }); const connector = new NodeConnector(worker); @@ -62,8 +79,39 @@ export class WorkerWallet implements Wallet { await client.open(); const wallet = new WorkerWallet(worker, client); - // Warmup / readiness check — blocks until the worker has finished creating the TestWallet. - await wallet.getChainInfo(); + + const { promise: workerDied, reject: rejectWorkerDied } = promiseWithResolvers(); + // reject if the worker exits or errors before the warmup completes. + const onError = (err: Error): void => { + worker.off('exit', onExit!); + rejectWorkerDied(new Error(`Worker wallet thread error: ${err.message}`)); + }; + + const onExit = (code: number): void => { + worker.off('error', onError!); + rejectWorkerDied(new Error(`Worker wallet thread exited with code ${code} before becoming ready`)); + }; + + worker.once('error', onError); + worker.once('exit', onExit); + + const timeout = sleep(WORKER_READY_TIMEOUT_MS).then(() => { + throw new Error(`Worker wallet creation timed out after ${WORKER_READY_TIMEOUT_MS / 1000}s`); + }); + + try { + // wait for worker wallet to start + await Promise.race([wallet.getChainInfo(), workerDied, timeout]); + } catch (err) { + log.error('Worker wallet creation failed, cleaning up', { error: String(err) }); + client.close(); + await worker.terminate(); + throw err; + } finally { + worker.off('error', onError); + worker.off('exit', onExit); + } + return wallet; } diff --git a/yarn-project/ethereum/src/contracts/rollup.ts b/yarn-project/ethereum/src/contracts/rollup.ts index 30a1ba33ef84..40e10d287284 100644 --- a/yarn-project/ethereum/src/contracts/rollup.ts +++ b/yarn-project/ethereum/src/contracts/rollup.ts @@ -193,10 +193,10 @@ export type CheckpointProposedArgs = { checkpointNumber: CheckpointNumber; archive: Fr; versionedBlobHashes: Buffer[]; - /** Hash of attestations. Undefined for older events (backwards compatibility). */ - attestationsHash?: Buffer32; - /** Digest of the payload. Undefined for older events (backwards compatibility). */ - payloadDigest?: Buffer32; + /** Hash of attestations emitted in the CheckpointProposed event. */ + attestationsHash: Buffer32; + /** Digest of the payload emitted in the CheckpointProposed event. */ + payloadDigest: Buffer32; }; /** Log type for CheckpointProposed events. */ @@ -1060,8 +1060,22 @@ export class RollupContract { checkpointNumber: CheckpointNumber.fromBigInt(log.args.checkpointNumber!), archive: Fr.fromString(log.args.archive!), versionedBlobHashes: log.args.versionedBlobHashes!.map(h => Buffer.from(h.slice(2), 'hex')), - attestationsHash: log.args.attestationsHash ? Buffer32.fromString(log.args.attestationsHash) : undefined, - payloadDigest: log.args.payloadDigest ? Buffer32.fromString(log.args.payloadDigest) : undefined, + attestationsHash: (() => { + if (!log.args.attestationsHash) { + throw new Error( + `CheckpointProposed event missing attestationsHash for checkpoint ${log.args.checkpointNumber}`, + ); + } + return Buffer32.fromString(log.args.attestationsHash); + })(), + payloadDigest: (() => { + if (!log.args.payloadDigest) { + throw new Error( + `CheckpointProposed event missing payloadDigest for checkpoint ${log.args.checkpointNumber}`, + ); + } + return Buffer32.fromString(log.args.payloadDigest); + })(), }, })); } diff --git a/yarn-project/foundation/src/transport/transport_client.ts b/yarn-project/foundation/src/transport/transport_client.ts index e1aa0260e811..fb2b98f86ddf 100644 --- a/yarn-project/foundation/src/transport/transport_client.ts +++ b/yarn-project/foundation/src/transport/transport_client.ts @@ -91,7 +91,7 @@ export class TransportClient extends EventEmitter { } const msgId = this.msgId++; const msg = { msgId, payload }; - log.debug(format(`->`, msg)); + log.trace(format(`->`, msg)); return new Promise((resolve, reject) => { this.pendingRequests.push({ resolve, reject, msgId }); this.socket!.send(msg, transfer).catch(reject); @@ -111,7 +111,7 @@ export class TransportClient extends EventEmitter { this.close(); return; } - log.debug(format(`<-`, msg)); + log.trace(format(`<-`, msg)); if (isEventMessage(msg)) { this.emit('event_msg', msg.payload); return; diff --git a/yarn-project/p2p/src/mem_pools/tx_pool_v2/eviction/fee_payer_balance_eviction_rule.test.ts b/yarn-project/p2p/src/mem_pools/tx_pool_v2/eviction/fee_payer_balance_eviction_rule.test.ts index ae2b886818a9..ecb671eb7d80 100644 --- a/yarn-project/p2p/src/mem_pools/tx_pool_v2/eviction/fee_payer_balance_eviction_rule.test.ts +++ b/yarn-project/p2p/src/mem_pools/tx_pool_v2/eviction/fee_payer_balance_eviction_rule.test.ts @@ -376,7 +376,7 @@ describe('FeePayerBalanceEvictionRule', () => { await rule.evict(context, pool); - expect(mockWorldState.syncImmediate).toHaveBeenCalledWith(BlockNumber(3)); + expect(mockWorldState.syncImmediate).toHaveBeenCalledWith(); expect(mockWorldState.getSnapshot).toHaveBeenCalledWith(BlockNumber(3)); }); diff --git a/yarn-project/p2p/src/mem_pools/tx_pool_v2/eviction/fee_payer_balance_eviction_rule.ts b/yarn-project/p2p/src/mem_pools/tx_pool_v2/eviction/fee_payer_balance_eviction_rule.ts index 969fd127b1d1..6bd67d2929d0 100644 --- a/yarn-project/p2p/src/mem_pools/tx_pool_v2/eviction/fee_payer_balance_eviction_rule.ts +++ b/yarn-project/p2p/src/mem_pools/tx_pool_v2/eviction/fee_payer_balance_eviction_rule.ts @@ -34,7 +34,7 @@ export class FeePayerBalanceEvictionRule implements EvictionRule { } if (context.event === EvictionEvent.CHAIN_PRUNED) { - await this.worldState.syncImmediate(context.blockNumber); + await this.worldState.syncImmediate(); const feePayers = pool.getPendingFeePayers(); return await this.evictForFeePayers(feePayers, this.worldState.getSnapshot(context.blockNumber), pool); } diff --git a/yarn-project/p2p/src/mem_pools/tx_pool_v2/eviction/invalid_txs_after_reorg_rule.test.ts b/yarn-project/p2p/src/mem_pools/tx_pool_v2/eviction/invalid_txs_after_reorg_rule.test.ts index 0d02b3431f88..c7ab0ab474a6 100644 --- a/yarn-project/p2p/src/mem_pools/tx_pool_v2/eviction/invalid_txs_after_reorg_rule.test.ts +++ b/yarn-project/p2p/src/mem_pools/tx_pool_v2/eviction/invalid_txs_after_reorg_rule.test.ts @@ -137,7 +137,7 @@ describe('InvalidTxsAfterReorgRule', () => { expect(result.txsEvicted).toContain('0x1111'); expect(result.txsEvicted).toContain('0x2222'); // Ensure syncImmediate is called before accessing the world state snapshot - expect(worldState.syncImmediate).toHaveBeenCalledWith(BlockNumber(1)); + expect(worldState.syncImmediate).toHaveBeenCalledWith(); }); it('handles large number of transactions efficiently', async () => { diff --git a/yarn-project/p2p/src/mem_pools/tx_pool_v2/eviction/invalid_txs_after_reorg_rule.ts b/yarn-project/p2p/src/mem_pools/tx_pool_v2/eviction/invalid_txs_after_reorg_rule.ts index 72462a8a687f..782d1beb5cb6 100644 --- a/yarn-project/p2p/src/mem_pools/tx_pool_v2/eviction/invalid_txs_after_reorg_rule.ts +++ b/yarn-project/p2p/src/mem_pools/tx_pool_v2/eviction/invalid_txs_after_reorg_rule.ts @@ -45,8 +45,8 @@ export class InvalidTxsAfterReorgRule implements EvictionRule { txsByBlockHash.get(blockHashStr)!.push(meta.txHash); } - // Ensure world state is synced to this block before accessing the snapshot - await this.worldState.syncImmediate(context.blockNumber); + // Sync without a block number to ensure the world state processes the prune event. + await this.worldState.syncImmediate(); const db = this.worldState.getSnapshot(context.blockNumber); // Check which blocks exist in the archive diff --git a/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.test.ts b/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.test.ts index 8ef5a19129ba..4361634eb771 100644 --- a/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.test.ts +++ b/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.test.ts @@ -256,6 +256,7 @@ describe('CheckpointProposalJob', () => { validatorClient.signAttestationsAndSigners.mockImplementation(() => Promise.resolve(getSignatures()[0].signature)); validatorClient.getCoinbaseForAttestor.mockReturnValue(coinbase); validatorClient.getFeeRecipientForAttestor.mockReturnValue(feeRecipient); + validatorClient.getValidatorAddresses.mockReturnValue([attestorAddress]); slasherClient = mock(); slasherClient.getProposerActions.mockResolvedValue([]); diff --git a/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.timing.test.ts b/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.timing.test.ts index 2e9ebb18219e..ad88b7d040c1 100644 --- a/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.timing.test.ts +++ b/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.timing.test.ts @@ -436,6 +436,7 @@ describe('CheckpointProposalJob Timing Tests', () => { validatorClient.signAttestationsAndSigners.mockResolvedValue(mockedSig); validatorClient.getCoinbaseForAttestor.mockReturnValue(coinbase); validatorClient.getFeeRecipientForAttestor.mockReturnValue(globalVariables.feeRecipient); + validatorClient.getValidatorAddresses.mockReturnValue([attestorAddress]); slasherClient = mock(); slasherClient.getProposerActions.mockResolvedValue([]); diff --git a/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.ts b/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.ts index 44abe045ba91..184e83a76506 100644 --- a/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.ts +++ b/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.ts @@ -38,7 +38,7 @@ import { } from '@aztec/stdlib/interfaces/server'; import { type L1ToL2MessageSource, computeInHashFromL1ToL2Messages } from '@aztec/stdlib/messaging'; import type { BlockProposalOptions, CheckpointProposal, CheckpointProposalOptions } from '@aztec/stdlib/p2p'; -import { orderAttestations } from '@aztec/stdlib/p2p'; +import { orderAttestations, trimAttestations } from '@aztec/stdlib/p2p'; import type { L2BlockBuiltStats } from '@aztec/stdlib/stats'; import { type FailedTx, Tx } from '@aztec/stdlib/tx'; import { AttestationTimeoutError } from '@aztec/stdlib/validators'; @@ -743,8 +743,20 @@ export class CheckpointProposalJob implements Traceable { collectedAttestationsCount = attestations.length; + // Trim attestations to minimum required to save L1 calldata gas + const localAddresses = this.validatorClient.getValidatorAddresses(); + const trimmed = trimAttestations( + attestations, + numberOfRequiredAttestations, + this.attestorAddress, + localAddresses, + ); + if (trimmed.length < attestations.length) { + this.log.debug(`Trimmed attestations from ${attestations.length} to ${trimmed.length} for L1 submission`); + } + // Rollup contract requires that the signatures are provided in the order of the committee - const sorted = orderAttestations(attestations, committee); + const sorted = orderAttestations(trimmed, committee); // Manipulate the attestations if we've been configured to do so if (this.config.injectFakeAttestation || this.config.shuffleAttestationOrdering) { diff --git a/yarn-project/stdlib/src/p2p/attestation_utils.test.ts b/yarn-project/stdlib/src/p2p/attestation_utils.test.ts new file mode 100644 index 000000000000..c06353f6f959 --- /dev/null +++ b/yarn-project/stdlib/src/p2p/attestation_utils.test.ts @@ -0,0 +1,151 @@ +import { SlotNumber } from '@aztec/foundation/branded-types'; +import { Secp256k1Signer } from '@aztec/foundation/crypto/secp256k1-signer'; +import { Fr } from '@aztec/foundation/curves/bn254'; + +import { jest } from '@jest/globals'; + +import { CheckpointHeader } from '../rollup/index.js'; +import { trimAttestations } from './attestation_utils.js'; +import { CheckpointAttestation } from './checkpoint_attestation.js'; +import { ConsensusPayload } from './consensus_payload.js'; +import { SignatureDomainSeparator, getHashedSignaturePayloadEthSignedMessage } from './signature_utils.js'; + +function makeAttestation(signer: Secp256k1Signer): CheckpointAttestation { + const header = CheckpointHeader.random({ slotNumber: SlotNumber(0) }); + const payload = new ConsensusPayload(header, Fr.random(), 0n); + const attestationHash = getHashedSignaturePayloadEthSignedMessage( + payload, + SignatureDomainSeparator.checkpointAttestation, + ); + const proposalHash = getHashedSignaturePayloadEthSignedMessage(payload, SignatureDomainSeparator.checkpointProposal); + return new CheckpointAttestation(payload, signer.sign(attestationHash), signer.sign(proposalHash)); +} + +function makeSignerAndAttestation() { + const signer = Secp256k1Signer.random(); + return { signer, attestation: makeAttestation(signer), address: signer.address }; +} + +describe('trimAttestations', () => { + it('returns attestations unchanged when count <= required', () => { + const items = Array.from({ length: 3 }, () => makeSignerAndAttestation()); + const proposer = items[0]; + + const result = trimAttestations( + items.map(i => i.attestation), + 3, + proposer.address, + [], + ); + + expect(result).toHaveLength(3); + }); + + it('trims to required count', () => { + const items = Array.from({ length: 5 }, () => makeSignerAndAttestation()); + const proposer = items[0]; + + const result = trimAttestations( + items.map(i => i.attestation), + 3, + proposer.address, + [], + ); + + expect(result).toHaveLength(3); + }); + + it('always keeps proposer attestation', () => { + const items = Array.from({ length: 5 }, () => makeSignerAndAttestation()); + // Proposer is the last item in the array + const proposer = items[4]; + + const result = trimAttestations( + items.map(i => i.attestation), + 3, + proposer.address, + [], + ); + + expect(result).toHaveLength(3); + const resultSenders = result.map(a => a.getSender()!.toString()); + expect(resultSenders).toContain(proposer.address.toString()); + }); + + it('prioritizes local validator attestations over external ones', () => { + const proposer = makeSignerAndAttestation(); + const local1 = makeSignerAndAttestation(); + const local2 = makeSignerAndAttestation(); + const external1 = makeSignerAndAttestation(); + const external2 = makeSignerAndAttestation(); + + const allAttestations = [proposer, local1, local2, external1, external2].map(i => i.attestation); + const localAddresses = [local1.address, local2.address]; + + const result = trimAttestations(allAttestations, 3, proposer.address, localAddresses); + + expect(result).toHaveLength(3); + const resultSenders = new Set(result.map(a => a.getSender()!.toString())); + expect(resultSenders.has(proposer.address.toString())).toBe(true); + expect(resultSenders.has(local1.address.toString())).toBe(true); + expect(resultSenders.has(local2.address.toString())).toBe(true); + expect(resultSenders.has(external1.address.toString())).toBe(false); + expect(resultSenders.has(external2.address.toString())).toBe(false); + }); + + it('fills with external attestations when not enough local ones', () => { + const proposer = makeSignerAndAttestation(); + const local1 = makeSignerAndAttestation(); + const external1 = makeSignerAndAttestation(); + const external2 = makeSignerAndAttestation(); + const external3 = makeSignerAndAttestation(); + + const allAttestations = [proposer, local1, external1, external2, external3].map(i => i.attestation); + + const result = trimAttestations(allAttestations, 3, proposer.address, [local1.address]); + + expect(result).toHaveLength(3); + const resultSenders = new Set(result.map(a => a.getSender()!.toString())); + expect(resultSenders.has(proposer.address.toString())).toBe(true); + expect(resultSenders.has(local1.address.toString())).toBe(true); + // One external fills the remaining slot + const externalIncluded = [external1, external2, external3].filter(e => resultSenders.has(e.address.toString())); + expect(externalIncluded).toHaveLength(1); + }); + + it('handles proposer also being in local addresses without double-counting', () => { + const proposer = makeSignerAndAttestation(); + const local1 = makeSignerAndAttestation(); + const external1 = makeSignerAndAttestation(); + const external2 = makeSignerAndAttestation(); + + const allAttestations = [proposer, local1, external1, external2].map(i => i.attestation); + // Proposer address is also listed in local addresses + const localAddresses = [proposer.address, local1.address]; + + const result = trimAttestations(allAttestations, 3, proposer.address, localAddresses); + + expect(result).toHaveLength(3); + const resultSenders = result.map(a => a.getSender()!.toString()); + // Proposer should appear exactly once + expect(resultSenders.filter(s => s === proposer.address.toString())).toHaveLength(1); + expect(resultSenders).toContain(local1.address.toString()); + }); + + it('skips attestations with unrecoverable signatures', () => { + const proposer = makeSignerAndAttestation(); + const valid = makeSignerAndAttestation(); + const external1 = makeSignerAndAttestation(); + + const badAttestation = makeSignerAndAttestation().attestation; + jest.spyOn(badAttestation, 'getSender').mockReturnValue(undefined); + + const allAttestations = [proposer.attestation, valid.attestation, badAttestation, external1.attestation]; + + const result = trimAttestations(allAttestations, 3, proposer.address, []); + + expect(result).toHaveLength(3); + const resultSenders = result.map(a => a.getSender()?.toString()).filter(Boolean); + expect(resultSenders).toHaveLength(3); + }); +}); diff --git a/yarn-project/stdlib/src/p2p/attestation_utils.ts b/yarn-project/stdlib/src/p2p/attestation_utils.ts index 646ea04d546c..33fb150909af 100644 --- a/yarn-project/stdlib/src/p2p/attestation_utils.ts +++ b/yarn-project/stdlib/src/p2p/attestation_utils.ts @@ -33,3 +33,59 @@ export function orderAttestations( return orderedAttestations; } + +/** + * Trims attestations to the minimum required number to save L1 calldata gas. + * Each signature costs 65 bytes of calldata vs 20 bytes for just an address. + * + * Priority order for keeping attestations: + * 1. The proposer's attestation (required by L1 contract - MissingProposerSignature revert) + * 2. Attestations from the local node's validator keys + * 3. Remaining attestations filled to reach the required count + */ +export function trimAttestations( + attestations: CheckpointAttestation[], + required: number, + proposerAddress: EthAddress, + localAddresses: EthAddress[], +): CheckpointAttestation[] { + if (attestations.length <= required) { + return attestations; + } + + const proposerAttestation: CheckpointAttestation[] = []; + const localAttestations: CheckpointAttestation[] = []; + const otherAttestations: CheckpointAttestation[] = []; + + for (const attestation of attestations) { + const sender = attestation.getSender(); + if (!sender) { + continue; + } + if (sender.equals(proposerAddress)) { + proposerAttestation.push(attestation); + } else if (localAddresses.some(addr => addr.equals(sender))) { + localAttestations.push(attestation); + } else { + otherAttestations.push(attestation); + } + } + + const result: CheckpointAttestation[] = [...proposerAttestation]; + + for (const att of localAttestations) { + if (result.length >= required) { + break; + } + result.push(att); + } + + for (const att of otherAttestations) { + if (result.length >= required) { + break; + } + result.push(att); + } + + return result; +} diff --git a/yarn-project/telemetry-client/src/metrics.ts b/yarn-project/telemetry-client/src/metrics.ts index 4a0cf50aa040..c195ce888502 100644 --- a/yarn-project/telemetry-client/src/metrics.ts +++ b/yarn-project/telemetry-client/src/metrics.ts @@ -350,6 +350,13 @@ export const ARCHIVER_BLOCK_PROPOSAL_TX_TARGET_COUNT: MetricDefinition = { valueType: ValueType.INT, }; +export const ARCHIVER_CHECKPOINT_L1_INCLUSION_DELAY: MetricDefinition = { + name: 'aztec.archiver.checkpoint_l1_inclusion_delay', + description: 'Seconds into the L2 slot when the checkpoint L1 tx was included', + unit: 's', + valueType: ValueType.INT, +}; + export const NODE_RECEIVE_TX_DURATION: MetricDefinition = { name: 'aztec.node.receive_tx.duration', description: 'The duration of the receiveTx method',