diff --git a/src/Rollups.sol b/src/Rollups.sol index 2d3c922..e0196f9 100644 --- a/src/Rollups.sol +++ b/src/Rollups.sol @@ -79,32 +79,63 @@ contract Rollups { /// @notice Mapping from action hash to array of pre-computed executions mapping(bytes32 actionHash => Execution[] executions) internal _executions; + /// @notice Mapping from (actionHash, index) to block number when execution was loaded + /// @dev Tracked separately from Execution struct to avoid breaking ZK proof public inputs + mapping(bytes32 => uint256) internal _executionBlockLoaded; + /// @notice Mapping of authorized L2Proxy contracts mapping(address proxy => bool authorized) public authorizedProxies; /// @notice Last block number when state was modified uint256 public lastStateUpdateBlock; + /// @notice Default maximum age (in blocks) for stale execution cleanup + /// @dev ~51 minutes at 12s/slot, aligned with BLOCKHASH opcode window + uint256 public constant MAX_EXECUTION_AGE = 256; + /// @notice Emitted when a new rollup is created - event RollupCreated(uint256 indexed rollupId, address indexed owner, bytes32 verificationKey, bytes32 initialState); + event RollupCreated( + uint256 indexed rollupId, + address indexed owner, + bytes32 verificationKey, + bytes32 initialState + ); /// @notice Emitted when a rollup state is updated event StateUpdated(uint256 indexed rollupId, bytes32 newStateRoot); /// @notice Emitted when a rollup verification key is updated - event VerificationKeyUpdated(uint256 indexed rollupId, bytes32 newVerificationKey); + event VerificationKeyUpdated( + uint256 indexed rollupId, + bytes32 newVerificationKey + ); /// @notice Emitted when a rollup owner is transferred - event OwnershipTransferred(uint256 indexed rollupId, address indexed previousOwner, address indexed newOwner); + event OwnershipTransferred( + uint256 indexed rollupId, + address indexed previousOwner, + address indexed newOwner + ); /// @notice Emitted when a new L2Proxy is created - event L2ProxyCreated(address indexed proxy, address indexed originalAddress, uint256 indexed originalRollupId); + event L2ProxyCreated( + address indexed proxy, + address indexed originalAddress, + uint256 indexed originalRollupId + ); /// @notice Emitted when executions are loaded event ExecutionsLoaded(uint256 count); + /// @notice Emitted when stale executions are cleaned up + event StaleExecutionsCleaned(bytes32 indexed actionHash, uint256 count); + /// @notice Emitted when an L2 execution is performed - event L2ExecutionPerformed(uint256 indexed rollupId, bytes32 currentState, bytes32 newState); + event L2ExecutionPerformed( + uint256 indexed rollupId, + bytes32 currentState, + bytes32 newState + ); /// @notice Error when proof verification fails error InvalidProof(); @@ -136,6 +167,9 @@ contract Rollups { /// @notice Error when a call execution fails error CallExecutionFailed(); + /// @notice Error when cleanup is called with no stale executions to remove + error NoStaleExecutions(); + /// @notice Error when a scope reverts, carrying the next action to continue with /// @param nextAction The ABI-encoded next action to continue with /// @param stateRoot The state root to restore when catching the revert @@ -174,8 +208,12 @@ contract Rollups { /// @param originalAddress The original address this proxy represents /// @param originalRollupId The original rollup ID /// @return proxy The address of the deployed Proxy - function createL2ProxyContract(address originalAddress, uint256 originalRollupId) external returns (address proxy) { - return _createL2ProxyContractInternal(originalAddress, originalRollupId); + function createL2ProxyContract( + address originalAddress, + uint256 originalRollupId + ) external returns (address proxy) { + return + _createL2ProxyContractInternal(originalAddress, originalRollupId); } /// @notice Modifier to check if caller is the rollup owner @@ -271,7 +309,10 @@ contract Rollups { /// @notice Updates the state root for a rollup (owner only, no proof required) /// @param rollupId The rollup ID to update /// @param newStateRoot The new state root - function setStateByOwner(uint256 rollupId, bytes32 newStateRoot) external onlyRollupOwner(rollupId) { + function setStateByOwner( + uint256 rollupId, + bytes32 newStateRoot + ) external onlyRollupOwner(rollupId) { rollups[rollupId].stateRoot = newStateRoot; emit StateUpdated(rollupId, newStateRoot); } @@ -279,7 +320,10 @@ contract Rollups { /// @notice Updates the verification key for a rollup (owner only) /// @param rollupId The rollup ID to update /// @param newVerificationKey The new verification key - function setVerificationKey(uint256 rollupId, bytes32 newVerificationKey) external onlyRollupOwner(rollupId) { + function setVerificationKey( + uint256 rollupId, + bytes32 newVerificationKey + ) external onlyRollupOwner(rollupId) { rollups[rollupId].verificationKey = newVerificationKey; emit VerificationKeyUpdated(rollupId, newVerificationKey); } @@ -287,7 +331,10 @@ contract Rollups { /// @notice Transfers ownership of a rollup to a new owner /// @param rollupId The rollup ID /// @param newOwner The new owner address - function transferRollupOwnership(uint256 rollupId, address newOwner) external onlyRollupOwner(rollupId) { + function transferRollupOwnership( + uint256 rollupId, + address newOwner + ) external onlyRollupOwner(rollupId) { address previousOwner = rollups[rollupId].owner; rollups[rollupId].owner = newOwner; emit OwnershipTransferred(rollupId, previousOwner, newOwner); @@ -296,14 +343,21 @@ contract Rollups { /// @notice Loads pre-computed L2 executions with ZK proof verification /// @param executions The executions to load /// @param proof The ZK proof - function loadL2Executions(Execution[] calldata executions, bytes calldata proof) external { + function loadL2Executions( + Execution[] calldata executions, + bytes calldata proof + ) external { // Build public inputs hash from all executions bytes32[] memory executionHashes = new bytes32[](executions.length); for (uint256 i = 0; i < executions.length; i++) { // Collect verification keys for each state delta - bytes32[] memory verificationKeys = new bytes32[](executions[i].stateDeltas.length); + bytes32[] memory verificationKeys = new bytes32[]( + executions[i].stateDeltas.length + ); for (uint256 j = 0; j < executions[i].stateDeltas.length; j++) { - verificationKeys[j] = rollups[executions[i].stateDeltas[j].rollupId].verificationKey; + verificationKeys[j] = rollups[ + executions[i].stateDeltas[j].rollupId + ].verificationKey; } executionHashes[i] = keccak256( @@ -318,15 +372,21 @@ contract Rollups { // Hash all execution hashes into a single public inputs hash // First byte indicates proof type: 0x01 = loadL2Executions - bytes32 publicInputsHash = keccak256(abi.encodePacked(bytes1(0x01), abi.encode(executionHashes))); + bytes32 publicInputsHash = keccak256( + abi.encodePacked(bytes1(0x01), abi.encode(executionHashes)) + ); if (!zkVerifier.verify(proof, publicInputsHash)) { revert InvalidProof(); } - // Store executions - key is actionHash + // Store executions - key is actionHash, track block loaded for cleanup for (uint256 i = 0; i < executions.length; i++) { - _executions[executions[i].actionHash].push(executions[i]); + bytes32 ah = executions[i].actionHash; + uint256 idx = _executions[ah].length; + _executions[ah].push(executions[i]); + _executionBlockLoaded[keccak256(abi.encode(ah, idx))] = block + .number; } emit ExecutionsLoaded(executions.length); @@ -335,7 +395,9 @@ contract Rollups { /// @notice Executes an L2 execution by an authorized proxy /// @param actionHash The action hash to look up /// @return nextAction The next action to perform - function executeL2Execution(bytes32 actionHash) external returns (Action memory nextAction) { + function executeL2Execution( + bytes32 actionHash + ) external returns (Action memory nextAction) { if (!authorizedProxies[msg.sender]) { revert UnauthorizedProxy(); } @@ -345,7 +407,9 @@ contract Rollups { /// @notice Internal function to find and apply an execution /// @param actionHash The action hash to look up /// @return nextAction The next action to perform - function _findAndApplyExecution(bytes32 actionHash) internal returns (Action memory nextAction) { + function _findAndApplyExecution( + bytes32 actionHash + ) internal returns (Action memory nextAction) { // Look up executions array Execution[] storage executions = _executions[actionHash]; @@ -381,7 +445,11 @@ contract Rollups { config.etherBalance += uint256(delta.etherDelta); } - emit L2ExecutionPerformed(delta.rollupId, delta.currentState, delta.newState); + emit L2ExecutionPerformed( + delta.rollupId, + delta.currentState, + delta.newState + ); } // Record this block as having an L2 execution @@ -423,10 +491,15 @@ contract Rollups { if (nextAction.actionType == ActionType.CALL) { if (_isChildScope(scope, nextAction.scope)) { // Target is deeper - navigate by appending next element - uint256[] memory newScopeArr = _appendToScope(scope, nextAction.scope[scope.length]); + uint256[] memory newScopeArr = _appendToScope( + scope, + nextAction.scope[scope.length] + ); // Use try/catch for recursive call to handle reverts from child scopes - try this.newScope(newScopeArr, nextAction) returns (Action memory retAction) { + try this.newScope(newScopeArr, nextAction) returns ( + Action memory retAction + ) { nextAction = retAction; } catch (bytes memory revertData) { nextAction = _handleScopeRevert(revertData); @@ -443,8 +516,14 @@ contract Rollups { // This is the target revert scope - capture state and revert uint256 rollupId = nextAction.rollupId; bytes32 stateRoot = rollups[rollupId].stateRoot; - Action memory continuation = _getRevertContinuation(rollupId); - revert ScopeReverted(abi.encode(continuation), stateRoot, rollupId); + Action memory continuation = _getRevertContinuation( + rollupId + ); + revert ScopeReverted( + abi.encode(continuation), + stateRoot, + rollupId + ); } else { // Revert is for parent/sibling scope - return to caller break; @@ -477,7 +556,10 @@ contract Rollups { ); if (!authorizedProxies[sourceProxy]) { - _createL2ProxyContractInternal(action.sourceAddress, action.sourceRollup); + _createL2ProxyContractInternal( + action.sourceAddress, + action.sourceRollup + ); } if (action.value > 0) { @@ -488,7 +570,8 @@ contract Rollups { config.etherBalance -= action.value; } - (bool success, bytes memory returnData) = L2Proxy(payable(sourceProxy)).executeOnBehalf{value: action.value}( + (bool success, bytes memory returnData) = L2Proxy(payable(sourceProxy)) + .executeOnBehalf{value: action.value}( action.destination, action.data ); @@ -517,7 +600,10 @@ contract Rollups { /// @param rollupId The rollup ID for the transaction /// @param rlpEncodedTx The RLP-encoded transaction data /// @return result The result data from the execution - function executeL2TX(uint256 rollupId, bytes calldata rlpEncodedTx) external returns (bytes memory result) { + function executeL2TX( + uint256 rollupId, + bytes calldata rlpEncodedTx + ) external returns (bytes memory result) { // Build the L2TX action Action memory action = Action({ actionType: ActionType.L2TX, @@ -539,7 +625,9 @@ contract Rollups { // Delegate all scope handling to newScope with try/catch for reverts // Start with empty scope, action.scope contains target uint256[] memory emptyScope = new uint256[](0); - try this.newScope(emptyScope, nextAction) returns (Action memory retAction) { + try this.newScope(emptyScope, nextAction) returns ( + Action memory retAction + ) { nextAction = retAction; } catch (bytes memory revertData) { // Root scope caught a revert - decode and continue @@ -558,16 +646,104 @@ contract Rollups { /// @param originalAddress The original address this proxy represents /// @param originalRollupId The original rollup ID /// @return proxy The address of the deployed Proxy - function _createL2ProxyContractInternal(address originalAddress, uint256 originalRollupId) internal returns (address proxy) { - bytes32 salt = keccak256(abi.encodePacked(block.chainid, originalRollupId, originalAddress)); + function _createL2ProxyContractInternal( + address originalAddress, + uint256 originalRollupId + ) internal returns (address proxy) { + bytes32 salt = keccak256( + abi.encodePacked(block.chainid, originalRollupId, originalAddress) + ); - proxy = address(new Proxy{salt: salt}(l2ProxyImplementation, address(this), originalAddress, originalRollupId)); + proxy = address( + new Proxy{salt: salt}( + l2ProxyImplementation, + address(this), + originalAddress, + originalRollupId + ) + ); authorizedProxies[proxy] = true; emit L2ProxyCreated(proxy, originalAddress, originalRollupId); } + /// @notice Removes expired executions (TTL-based garbage collection) + /// @dev Permissionless — anyone can call to reclaim storage. Callers receive + /// gas refunds from SSTORE zero-ing via EIP-2929/3529. + /// This implements TTL (time-to-live) semantics: executions expire after + /// maxAge blocks and must be re-submitted if still needed. + /// @param actionHash The action hash whose execution array to clean + /// @param maxAge Maximum age in blocks. Executions loaded more than maxAge blocks ago + /// expire and are removed. Pass 0 to use the default MAX_EXECUTION_AGE. + function cleanupStaleExecutions( + bytes32 actionHash, + uint256 maxAge + ) external { + if (maxAge == 0) { + maxAge = MAX_EXECUTION_AGE; + } + + Execution[] storage executions = _executions[actionHash]; + uint256 len = executions.length; + uint256 cleaned = 0; + + // Iterate backwards to safely remove elements via swap-and-pop + for (uint256 i = len; i > 0; i--) { + bytes32 key = keccak256(abi.encode(actionHash, i - 1)); + uint256 loadedAt = _executionBlockLoaded[key]; + + // TTL expiry check: execution has exceeded its validity window + if (loadedAt > 0 && block.number > loadedAt + maxAge) { + // Clean up blockLoaded tracking + delete _executionBlockLoaded[key]; + + // Swap with last element and pop + uint256 lastIndex = executions.length - 1; + if (i - 1 != lastIndex) { + // Update blockLoaded key for the swapped element + bytes32 lastKey = keccak256( + abi.encode(actionHash, lastIndex) + ); + _executionBlockLoaded[ + keccak256(abi.encode(actionHash, i - 1)) + ] = _executionBlockLoaded[lastKey]; + delete _executionBlockLoaded[lastKey]; + + executions[i - 1] = executions[lastIndex]; + } + executions.pop(); + cleaned++; + } + } + + if (cleaned == 0) { + revert NoStaleExecutions(); + } + + emit StaleExecutionsCleaned(actionHash, cleaned); + } + + /// @notice Returns the number of stored executions for a given action hash + /// @param actionHash The action hash to query + /// @return count The number of executions stored + function getExecutionCount( + bytes32 actionHash + ) external view returns (uint256 count) { + return _executions[actionHash].length; + } + + /// @notice Returns the block number when an execution was loaded + /// @param actionHash The action hash + /// @param index The index within the executions array + /// @return blockNumber The block number when the execution was stored (0 if not tracked) + function getExecutionBlockLoaded( + bytes32 actionHash, + uint256 index + ) external view returns (uint256 blockNumber) { + return _executionBlockLoaded[keccak256(abi.encode(actionHash, index))]; + } + /// @notice Deposits ether to a rollup's balance /// @param rollupId The rollup ID to deposit to function depositEther(uint256 rollupId) external payable { @@ -586,7 +762,7 @@ contract Rollups { revert InsufficientRollupBalance(); } config.etherBalance -= amount; - (bool success,) = payable(msg.sender).call{value: amount}(""); + (bool success, ) = payable(msg.sender).call{value: amount}(""); if (!success) { revert EtherTransferFailed(); } @@ -596,7 +772,10 @@ contract Rollups { /// @param scope The original scope array /// @param element The element to append /// @return The new scope array with the element appended - function _appendToScope(uint256[] memory scope, uint256 element) internal pure returns (uint256[] memory) { + function _appendToScope( + uint256[] memory scope, + uint256 element + ) internal pure returns (uint256[] memory) { uint256[] memory result = new uint256[](scope.length + 1); for (uint256 i = 0; i < scope.length; i++) { result[i] = scope[i]; @@ -609,7 +788,10 @@ contract Rollups { /// @param a First scope array /// @param b Second scope array /// @return True if scopes match exactly - function _scopesMatch(uint256[] memory a, uint256[] memory b) internal pure returns (bool) { + function _scopesMatch( + uint256[] memory a, + uint256[] memory b + ) internal pure returns (bool) { if (a.length != b.length) return false; for (uint256 i = 0; i < a.length; i++) { if (a[i] != b[i]) return false; @@ -621,7 +803,10 @@ contract Rollups { /// @param currentScope The current scope to check against /// @param targetScope The target scope to check /// @return True if targetScope is a child of currentScope - function _isChildScope(uint256[] memory currentScope, uint256[] memory targetScope) internal pure returns (bool) { + function _isChildScope( + uint256[] memory currentScope, + uint256[] memory targetScope + ) internal pure returns (bool) { if (targetScope.length <= currentScope.length) return false; for (uint256 i = 0; i < currentScope.length; i++) { if (currentScope[i] != targetScope[i]) return false; @@ -632,7 +817,9 @@ contract Rollups { /// @notice Handles a ScopeReverted exception by decoding the action and restoring rollup state /// @param revertData The raw revert data (includes 4-byte selector) /// @return nextAction The decoded continuation action - function _handleScopeRevert(bytes memory revertData) internal returns (Action memory nextAction) { + function _handleScopeRevert( + bytes memory revertData + ) internal returns (Action memory nextAction) { // Skip 4-byte selector, decode parameters require(revertData.length > 4, "Invalid revert data"); bytes memory withoutSelector = new bytes(revertData.length - 4); @@ -640,7 +827,8 @@ contract Rollups { withoutSelector[i - 4] = revertData[i]; } // Decode: (bytes nextAction, bytes32 stateRoot, uint256 rollupId) - (bytes memory actionBytes, bytes32 stateRoot, uint256 rollupId) = abi.decode(withoutSelector, (bytes, bytes32, uint256)); + (bytes memory actionBytes, bytes32 stateRoot, uint256 rollupId) = abi + .decode(withoutSelector, (bytes, bytes32, uint256)); // Restore state root rollups[rollupId].stateRoot = stateRoot; @@ -651,7 +839,9 @@ contract Rollups { /// @notice Gets the continuation action after a revert at the current scope /// @param rollupId The rollup ID for the REVERT_CONTINUE action /// @return nextAction The next action from REVERT_CONTINUE lookup - function _getRevertContinuation(uint256 rollupId) internal returns (Action memory nextAction) { + function _getRevertContinuation( + uint256 rollupId + ) internal returns (Action memory nextAction) { // Build REVERT_CONTINUE action (empty data) Action memory revertContinueAction = Action({ actionType: ActionType.REVERT_CONTINUE, @@ -675,15 +865,40 @@ contract Rollups { /// @param originalRollupId The original rollup ID /// @param domain The domain (chain ID) for the address computation /// @return The computed proxy address - function computeL2ProxyAddress(address originalAddress, uint256 originalRollupId, uint256 domain) external view returns (address) { - bytes32 salt = keccak256(abi.encodePacked(domain, originalRollupId, originalAddress)); + function computeL2ProxyAddress( + address originalAddress, + uint256 originalRollupId, + uint256 domain + ) external view returns (address) { + bytes32 salt = keccak256( + abi.encodePacked(domain, originalRollupId, originalAddress) + ); bytes32 bytecodeHash = keccak256( abi.encodePacked( type(Proxy).creationCode, - abi.encode(l2ProxyImplementation, address(this), originalAddress, originalRollupId) + abi.encode( + l2ProxyImplementation, + address(this), + originalAddress, + originalRollupId + ) ) ); - return address(uint160(uint256(keccak256(abi.encodePacked(bytes1(0xff), address(this), salt, bytecodeHash))))); + return + address( + uint160( + uint256( + keccak256( + abi.encodePacked( + bytes1(0xff), + address(this), + salt, + bytecodeHash + ) + ) + ) + ) + ); } } diff --git a/test/StaleExecutionCleanup.t.sol b/test/StaleExecutionCleanup.t.sol new file mode 100644 index 0000000..916360f --- /dev/null +++ b/test/StaleExecutionCleanup.t.sol @@ -0,0 +1,386 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import {Test} from "forge-std/Test.sol"; +import { + Rollups, + Action, + ActionType, + Execution, + StateDelta, + StateCommitment +} from "../src/Rollups.sol"; +import {IZKVerifier} from "../src/IZKVerifier.sol"; + +/// @notice Mock verifier that always returns true +contract MockZKVerifier is IZKVerifier { + function verify(bytes calldata, bytes32) external pure returns (bool) { + return true; + } +} + +/// @title StaleExecutionCleanupTest +/// @notice Tests for the stale execution cleanup mechanism +/// @dev Verifies that expired, state-mismatched executions can be permissionlessly removed +contract StaleExecutionCleanupTest is Test { + Rollups public rollups; + MockZKVerifier public verifier; + + address public alice = makeAddr("alice"); + address public bob = makeAddr("bob"); + bytes32 constant DEFAULT_VK = keccak256("verificationKey"); + + function setUp() public { + verifier = new MockZKVerifier(); + rollups = new Rollups(address(verifier), 1); + } + + /*////////////////////////////////////////////////////////////// + HELPER FUNCTIONS + //////////////////////////////////////////////////////////////*/ + + /// @dev Builds a single execution for a rollup with given states + function _buildExecution( + uint256 rollupId, + bytes32 currentState, + bytes32 newState + ) internal view returns (Execution[] memory) { + Action memory action = Action({ + actionType: ActionType.CALL, + rollupId: rollupId, + destination: address(this), + value: 0, + data: "", + failed: false, + sourceAddress: address(this), + sourceRollup: rollupId, + scope: new uint256[](0) + }); + + Action memory resultAction = Action({ + actionType: ActionType.RESULT, + rollupId: 0, + destination: address(0), + value: 0, + data: "", + failed: false, + sourceAddress: address(0), + sourceRollup: 0, + scope: new uint256[](0) + }); + + StateDelta[] memory stateDeltas = new StateDelta[](1); + stateDeltas[0] = StateDelta({ + rollupId: rollupId, + currentState: currentState, + newState: newState, + etherDelta: 0 + }); + + Execution[] memory executions = new Execution[](1); + executions[0].stateDeltas = stateDeltas; + executions[0].actionHash = keccak256(abi.encode(action)); + executions[0].nextAction = resultAction; + return executions; + } + + /// @dev Returns the actionHash from a built execution + function _getActionHash( + Execution[] memory executions + ) internal pure returns (bytes32) { + return executions[0].actionHash; + } + + /*////////////////////////////////////////////////////////////// + BLOCK LOADED TRACKING TESTS + //////////////////////////////////////////////////////////////*/ + + /// @notice Verifies that blockLoaded is recorded when executions are loaded + function test_BlockLoadedIsTracked() public { + uint256 rollupId = rollups.createRollup(bytes32(0), DEFAULT_VK, alice); + Execution[] memory executions = _buildExecution( + rollupId, + bytes32(0), + keccak256("new") + ); + + rollups.loadL2Executions(executions, "proof"); + + bytes32 actionHash = _getActionHash(executions); + assertEq( + rollups.getExecutionBlockLoaded(actionHash, 0), + block.number, + "Block loaded should be recorded" + ); + } + + /// @notice Verifies execution count view works + function test_GetExecutionCount() public { + uint256 rollupId = rollups.createRollup(bytes32(0), DEFAULT_VK, alice); + Execution[] memory executions = _buildExecution( + rollupId, + bytes32(0), + keccak256("new") + ); + + bytes32 actionHash = _getActionHash(executions); + assertEq(rollups.getExecutionCount(actionHash), 0, "Should start at 0"); + + rollups.loadL2Executions(executions, "proof"); + assertEq( + rollups.getExecutionCount(actionHash), + 1, + "Should be 1 after load" + ); + } + + /// @notice Verifies multiple loads to same actionHash track independently + function test_MultipleLoadsTrackedIndependently() public { + uint256 rollupId = rollups.createRollup(bytes32(0), DEFAULT_VK, alice); + Execution[] memory exec1 = _buildExecution( + rollupId, + bytes32(0), + keccak256("new1") + ); + bytes32 actionHash = _getActionHash(exec1); + + // First load at block 100 + vm.roll(100); + rollups.loadL2Executions(exec1, "proof"); + assertEq( + rollups.getExecutionCount(actionHash), + 1, + "Count after first load" + ); + + // Second load at block 200 + vm.roll(200); + rollups.loadL2Executions(exec1, "proof"); + assertEq( + rollups.getExecutionCount(actionHash), + 2, + "Count after second load" + ); + + // Verify each was tracked at the correct block + assertEq( + rollups.getExecutionBlockLoaded(actionHash, 0), + 100, + "First load at block 100" + ); + assertEq( + rollups.getExecutionBlockLoaded(actionHash, 1), + 200, + "Second load at block 200" + ); + } + + /*////////////////////////////////////////////////////////////// + CLEANUP FUNCTIONALITY TESTS + //////////////////////////////////////////////////////////////*/ + + /// @notice Cleanup removes stale executions whose state no longer matches + function test_CleanupRemovesStaleExecutions() public { + uint256 rollupId = rollups.createRollup(bytes32(0), DEFAULT_VK, alice); + Execution[] memory executions = _buildExecution( + rollupId, + bytes32(0), + keccak256("new") + ); + bytes32 actionHash = _getActionHash(executions); + + rollups.loadL2Executions(executions, "proof"); + assertEq(rollups.getExecutionCount(actionHash), 1); + + // Advance rollup state so the loaded execution becomes stale + vm.prank(alice); + rollups.setStateByOwner(rollupId, keccak256("advanced")); + + // Advance past MAX_EXECUTION_AGE + vm.roll(block.number + 257); + + // Anyone can call cleanup + vm.prank(bob); + rollups.cleanupStaleExecutions(actionHash, 0); + + assertEq( + rollups.getExecutionCount(actionHash), + 0, + "Stale execution should be removed" + ); + } + + /// @notice Cleanup emits the correct event + function test_CleanupEmitsEvent() public { + uint256 rollupId = rollups.createRollup(bytes32(0), DEFAULT_VK, alice); + Execution[] memory executions = _buildExecution( + rollupId, + bytes32(0), + keccak256("new") + ); + bytes32 actionHash = _getActionHash(executions); + + rollups.loadL2Executions(executions, "proof"); + + vm.prank(alice); + rollups.setStateByOwner(rollupId, keccak256("advanced")); + vm.roll(block.number + 257); + + vm.expectEmit(true, false, false, true); + emit Rollups.StaleExecutionsCleaned(actionHash, 1); + rollups.cleanupStaleExecutions(actionHash, 0); + } + + /// @notice Cleanup reverts if no stale executions exist + function test_CleanupRevertsIfNothingToClean() public { + uint256 rollupId = rollups.createRollup(bytes32(0), DEFAULT_VK, alice); + Execution[] memory executions = _buildExecution( + rollupId, + bytes32(0), + keccak256("new") + ); + bytes32 actionHash = _getActionHash(executions); + + rollups.loadL2Executions(executions, "proof"); + + // State still matches AND not expired — nothing to clean + vm.expectRevert(Rollups.NoStaleExecutions.selector); + rollups.cleanupStaleExecutions(actionHash, 0); + } + + /// @notice Cleanup DOES remove executions that have expired, even if they still match current state + function test_CleanupRemovesExpiredMatchingExecutions() public { + uint256 rollupId = rollups.createRollup(bytes32(0), DEFAULT_VK, alice); + Execution[] memory executions = _buildExecution( + rollupId, + bytes32(0), + keccak256("new") + ); + bytes32 actionHash = _getActionHash(executions); + + rollups.loadL2Executions(executions, "proof"); + + // State still matches, but we advance past expiry + vm.roll(block.number + 257); + + rollups.cleanupStaleExecutions(actionHash, 0); + + // Execution should be removed because it expired (TTL semantics) + assertEq(rollups.getExecutionCount(actionHash), 0); + } + + /// @notice Cleanup does NOT remove executions that haven't expired yet + function test_CleanupRevertsIfNotExpired() public { + uint256 rollupId = rollups.createRollup(bytes32(0), DEFAULT_VK, alice); + Execution[] memory executions = _buildExecution( + rollupId, + bytes32(0), + keccak256("new") + ); + bytes32 actionHash = _getActionHash(executions); + + rollups.loadL2Executions(executions, "proof"); + + // Advance state but NOT past expiry + vm.prank(alice); + rollups.setStateByOwner(rollupId, keccak256("advanced")); + vm.roll(block.number + 100); // only 100 blocks, need 256 + + vm.expectRevert(Rollups.NoStaleExecutions.selector); + rollups.cleanupStaleExecutions(actionHash, 0); + } + + /// @notice Custom maxAge parameter works + function test_CleanupWithCustomMaxAge() public { + uint256 rollupId = rollups.createRollup(bytes32(0), DEFAULT_VK, alice); + Execution[] memory executions = _buildExecution( + rollupId, + bytes32(0), + keccak256("new") + ); + bytes32 actionHash = _getActionHash(executions); + + rollups.loadL2Executions(executions, "proof"); + + vm.prank(alice); + rollups.setStateByOwner(rollupId, keccak256("advanced")); + + // Only advance 11 blocks, use maxAge=10 + vm.roll(block.number + 11); + + rollups.cleanupStaleExecutions(actionHash, 10); + assertEq(rollups.getExecutionCount(actionHash), 0); + } + + /// @notice Cleanup selective removal handles multiple executions properly + function test_CleanupSelectiveRemoval() public { + uint256 rollupId = rollups.createRollup(bytes32(0), DEFAULT_VK, alice); + + // First execution — will expire + Execution[] memory executions1 = _buildExecution( + rollupId, + bytes32(0), + keccak256("new1") + ); + bytes32 actionHash = _getActionHash(executions1); + rollups.loadL2Executions(executions1, "proof"); + + // Advance block so the first is older, but not yet expired + vm.roll(block.number + 50); + + // Second execution — will NOT expire + Execution[] memory executions2 = _buildExecution( + rollupId, + bytes32(0), + keccak256("new2") + ); + rollups.loadL2Executions(executions2, "proof2"); + + // Validate both are loaded under the same actionHash + assertEq(rollups.getExecutionCount(actionHash), 2); + + // Advance 210 blocks: first execution is 260 blocks old (expired) + // second execution is 210 blocks old (not expired) + vm.roll(block.number + 210); + + rollups.cleanupStaleExecutions(actionHash, 0); + + assertEq( + rollups.getExecutionCount(actionHash), + 1, + "Only expired execution should be removed" + ); + } + + /// @notice Cleanup is fully permissionless + function test_CleanupIsPermissionless() public { + uint256 rollupId = rollups.createRollup(bytes32(0), DEFAULT_VK, alice); + Execution[] memory executions = _buildExecution( + rollupId, + bytes32(0), + keccak256("new") + ); + bytes32 actionHash = _getActionHash(executions); + + rollups.loadL2Executions(executions, "proof"); + + vm.prank(alice); + rollups.setStateByOwner(rollupId, keccak256("advanced")); + vm.roll(block.number + 257); + + // Random address can call cleanup + address randomAddress = makeAddr("random_cleanup_bot"); + vm.prank(randomAddress); + rollups.cleanupStaleExecutions(actionHash, 0); + + assertEq(rollups.getExecutionCount(actionHash), 0); + } + + /// @notice Cleanup on empty array reverts + function test_CleanupOnEmptyArrayReverts() public { + bytes32 fakeHash = keccak256("nonexistent"); + + vm.expectRevert(Rollups.NoStaleExecutions.selector); + rollups.cleanupStaleExecutions(fakeHash, 0); + } +}