From 6b1d023fe7f24d95a9b5bddbe7915a9bbac32036 Mon Sep 17 00:00:00 2001 From: Michal Rzeszutko Date: Fri, 13 Feb 2026 13:36:53 +0000 Subject: [PATCH 1/3] check calldata against emitted hashes --- yarn-project/archiver/src/l1/README.md | 52 +- .../src/l1/calldata_retriever.test.ts | 462 +++++++++++++++++- .../archiver/src/l1/calldata_retriever.ts | 216 ++++++-- 3 files changed, 658 insertions(+), 72 deletions(-) diff --git a/yarn-project/archiver/src/l1/README.md b/yarn-project/archiver/src/l1/README.md index c1cb4ecdab8c..e1c4b466f082 100644 --- a/yarn-project/archiver/src/l1/README.md +++ b/yarn-project/archiver/src/l1/README.md @@ -56,38 +56,26 @@ 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. - -However, modifying the rollup contract is out of scope for this change. But we can implement this approach in `v2`. +### Relaxed Hash-Verified Extraction (Multicall3) + +When strict multicall3 validation fails (e.g., due to unrecognized calls from new contract interactions), +the retriever attempts a relaxed hash-verified extraction before falling back to trace: + +1. **Strict first**: All calls must be on the allowlist. If this passes, the single propose call is returned immediately. +2. **Relaxed second**: If strict fails and both `attestationsHash` and `payloadDigest` are available from the + `CheckpointProposed` event: + - Filter candidate `propose` calls by matching both target address (rollup) and function selector (`propose`). + - Verify each candidate's calldata by computing `attestationsHash` (keccak256 of ABI-encoded attestations) + and `payloadDigest` (keccak256 of the consensus payload signing hash) and comparing against expected values. + - Return the uniquely verified candidate (exactly one must match). + - If zero or multiple candidates verify, return undefined and fall back to trace. +3. **Requirements**: Relaxed mode requires **both** hashes to be present. If only one hash is available (or neither), + relaxed mode is skipped entirely and behavior remains backwards-compatible (falls through to trace). + +Note: `decodeAndBuildCheckpoint` continues to perform final hash validation after extraction. The extraction-time +verification is an optimization to avoid expensive `debug_traceTransaction` / `trace_transaction` RPC calls. + +This approach also applies to multicall3 transactions wrapped by Spire Proposer. ### Debug and Trace Transaction Fallback diff --git a/yarn-project/archiver/src/l1/calldata_retriever.test.ts b/yarn-project/archiver/src/l1/calldata_retriever.test.ts index 0cb5bdf04650..aaa6be68f140 100644 --- a/yarn-project/archiver/src/l1/calldata_retriever.test.ts +++ b/yarn-project/archiver/src/l1/calldata_retriever.test.ts @@ -40,8 +40,11 @@ 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 }, + ): Hex | undefined { + return super.tryDecodeMulticall3(tx, expectedHashes); } public override tryDecodeDirectPropose(tx: Transaction): Hex | undefined { @@ -60,6 +63,13 @@ class TestCalldataRetriever extends CalldataRetriever { ) { return super.decodeAndBuildCheckpoint(proposeCalldata, blockHash, checkpointNumber, expectedHashes); } + + public override verifyProposeCalldataHashes( + proposeCalldata: Hex, + expectedHashes: { attestationsHash?: Hex; payloadDigest?: Hex }, + ): boolean { + return super.verifyProposeCalldataHashes(proposeCalldata, expectedHashes); + } } describe('CalldataRetriever', () => { @@ -136,6 +146,26 @@ 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). + * In production, expectedHashes come from the blockchain event logs (computed by the Solidity contract). + * + * The hash validation logic tests use mocking rather than recomputing hashes because: + * 1. In production, expectedHashes come from blockchain event logs (computed by Solidity) + * 2. We're testing validation logic, not hash computation correctness + * 3. Recomputing hashes in tests would duplicate production logic, hiding bugs + * 4. Correctness of hash computation is covered by e2e tests + */ + 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, @@ -1244,4 +1274,432 @@ describe('CalldataRetriever', () => { expect(instrumentation.recordBlockProposalTxTarget).toHaveBeenCalledWith(SPIRE_PROPOSER_ADDRESS, false); }); }); + + describe('verifyProposeCalldataHashes', () => { + it('should return true when both hashes match', () => { + const proposeCalldata = makeProposeCalldata(); + // Mock the hash computation to return specific test values + const hashes = mockHashComputation( + '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' as Hex, + '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' as Hex, + ); + + // When computed hashes match expected hashes, should return true + expect(retriever.verifyProposeCalldataHashes(proposeCalldata, hashes)).toBe(true); + }); + + it('should return false when attestationsHash does not match', () => { + const proposeCalldata = makeProposeCalldata(); + // Mock to return specific hashes + mockHashComputation( + '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' as Hex, + '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' as Hex, + ); + + // Pass different attestationsHash - should return false + expect( + retriever.verifyProposeCalldataHashes(proposeCalldata, { + attestationsHash: '0x0000000000000000000000000000000000000000000000000000000000000001' as Hex, + payloadDigest: '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' as Hex, + }), + ).toBe(false); + }); + + it('should return false when payloadDigest does not match', () => { + const proposeCalldata = makeProposeCalldata(); + // Mock to return specific hashes + mockHashComputation( + '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' as Hex, + '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' as Hex, + ); + + // Pass different payloadDigest - should return false + expect( + retriever.verifyProposeCalldataHashes(proposeCalldata, { + attestationsHash: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' as Hex, + payloadDigest: '0x0000000000000000000000000000000000000000000000000000000000000002' as Hex, + }), + ).toBe(false); + }); + + it('should return false when only attestationsHash is provided', () => { + const proposeCalldata = makeProposeCalldata(); + // Mock to return specific hashes + mockHashComputation( + '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' as Hex, + '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' as Hex, + ); + + // Only provide attestationsHash - should return false (both required) + expect( + retriever.verifyProposeCalldataHashes(proposeCalldata, { + attestationsHash: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' as Hex, + }), + ).toBe(false); + }); + + it('should return false when only payloadDigest is provided', () => { + const proposeCalldata = makeProposeCalldata(); + // Mock to return specific hashes + mockHashComputation( + '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' as Hex, + '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' as Hex, + ); + + // Only provide payloadDigest - should return false (both required) + expect( + retriever.verifyProposeCalldataHashes(proposeCalldata, { + payloadDigest: '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' as Hex, + }), + ).toBe(false); + }); + + it('should return false for invalid calldata', () => { + // Invalid calldata should fail to decode and return false + expect( + retriever.verifyProposeCalldataHashes('0xinvalid' as Hex, { + attestationsHash: '0x0000000000000000000000000000000000000000000000000000000000000001' as Hex, + payloadDigest: '0x0000000000000000000000000000000000000000000000000000000000000002' as Hex, + }), + ).toBe(false); + }); + }); + + describe('tryDecodeMulticall3 relaxed mode', () => { + it('should use strict mode when all calls are valid even with hashes present', () => { + const proposeCalldata = makeProposeCalldata(); + // Mock hash computation - even with hashes present, strict mode succeeds so hashes aren't needed + const hashes = mockHashComputation(); + + const tx = makeMulticall3Transaction([{ target: rollupAddress.toString() as Hex, callData: proposeCalldata }]); + + const result = retriever.tryDecodeMulticall3(tx, hashes); + expect(result).toBe(proposeCalldata); + }); + + it('should return verified calldata in relaxed mode when unknown call + valid propose with matching hashes', () => { + const proposeCalldata = makeProposeCalldata(); + // Mock to return test hashes that will validate the calldata + const hashes = mockHashComputation( + '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef' as Hex, + '0xfedcba0987654321fedcba0987654321fedcba0987654321fedcba0987654321' 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 }, + ]); + + const result = retriever.tryDecodeMulticall3(tx, hashes); + expect(result).toBe(proposeCalldata); + }); + + it('should return undefined when unknown call + propose with wrong target address', () => { + const proposeCalldata = makeProposeCalldata(); + const hashes = mockHashComputation(); + const unknownAddress = EthAddress.random(); + const wrongRollupAddress = EthAddress.random(); + + const tx = makeMulticall3Transaction([ + { target: unknownAddress.toString() as Hex, callData: '0x12345678' as Hex }, + { target: wrongRollupAddress.toString() as Hex, callData: proposeCalldata }, + ]); + + const result = retriever.tryDecodeMulticall3(tx, hashes); + expect(result).toBeUndefined(); + }); + + it('should return undefined when unknown call + call to rollup with wrong selector', () => { + const hashes = mockHashComputation(); + const unknownAddress = EthAddress.random(); + const invalidateBadSelector = toFunctionSelector( + RollupAbi.find(x => x.type === 'function' && x.name === 'invalidateBadAttestation')!, + ); + const nonProposeCalldata = (invalidateBadSelector + '0'.repeat(120)) as Hex; + + const tx = makeMulticall3Transaction([ + { target: unknownAddress.toString() as Hex, callData: '0x12345678' as Hex }, + { target: rollupAddress.toString() as Hex, callData: nonProposeCalldata }, + ]); + + const result = retriever.tryDecodeMulticall3(tx, hashes); + expect(result).toBeUndefined(); + }); + + it('should return undefined when unknown call + only one hash provided', () => { + const proposeCalldata = makeProposeCalldata(); + const hashes = mockHashComputation(); + const unknownAddress = EthAddress.random(); + + const tx = makeMulticall3Transaction([ + { target: unknownAddress.toString() as Hex, callData: '0x12345678' as Hex }, + { target: rollupAddress.toString() as Hex, callData: proposeCalldata }, + ]); + + // Only attestationsHash, no payloadDigest -> strict failure, no relaxed + const result = retriever.tryDecodeMulticall3(tx, { attestationsHash: hashes.attestationsHash }); + expect(result).toBeUndefined(); + }); + + it('should return undefined when unknown call + wrong hashes', () => { + const proposeCalldata = makeProposeCalldata(); + const unknownAddress = EthAddress.random(); + + // Mock to return different hashes than what we'll pass in expectedHashes + mockHashComputation( + '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' as Hex, + '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' as Hex, + ); + + const tx = makeMulticall3Transaction([ + { target: unknownAddress.toString() as Hex, callData: '0x12345678' as Hex }, + { 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, + }); + expect(result).toBeUndefined(); + }); + + it('should return the verified candidate when multiple propose candidates with exactly one verified', () => { + const proposeCalldata1 = makeProposeCalldata(); + const proposeCalldata2 = makeProposeCalldata(); + const unknownAddress = EthAddress.random(); + + // Mock verifyProposeCalldataHashes to return true only for proposeCalldata1 + const hashes = mockHashComputation( + '0x1111111111111111111111111111111111111111111111111111111111111111' as Hex, + '0x2222222222222222222222222222222222222222222222222222222222222222' as Hex, + ); + + // Mock verifyProposeCalldataHashes to be selective - only first calldata verifies + jest.spyOn(retriever, 'verifyProposeCalldataHashes').mockImplementation((calldata, _hashes) => { + return calldata === proposeCalldata1; + }); + + const tx = makeMulticall3Transaction([ + { target: unknownAddress.toString() as Hex, callData: '0x12345678' as Hex }, + { target: rollupAddress.toString() as Hex, callData: proposeCalldata1 }, + { target: rollupAddress.toString() as Hex, callData: proposeCalldata2 }, + ]); + + const result = retriever.tryDecodeMulticall3(tx, hashes); + expect(result).toBe(proposeCalldata1); + }); + + it('should return undefined when multiple propose candidates with multiple verified', () => { + const proposeCalldata = makeProposeCalldata(); + const hashes = mockHashComputation(); + const unknownAddress = EthAddress.random(); + + // Same calldata twice -> both verify + const tx = makeMulticall3Transaction([ + { target: unknownAddress.toString() as Hex, callData: '0x12345678' as Hex }, + { target: rollupAddress.toString() as Hex, callData: proposeCalldata }, + { target: rollupAddress.toString() as Hex, callData: proposeCalldata }, + ]); + + const result = retriever.tryDecodeMulticall3(tx, hashes); + expect(result).toBeUndefined(); + }); + + it('should return undefined when no propose candidates in relaxed mode', () => { + const unknownAddress = EthAddress.random(); + + const tx = makeMulticall3Transaction([ + { target: unknownAddress.toString() as Hex, callData: '0x12345678' as Hex }, + ]); + + const result = retriever.tryDecodeMulticall3(tx, { + attestationsHash: '0x0000000000000000000000000000000000000000000000000000000000000001' as Hex, + payloadDigest: '0x0000000000000000000000000000000000000000000000000000000000000002' as Hex, + }); + expect(result).toBeUndefined(); + }); + }); + + describe('integration: relaxed mode', () => { + const checkpointNumber = CheckpointNumber(42); + + it('should succeed via relaxed mode when multicall3 has unknown calls and both hashes are provided', async () => { + const proposeCalldata = makeProposeCalldata(); + // Mock hash computation to return test hashes that will validate + 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 relaxed mode through Spire-wrapped multicall3 with unknown calls', async () => { + const proposeCalldata = makeProposeCalldata(); + // Mock hash computation to return test hashes that will validate + 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, + ); + }); + + it('should skip relaxed mode and preserve backwards-compatible behavior when no hashes provided', async () => { + const proposeCalldata = makeProposeCalldata(); + const unknownAddress = EthAddress.random(); + + // Multicall3 with unknown call -> strict fails, no hashes -> relaxed skipped -> falls to trace + const tx = makeMulticall3Transaction([ + { target: unknownAddress.toString() as Hex, callData: '0x12345678' as Hex }, + { target: rollupAddress.toString() as Hex, callData: proposeCalldata }, + ]); + + publicClient.getTransaction.mockResolvedValue(tx); + + // Trace fallback returns the propose calldata + 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: [], + }, + ]); + + const result = await retriever.getCheckpointFromRollupTx(txHash, [], checkpointNumber, {}); + + expect(result.checkpointNumber).toBe(checkpointNumber); + expect(result.header).toBeInstanceOf(CheckpointHeader); + // Should have fallen back to trace + expect(instrumentation.recordBlockProposalTxTarget).toHaveBeenCalledWith(MULTI_CALL_3_ADDRESS, true); + expect(debugClient.request).toHaveBeenCalled(); + }); + }); }); diff --git a/yarn-project/archiver/src/l1/calldata_retriever.ts b/yarn-project/archiver/src/l1/calldata_retriever.ts index 160123c13338..9793d1c76e9d 100644 --- a/yarn-project/archiver/src/l1/calldata_retriever.ts +++ b/yarn-project/archiver/src/l1/calldata_retriever.ts @@ -99,14 +99,18 @@ export class CalldataRetriever { hasPayloadDigest: !!expectedHashes.payloadDigest, }); const tx = await this.publicClient.getTransaction({ hash: txHash }); - const proposeCalldata = await this.getProposeCallData(tx, checkpointNumber); + const proposeCalldata = await this.getProposeCallData(tx, checkpointNumber, expectedHashes); return this.decodeAndBuildCheckpoint(proposeCalldata, tx.blockHash!, 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); + protected async getProposeCallData( + tx: Transaction, + checkpointNumber: CheckpointNumber, + expectedHashes?: { attestationsHash?: Hex; payloadDigest?: Hex }, + ): Promise { + // Try to decode as multicall3 with validation (strict first, relaxed if hashes available) + const proposeCalldata = this.tryDecodeMulticall3(tx, expectedHashes); if (proposeCalldata) { this.logger.trace(`Decoded propose calldata from multicall3 for tx ${tx.hash}`); this.instrumentation?.recordBlockProposalTxTarget(tx.to!, false); @@ -122,7 +126,7 @@ export class CalldataRetriever { } // Try to decode as Spire Proposer multicall wrapper - const spireProposeCalldata = await this.tryDecodeSpireProposer(tx); + const spireProposeCalldata = await this.tryDecodeSpireProposer(tx, expectedHashes); if (spireProposeCalldata) { this.logger.trace(`Decoded propose calldata from Spire Proposer for tx ${tx.hash}`); this.instrumentation?.recordBlockProposalTxTarget(tx.to!, false); @@ -141,9 +145,13 @@ export class CalldataRetriever { * 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. * @param tx - The transaction to decode + * @param expectedHashes - Optional expected hashes for relaxed multicall3 validation * @returns The propose calldata if successfully decoded and validated, undefined otherwise */ - protected async tryDecodeSpireProposer(tx: Transaction): Promise { + protected async tryDecodeSpireProposer( + tx: Transaction, + expectedHashes?: { attestationsHash?: Hex; payloadDigest?: Hex }, + ): Promise { // Try to decode as Spire Proposer multicall (extracts the wrapped call) const spireWrappedCall = await getCallFromSpireProposer(tx, this.publicClient, this.logger); if (!spireWrappedCall) { @@ -155,7 +163,7 @@ export class CalldataRetriever { // Now try to decode the wrapped call as either multicall3 or direct propose const wrappedTx = { to: spireWrappedCall.to, input: spireWrappedCall.data, hash: tx.hash }; - const multicall3Calldata = this.tryDecodeMulticall3(wrappedTx); + const multicall3Calldata = this.tryDecodeMulticall3(wrappedTx, expectedHashes); if (multicall3Calldata) { this.logger.trace(`Decoded propose calldata from Spire Proposer to multicall3 for tx ${tx.hash}`); return multicall3Calldata; @@ -175,11 +183,17 @@ export class CalldataRetriever { /** * Attempts to decode transaction input as multicall3 and extract propose calldata. - * Returns undefined if validation fails. + * Tries strict validation first (all calls must be on the allowlist). If strict fails due to + * unrecognized calls and both expected hashes are available, falls back to relaxed mode which + * filters candidate propose calls by target address + selector and verifies them against hashes. * @param tx - The transaction-like object with to, input, and hash + * @param expectedHashes - Optional expected hashes from CheckpointProposed event * @returns The propose calldata 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 }, + ): Hex | undefined { const txHash = tx.hash; try { @@ -208,9 +222,10 @@ export class CalldataRetriever { const [calls] = multicall3Args; - // Validate all calls and find propose calls + // Strict mode: validate all calls against the allowlist and find propose calls const rollupAddressLower = this.rollupAddress.toString().toLowerCase(); const proposeCalls: Hex[] = []; + let strictFailed = false; for (let i = 0; i < calls.length; i++) { const addr = calls[i].target.toLowerCase(); @@ -220,7 +235,8 @@ export class CalldataRetriever { 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; + strictFailed = true; + break; } const functionSelector = callData.slice(0, 10) as Hex; @@ -237,7 +253,8 @@ export class CalldataRetriever { validCalls: this.validContractCalls.map(c => ({ address: c.address, selector: c.functionSelector })), txHash, }); - return undefined; + strictFailed = true; + break; } this.logger.trace(`Valid call found to ${addr}`, { validCall }); @@ -248,19 +265,81 @@ export class CalldataRetriever { } } - // Validate exactly ONE propose call - if (proposeCalls.length === 0) { - this.logger.warn(`No propose calls found in multicall3`, { txHash }); + // If strict validation passed, return the single propose call + if (!strictFailed) { + if (proposeCalls.length === 0) { + this.logger.warn(`No propose calls found in multicall3`, { txHash }); + return undefined; + } + + if (proposeCalls.length > 1) { + this.logger.warn(`Multiple propose calls found in multicall3 (${proposeCalls.length})`, { txHash }); + return undefined; + } + + return proposeCalls[0]; + } + + // Relaxed mode: strict failed, try hash-verified extraction + // Only attempt when both hashes are present + if (!expectedHashes?.attestationsHash || !expectedHashes?.payloadDigest) { + this.logger.debug(`Strict multicall3 validation failed and relaxed mode unavailable (missing hashes)`, { + txHash, + hasAttestationsHash: !!expectedHashes?.attestationsHash, + hasPayloadDigest: !!expectedHashes?.payloadDigest, + }); return undefined; } - if (proposeCalls.length > 1) { - this.logger.warn(`Multiple propose calls found in multicall3 (${proposeCalls.length})`, { txHash }); + this.logger.debug(`Strict multicall3 validation failed, attempting relaxed hash-verified extraction`, { + txHash, + }); + + // Filter candidate propose calls: target must be rollup address and selector must be propose + const proposeSelectorLower = PROPOSE_SELECTOR.toLowerCase(); + const candidates: Hex[] = []; + + for (const call of calls) { + const addr = call.target.toLowerCase(); + const callData = call.callData; + + if (callData.length < 10) { + continue; + } + + const selector = callData.slice(0, 10).toLowerCase(); + if (addr === rollupAddressLower && selector === proposeSelectorLower) { + candidates.push(callData); + } + } + + if (candidates.length === 0) { + this.logger.debug(`No propose candidates found in relaxed mode`, { txHash }); return undefined; } - // Successfully extracted single propose call - return proposeCalls[0]; + // Verify each candidate against expected hashes + const verified: Hex[] = []; + for (const candidate of candidates) { + if (this.verifyProposeCalldataHashes(candidate, expectedHashes)) { + verified.push(candidate); + } + } + + if (verified.length === 1) { + this.logger.info(`Relaxed mode: verified single propose candidate via hash matching`, { txHash }); + return verified[0]; + } + + if (verified.length > 1) { + this.logger.warn(`Relaxed mode: multiple candidates verified (${verified.length}), cannot disambiguate`, { + txHash, + }); + } else { + this.logger.debug(`Relaxed mode: 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 }); @@ -362,6 +441,76 @@ export class CalldataRetriever { return calls[0].input; } + /** + * Computes the keccak256 hash of ABI-encoded CommitteeAttestations. + * Shared by both extraction-time verification (relaxed mode) and final validation. + */ + private computeAttestationsHash(packedAttestations: ViemCommitteeAttestations): Hex { + return keccak256(encodeAbiParameters([this.getCommitteeAttestationsStructDef()], [packedAttestations])); + } + + /** + * Computes the keccak256 payload digest from the checkpoint header and archive root. + * Shared by both extraction-time verification (relaxed mode) and final validation. + */ + private computePayloadDigest(header: CheckpointHeader, archiveRoot: Fr): Hex { + const consensusPayload = new ConsensusPayload(header, archiveRoot); + const payloadToSign = consensusPayload.getPayloadToSign(SignatureDomainSeparator.checkpointAttestation); + return keccak256(payloadToSign); + } + + /** + * Verifies candidate propose calldata against expected hashes. + * Returns true only when both hashes are provided and both match. + * Returns false for missing hash(es), decode errors, or mismatches. + */ + protected verifyProposeCalldataHashes( + proposeCalldata: Hex, + expectedHashes: { attestationsHash?: Hex; payloadDigest?: Hex }, + ): boolean { + // Both hashes are required for verification + if (!expectedHashes.attestationsHash || !expectedHashes.payloadDigest) { + return false; + } + + try { + const { functionName, args } = decodeFunctionData({ abi: RollupAbi, data: proposeCalldata }); + if (functionName !== 'propose') { + return false; + } + + const [decodedArgs, packedAttestations] = args! as readonly [ + { archive: Hex; oracleInput: { feeAssetPriceModifier: bigint }; header: ViemHeader }, + ViemCommitteeAttestations, + ...unknown[], + ]; + + // Verify attestationsHash + const computedAttestationsHash = this.computeAttestationsHash(packedAttestations); + const attestationsMatch = Buffer.from(hexToBytes(computedAttestationsHash)).equals( + Buffer.from(hexToBytes(expectedHashes.attestationsHash)), + ); + if (!attestationsMatch) { + return false; + } + + // Verify payloadDigest + const header = CheckpointHeader.fromViem(decodedArgs.header); + const archiveRoot = new Fr(Buffer.from(hexToBytes(decodedArgs.archive))); + const computedPayloadDigest = this.computePayloadDigest(header, archiveRoot); + const payloadMatch = Buffer.from(hexToBytes(computedPayloadDigest)).equals( + Buffer.from(hexToBytes(expectedHashes.payloadDigest)), + ); + if (!payloadMatch) { + return false; + } + + return true; + } catch { + return false; + } + } + /** * Extracts the CommitteeAttestations struct definition from RollupAbi. * Finds the _attestations parameter by name in the propose function. @@ -450,16 +599,13 @@ export class CalldataRetriever { // 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]), - ); + const computedAttestationsHash = this.computeAttestationsHash(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)) { + if ( + !Buffer.from(hexToBytes(computedAttestationsHash)).equals( + Buffer.from(hexToBytes(expectedHashes.attestationsHash)), + ) + ) { throw new Error( `Attestations hash mismatch for checkpoint ${checkpointNumber}: ` + `computed=${computedAttestationsHash}, expected=${expectedHashes.attestationsHash}`, @@ -474,17 +620,11 @@ export class CalldataRetriever { // 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 consensusPayload = new ConsensusPayload(header, archiveRoot); - 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)); + const computedPayloadDigest = this.computePayloadDigest(header, archiveRoot); - if (!computedBuffer.equals(expectedBuffer)) { + if ( + !Buffer.from(hexToBytes(computedPayloadDigest)).equals(Buffer.from(hexToBytes(expectedHashes.payloadDigest))) + ) { throw new Error( `Payload digest mismatch for checkpoint ${checkpointNumber}: ` + `computed=${computedPayloadDigest}, expected=${expectedHashes.payloadDigest}`, From 35577bd30451375b8c7b96ba3f2c4093a638be9e Mon Sep 17 00:00:00 2001 From: Michal Rzeszutko Date: Mon, 16 Feb 2026 14:20:00 +0000 Subject: [PATCH 2/3] removing allowlist and backward compatibilty --- .../archiver/src/archiver-sync.test.ts | 1 - yarn-project/archiver/src/factory.ts | 1 - yarn-project/archiver/src/l1/README.md | 81 +- .../archiver/src/l1/bin/retrieve-calldata.ts | 67 +- .../src/l1/calldata_retriever.test.ts | 906 +++++++----------- .../archiver/src/l1/calldata_retriever.ts | 643 ++++--------- .../archiver/src/l1/data_retrieval.ts | 19 +- .../archiver/src/l1/spire_proposer.test.ts | 109 +-- .../archiver/src/l1/spire_proposer.ts | 22 +- .../archiver/src/modules/l1_synchronizer.ts | 7 - .../archiver/src/test/fake_l1_state.ts | 7 +- yarn-project/ethereum/src/contracts/rollup.ts | 26 +- 12 files changed, 650 insertions(+), 1239 deletions(-) diff --git a/yarn-project/archiver/src/archiver-sync.test.ts b/yarn-project/archiver/src/archiver-sync.test.ts index 11cd821b297d..e5aaa1b78549 100644 --- a/yarn-project/archiver/src/archiver-sync.test.ts +++ b/yarn-project/archiver/src/archiver-sync.test.ts @@ -122,7 +122,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 eeb5090c406e..707a72d83c45 100644 --- a/yarn-project/archiver/src/factory.ts +++ b/yarn-project/archiver/src/factory.ts @@ -134,7 +134,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 e1c4b466f082..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,52 +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. - -### Relaxed Hash-Verified Extraction (Multicall3) - -When strict multicall3 validation fails (e.g., due to unrecognized calls from new contract interactions), -the retriever attempts a relaxed hash-verified extraction before falling back to trace: - -1. **Strict first**: All calls must be on the allowlist. If this passes, the single propose call is returned immediately. -2. **Relaxed second**: If strict fails and both `attestationsHash` and `payloadDigest` are available from the - `CheckpointProposed` event: - - Filter candidate `propose` calls by matching both target address (rollup) and function selector (`propose`). - - Verify each candidate's calldata by computing `attestationsHash` (keccak256 of ABI-encoded attestations) - and `payloadDigest` (keccak256 of the consensus payload signing hash) and comparing against expected values. - - Return the uniquely verified candidate (exactly one must match). - - If zero or multiple candidates verify, return undefined and fall back to trace. -3. **Requirements**: Relaxed mode requires **both** hashes to be present. If only one hash is available (or neither), - relaxed mode is skipped entirely and behavior remains backwards-compatible (falls through to trace). - -Note: `decodeAndBuildCheckpoint` continues to perform final hash validation after extraction. The extraction-time -verification is an optimization to avoid expensive `debug_traceTransaction` / `trace_transaction` RPC calls. +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. -This approach also applies to multicall3 transactions wrapped by Spire Proposer. +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 aaa6be68f140..ee3608093437 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'; @@ -42,33 +42,33 @@ import { class TestCalldataRetriever extends CalldataRetriever { public override tryDecodeMulticall3( tx: Transaction, - expectedHashes?: { attestationsHash?: Hex; payloadDigest?: Hex }, - ): Hex | undefined { - return super.tryDecodeMulticall3(tx, expectedHashes); + 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); - } - - public override verifyProposeCalldataHashes( - proposeCalldata: Hex, - expectedHashes: { attestationsHash?: Hex; payloadDigest?: Hex }, - ): boolean { - return super.verifyProposeCalldataHashes(proposeCalldata, expectedHashes); + return super.tryDecodeAndVerifyPropose(proposeCalldata, expectedHashes, checkpointNumber, blockHash); } } @@ -82,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'; @@ -94,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 { @@ -149,13 +149,6 @@ 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). - * In production, expectedHashes come from the blockchain event logs (computed by the Solidity contract). - * - * The hash validation logic tests use mocking rather than recomputing hashes because: - * 1. In production, expectedHashes come from blockchain event logs (computed by Solidity) - * 2. We're testing validation logic, not hash computation correctness - * 3. Recomputing hashes in tests would duplicate production logic, hiding bugs - * 4. Correctness of hash computation is covered by e2e tests */ function mockHashComputation( attestationsHash: Hex = '0x1111111111111111111111111111111111111111111111111111111111111111', @@ -181,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); @@ -201,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 = { @@ -212,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); @@ -221,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(); @@ -254,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] }); @@ -263,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(); @@ -278,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 }]); @@ -319,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); @@ -331,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(); @@ -391,7 +382,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, }); @@ -402,132 +399,179 @@ 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); + }); - expect(result).toBe(proposeCalldata); + it('should return first when multiple propose candidates all verify (with warning)', () => { + const proposeCalldata = makeProposeCalldata(); + const hashes = mockHashComputation(); + + // 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, + }; + } + 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); - - expect(result).toBeUndefined(); - }); - - it('should return undefined when call to unknown address', () => { - const proposeCalldata = makeProposeCalldata(); - const unknownAddress = EthAddress.random(); - - const tx = makeMulticall3Transaction([ - { target: rollupAddress.toString() as Hex, callData: proposeCalldata }, - { target: unknownAddress.toString() as Hex, callData: '0x12345678' as Hex }, - ]); - - const result = retriever.tryDecodeMulticall3(tx); + const result = retriever.tryDecodeMulticall3(tx, hashes, checkpointNumber, blockHash as Hex); expect(result).toBeUndefined(); }); - it('should return undefined when unknown function selector on rollup', () => { + it('should return undefined when propose call to wrong address', () => { const proposeCalldata = makeProposeCalldata(); - const invalidCalldata = '0x99999999' as Hex; // Unknown selector + const hashes = mockHashComputation(); + const wrongRollupAddress = EthAddress.random(); const tx = makeMulticall3Transaction([ - { target: rollupAddress.toString() as Hex, callData: proposeCalldata }, - { target: rollupAddress.toString() as Hex, callData: invalidCalldata }, + { target: wrongRollupAddress.toString() as Hex, callData: proposeCalldata }, ]); - 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')!, ); @@ -537,49 +581,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(); }); @@ -588,6 +636,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, @@ -595,13 +644,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, @@ -609,25 +662,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')!, ); @@ -639,26 +694,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: [ { @@ -684,15 +768,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, + })), ], }); @@ -706,21 +788,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, @@ -735,21 +817,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 getCallsFromSpireProposer(tx, publicClient, logger); + + expect(result).toBeDefined(); + 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 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(2); }); it('should return undefined when not to Spire Proposer address', async () => { @@ -760,7 +858,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(); @@ -768,137 +866,38 @@ 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'); + expect(result).toHaveLength(1); + expect(result![0].to.toLowerCase()).toBe(unknownAddress.toString().toLowerCase()); + expect(result![0].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); - }); - }); + }); describe('verifyProxyImplementation', () => { it('should return true when proxy points to expected implementation', async () => { @@ -1131,57 +1130,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(); - expect(() => retriever.decodeAndBuildCheckpoint(malformedCalldata, blockHash, checkpointNumber, {})).toThrow(); + 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(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); @@ -1203,6 +1248,7 @@ describe('CalldataRetriever', () => { const SPIRE_PROPOSER_EXPECTED_IMPLEMENTATION = '0x7d38d47e7c82195e6e607d3b0f1c20c615c7bf42'; const proposeCalldata = makeProposeCalldata(); + const hashes = mockHashComputation(); // Create Spire Proposer multicall transaction const spireMulticallData = encodeFunctionData({ @@ -1254,7 +1300,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); @@ -1273,261 +1319,9 @@ describe('CalldataRetriever', () => { // Verify instrumentation was called with Spire Proposer address expect(instrumentation.recordBlockProposalTxTarget).toHaveBeenCalledWith(SPIRE_PROPOSER_ADDRESS, false); }); - }); - - describe('verifyProposeCalldataHashes', () => { - it('should return true when both hashes match', () => { - const proposeCalldata = makeProposeCalldata(); - // Mock the hash computation to return specific test values - const hashes = mockHashComputation( - '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' as Hex, - '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' as Hex, - ); - - // When computed hashes match expected hashes, should return true - expect(retriever.verifyProposeCalldataHashes(proposeCalldata, hashes)).toBe(true); - }); - - it('should return false when attestationsHash does not match', () => { - const proposeCalldata = makeProposeCalldata(); - // Mock to return specific hashes - mockHashComputation( - '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' as Hex, - '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' as Hex, - ); - - // Pass different attestationsHash - should return false - expect( - retriever.verifyProposeCalldataHashes(proposeCalldata, { - attestationsHash: '0x0000000000000000000000000000000000000000000000000000000000000001' as Hex, - payloadDigest: '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' as Hex, - }), - ).toBe(false); - }); - - it('should return false when payloadDigest does not match', () => { - const proposeCalldata = makeProposeCalldata(); - // Mock to return specific hashes - mockHashComputation( - '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' as Hex, - '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' as Hex, - ); - - // Pass different payloadDigest - should return false - expect( - retriever.verifyProposeCalldataHashes(proposeCalldata, { - attestationsHash: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' as Hex, - payloadDigest: '0x0000000000000000000000000000000000000000000000000000000000000002' as Hex, - }), - ).toBe(false); - }); - - it('should return false when only attestationsHash is provided', () => { - const proposeCalldata = makeProposeCalldata(); - // Mock to return specific hashes - mockHashComputation( - '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' as Hex, - '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' as Hex, - ); - - // Only provide attestationsHash - should return false (both required) - expect( - retriever.verifyProposeCalldataHashes(proposeCalldata, { - attestationsHash: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' as Hex, - }), - ).toBe(false); - }); - - it('should return false when only payloadDigest is provided', () => { - const proposeCalldata = makeProposeCalldata(); - // Mock to return specific hashes - mockHashComputation( - '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' as Hex, - '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' as Hex, - ); - - // Only provide payloadDigest - should return false (both required) - expect( - retriever.verifyProposeCalldataHashes(proposeCalldata, { - payloadDigest: '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' as Hex, - }), - ).toBe(false); - }); - - it('should return false for invalid calldata', () => { - // Invalid calldata should fail to decode and return false - expect( - retriever.verifyProposeCalldataHashes('0xinvalid' as Hex, { - attestationsHash: '0x0000000000000000000000000000000000000000000000000000000000000001' as Hex, - payloadDigest: '0x0000000000000000000000000000000000000000000000000000000000000002' as Hex, - }), - ).toBe(false); - }); - }); - - describe('tryDecodeMulticall3 relaxed mode', () => { - it('should use strict mode when all calls are valid even with hashes present', () => { - const proposeCalldata = makeProposeCalldata(); - // Mock hash computation - even with hashes present, strict mode succeeds so hashes aren't needed - const hashes = mockHashComputation(); - - const tx = makeMulticall3Transaction([{ target: rollupAddress.toString() as Hex, callData: proposeCalldata }]); - - const result = retriever.tryDecodeMulticall3(tx, hashes); - expect(result).toBe(proposeCalldata); - }); - - it('should return verified calldata in relaxed mode when unknown call + valid propose with matching hashes', () => { - const proposeCalldata = makeProposeCalldata(); - // Mock to return test hashes that will validate the calldata - const hashes = mockHashComputation( - '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef' as Hex, - '0xfedcba0987654321fedcba0987654321fedcba0987654321fedcba0987654321' 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 }, - ]); - - const result = retriever.tryDecodeMulticall3(tx, hashes); - expect(result).toBe(proposeCalldata); - }); - - it('should return undefined when unknown call + propose with wrong target address', () => { - const proposeCalldata = makeProposeCalldata(); - const hashes = mockHashComputation(); - const unknownAddress = EthAddress.random(); - const wrongRollupAddress = EthAddress.random(); - - const tx = makeMulticall3Transaction([ - { target: unknownAddress.toString() as Hex, callData: '0x12345678' as Hex }, - { target: wrongRollupAddress.toString() as Hex, callData: proposeCalldata }, - ]); - - const result = retriever.tryDecodeMulticall3(tx, hashes); - expect(result).toBeUndefined(); - }); - - it('should return undefined when unknown call + call to rollup with wrong selector', () => { - const hashes = mockHashComputation(); - const unknownAddress = EthAddress.random(); - const invalidateBadSelector = toFunctionSelector( - RollupAbi.find(x => x.type === 'function' && x.name === 'invalidateBadAttestation')!, - ); - const nonProposeCalldata = (invalidateBadSelector + '0'.repeat(120)) as Hex; - - const tx = makeMulticall3Transaction([ - { target: unknownAddress.toString() as Hex, callData: '0x12345678' as Hex }, - { target: rollupAddress.toString() as Hex, callData: nonProposeCalldata }, - ]); - - const result = retriever.tryDecodeMulticall3(tx, hashes); - expect(result).toBeUndefined(); - }); - it('should return undefined when unknown call + only one hash provided', () => { + it('should succeed via hash matching when multicall3 has unknown calls', async () => { const proposeCalldata = makeProposeCalldata(); - const hashes = mockHashComputation(); - const unknownAddress = EthAddress.random(); - - const tx = makeMulticall3Transaction([ - { target: unknownAddress.toString() as Hex, callData: '0x12345678' as Hex }, - { target: rollupAddress.toString() as Hex, callData: proposeCalldata }, - ]); - - // Only attestationsHash, no payloadDigest -> strict failure, no relaxed - const result = retriever.tryDecodeMulticall3(tx, { attestationsHash: hashes.attestationsHash }); - expect(result).toBeUndefined(); - }); - - it('should return undefined when unknown call + wrong hashes', () => { - const proposeCalldata = makeProposeCalldata(); - const unknownAddress = EthAddress.random(); - - // Mock to return different hashes than what we'll pass in expectedHashes - mockHashComputation( - '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' as Hex, - '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' as Hex, - ); - - const tx = makeMulticall3Transaction([ - { target: unknownAddress.toString() as Hex, callData: '0x12345678' as Hex }, - { 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, - }); - expect(result).toBeUndefined(); - }); - - it('should return the verified candidate when multiple propose candidates with exactly one verified', () => { - const proposeCalldata1 = makeProposeCalldata(); - const proposeCalldata2 = makeProposeCalldata(); - const unknownAddress = EthAddress.random(); - - // Mock verifyProposeCalldataHashes to return true only for proposeCalldata1 - const hashes = mockHashComputation( - '0x1111111111111111111111111111111111111111111111111111111111111111' as Hex, - '0x2222222222222222222222222222222222222222222222222222222222222222' as Hex, - ); - - // Mock verifyProposeCalldataHashes to be selective - only first calldata verifies - jest.spyOn(retriever, 'verifyProposeCalldataHashes').mockImplementation((calldata, _hashes) => { - return calldata === proposeCalldata1; - }); - - const tx = makeMulticall3Transaction([ - { target: unknownAddress.toString() as Hex, callData: '0x12345678' as Hex }, - { target: rollupAddress.toString() as Hex, callData: proposeCalldata1 }, - { target: rollupAddress.toString() as Hex, callData: proposeCalldata2 }, - ]); - - const result = retriever.tryDecodeMulticall3(tx, hashes); - expect(result).toBe(proposeCalldata1); - }); - - it('should return undefined when multiple propose candidates with multiple verified', () => { - const proposeCalldata = makeProposeCalldata(); - const hashes = mockHashComputation(); - const unknownAddress = EthAddress.random(); - - // Same calldata twice -> both verify - const tx = makeMulticall3Transaction([ - { target: unknownAddress.toString() as Hex, callData: '0x12345678' as Hex }, - { target: rollupAddress.toString() as Hex, callData: proposeCalldata }, - { target: rollupAddress.toString() as Hex, callData: proposeCalldata }, - ]); - - const result = retriever.tryDecodeMulticall3(tx, hashes); - expect(result).toBeUndefined(); - }); - - it('should return undefined when no propose candidates in relaxed mode', () => { - const unknownAddress = EthAddress.random(); - - const tx = makeMulticall3Transaction([ - { target: unknownAddress.toString() as Hex, callData: '0x12345678' as Hex }, - ]); - - const result = retriever.tryDecodeMulticall3(tx, { - attestationsHash: '0x0000000000000000000000000000000000000000000000000000000000000001' as Hex, - payloadDigest: '0x0000000000000000000000000000000000000000000000000000000000000002' as Hex, - }); - expect(result).toBeUndefined(); - }); - }); - - describe('integration: relaxed mode', () => { - const checkpointNumber = CheckpointNumber(42); - - it('should succeed via relaxed mode when multicall3 has unknown calls and both hashes are provided', async () => { - const proposeCalldata = makeProposeCalldata(); - // Mock hash computation to return test hashes that will validate const hashes = mockHashComputation( '0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890' as Hex, '0x0fedcba987654321fedcba987654321fedcba987654321fedcba987654321fed' as Hex, @@ -1549,9 +1343,8 @@ describe('CalldataRetriever', () => { expect(instrumentation.recordBlockProposalTxTarget).toHaveBeenCalledWith(MULTI_CALL_3_ADDRESS, false); }); - it('should succeed via relaxed mode through Spire-wrapped multicall3 with unknown calls', async () => { + it('should succeed via Spire-wrapped multicall3 with unknown calls', async () => { const proposeCalldata = makeProposeCalldata(); - // Mock hash computation to return test hashes that will validate const hashes = mockHashComputation( '0x9876543210fedcba9876543210fedcba9876543210fedcba9876543210fedcba' as Hex, '0x0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef' as Hex, @@ -1662,44 +1455,5 @@ describe('CalldataRetriever', () => { /hash mismatch/i, ); }); - - it('should skip relaxed mode and preserve backwards-compatible behavior when no hashes provided', async () => { - const proposeCalldata = makeProposeCalldata(); - const unknownAddress = EthAddress.random(); - - // Multicall3 with unknown call -> strict fails, no hashes -> relaxed skipped -> falls to trace - const tx = makeMulticall3Transaction([ - { target: unknownAddress.toString() as Hex, callData: '0x12345678' as Hex }, - { target: rollupAddress.toString() as Hex, callData: proposeCalldata }, - ]); - - publicClient.getTransaction.mockResolvedValue(tx); - - // Trace fallback returns the propose calldata - 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: [], - }, - ]); - - const result = await retriever.getCheckpointFromRollupTx(txHash, [], checkpointNumber, {}); - - expect(result.checkpointNumber).toBe(checkpointNumber); - expect(result.header).toBeInstanceOf(CheckpointHeader); - // Should have fallen back to trace - expect(instrumentation.recordBlockProposalTxTarget).toHaveBeenCalledWith(MULTI_CALL_3_ADDRESS, true); - expect(debugClient.request).toHaveBeenCalled(); - }); }); }); diff --git a/yarn-project/archiver/src/l1/calldata_retriever.ts b/yarn-project/archiver/src/l1/calldata_retriever.ts index 9793d1c76e9d..de0b2b49d8fc 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,23 @@ 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; +}; + /** * 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 +50,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 +65,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,54 +73,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; - }> { - 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, expectedHashes); - return this.decodeAndBuildCheckpoint(proposeCalldata, tx.blockHash!, checkpointNumber, expectedHashes); + return this.getCheckpointFromTx(tx, checkpointNumber, expectedHashes); } - /** Gets rollup propose calldata from a transaction */ - protected async getProposeCallData( + /** 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 validation (strict first, relaxed if hashes available) - const proposeCalldata = this.tryDecodeMulticall3(tx, expectedHashes); - if (proposeCalldata) { + 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, expectedHashes); - 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 @@ -138,62 +117,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 - * @param expectedHashes - Optional expected hashes for relaxed multicall3 validation - * @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, - expectedHashes?: { attestationsHash?: Hex; payloadDigest?: Hex }, - ): Promise { - // Try to decode as Spire Proposer multicall (extracts the wrapped call) - const spireWrappedCall = await getCallFromSpireProposer(tx, this.publicClient, this.logger); - if (!spireWrappedCall) { + 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, expectedHashes); - 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. - * Tries strict validation first (all calls must be on the allowlist). If strict fails due to - * unrecognized calls and both expected hashes are available, falls back to relaxed mode which - * filters candidate propose calls by target address + selector and verifies them against hashes. + * 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 - * @param expectedHashes - Optional expected hashes from CheckpointProposed event - * @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 }, - expectedHashes?: { attestationsHash?: Hex; payloadDigest?: Hex }, - ): Hex | undefined { + expectedHashes: { attestationsHash: Hex; payloadDigest: Hex }, + checkpointNumber: CheckpointNumber, + blockHash: Hex, + ): CheckpointData | undefined { const txHash = tx.hash; try { @@ -222,80 +221,8 @@ export class CalldataRetriever { const [calls] = multicall3Args; - // Strict mode: validate all calls against the allowlist and find propose calls + // Find all calls matching rollup address + propose selector const rollupAddressLower = this.rollupAddress.toString().toLowerCase(); - const proposeCalls: Hex[] = []; - let strictFailed = false; - - for (let i = 0; i < calls.length; i++) { - const addr = calls[i].target.toLowerCase(); - const callData = calls[i].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 }); - strictFailed = true; - break; - } - 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, - }); - strictFailed = true; - break; - } - - this.logger.trace(`Valid call found to ${addr}`, { validCall }); - - // Collect propose calls specifically - if (addr === rollupAddressLower && validCall.functionName === 'propose') { - proposeCalls.push(callData); - } - } - - // If strict validation passed, return the single propose call - if (!strictFailed) { - if (proposeCalls.length === 0) { - this.logger.warn(`No propose calls found in multicall3`, { txHash }); - return undefined; - } - - if (proposeCalls.length > 1) { - this.logger.warn(`Multiple propose calls found in multicall3 (${proposeCalls.length})`, { txHash }); - return undefined; - } - - return proposeCalls[0]; - } - - // Relaxed mode: strict failed, try hash-verified extraction - // Only attempt when both hashes are present - if (!expectedHashes?.attestationsHash || !expectedHashes?.payloadDigest) { - this.logger.debug(`Strict multicall3 validation failed and relaxed mode unavailable (missing hashes)`, { - txHash, - hasAttestationsHash: !!expectedHashes?.attestationsHash, - hasPayloadDigest: !!expectedHashes?.payloadDigest, - }); - return undefined; - } - - this.logger.debug(`Strict multicall3 validation failed, attempting relaxed hash-verified extraction`, { - txHash, - }); - - // Filter candidate propose calls: target must be rollup address and selector must be propose const proposeSelectorLower = PROPOSE_SELECTOR.toLowerCase(); const candidates: Hex[] = []; @@ -314,31 +241,33 @@ export class CalldataRetriever { } if (candidates.length === 0) { - this.logger.debug(`No propose candidates found in relaxed mode`, { txHash }); + this.logger.debug(`No propose candidates found in multicall3`, { txHash }); return undefined; } - // Verify each candidate against expected hashes - const verified: Hex[] = []; + // Decode, verify, and build for each candidate + const verified: CheckpointData[] = []; for (const candidate of candidates) { - if (this.verifyProposeCalldataHashes(candidate, expectedHashes)) { - verified.push(candidate); + const result = this.tryDecodeAndVerifyPropose(candidate, expectedHashes, checkpointNumber, blockHash); + if (result) { + verified.push(result); } } if (verified.length === 1) { - this.logger.info(`Relaxed mode: verified single propose candidate via hash matching`, { txHash }); + this.logger.trace(`Verified single propose candidate via hash matching`, { txHash }); return verified[0]; } if (verified.length > 1) { - this.logger.warn(`Relaxed mode: multiple candidates verified (${verified.length}), cannot disambiguate`, { - txHash, - }); - } else { - this.logger.debug(`Relaxed mode: no candidates verified against expected hashes`, { txHash }); + this.logger.warn( + `Multiple propose candidates verified (${verified.length}), returning first (identical data)`, + { txHash }, + ); + return verified[0]; } + this.logger.debug(`No candidates verified against expected hashes`, { txHash }); return undefined; } catch (err) { // Any decoding error triggers fallback to trace @@ -349,11 +278,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 @@ -362,18 +299,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 }); @@ -442,41 +377,24 @@ export class CalldataRetriever { } /** - * Computes the keccak256 hash of ABI-encoded CommitteeAttestations. - * Shared by both extraction-time verification (relaxed mode) and final validation. - */ - private computeAttestationsHash(packedAttestations: ViemCommitteeAttestations): Hex { - return keccak256(encodeAbiParameters([this.getCommitteeAttestationsStructDef()], [packedAttestations])); - } - - /** - * Computes the keccak256 payload digest from the checkpoint header and archive root. - * Shared by both extraction-time verification (relaxed mode) and final validation. - */ - private computePayloadDigest(header: CheckpointHeader, archiveRoot: Fr): Hex { - const consensusPayload = new ConsensusPayload(header, archiveRoot); - const payloadToSign = consensusPayload.getPayloadToSign(SignatureDomainSeparator.checkpointAttestation); - return keccak256(payloadToSign); - } - - /** - * Verifies candidate propose calldata against expected hashes. - * Returns true only when both hashes are provided and both match. - * Returns false for missing hash(es), decode errors, or mismatches. + * 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 verifyProposeCalldataHashes( + protected tryDecodeAndVerifyPropose( proposeCalldata: Hex, - expectedHashes: { attestationsHash?: Hex; payloadDigest?: Hex }, - ): boolean { - // Both hashes are required for verification - if (!expectedHashes.attestationsHash || !expectedHashes.payloadDigest) { - return false; - } - + expectedHashes: { attestationsHash: Hex; payloadDigest: Hex }, + checkpointNumber: CheckpointNumber, + blockHash: Hex, + ): CheckpointData | undefined { try { const { functionName, args } = decodeFunctionData({ abi: RollupAbi, data: proposeCalldata }); if (functionName !== 'propose') { - return false; + return undefined; } const [decodedArgs, packedAttestations] = args! as readonly [ @@ -487,34 +405,71 @@ export class CalldataRetriever { // Verify attestationsHash const computedAttestationsHash = this.computeAttestationsHash(packedAttestations); - const attestationsMatch = Buffer.from(hexToBytes(computedAttestationsHash)).equals( - Buffer.from(hexToBytes(expectedHashes.attestationsHash)), - ); - if (!attestationsMatch) { - return false; + 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 computedPayloadDigest = this.computePayloadDigest(header, archiveRoot); - const payloadMatch = Buffer.from(hexToBytes(computedPayloadDigest)).equals( - Buffer.from(hexToBytes(expectedHashes.payloadDigest)), - ); - if (!payloadMatch) { - return false; + 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; } - return true; + 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, + }; } catch { - return false; + 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 and archive root. */ + private computePayloadDigest(header: CheckpointHeader, archiveRoot: Fr): Hex { + const consensusPayload = new ConsensusPayload(header, archiveRoot); + 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 @@ -547,253 +502,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; - } { - 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) { - const computedAttestationsHash = this.computeAttestationsHash(packedAttestations); - - if ( - !Buffer.from(hexToBytes(computedAttestationsHash)).equals( - Buffer.from(hexToBytes(expectedHashes.attestationsHash)), - ) - ) { - 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) { - const computedPayloadDigest = this.computePayloadDigest(header, archiveRoot); - - if ( - !Buffer.from(hexToBytes(computedPayloadDigest)).equals(Buffer.from(hexToBytes(expectedHashes.payloadDigest))) - ) { - 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, - }; - } } -/** - * 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 7156b9b0c919..ab3f6fbaa426 100644 --- a/yarn-project/archiver/src/l1/data_retrieval.ts +++ b/yarn-project/archiver/src/l1/data_retrieval.ts @@ -154,11 +154,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, @@ -202,7 +197,6 @@ export async function retrieveCheckpointsFromRollup( blobClient, checkpointProposedLogs, rollupConstants, - contractAddresses, instrumentation, logger, isHistoricalSync, @@ -223,7 +217,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. @@ -236,11 +229,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, @@ -252,7 +240,7 @@ async function processCheckpointProposedLogs( targetCommitteeSize, instrumentation, logger, - { ...contractAddresses, rollupAddress: EthAddress.fromString(rollup.address) }, + EthAddress.fromString(rollup.address), ); await asyncPool(10, logs, async log => { @@ -263,10 +251,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 7d8992c09616..222b2b7b0eb8 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'; @@ -60,10 +58,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; @@ -706,7 +700,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 4afb84902926..82468cf434d1 100644 --- a/yarn-project/archiver/src/test/fake_l1_state.ts +++ b/yarn-project/archiver/src/test/fake_l1_state.ts @@ -510,10 +510,9 @@ 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, + // Use random hashes for testing; hash validation is mocked in CalldataRetriever tests + payloadDigest: Buffer32.random(), + attestationsHash: Buffer32.random(), }, })); } 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); + })(), }, })); } From e210b4f8e279e4cc682869351b0549ac65b9ba1b Mon Sep 17 00:00:00 2001 From: Michal Rzeszutko Date: Mon, 16 Feb 2026 16:41:48 +0000 Subject: [PATCH 3/3] fix: compute correct attestationsHash and payloadDigest in FakeL1State CalldataRetriever now validates propose calldata against expected hashes from the CheckpointProposed event. The FakeL1State was emitting random hashes, causing all decode strategies to fail and fall back to trace, which also fails in the mock environment. Now compute the correct hashes from the checkpoint data, matching the CalldataRetriever's verification logic. Co-Authored-By: Claude Opus 4.6 --- .../archiver/src/test/fake_l1_state.ts | 69 ++++++++++++++++--- 1 file changed, 60 insertions(+), 9 deletions(-) diff --git a/yarn-project/archiver/src/test/fake_l1_state.ts b/yarn-project/archiver/src/test/fake_l1_state.ts index 82468cf434d1..10521d8dc1d0 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 = this.makeRollupTx(checkpoint, signers); + // Create the transaction, blobs, and event hashes + const { tx, attestationsHash, payloadDigest } = this.makeRollupTx(checkpoint, signers); const blobHashes = this.makeVersionedBlobHashes(checkpoint); const blobs = this.makeBlobsFromCheckpoint(checkpoint); @@ -208,6 +222,8 @@ export class FakeL1State { blobHashes, blobs, signers, + attestationsHash, + payloadDigest, }); // Update last archive for auto-chaining @@ -510,9 +526,8 @@ export class FakeL1State { checkpointNumber: cpData.checkpointNumber, archive: cpData.checkpoint.archive.root, versionedBlobHashes: cpData.blobHashes.map(h => Buffer.from(h.slice(2), 'hex')), - // Use random hashes for testing; hash validation is mocked in CalldataRetriever tests - payloadDigest: Buffer32.random(), - attestationsHash: Buffer32.random(), + attestationsHash: cpData.attestationsHash, + payloadDigest: cpData.payloadDigest, }, })); } @@ -538,7 +553,10 @@ export class FakeL1State { })); } - private makeRollupTx(checkpoint: Checkpoint, signers: Secp256k1Signer[]): Transaction { + private makeRollupTx( + checkpoint: Checkpoint, + signers: Secp256k1Signer[], + ): { tx: Transaction; attestationsHash: Buffer32; payloadDigest: Buffer32 } { const attestations = signers .map(signer => makeCheckpointAttestationFromCheckpoint(checkpoint, signer)) .map(attestation => CommitteeAttestation.fromSignature(attestation.signature)) @@ -556,6 +574,8 @@ export class FakeL1State { signers[0], ); + const packedAttestations = attestationsAndSigners.getPackedAttestations(); + const rollupInput = encodeFunctionData({ abi: RollupAbi, functionName: 'propose', @@ -565,7 +585,7 @@ export class FakeL1State { archive, oracleInput: { feeAssetPriceModifier: 0n }, }, - attestationsAndSigners.getPackedAttestations(), + packedAttestations, attestationsAndSigners.getSigners().map(signer => signer.toString()), attestationsAndSignersSignature.toViemSignature(), blobInput, @@ -586,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 makeVersionedBlobHashes(checkpoint: Checkpoint): `0x${string}`[] {