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/l1_synchronizer.ts b/yarn-project/archiver/src/modules/l1_synchronizer.ts index 22b1ed5aba29..ae4bca9dc898 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 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/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); + })(), }, })); }