Skip to content

Instantly share code, notes, and snippets.

@koeppelmann
Created March 23, 2026 19:54
Show Gist options
  • Select an option

  • Save koeppelmann/18f786dffa6f4ae4b8ab996d3f13f82d to your computer and use it in GitHub Desktop.

Select an option

Save koeppelmann/18f786dffa6f4ae4b8ab996d3f13f82d to your computer and use it in GitHub Desktop.
Regression test for issue #246: depth-2 L2→L1→L2 return data
// 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