Created
March 23, 2026 19:54
-
-
Save koeppelmann/18f786dffa6f4ae4b8ab996d3f13f82d to your computer and use it in GitHub Desktop.
Regression test for issue #246: depth-2 L2→L1→L2 return data
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // SPDX-License-Identifier: MIT | |
| pragma solidity ^0.8.24; | |
| import {Test, console} from "forge-std/Test.sol"; | |
| import {Rollups, RollupConfig} from "../src/Rollups.sol"; | |
| import {CrossChainManagerL2} from "../src/CrossChainManagerL2.sol"; | |
| import {CrossChainProxy} from "../src/CrossChainProxy.sol"; | |
| import {Action, ActionType, ExecutionEntry, StateDelta, ProxyInfo} from "../src/ICrossChainManager.sol"; | |
| import {IZKVerifier} from "../src/IZKVerifier.sol"; | |
| import {Counter} from "./mocks/CounterContracts.sol"; | |
| contract MockZKVerifier246 is IZKVerifier { | |
| function verify(bytes calldata, bytes32) external pure override returns (bool) { | |
| return true; | |
| } | |
| } | |
| /// @title Logger | |
| /// @notice Calls a target with payload, stores the call details including return data. | |
| /// Unlike CounterAndProxy, Logger.execute() RETURNS the call result as bytes, | |
| /// making it visible to cross-chain callers. | |
| contract Logger { | |
| struct Call { | |
| uint256 id; | |
| address target; | |
| bytes payload; | |
| address caller; | |
| bytes returnData; | |
| } | |
| uint256 public callCounter; | |
| Call[] public calls; | |
| function execute(address target, bytes calldata payload) external returns (bytes memory) { | |
| (bool success, bytes memory returnData) = target.call(payload); | |
| require(success, "call failed"); | |
| callCounter++; | |
| calls.push(Call(callCounter, target, payload, msg.sender, returnData)); | |
| return returnData; | |
| } | |
| function getCalls() external view returns (Call[] memory) { | |
| return calls; | |
| } | |
| } | |
| /// @title IntegrationTestDepth2ReturnData | |
| /// @notice Regression test for issue #246: depth-2 L2→L1→L2 cross-chain calls | |
| /// where the inner hop returns non-empty data. | |
| /// | |
| /// Unlike the existing Scenario 3 (which uses CounterAndProxy with void return), | |
| /// this test uses Logger contracts that RETURN the inner call's result. This | |
| /// exposes the bug where the RESULT entry for the inner L1→L2 hop carries | |
| /// empty data instead of the actual Counter return value. | |
| /// | |
| /// ┌──────────────────────────────────────────────────────────────────────────┐ | |
| /// │ Legend │ | |
| /// │ E = Logger on L1 (execute(target,data) → returns bytes) │ | |
| /// │ F = Logger on L2 (execute(target,data) → returns bytes) │ | |
| /// │ B = Counter on L2 (increment() → returns uint256) │ | |
| /// │ E' = CrossChainProxy for E on L2 (L2 contracts call E via E') │ | |
| /// │ B' = CrossChainProxy for B on L1 (E calls B via B') │ | |
| /// └──────────────────────────────────────────────────────────────────────────┘ | |
| /// | |
| /// Flow: Alice → F → E' ──cross-chain──→ E → B' ──cross-chain──→ B | |
| /// | |
| /// ┌────────────────────────────────────────────────────────────────────┐ | |
| /// │ L2 EOA calls F.execute(E', E.execute(B', B.increment())) │ | |
| /// │ → F calls E' (proxy for E on L2) → L2→L1 cross-chain call │ | |
| /// │ → E.execute(B', increment()) runs on L1 │ | |
| /// │ → E calls B' (proxy for B on L1) → L1→L2 return call │ | |
| /// │ → B.increment() runs on L2, returns uint256(1) │ | |
| /// │ → E receives uint256(1) as returnData, stores it │ | |
| /// │ → F receives E's return (bytes wrapping uint256(1)), stores it│ | |
| /// └────────────────────────────────────────────────────────────────────┘ | |
| /// | |
| /// Expected: E.calls[0].returnData == abi.encode(uint256(1)) | |
| /// F.calls[0].returnData == abi.encode(abi.encode(uint256(1))) | |
| /// | |
| /// Bug (#246): E.calls[0].returnData == "" (empty) because the RESULT entry | |
| /// for the inner B' → B hop carries empty data. | |
| contract IntegrationTestDepth2ReturnData is Test { | |
| // ── L1 infrastructure ── | |
| Rollups public rollups; | |
| MockZKVerifier246 public verifier; | |
| // ── L2 infrastructure ── | |
| CrossChainManagerL2 public managerL2; | |
| // ── Application contracts ── | |
| Logger public loggerL1; // E — Logger on L1 | |
| Logger public loggerL2; // F — Logger on L2 | |
| Counter public counterL2; // B — Counter on L2 | |
| // ── Proxies ── | |
| address public loggerL1ProxyOnL2; // E' — proxy for E, on L2 | |
| address public counterL2ProxyOnL1; // B' — proxy for B, on L1 | |
| // ── Constants ── | |
| uint256 constant L2_ROLLUP_ID = 1; | |
| uint256 constant MAINNET_ROLLUP_ID = 0; | |
| address constant SYSTEM_ADDRESS = address(0xFFfFfFffFFfffFFfFFfFFFFFffFFFffffFfFFFfF); | |
| bytes32 constant DEFAULT_VK = keccak256("verificationKey"); | |
| address public alice = makeAddr("alice"); | |
| function setUp() public { | |
| // ── L1 infrastructure ── | |
| verifier = new MockZKVerifier246(); | |
| rollups = new Rollups(address(verifier), 1); | |
| rollups.createRollup(keccak256("l2-initial-state"), DEFAULT_VK, address(this)); | |
| // ── L2 infrastructure ── | |
| managerL2 = new CrossChainManagerL2(L2_ROLLUP_ID, SYSTEM_ADDRESS); | |
| // ── Deploy application contracts ── | |
| loggerL1 = new Logger(); // E | |
| loggerL2 = new Logger(); // F | |
| counterL2 = new Counter(); // B | |
| // ── Deploy proxies ── | |
| // E': proxy for E(Logger on L1), lives on L2 | |
| loggerL1ProxyOnL2 = managerL2.createCrossChainProxy(address(loggerL1), MAINNET_ROLLUP_ID); | |
| // B': proxy for B(Counter on L2), lives on L1 | |
| counterL2ProxyOnL1 = rollups.createCrossChainProxy(address(counterL2), L2_ROLLUP_ID); | |
| } | |
| function _getRollupState(uint256 rollupId) internal view returns (bytes32) { | |
| (,, bytes32 stateRoot,) = rollups.rollups(rollupId); | |
| return stateRoot; | |
| } | |
| /// @notice Depth-2 test: L2 Logger → L1 Logger → L2 Counter | |
| /// | |
| /// This is the scenario that exposes issue #246. The inner call | |
| /// (L1 Logger → L2 Counter via proxy) returns uint256(1), but | |
| /// the RESULT entry has empty data, so L1 Logger sees empty returnData. | |
| function test_Depth2_LoggerToLoggerToCounter_ReturnData() public { | |
| bytes memory incrementCallData = abi.encodeWithSelector(Counter.increment.selector); | |
| // Inner payload: E.execute(B', increment()) | |
| bytes memory loggerExecuteCallData = abi.encodeWithSelector( | |
| Logger.execute.selector, | |
| counterL2ProxyOnL1, | |
| incrementCallData | |
| ); | |
| // ════════════════════════════════════════════ | |
| // Phase 1: L1 — executeL2TX triggers E(Logger) on L1 | |
| // ════════════════════════════════════════════ | |
| // | |
| // E.execute(B', increment()) runs on L1: | |
| // 1. E calls B' (proxy for B on L1) | |
| // 2. B' → executeCrossChainCall → CALL to B matched → RESULT(uint256(1)) | |
| // 3. E receives uint256(1), stores in calls[0].returnData | |
| // 4. E returns abi.encode(uint256(1)) as bytes | |
| // | |
| // Needs 3 entries: | |
| // Entry 1: L2TX → CALL to E (consumed by executeL2TX) | |
| // Entry 2: CALL to B → RESULT(1) (consumed inside reentrant executeCrossChainCall) | |
| // Entry 3: RESULT(E's return) → terminal (consumed after E returns) | |
| bytes memory rlpAliceTx = hex"03"; | |
| Action memory l2txAction = Action({ | |
| actionType: ActionType.L2TX, | |
| rollupId: L2_ROLLUP_ID, | |
| destination: address(0), | |
| value: 0, | |
| data: rlpAliceTx, | |
| failed: false, | |
| sourceAddress: address(0), | |
| sourceRollup: MAINNET_ROLLUP_ID, | |
| scope: new uint256[](0) | |
| }); | |
| // CALL to E: the outer call | |
| // source=Alice from L2 | |
| Action memory callToE = Action({ | |
| actionType: ActionType.CALL, | |
| rollupId: MAINNET_ROLLUP_ID, | |
| destination: address(loggerL1), | |
| value: 0, | |
| data: loggerExecuteCallData, | |
| failed: false, | |
| sourceAddress: alice, | |
| sourceRollup: L2_ROLLUP_ID, | |
| scope: new uint256[](0) | |
| }); | |
| // CALL to B: what E calling B' produces inside executeCrossChainCall | |
| Action memory callToB = Action({ | |
| actionType: ActionType.CALL, | |
| rollupId: L2_ROLLUP_ID, | |
| destination: address(counterL2), | |
| value: 0, | |
| data: incrementCallData, | |
| failed: false, | |
| sourceAddress: address(loggerL1), | |
| sourceRollup: MAINNET_ROLLUP_ID, | |
| scope: new uint256[](0) | |
| }); | |
| // RESULT from B.increment() returning 1 | |
| // THIS IS THE KEY: data must be abi.encode(uint256(1)), not empty | |
| Action memory resultFromB = Action({ | |
| actionType: ActionType.RESULT, | |
| rollupId: L2_ROLLUP_ID, | |
| destination: address(0), | |
| value: 0, | |
| data: abi.encode(uint256(1)), | |
| failed: false, | |
| sourceAddress: address(0), | |
| sourceRollup: 0, | |
| scope: new uint256[](0) | |
| }); | |
| // RESULT from E.execute() — returns bytes memory containing abi.encode(uint256(1)). | |
| // Solidity ABI-encodes this as abi.encode(bytes), and executeOnBehalf uses | |
| // assembly return to pass the raw ABI-encoded bytes (96 bytes total). | |
| // The Rollups contract builds a RESULT with this raw return as data. | |
| Action memory resultFromE = Action({ | |
| actionType: ActionType.RESULT, | |
| rollupId: MAINNET_ROLLUP_ID, | |
| destination: address(0), | |
| value: 0, | |
| data: abi.encode(abi.encode(uint256(1))), | |
| failed: false, | |
| sourceAddress: address(0), | |
| sourceRollup: 0, | |
| scope: new uint256[](0) | |
| }); | |
| bytes32 s0 = keccak256("l2-initial-state"); | |
| bytes32 s1 = keccak256("l2-246-step1"); | |
| bytes32 s2 = keccak256("l2-246-step2"); | |
| bytes32 s3 = keccak256("l2-246-step3"); | |
| // postBatch: 3 deferred entries on L1 | |
| { | |
| StateDelta[] memory deltas1 = new StateDelta[](1); | |
| deltas1[0] = StateDelta({ rollupId: L2_ROLLUP_ID, currentState: s0, newState: s1, etherDelta: 0 }); | |
| StateDelta[] memory deltas2 = new StateDelta[](1); | |
| deltas2[0] = StateDelta({ rollupId: L2_ROLLUP_ID, currentState: s1, newState: s2, etherDelta: 0 }); | |
| StateDelta[] memory deltas3 = new StateDelta[](1); | |
| deltas3[0] = StateDelta({ rollupId: L2_ROLLUP_ID, currentState: s2, newState: s3, etherDelta: 0 }); | |
| ExecutionEntry[] memory entries = new ExecutionEntry[](3); | |
| // Entry 1: L2TX → CALL to E | |
| entries[0].stateDeltas = deltas1; | |
| entries[0].actionHash = keccak256(abi.encode(l2txAction)); | |
| entries[0].nextAction = callToE; | |
| // Entry 2: CALL to B → RESULT(1) | |
| // BUG: if the builder uses result_void here instead of resultFromB, | |
| // the action hash won't match and we get ExecutionNotFound | |
| entries[1].stateDeltas = deltas2; | |
| entries[1].actionHash = keccak256(abi.encode(callToB)); | |
| entries[1].nextAction = resultFromB; | |
| // Entry 3: RESULT from E → terminal | |
| entries[2].stateDeltas = deltas3; | |
| entries[2].actionHash = keccak256(abi.encode(resultFromE)); | |
| entries[2].nextAction = resultFromE; | |
| rollups.postBatch(entries, 0, "", "proof"); | |
| } | |
| // Trigger | |
| rollups.executeL2TX(L2_ROLLUP_ID, rlpAliceTx); | |
| // ── L1 assertions ── | |
| assertEq(loggerL1.callCounter(), 1, "E should have 1 call"); | |
| Logger.Call[] memory l1Calls = loggerL1.getCalls(); | |
| // THE KEY ASSERTION: L1 Logger must have received the return data | |
| // from B.increment() (uint256(1)) through the B' proxy | |
| assertEq(l1Calls[0].returnData.length, 32, "E.returnData should be 32 bytes (abi-encoded uint256)"); | |
| uint256 returnVal = abi.decode(l1Calls[0].returnData, (uint256)); | |
| assertEq(returnVal, 1, "E should have received uint256(1) from B.increment()"); | |
| assertEq(l1Calls[0].target, counterL2ProxyOnL1, "E.target should be B'"); | |
| assertEq(_getRollupState(L2_ROLLUP_ID), s3, "L2 state should be S3"); | |
| // ════════════════════════════════════════════ | |
| // Phase 2: L2 — Alice calls E', scope navigation executes B on L2 | |
| // ════════════════════════════════════════════ | |
| // CALL#1: outer call by Alice to E' | |
| Action memory l2Call1 = Action({ | |
| actionType: ActionType.CALL, | |
| rollupId: MAINNET_ROLLUP_ID, | |
| destination: address(loggerL1), | |
| value: 0, | |
| data: loggerExecuteCallData, | |
| failed: false, | |
| sourceAddress: alice, | |
| sourceRollup: L2_ROLLUP_ID, | |
| scope: new uint256[](0) | |
| }); | |
| // CALL#2: inner call at scope=[0] — E calling B' → B | |
| uint256[] memory scope0 = new uint256[](1); | |
| scope0[0] = 0; | |
| Action memory l2Call2 = Action({ | |
| actionType: ActionType.CALL, | |
| rollupId: L2_ROLLUP_ID, | |
| destination: address(counterL2), | |
| value: 0, | |
| data: incrementCallData, | |
| failed: false, | |
| sourceAddress: address(loggerL1), | |
| sourceRollup: MAINNET_ROLLUP_ID, | |
| scope: scope0 | |
| }); | |
| { | |
| StateDelta[] memory emptyDeltas = new StateDelta[](0); | |
| ExecutionEntry[] memory l2Entries = new ExecutionEntry[](2); | |
| l2Entries[0].stateDeltas = emptyDeltas; | |
| l2Entries[0].actionHash = keccak256(abi.encode(l2Call1)); | |
| l2Entries[0].nextAction = l2Call2; | |
| l2Entries[1].stateDeltas = emptyDeltas; | |
| l2Entries[1].actionHash = keccak256(abi.encode(resultFromB)); | |
| l2Entries[1].nextAction = resultFromB; | |
| vm.prank(SYSTEM_ADDRESS); | |
| managerL2.loadExecutionTable(l2Entries); | |
| } | |
| // Alice calls E' on L2 | |
| vm.prank(alice); | |
| (bool success, bytes memory outerReturn) = loggerL1ProxyOnL2.call(loggerExecuteCallData); | |
| assertTrue(success, "E' call should succeed"); | |
| // ── L2 assertions ── | |
| assertEq(counterL2.counter(), 1, "B(Counter on L2) should be 1"); | |
| // Logger L2 should also have recorded the call if F was in the path | |
| // (In this test, Alice calls E' directly with loggerExecuteCallData, | |
| // so F is not in the L2 path — the L2 side only runs the scope nav) | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment