diff --git a/src/policies/TimelockPolicy.sol b/src/policies/TimelockPolicy.sol index b88d1e1..5bb6ed1 100644 --- a/src/policies/TimelockPolicy.sol +++ b/src/policies/TimelockPolicy.sol @@ -4,6 +4,7 @@ pragma solidity ^0.8.0; import {PackedUserOperation} from "account-abstraction/interfaces/PackedUserOperation.sol"; import {IAccountExecute} from "account-abstraction/interfaces/IAccountExecute.sol"; import {IERC7579Execution} from "openzeppelin-contracts/contracts/interfaces/draft-IERC7579.sol"; +import {LibERC7579} from "solady/accounts/LibERC7579.sol"; import {IModule, IStatelessValidator, IStatelessValidatorWithSender} from "src/interfaces/IERC7579Modules.sol"; import {PolicyBase} from "src/base/PolicyBase.sol"; import { @@ -29,14 +30,13 @@ contract TimelockPolicy is PolicyBase, IStatelessValidator, IStatelessValidatorW struct TimelockConfig { uint48 delay; // Timelock delay in seconds uint48 expirationPeriod; // How long after validAfter the proposal remains valid - uint48 gracePeriod; // Period after validAfter during which only owner can execute/cancel + address guardian; // Address that can cancel proposals without timelock (address(0) = no guardian) bool initialized; } struct Proposal { ProposalStatus status; - uint48 validAfter; // Timestamp when timelock passes (grace period starts) - uint48 graceEnd; // Timestamp when grace period ends (public execution allowed) + uint48 validAfter; // Timestamp when timelock passes and proposal becomes executable uint48 validUntil; // Timestamp when proposal expires uint256 epoch; // Epoch when proposal was created } @@ -60,22 +60,21 @@ contract TimelockPolicy is PolicyBase, IStatelessValidator, IStatelessValidatorW event ProposalCancelled(address indexed wallet, bytes32 indexed id, bytes32 indexed proposalHash); event TimelockConfigUpdated( - address indexed wallet, bytes32 indexed id, uint256 delay, uint256 expirationPeriod, uint256 gracePeriod + address indexed wallet, bytes32 indexed id, uint256 delay, uint256 expirationPeriod, address guardian ); error InvalidDelay(); error InvalidExpirationPeriod(); - error InvalidGracePeriod(); error ProposalNotPending(); error OnlyAccount(); error ParametersTooLarge(); /** * @notice Install the timelock policy - * @param _data Encoded: (uint48 delay, uint48 expirationPeriod, uint48 gracePeriod) + * @param _data Encoded: (uint48 delay, uint48 expirationPeriod, address guardian) */ function _policyOninstall(bytes32 id, bytes calldata _data) internal override { - (uint48 delay, uint48 expirationPeriod, uint48 gracePeriod) = abi.decode(_data, (uint48, uint48, uint48)); + (uint48 delay, uint48 expirationPeriod, address guardian) = abi.decode(_data, (uint48, uint48, address)); if (timelockConfig[id][msg.sender].initialized) { revert IModule.AlreadyInitialized(msg.sender); @@ -83,9 +82,8 @@ contract TimelockPolicy is PolicyBase, IStatelessValidator, IStatelessValidatorW if (delay == 0) revert InvalidDelay(); if (expirationPeriod == 0) revert InvalidExpirationPeriod(); - if (gracePeriod == 0) revert InvalidGracePeriod(); - // Prevent uint48 overflow: uint48(block.timestamp) + delay + gracePeriod + expirationPeriod - if (uint256(delay) + uint256(gracePeriod) + uint256(expirationPeriod) > type(uint48).max - block.timestamp) { + // Prevent uint48 overflow: uint48(block.timestamp) + delay + expirationPeriod + if (uint256(delay) + uint256(expirationPeriod) > type(uint48).max - block.timestamp) { revert ParametersTooLarge(); } @@ -93,9 +91,9 @@ contract TimelockPolicy is PolicyBase, IStatelessValidator, IStatelessValidatorW currentEpoch[id][msg.sender]++; timelockConfig[id][msg.sender] = - TimelockConfig({delay: delay, expirationPeriod: expirationPeriod, gracePeriod: gracePeriod, initialized: true}); + TimelockConfig({delay: delay, expirationPeriod: expirationPeriod, guardian: guardian, initialized: true}); - emit TimelockConfigUpdated(msg.sender, id, delay, expirationPeriod, gracePeriod); + emit TimelockConfigUpdated(msg.sender, id, delay, expirationPeriod, guardian); } /** @@ -120,15 +118,16 @@ contract TimelockPolicy is PolicyBase, IStatelessValidator, IStatelessValidatorW /** * @notice Cancel a pending proposal - * @dev Only the account itself can cancel proposals to prevent griefing + * @dev Only the account itself or its designated guardian can cancel proposals * @param id The policy ID * @param account The account address * @param callData The calldata of the proposal * @param nonce The nonce of the proposal */ function cancelProposal(bytes32 id, address account, bytes calldata callData, uint256 nonce) external { - // Only the account itself can cancel its own proposals - if (msg.sender != account) revert OnlyAccount(); + // Only the account itself or the designated guardian can cancel proposals + address guardianAddr = timelockConfig[id][account].guardian; + if (msg.sender != account && (guardianAddr == address(0) || msg.sender != guardianAddr)) revert OnlyAccount(); TimelockConfig storage config = timelockConfig[id][account]; if (!config.initialized) revert IModule.NotInitialized(account); @@ -191,8 +190,7 @@ contract TimelockPolicy is PolicyBase, IStatelessValidator, IStatelessValidatorW // Calculate proposal timing uint48 validAfter = uint48(block.timestamp) + config.delay; - uint48 graceEnd = validAfter + config.gracePeriod; - uint48 validUntil = graceEnd + config.expirationPeriod; + uint48 validUntil = validAfter + config.expirationPeriod; // Create userOp key for storage lookup (using PROPOSAL calldata and nonce, not current userOp) bytes32 userOpKey = keccak256(abi.encode(userOp.sender, keccak256(proposalCallData), proposalNonce)); @@ -204,8 +202,12 @@ contract TimelockPolicy is PolicyBase, IStatelessValidator, IStatelessValidatorW } // Create proposal with current epoch - proposals[userOpKey][id][account] = - Proposal({status: ProposalStatus.Pending, validAfter: validAfter, graceEnd: graceEnd, validUntil: validUntil, epoch: currentEpoch[id][account]}); + proposals[userOpKey][id][account] = Proposal({ + status: ProposalStatus.Pending, + validAfter: validAfter, + validUntil: validUntil, + epoch: currentEpoch[id][account] + }); emit ProposalCreated(account, id, userOpKey, validAfter, validUntil); return _packValidationData(0, 0); @@ -213,8 +215,8 @@ contract TimelockPolicy is PolicyBase, IStatelessValidator, IStatelessValidatorW /** * @notice Handle proposal execution from userOp - * @dev Returns graceEnd as validAfter to prevent execution during grace period. - * This gives the owner time to cancel proposals without race conditions. + * @dev Returns validAfter/validUntil so EntryPoint enforces the timelock window. + * The guardian mechanism provides the cancellation path (not a grace period). */ function _handleProposalExecutionInternal(bytes32 id, PackedUserOperation calldata userOp, address account) internal @@ -236,16 +238,14 @@ contract TimelockPolicy is PolicyBase, IStatelessValidator, IStatelessValidatorW emit ProposalExecuted(account, id, userOpKey); - // Return graceEnd (not validAfter) as the earliest execution time - // This prevents race conditions by ensuring the owner has a grace period to cancel - return _packValidationData(proposal.graceEnd, proposal.validUntil); + return _packValidationData(proposal.validAfter, proposal.validUntil); } /** * @notice Check if calldata is a no-op operation * @dev Recognizes 4 forms of no-op: * 1. Empty calldata - * 2. ERC-7579 execute(mode=0x00, "") — single-call with empty execution data + * 2. ERC-7579 execute(mode=0x00, abi.encodePacked(address(0), uint256(0))) — single-call, zero-target, zero-value, no inner calldata * 3. executeUserOp + empty inner calldata (just the 4-byte selector) * 4. executeUserOp + ERC-7579 execute no-op (selector + form 2) */ @@ -255,7 +255,7 @@ contract TimelockPolicy is PolicyBase, IStatelessValidator, IStatelessValidatorW // Case 1: Empty calldata if (len == 0) return true; - // Case 2: ERC-7579 execute with empty execution data + // Case 2: ERC-7579 execute with minimal no-op execution data if (_isNoOpERC7579Execute(callData)) return true; // Cases 3 & 4: executeUserOp wrapper @@ -270,22 +270,41 @@ contract TimelockPolicy is PolicyBase, IStatelessValidator, IStatelessValidatorW } /** - * @notice Check if calldata is an ERC-7579 execute call with empty execution data + * @notice Check if calldata is an ERC-7579 execute call that performs a zero-value no-op * @dev execute(bytes32 mode, bytes calldata executionCalldata) where: - * - mode byte 0 is 0x00 (single call, not batch/delegatecall) - * - executionCalldata is empty - * ABI layout: selector(4) + mode(32) + offset(32) + length(32) = 100 bytes + * - mode is CALLTYPE_SINGLE (not batch/delegatecall) + * - executionCalldata decodes via LibERC7579.decodeSingle() to (address(0), 0, empty) + * - target is address(0) (a non-zero target could trigger receive()/fallback() side effects) + * - value is 0 (no ETH transfer) + * - no inner calldata */ function _isNoOpERC7579Execute(bytes calldata callData) internal pure returns (bool) { - if (callData.length != 100) return false; + // Minimum: selector(4) + mode(32) + ABI bytes header: offset(32) + length(32) = 100 + if (callData.length < 100) return false; if (bytes4(callData[0:4]) != IERC7579Execution.execute.selector) return false; - // Mode byte must be 0x00 (single call, not delegatecall or batch) - if (callData[4] != 0x00) return false; - // Offset must be 64 (standard ABI encoding for dynamic param after one fixed param) - if (uint256(bytes32(callData[36:68])) != 64) return false; - // Execution data length must be 0 - if (uint256(bytes32(callData[68:100])) != 0) return false; - return true; + + // Decode mode and check call type via LibERC7579 + bytes32 mode = bytes32(callData[4:36]); + if (LibERC7579.getCallType(mode) != LibERC7579.CALLTYPE_SINGLE) return false; + + // Extract executionCalldata from ABI-encoded bytes parameter + uint256 offset = uint256(bytes32(callData[36:68])); + uint256 lenPos = 4 + offset; + if (callData.length < lenPos + 32) return false; + uint256 dataLen = uint256(bytes32(callData[lenPos:lenPos + 32])); + uint256 dataPos = lenPos + 32; + if (callData.length < dataPos + dataLen) return false; + + bytes calldata executionCalldata = callData[dataPos:dataPos + dataLen]; + + // decodeSingle requires length > 0x33 (target(20) + value(32) minimum) + if (executionCalldata.length <= 0x33) return false; + + // Use LibERC7579 to decode — same decoding path the account uses + (address target, uint256 val, bytes calldata innerCalldata) = LibERC7579.decodeSingle(executionCalldata); + + // No-op: zero target, zero value, and no inner calldata + return target == address(0) && val == 0 && innerCalldata.length == 0; } /** @@ -341,9 +360,8 @@ contract TimelockPolicy is PolicyBase, IStatelessValidator, IStatelessValidatorW TimelockConfig storage config = timelockConfig[id][account]; if (!config.initialized) return SIG_VALIDATION_FAILED_UINT; - // Check if this is a proposal creation request - // Criteria: calldata is a no-op AND signature has proposal data (length >= 65) - if (_isNoOpCalldata(userOp.callData) && sig.length >= 65) { + // Check if this is a proposal creation request (no-op calldata with proposal data in sig) + if (_isNoOpCalldata(userOp.callData)) { return _handleProposalCreationInternal(id, userOp, config, sig, account); } @@ -359,18 +377,17 @@ contract TimelockPolicy is PolicyBase, IStatelessValidator, IStatelessValidatorW * @param id The policy ID * @param wallet The wallet address * @return status The proposal status - * @return validAfter When the timelock passes (grace period starts) - * @return graceEnd When the grace period ends (public execution allowed) + * @return validAfter When the timelock passes and proposal becomes executable * @return validUntil When the proposal expires */ function getProposal(address account, bytes calldata callData, uint256 nonce, bytes32 id, address wallet) external view - returns (ProposalStatus status, uint256 validAfter, uint256 graceEnd, uint256 validUntil) + returns (ProposalStatus status, uint256 validAfter, uint256 validUntil) { bytes32 userOpKey = keccak256(abi.encode(account, keccak256(callData), nonce)); Proposal storage proposal = proposals[userOpKey][id][wallet]; - return (proposal.status, proposal.validAfter, proposal.graceEnd, proposal.validUntil); + return (proposal.status, proposal.validAfter, proposal.validUntil); } /** diff --git a/test/TimelockPolicy.t.sol b/test/TimelockPolicy.t.sol index 5b287bf..34e411e 100644 --- a/test/TimelockPolicy.t.sol +++ b/test/TimelockPolicy.t.sol @@ -11,7 +11,7 @@ import "forge-std/console.sol"; contract TimelockPolicyTest is PolicyTestBase, StatelessValidatorTestBase, StatelessValidatorWithSenderTestBase { uint48 delay = 1 days; uint48 expirationPeriod = 1 days; - uint48 gracePeriod = 1 hours; + address guardian = address(0); function deployModule() internal virtual override returns (IModule) { return new TimelockPolicy(); @@ -20,7 +20,7 @@ contract TimelockPolicyTest is PolicyTestBase, StatelessValidatorTestBase, State function _initializeTest() internal override {} function installData() internal view override returns (bytes memory) { - return abi.encode(delay, expirationPeriod, gracePeriod); + return abi.encode(delay, expirationPeriod, guardian); } function validUserOp() internal view virtual override returns (PackedUserOperation memory) { @@ -115,7 +115,7 @@ contract TimelockPolicyTest is PolicyTestBase, StatelessValidatorTestBase, State bytes32 message = keccak256(abi.encodePacked("TEST_MESSAGE")); (, bytes memory sig) = statelessValidationSignature(message, false); - bytes memory data = abi.encode(uint48(0), uint48(0), uint48(0)); + bytes memory data = abi.encode(uint48(0), uint48(0), address(0)); vm.startPrank(WALLET); vm.expectRevert("TimelockPolicy: stateless signature validation not supported"); @@ -143,7 +143,7 @@ contract TimelockPolicyTest is PolicyTestBase, StatelessValidatorTestBase, State bytes32 message = keccak256(abi.encodePacked("TEST_MESSAGE")); (address caller, bytes memory sig) = statelessValidationSignatureWithSender(message, false); - bytes memory data = abi.encode(uint48(0), uint48(0), uint48(0)); + bytes memory data = abi.encode(uint48(0), uint48(0), address(0)); vm.startPrank(WALLET); vm.expectRevert("TimelockPolicy: stateless signature validation not supported"); @@ -192,8 +192,8 @@ contract TimelockPolicyTest is PolicyTestBase, StatelessValidatorTestBase, State policyModule.checkUserOpPolicy(policyId(), noopOp); vm.stopPrank(); - // Fast forward past the delay AND grace period - vm.warp(block.timestamp + delay + gracePeriod + 1); + // Fast forward past the delay + vm.warp(block.timestamp + delay + 1); // Now execute the proposal vm.startPrank(WALLET); @@ -282,13 +282,12 @@ contract TimelockPolicyTest is PolicyTestBase, StatelessValidatorTestBase, State assertEq(result, 0); // Verify proposal was created - (TimelockPolicy.ProposalStatus status, uint256 validAfter, uint256 graceEnd, uint256 validUntil) = + (TimelockPolicy.ProposalStatus status, uint256 validAfter, uint256 validUntil) = policyModule.getProposal(WALLET, callData, nonce, policyId(), WALLET); assertEq(uint256(status), uint256(TimelockPolicy.ProposalStatus.Pending)); assertEq(validAfter, block.timestamp + delay); - assertEq(graceEnd, block.timestamp + delay + gracePeriod); - assertEq(validUntil, block.timestamp + delay + gracePeriod + expirationPeriod); + assertEq(validUntil, block.timestamp + delay + expirationPeriod); } function testCancelProposal() public { @@ -324,7 +323,7 @@ contract TimelockPolicyTest is PolicyTestBase, StatelessValidatorTestBase, State vm.stopPrank(); // Verify proposal was cancelled - (TimelockPolicy.ProposalStatus status,,,) = policyModule.getProposal(WALLET, callData, nonce, policyId(), WALLET); + (TimelockPolicy.ProposalStatus status,,) = policyModule.getProposal(WALLET, callData, nonce, policyId(), WALLET); assertEq(uint256(status), uint256(TimelockPolicy.ProposalStatus.Cancelled)); } @@ -366,7 +365,7 @@ contract TimelockPolicyTest is PolicyTestBase, StatelessValidatorTestBase, State assertEq(result, 0); // Verify proposal was created - (TimelockPolicy.ProposalStatus status,,,) = + (TimelockPolicy.ProposalStatus status,,) = policyModule.getProposal(WALLET, proposalCallData, proposalNonce, policyId(), WALLET); assertEq(uint256(status), uint256(TimelockPolicy.ProposalStatus.Pending)); @@ -421,8 +420,8 @@ contract TimelockPolicyTest is PolicyTestBase, StatelessValidatorTestBase, State assertEq(validationResult, 1); } - // Test that execution cannot happen during grace period (race condition prevention) - function testExecutionBlockedDuringGracePeriod() public { + // Test that execution returns correct validAfter (delay end) for EntryPoint enforcement + function testExecutionReturnsCorrectValidAfter() public { TimelockPolicy policyModule = TimelockPolicy(address(module)); vm.startPrank(WALLET); policyModule.onInstall(abi.encodePacked(policyId(), installData())); @@ -448,28 +447,30 @@ contract TimelockPolicyTest is PolicyTestBase, StatelessValidatorTestBase, State policyModule.checkUserOpPolicy(policyId(), noopOp); vm.stopPrank(); - // Fast forward past delay but NOT past grace period + // Get stored proposal timing + (, uint256 storedValidAfter, uint256 storedValidUntil) = + policyModule.getProposal(WALLET, userOp.callData, userOp.nonce, policyId(), WALLET); + + // Fast forward past delay vm.warp(block.timestamp + delay + 1); - // Try to execute the proposal + // Execute the proposal vm.startPrank(WALLET); uint256 validationResult = policyModule.checkUserOpPolicy(policyId(), userOp); vm.stopPrank(); - // Validation should succeed but with graceEnd as validAfter - // The EntryPoint would reject execution during grace period + // Validation should succeed assertFalse(validationResult == 1); // Not a failure // Extract validAfter from packed validation data - // Format: uint48 returnedValidAfter = uint48(validationResult >> 208); - // validAfter should be graceEnd (delay + gracePeriod), not just delay - assertEq(returnedValidAfter, uint48(block.timestamp - 1 + gracePeriod)); + // validAfter should match the stored validAfter (delay end) + assertEq(returnedValidAfter, uint48(storedValidAfter), "validAfter should be delay end"); } - // Test that owner can still cancel during grace period - function testCancelDuringGracePeriod() public { + // Test that owner can cancel during delay period + function testCancelDuringDelayPeriod() public { TimelockPolicy policyModule = TimelockPolicy(address(module)); vm.startPrank(WALLET); policyModule.onInstall(abi.encodePacked(policyId(), installData())); @@ -479,8 +480,7 @@ contract TimelockPolicyTest is PolicyTestBase, StatelessValidatorTestBase, State uint256 nonce = 1; // Create proposal via no-op UserOp - bytes memory sig = - abi.encodePacked(bytes32(callData.length), callData, bytes32(nonce), bytes1(0x00)); + bytes memory sig = abi.encodePacked(bytes32(callData.length), callData, bytes32(nonce), bytes1(0x00)); PackedUserOperation memory noopOp = PackedUserOperation({ sender: WALLET, nonce: 0, @@ -496,16 +496,16 @@ contract TimelockPolicyTest is PolicyTestBase, StatelessValidatorTestBase, State policyModule.checkUserOpPolicy(policyId(), noopOp); vm.stopPrank(); - // Fast forward past delay but still in grace period - vm.warp(block.timestamp + delay + 1); + // Fast forward partially into delay (still before validAfter) + vm.warp(block.timestamp + delay / 2); - // Cancel proposal (should still work during grace period) + // Cancel proposal (should work during delay period) vm.startPrank(WALLET); policyModule.cancelProposal(policyId(), WALLET, callData, nonce); vm.stopPrank(); // Verify proposal was cancelled - (TimelockPolicy.ProposalStatus status,,,) = policyModule.getProposal(WALLET, callData, nonce, policyId(), WALLET); + (TimelockPolicy.ProposalStatus status,,) = policyModule.getProposal(WALLET, callData, nonce, policyId(), WALLET); assertEq(uint256(status), uint256(TimelockPolicy.ProposalStatus.Cancelled)); } } diff --git a/test/btt/Timelock.t.sol b/test/btt/Timelock.t.sol index e51f3a5..b0f1dda 100644 --- a/test/btt/Timelock.t.sol +++ b/test/btt/Timelock.t.sol @@ -6,6 +6,7 @@ import {TimelockPolicy} from "../../src/policies/TimelockPolicy.sol"; import {PackedUserOperation} from "account-abstraction/interfaces/PackedUserOperation.sol"; import {IERC7579Execution} from "openzeppelin-contracts/contracts/interfaces/draft-IERC7579.sol"; import {IAccountExecute} from "account-abstraction/interfaces/IAccountExecute.sol"; +import {LibERC7579} from "solady/accounts/LibERC7579.sol"; import {IModule} from "../../src/interfaces/IERC7579Modules.sol"; import { MODULE_TYPE_POLICY, @@ -26,7 +27,7 @@ contract TimelockTest is Test { uint48 public constant DELAY = 1 hours; uint48 public constant EXPIRATION = 1 days; - uint48 public constant GRACE_PERIOD = 30 minutes; + address public constant GUARDIAN = address(0); uint256 public constant SIG_VALIDATION_FAILED = 1; @@ -34,7 +35,7 @@ contract TimelockTest is Test { timelockPolicy = new TimelockPolicy(); // Install policy for WALLET - bytes memory installData = abi.encode(POLICY_ID, DELAY, EXPIRATION, GRACE_PERIOD); + bytes memory installData = abi.encode(POLICY_ID, DELAY, EXPIRATION, GUARDIAN); vm.prank(WALLET); timelockPolicy.onInstall(installData); } @@ -102,22 +103,26 @@ contract TimelockTest is Test { address newWallet = address(0x5555); bytes32 newId = bytes32(uint256(2)); + address newGuardian = address(0x9999); + vm.expectEmit(true, true, true, true); - emit TimelockPolicy.TimelockConfigUpdated(newWallet, newId, 2 hours, 2 days, 30 minutes); + emit TimelockPolicy.TimelockConfigUpdated(newWallet, newId, 2 hours, 2 days, newGuardian); - bytes memory installData = abi.encode(newId, uint48(2 hours), uint48(2 days), uint48(30 minutes)); + bytes memory installData = abi.encode(newId, uint48(2 hours), uint48(2 days), newGuardian); vm.prank(newWallet); timelockPolicy.onInstall(installData); - (uint48 delay, uint48 expiration, uint48 gracePeriod_, bool initialized) = timelockPolicy.timelockConfig(newId, newWallet); + (uint48 delay, uint48 expiration, address guardian_, bool initialized) = + timelockPolicy.timelockConfig(newId, newWallet); assertEq(delay, 2 hours, "Delay should be stored"); assertEq(expiration, 2 days, "Expiration should be stored"); + assertEq(guardian_, newGuardian, "Guardian should be stored"); assertTrue(initialized, "Should be initialized"); } function test_GivenAlreadyInitialized() external whenCallingOnInstall { // it should revert with AlreadyInitialized - bytes memory installData = abi.encode(POLICY_ID, DELAY, EXPIRATION, GRACE_PERIOD); + bytes memory installData = abi.encode(POLICY_ID, DELAY, EXPIRATION, GUARDIAN); vm.prank(WALLET); vm.expectRevert(abi.encodeWithSelector(IModule.AlreadyInitialized.selector, WALLET)); timelockPolicy.onInstall(installData); @@ -126,7 +131,7 @@ contract TimelockTest is Test { function test_GivenDelayIsZero() external whenCallingOnInstall { // it should revert with InvalidDelay address newWallet = address(0x6666); - bytes memory installData = abi.encode(POLICY_ID, uint48(0), EXPIRATION, GRACE_PERIOD); + bytes memory installData = abi.encode(POLICY_ID, uint48(0), EXPIRATION, GUARDIAN); vm.prank(newWallet); vm.expectRevert(TimelockPolicy.InvalidDelay.selector); timelockPolicy.onInstall(installData); @@ -135,7 +140,7 @@ contract TimelockTest is Test { function test_GivenExpirationIsZero() external whenCallingOnInstall { // it should revert with InvalidExpirationPeriod address newWallet = address(0x7777); - bytes memory installData = abi.encode(POLICY_ID, DELAY, uint48(0), GRACE_PERIOD); + bytes memory installData = abi.encode(POLICY_ID, DELAY, uint48(0), GUARDIAN); vm.prank(newWallet); vm.expectRevert(TimelockPolicy.InvalidExpirationPeriod.selector); timelockPolicy.onInstall(installData); @@ -153,6 +158,7 @@ contract TimelockTest is Test { timelockPolicy.onUninstall(abi.encode(POLICY_ID)); (,,, bool initialized) = timelockPolicy.timelockConfig(POLICY_ID, WALLET); + assertFalse(initialized, "Config should be cleared"); } @@ -219,7 +225,7 @@ contract TimelockTest is Test { vm.prank(WALLET); timelockPolicy.cancelProposal(POLICY_ID, WALLET, callData, nonce); - (TimelockPolicy.ProposalStatus status,,,) = + (TimelockPolicy.ProposalStatus status,,) = timelockPolicy.getProposal(WALLET, callData, nonce, POLICY_ID, WALLET); assertEq(uint256(status), uint256(TimelockPolicy.ProposalStatus.Cancelled), "Status should be Cancelled"); } @@ -303,7 +309,7 @@ contract TimelockTest is Test { POLICY_ID, expectedKey, uint48(block.timestamp) + DELAY, - uint48(block.timestamp) + DELAY + GRACE_PERIOD + EXPIRATION + uint48(block.timestamp) + DELAY + EXPIRATION ); vm.prank(WALLET); @@ -312,7 +318,7 @@ contract TimelockTest is Test { // Proposal creation must return 0 for state persistence assertEq(result, 0, "Should return 0 for state persistence"); - (TimelockPolicy.ProposalStatus status,,,) = + (TimelockPolicy.ProposalStatus status,,) = timelockPolicy.getProposal(WALLET, proposalCallData, proposalNonce, POLICY_ID, WALLET); assertEq(uint256(status), uint256(TimelockPolicy.ProposalStatus.Pending), "Proposal should be Pending"); } @@ -389,7 +395,7 @@ contract TimelockTest is Test { timelockPolicy.checkUserOpPolicy(POLICY_ID, noopOp); // Get the actual stored proposal values - (, uint256 storedValidAfter, uint256 storedGraceEnd, uint256 storedValidUntil) = + (, uint256 storedValidAfter, uint256 storedValidUntil) = timelockPolicy.getProposal(WALLET, proposalCallData, proposalNonce, POLICY_ID, WALLET); vm.warp(block.timestamp + DELAY + 1); @@ -405,13 +411,12 @@ contract TimelockTest is Test { uint256 result = timelockPolicy.checkUserOpPolicy(POLICY_ID, executeOp); // Extract validAfter and validUntil from packed data - // Note: packed validAfter is actually graceEnd (to prevent execution during grace period) uint48 validAfter = uint48(result >> 208); uint48 validUntil = uint48(result >> 160); - assertEq(validAfter, storedGraceEnd, "validAfter in packed data should match graceEnd"); + assertEq(validAfter, storedValidAfter, "validAfter in packed data should match proposal validAfter"); assertEq(validUntil, storedValidUntil, "validUntil should match proposal"); - (TimelockPolicy.ProposalStatus status,,,) = + (TimelockPolicy.ProposalStatus status,,) = timelockPolicy.getProposal(WALLET, proposalCallData, proposalNonce, POLICY_ID, WALLET); assertEq(uint256(status), uint256(TimelockPolicy.ProposalStatus.Executed), "Proposal should be executed"); } @@ -586,7 +591,7 @@ contract TimelockTest is Test { } function test_GivenProposalExists() external whenCallingGetProposal { - // it should return status validAfter graceEnd and validUntil + // it should return status validAfter and validUntil bytes memory callData = abi.encodeWithSelector(IERC7579Execution.execute.selector, bytes32(0), "test"); uint256 nonce = 1200; @@ -596,25 +601,23 @@ contract TimelockTest is Test { vm.prank(WALLET); timelockPolicy.checkUserOpPolicy(POLICY_ID, noopOp); - (TimelockPolicy.ProposalStatus status, uint256 validAfter, uint256 graceEnd, uint256 validUntil) = + (TimelockPolicy.ProposalStatus status, uint256 validAfter, uint256 validUntil) = timelockPolicy.getProposal(WALLET, callData, nonce, POLICY_ID, WALLET); assertEq(uint256(status), uint256(TimelockPolicy.ProposalStatus.Pending), "Status should be Pending"); assertEq(validAfter, block.timestamp + DELAY, "validAfter should be correct"); - assertEq(graceEnd, block.timestamp + DELAY + GRACE_PERIOD, "graceEnd should be correct"); - assertEq(validUntil, block.timestamp + DELAY + GRACE_PERIOD + EXPIRATION, "validUntil should be correct"); + assertEq(validUntil, block.timestamp + DELAY + EXPIRATION, "validUntil should be correct"); } function test_GivenProposalDoesNotExist_WhenCallingGetProposal() external whenCallingGetProposal { // it should return None status and zeros bytes memory callData = abi.encodeWithSelector(IERC7579Execution.execute.selector, bytes32(0), "test"); - (TimelockPolicy.ProposalStatus status, uint256 validAfter, uint256 graceEnd, uint256 validUntil) = + (TimelockPolicy.ProposalStatus status, uint256 validAfter, uint256 validUntil) = timelockPolicy.getProposal(WALLET, callData, 9999, POLICY_ID, WALLET); assertEq(uint256(status), uint256(TimelockPolicy.ProposalStatus.None), "Status should be None"); assertEq(validAfter, 0, "validAfter should be 0"); - assertEq(graceEnd, 0, "graceEnd should be 0"); assertEq(validUntil, 0, "validUntil should be 0"); } @@ -650,10 +653,13 @@ contract TimelockTest is Test { assertEq(result, 0, "Empty calldata should be detected as noop"); } - // Case 2: ERC-7579 execute(mode=0x00, "") — single-call with empty execution data + // Case 2: ERC-7579 execute(CALLTYPE_SINGLE, abi.encodePacked(target, uint256(0))) — minimal decodeSingle()-compatible no-op function test_GivenCalldataIsERC7579ExecuteNoop() external whenDetectingNoopCalldata { // it should be detected as noop - bytes memory noopExecute = abi.encodeWithSelector(IERC7579Execution.execute.selector, bytes32(0), ""); + bytes32 mode = + LibERC7579.encodeMode(LibERC7579.CALLTYPE_SINGLE, LibERC7579.EXECTYPE_DEFAULT, bytes4(0), bytes22(0)); + bytes memory noopExecute = + abi.encodeWithSelector(IERC7579Execution.execute.selector, mode, abi.encodePacked(address(0), uint256(0))); bytes memory sig = _createProposalSignature("test", 1); PackedUserOperation memory userOp = _createUserOpWithCalldata(WALLET, noopExecute, 0, sig); @@ -679,7 +685,10 @@ contract TimelockTest is Test { // Case 4: executeUserOp + ERC-7579 execute no-op function test_GivenCalldataIsExecuteUserOpWithERC7579Noop() external whenDetectingNoopCalldata { // it should be detected as noop - bytes memory noopExecute = abi.encodeWithSelector(IERC7579Execution.execute.selector, bytes32(0), ""); + bytes32 mode = + LibERC7579.encodeMode(LibERC7579.CALLTYPE_SINGLE, LibERC7579.EXECTYPE_DEFAULT, bytes4(0), bytes22(0)); + bytes memory noopExecute = + abi.encodeWithSelector(IERC7579Execution.execute.selector, mode, abi.encodePacked(address(0), uint256(0))); bytes memory executeUserOpWrapped = abi.encodePacked(IAccountExecute.executeUserOp.selector, noopExecute); bytes memory sig = _createProposalSignature("test", 3); PackedUserOperation memory userOp = _createUserOpWithCalldata(WALLET, executeUserOpWrapped, 0, sig); @@ -705,10 +714,12 @@ contract TimelockTest is Test { // Negative: ERC-7579 execute with delegatecall mode function test_GivenCalldataIsERC7579ExecuteDelegatecall() external whenDetectingNoopCalldata { - // it should not be detected as noop — mode 0xFE is delegatecall - bytes32 delegatecallMode = bytes32(uint256(0xFE) << 248); - bytes memory delegatecallExecute = - abi.encodeWithSelector(IERC7579Execution.execute.selector, delegatecallMode, ""); + // it should not be detected as noop — CALLTYPE_DELEGATECALL + bytes32 delegatecallMode = + LibERC7579.encodeMode(LibERC7579.CALLTYPE_DELEGATECALL, LibERC7579.EXECTYPE_DEFAULT, bytes4(0), bytes22(0)); + bytes memory delegatecallExecute = abi.encodeWithSelector( + IERC7579Execution.execute.selector, delegatecallMode, abi.encodePacked(address(0), uint256(0)) + ); PackedUserOperation memory userOp = _createUserOpWithCalldata(WALLET, delegatecallExecute, 0, ""); vm.prank(WALLET); @@ -719,9 +730,12 @@ contract TimelockTest is Test { // Negative: ERC-7579 execute with batch mode function test_GivenCalldataIsERC7579ExecuteBatch() external whenDetectingNoopCalldata { - // it should not be detected as noop — mode 0x01 is batch - bytes32 batchMode = bytes32(uint256(0x01) << 248); - bytes memory batchExecute = abi.encodeWithSelector(IERC7579Execution.execute.selector, batchMode, ""); + // it should not be detected as noop — CALLTYPE_BATCH + bytes32 batchMode = + LibERC7579.encodeMode(LibERC7579.CALLTYPE_BATCH, LibERC7579.EXECTYPE_DEFAULT, bytes4(0), bytes22(0)); + bytes memory batchExecute = abi.encodeWithSelector( + IERC7579Execution.execute.selector, batchMode, abi.encodePacked(address(0), uint256(0)) + ); PackedUserOperation memory userOp = _createUserOpWithCalldata(WALLET, batchExecute, 0, ""); vm.prank(WALLET); @@ -746,9 +760,11 @@ contract TimelockTest is Test { // Negative: executeUserOp wrapping a delegatecall ERC-7579 execute function test_GivenCalldataIsExecuteUserOpWithDelegatecall() external whenDetectingNoopCalldata { // it should not be detected as noop - bytes32 delegatecallMode = bytes32(uint256(0xFE) << 248); - bytes memory delegatecallExecute = - abi.encodeWithSelector(IERC7579Execution.execute.selector, delegatecallMode, ""); + bytes32 delegatecallMode = + LibERC7579.encodeMode(LibERC7579.CALLTYPE_DELEGATECALL, LibERC7579.EXECTYPE_DEFAULT, bytes4(0), bytes22(0)); + bytes memory delegatecallExecute = abi.encodeWithSelector( + IERC7579Execution.execute.selector, delegatecallMode, abi.encodePacked(address(0), uint256(0)) + ); bytes memory wrapped = abi.encodePacked(IAccountExecute.executeUserOp.selector, delegatecallExecute); PackedUserOperation memory userOp = _createUserOpWithCalldata(WALLET, wrapped, 0, ""); @@ -773,7 +789,7 @@ contract TimelockTest is Test { timelockPolicy.checkUserOpPolicy(POLICY_ID, noopOp); // Get the actual stored proposal values - (, uint256 storedValidAfter, uint256 storedGraceEnd, uint256 storedValidUntil) = + (, uint256 storedValidAfter, uint256 storedValidUntil) = timelockPolicy.getProposal(WALLET, callData, nonce, POLICY_ID, WALLET); vm.warp(block.timestamp + DELAY + 1); @@ -783,7 +799,7 @@ contract TimelockTest is Test { vm.prank(WALLET); uint256 result = timelockPolicy.checkUserOpPolicy(POLICY_ID, userOp); - uint256 expectedPacked = _packValidationData(uint48(storedGraceEnd), uint48(storedValidUntil)); + uint256 expectedPacked = _packValidationData(uint48(storedValidAfter), uint48(storedValidUntil)); assertEq(result, expectedPacked, "Packed validation data should match expected"); } @@ -828,7 +844,7 @@ contract TimelockTest is Test { assertNotEq(firstResult, SIG_VALIDATION_FAILED, "First execution should succeed"); // Verify proposal is marked as executed - (TimelockPolicy.ProposalStatus status,,,) = + (TimelockPolicy.ProposalStatus status,,) = timelockPolicy.getProposal(WALLET, callData, nonce, POLICY_ID, WALLET); assertEq(uint256(status), uint256(TimelockPolicy.ProposalStatus.Executed), "Should be executed"); @@ -853,7 +869,7 @@ contract TimelockTest is Test { assertEq(result, 0, "Proposal creation must return 0 for state persistence"); // Verify the proposal was actually created and persisted - (TimelockPolicy.ProposalStatus status, uint256 validAfter, uint256 graceEnd, uint256 validUntil) = + (TimelockPolicy.ProposalStatus status, uint256 validAfter, uint256 validUntil) = timelockPolicy.getProposal(WALLET, proposalCallData, proposalNonce, POLICY_ID, WALLET); assertEq(uint256(status), uint256(TimelockPolicy.ProposalStatus.Pending), "Proposal state should persist"); @@ -877,8 +893,387 @@ contract TimelockTest is Test { timelockPolicy.cancelProposal(POLICY_ID, WALLET, callData, nonce); // Verify proposal is still pending - (TimelockPolicy.ProposalStatus status,,,) = + (TimelockPolicy.ProposalStatus status,,) = timelockPolicy.getProposal(WALLET, callData, nonce, POLICY_ID, WALLET); assertEq(uint256(status), uint256(TimelockPolicy.ProposalStatus.Pending), "Proposal should still be Pending"); } + + // ============ Guardian Cancellation Tests ============ + + modifier whenTestingGuardianCancellation() { + _; + } + + function test_GivenGuardianIsSet_GuardianCanCancel() external whenTestingGuardianCancellation { + // Setup: install policy with a guardian for a new wallet + address guardianWallet = address(0xA001); + address guardian = address(0xBEEF01); + bytes32 guardianPolicyId = bytes32(uint256(2)); + + bytes memory installData = abi.encode(guardianPolicyId, DELAY, EXPIRATION, guardian); + vm.prank(guardianWallet); + timelockPolicy.onInstall(installData); + + // Create proposal + bytes memory callData = abi.encodeWithSelector(IERC7579Execution.execute.selector, bytes32(0), "guardian_test"); + uint256 nonce = 2000; + bytes memory sig = _createProposalSignature(callData, nonce); + PackedUserOperation memory noopOp = _createNoopUserOp(guardianWallet, sig); + vm.prank(guardianWallet); + timelockPolicy.checkUserOpPolicy(guardianPolicyId, noopOp); + + // Guardian cancels + bytes32 expectedKey = keccak256(abi.encode(guardianWallet, keccak256(callData), nonce)); + vm.expectEmit(true, true, true, true); + emit TimelockPolicy.ProposalCancelled(guardianWallet, guardianPolicyId, expectedKey); + + vm.prank(guardian); + timelockPolicy.cancelProposal(guardianPolicyId, guardianWallet, callData, nonce); + + (TimelockPolicy.ProposalStatus status,,) = + timelockPolicy.getProposal(guardianWallet, callData, nonce, guardianPolicyId, guardianWallet); + assertEq(uint256(status), uint256(TimelockPolicy.ProposalStatus.Cancelled), "Guardian should be able to cancel"); + } + + function test_GivenGuardianIsSet_AccountCanStillCancel() external whenTestingGuardianCancellation { + // Setup: install policy with a guardian for a new wallet + address guardianWallet = address(0xA002); + address guardian = address(0xBEEF02); + bytes32 guardianPolicyId = bytes32(uint256(3)); + + bytes memory installData = abi.encode(guardianPolicyId, DELAY, EXPIRATION, guardian); + vm.prank(guardianWallet); + timelockPolicy.onInstall(installData); + + // Create proposal + bytes memory callData = abi.encodeWithSelector(IERC7579Execution.execute.selector, bytes32(0), "account_cancel"); + uint256 nonce = 2100; + bytes memory sig = _createProposalSignature(callData, nonce); + PackedUserOperation memory noopOp = _createNoopUserOp(guardianWallet, sig); + vm.prank(guardianWallet); + timelockPolicy.checkUserOpPolicy(guardianPolicyId, noopOp); + + // Account cancels (not guardian) + vm.prank(guardianWallet); + timelockPolicy.cancelProposal(guardianPolicyId, guardianWallet, callData, nonce); + + (TimelockPolicy.ProposalStatus status,,) = + timelockPolicy.getProposal(guardianWallet, callData, nonce, guardianPolicyId, guardianWallet); + assertEq( + uint256(status), uint256(TimelockPolicy.ProposalStatus.Cancelled), "Account should still be able to cancel" + ); + } + + function test_GivenGuardianIsSet_NonGuardianNonAccountCannotCancel() external whenTestingGuardianCancellation { + // Setup: install policy with a guardian for a new wallet + address guardianWallet = address(0xA003); + address guardian = address(0xBEEF03); + bytes32 guardianPolicyId = bytes32(uint256(4)); + + bytes memory installData = abi.encode(guardianPolicyId, DELAY, EXPIRATION, guardian); + vm.prank(guardianWallet); + timelockPolicy.onInstall(installData); + + // Create proposal + bytes memory callData = abi.encodeWithSelector(IERC7579Execution.execute.selector, bytes32(0), "attacker_test"); + uint256 nonce = 2200; + bytes memory sig = _createProposalSignature(callData, nonce); + PackedUserOperation memory noopOp = _createNoopUserOp(guardianWallet, sig); + vm.prank(guardianWallet); + timelockPolicy.checkUserOpPolicy(guardianPolicyId, noopOp); + + // Attacker tries to cancel — should revert + vm.prank(ATTACKER); + vm.expectRevert(TimelockPolicy.OnlyAccount.selector); + timelockPolicy.cancelProposal(guardianPolicyId, guardianWallet, callData, nonce); + } + + function test_GivenNoGuardian_OnlyAccountCanCancel() external whenTestingGuardianCancellation { + // WALLET has guardian = address(0) from setUp + // Create proposal + bytes memory callData = abi.encodeWithSelector(IERC7579Execution.execute.selector, bytes32(0), "no_guardian"); + uint256 nonce = 2300; + bytes memory sig = _createProposalSignature(callData, nonce); + PackedUserOperation memory noopOp = _createNoopUserOp(WALLET, sig); + vm.prank(WALLET); + timelockPolicy.checkUserOpPolicy(POLICY_ID, noopOp); + + // Non-account tries to cancel — should revert (no guardian set, so only account can cancel) + vm.prank(ATTACKER); + vm.expectRevert(TimelockPolicy.OnlyAccount.selector); + timelockPolicy.cancelProposal(POLICY_ID, WALLET, callData, nonce); + + // Account itself can cancel + vm.prank(WALLET); + timelockPolicy.cancelProposal(POLICY_ID, WALLET, callData, nonce); + + (TimelockPolicy.ProposalStatus status,,) = + timelockPolicy.getProposal(WALLET, callData, nonce, POLICY_ID, WALLET); + assertEq(uint256(status), uint256(TimelockPolicy.ProposalStatus.Cancelled), "Account should be able to cancel"); + } + + function test_GivenGuardianIsSet_ConfigStoresGuardian() external whenTestingGuardianCancellation { + address guardianWallet = address(0xA004); + address guardian = address(0xBEEF04); + bytes32 guardianPolicyId = bytes32(uint256(5)); + + bytes memory installData = abi.encode(guardianPolicyId, DELAY, EXPIRATION, guardian); + vm.prank(guardianWallet); + timelockPolicy.onInstall(installData); + + (,, address storedGuardian, bool initialized) = timelockPolicy.timelockConfig(guardianPolicyId, guardianWallet); + assertEq(storedGuardian, guardian, "Guardian should be stored in config"); + assertTrue(initialized, "Should be initialized"); + } + + // ============ Guardian Isolation and Advanced Tests ============ + + function test_GivenGuardianForPolicyA_CannotCancelProposalInPolicyB() external whenTestingGuardianCancellation { + // it should revert with OnlyAccount when guardian from policy A tries to cancel policy B proposal + address wallet = address(0xA100); + address guardianA = address(0xBEEF10); + bytes32 policyIdA = bytes32(uint256(10)); + bytes32 policyIdB = bytes32(uint256(11)); + + // Install policy A with guardianA + bytes memory installDataA = abi.encode(policyIdA, DELAY, EXPIRATION, guardianA); + vm.prank(wallet); + timelockPolicy.onInstall(installDataA); + + // Install policy B with no guardian + bytes memory installDataB = abi.encode(policyIdB, DELAY, EXPIRATION, address(0)); + vm.prank(wallet); + timelockPolicy.onInstall(installDataB); + + // Create proposal under policy B + bytes memory callData = abi.encodeWithSelector(IERC7579Execution.execute.selector, bytes32(0), "policy_b_test"); + uint256 nonce = 3000; + bytes memory sig = _createProposalSignature(callData, nonce); + PackedUserOperation memory noopOp = _createNoopUserOp(wallet, sig); + vm.prank(wallet); + timelockPolicy.checkUserOpPolicy(policyIdB, noopOp); + + // Guardian A tries to cancel proposal in policy B — should fail + vm.prank(guardianA); + vm.expectRevert(TimelockPolicy.OnlyAccount.selector); + timelockPolicy.cancelProposal(policyIdB, wallet, callData, nonce); + + // Verify proposal is still pending + (TimelockPolicy.ProposalStatus status,,) = + timelockPolicy.getProposal(wallet, callData, nonce, policyIdB, wallet); + assertEq(uint256(status), uint256(TimelockPolicy.ProposalStatus.Pending), "Proposal should still be pending"); + } + + function test_GivenGuardianForWalletA_CannotCancelProposalForWalletB() external whenTestingGuardianCancellation { + // it should revert with OnlyAccount when guardian for wallet A tries to cancel wallet B proposal + address walletA = address(0xA200); + address walletB = address(0xA201); + address guardianA = address(0xBEEF20); + bytes32 sharedPolicyId = bytes32(uint256(20)); + + // Install policy for wallet A with guardianA + bytes memory installDataA = abi.encode(sharedPolicyId, DELAY, EXPIRATION, guardianA); + vm.prank(walletA); + timelockPolicy.onInstall(installDataA); + + // Install policy for wallet B with no guardian + bytes memory installDataB = abi.encode(sharedPolicyId, DELAY, EXPIRATION, address(0)); + vm.prank(walletB); + timelockPolicy.onInstall(installDataB); + + // Create proposal for wallet B + bytes memory callData = abi.encodeWithSelector(IERC7579Execution.execute.selector, bytes32(0), "wallet_b_test"); + uint256 nonce = 3100; + bytes memory sig = _createProposalSignature(callData, nonce); + PackedUserOperation memory noopOp = _createNoopUserOp(walletB, sig); + vm.prank(walletB); + timelockPolicy.checkUserOpPolicy(sharedPolicyId, noopOp); + + // Guardian A tries to cancel wallet B's proposal — should fail + vm.prank(guardianA); + vm.expectRevert(TimelockPolicy.OnlyAccount.selector); + timelockPolicy.cancelProposal(sharedPolicyId, walletB, callData, nonce); + + // Verify wallet B's proposal is still pending + (TimelockPolicy.ProposalStatus status,,) = + timelockPolicy.getProposal(walletB, callData, nonce, sharedPolicyId, walletB); + assertEq(uint256(status), uint256(TimelockPolicy.ProposalStatus.Pending), "Proposal should still be pending"); + } + + function test_GivenProposalIsExecuted_GuardianCannotCancel() external whenTestingGuardianCancellation { + // it should revert with ProposalNotPending after proposal is executed + address guardianWallet = address(0xA300); + address guardian = address(0xBEEF30); + bytes32 guardianPolicyId = bytes32(uint256(30)); + + bytes memory installData = abi.encode(guardianPolicyId, DELAY, EXPIRATION, guardian); + vm.prank(guardianWallet); + timelockPolicy.onInstall(installData); + + // Create and execute proposal + bytes memory callData = abi.encodeWithSelector(IERC7579Execution.execute.selector, bytes32(0), "execute_test"); + uint256 nonce = 3200; + bytes memory sig = _createProposalSignature(callData, nonce); + PackedUserOperation memory noopOp = _createNoopUserOp(guardianWallet, sig); + vm.prank(guardianWallet); + timelockPolicy.checkUserOpPolicy(guardianPolicyId, noopOp); + + // Warp past delay and execute + vm.warp(block.timestamp + DELAY + 1); + PackedUserOperation memory executeOp = _createUserOpWithCalldata(guardianWallet, callData, nonce, ""); + vm.prank(guardianWallet); + timelockPolicy.checkUserOpPolicy(guardianPolicyId, executeOp); + + // Guardian tries to cancel executed proposal — should fail + vm.prank(guardian); + vm.expectRevert(TimelockPolicy.ProposalNotPending.selector); + timelockPolicy.cancelProposal(guardianPolicyId, guardianWallet, callData, nonce); + } + + function test_GivenProposalIsCancelled_GuardianCannotCancelAgain() external whenTestingGuardianCancellation { + // it should revert with ProposalNotPending on double cancel + address guardianWallet = address(0xA400); + address guardian = address(0xBEEF40); + bytes32 guardianPolicyId = bytes32(uint256(40)); + + bytes memory installData = abi.encode(guardianPolicyId, DELAY, EXPIRATION, guardian); + vm.prank(guardianWallet); + timelockPolicy.onInstall(installData); + + // Create proposal + bytes memory callData = abi.encodeWithSelector(IERC7579Execution.execute.selector, bytes32(0), "cancel_test"); + uint256 nonce = 3300; + bytes memory sig = _createProposalSignature(callData, nonce); + PackedUserOperation memory noopOp = _createNoopUserOp(guardianWallet, sig); + vm.prank(guardianWallet); + timelockPolicy.checkUserOpPolicy(guardianPolicyId, noopOp); + + // Guardian cancels + vm.prank(guardian); + timelockPolicy.cancelProposal(guardianPolicyId, guardianWallet, callData, nonce); + + // Guardian tries to cancel again — should fail + vm.prank(guardian); + vm.expectRevert(TimelockPolicy.ProposalNotPending.selector); + timelockPolicy.cancelProposal(guardianPolicyId, guardianWallet, callData, nonce); + } + + function test_GivenDelayPassed_GuardianCanStillCancel() external whenTestingGuardianCancellation { + // it should allow guardian to cancel even when proposal is executable + address guardianWallet = address(0xA500); + address guardian = address(0xBEEF50); + bytes32 guardianPolicyId = bytes32(uint256(50)); + + bytes memory installData = abi.encode(guardianPolicyId, DELAY, EXPIRATION, guardian); + vm.prank(guardianWallet); + timelockPolicy.onInstall(installData); + + // Create proposal + bytes memory callData = abi.encodeWithSelector(IERC7579Execution.execute.selector, bytes32(0), "after_delay"); + uint256 nonce = 3400; + bytes memory sig = _createProposalSignature(callData, nonce); + PackedUserOperation memory noopOp = _createNoopUserOp(guardianWallet, sig); + vm.prank(guardianWallet); + timelockPolicy.checkUserOpPolicy(guardianPolicyId, noopOp); + + // Warp past delay — proposal is now executable + vm.warp(block.timestamp + DELAY + 1); + + // Guardian cancels even though proposal is executable + vm.prank(guardian); + timelockPolicy.cancelProposal(guardianPolicyId, guardianWallet, callData, nonce); + + (TimelockPolicy.ProposalStatus status,,) = + timelockPolicy.getProposal(guardianWallet, callData, nonce, guardianPolicyId, guardianWallet); + assertEq( + uint256(status), uint256(TimelockPolicy.ProposalStatus.Cancelled), "Guardian should cancel even after delay" + ); + + // Verify execution now fails + PackedUserOperation memory executeOp = _createUserOpWithCalldata(guardianWallet, callData, nonce, ""); + vm.prank(guardianWallet); + uint256 result = timelockPolicy.checkUserOpPolicy(guardianPolicyId, executeOp); + assertEq(result, SIG_VALIDATION_FAILED, "Execution should fail after guardian cancel"); + } + + function test_GivenReinstallWithNewGuardian_OldGuardianCannotCancel() external whenTestingGuardianCancellation { + // it should prevent old guardian from canceling after reinstall with new guardian + address guardianWallet = address(0xA600); + address oldGuardian = address(0xBEEF60); + address newGuardian = address(0xBEEF61); + bytes32 guardianPolicyId = bytes32(uint256(60)); + + // Install with old guardian + bytes memory installData1 = abi.encode(guardianPolicyId, DELAY, EXPIRATION, oldGuardian); + vm.prank(guardianWallet); + timelockPolicy.onInstall(installData1); + + // Uninstall + vm.prank(guardianWallet); + timelockPolicy.onUninstall(abi.encode(guardianPolicyId)); + + // Reinstall with new guardian + bytes memory installData2 = abi.encode(guardianPolicyId, DELAY, EXPIRATION, newGuardian); + vm.prank(guardianWallet); + timelockPolicy.onInstall(installData2); + + // Create proposal under new installation + bytes memory callData = abi.encodeWithSelector(IERC7579Execution.execute.selector, bytes32(0), "reinstall_test"); + uint256 nonce = 3500; + bytes memory sig = _createProposalSignature(callData, nonce); + PackedUserOperation memory noopOp = _createNoopUserOp(guardianWallet, sig); + vm.prank(guardianWallet); + timelockPolicy.checkUserOpPolicy(guardianPolicyId, noopOp); + + // Old guardian tries to cancel — should fail + vm.prank(oldGuardian); + vm.expectRevert(TimelockPolicy.OnlyAccount.selector); + timelockPolicy.cancelProposal(guardianPolicyId, guardianWallet, callData, nonce); + + // New guardian can cancel + vm.prank(newGuardian); + timelockPolicy.cancelProposal(guardianPolicyId, guardianWallet, callData, nonce); + + (TimelockPolicy.ProposalStatus status,,) = + timelockPolicy.getProposal(guardianWallet, callData, nonce, guardianPolicyId, guardianWallet); + assertEq(uint256(status), uint256(TimelockPolicy.ProposalStatus.Cancelled), "New guardian should cancel"); + } + + function test_GivenGuardianCancels_NewProposalWithDifferentNonceWorks() external whenTestingGuardianCancellation { + // it should allow re-proposal with different nonce after guardian cancel + address guardianWallet = address(0xA700); + address guardian = address(0xBEEF70); + bytes32 guardianPolicyId = bytes32(uint256(70)); + + bytes memory installData = abi.encode(guardianPolicyId, DELAY, EXPIRATION, guardian); + vm.prank(guardianWallet); + timelockPolicy.onInstall(installData); + + // Create and cancel first proposal + bytes memory callData = abi.encodeWithSelector(IERC7579Execution.execute.selector, bytes32(0), "reproposal"); + uint256 nonce1 = 3600; + bytes memory sig1 = _createProposalSignature(callData, nonce1); + PackedUserOperation memory noopOp1 = _createNoopUserOp(guardianWallet, sig1); + vm.prank(guardianWallet); + timelockPolicy.checkUserOpPolicy(guardianPolicyId, noopOp1); + + vm.prank(guardian); + timelockPolicy.cancelProposal(guardianPolicyId, guardianWallet, callData, nonce1); + + // Create new proposal with different nonce, same calldata + uint256 nonce2 = 3601; + bytes memory sig2 = _createProposalSignature(callData, nonce2); + PackedUserOperation memory noopOp2 = _createNoopUserOp(guardianWallet, sig2); + vm.prank(guardianWallet); + uint256 result = timelockPolicy.checkUserOpPolicy(guardianPolicyId, noopOp2); + assertEq(result, 0, "New proposal creation should succeed"); + + // Verify new proposal exists and old is cancelled + (TimelockPolicy.ProposalStatus status1,,) = + timelockPolicy.getProposal(guardianWallet, callData, nonce1, guardianPolicyId, guardianWallet); + (TimelockPolicy.ProposalStatus status2,,) = + timelockPolicy.getProposal(guardianWallet, callData, nonce2, guardianPolicyId, guardianWallet); + assertEq(uint256(status1), uint256(TimelockPolicy.ProposalStatus.Cancelled), "Old proposal should be cancelled"); + assertEq(uint256(status2), uint256(TimelockPolicy.ProposalStatus.Pending), "New proposal should be pending"); + } } diff --git a/test/btt/TimelockCancellationRace.t.sol b/test/btt/TimelockCancellationRace.t.sol index 71eb396..a8db4c4 100644 --- a/test/btt/TimelockCancellationRace.t.sol +++ b/test/btt/TimelockCancellationRace.t.sol @@ -8,11 +8,11 @@ import {IModule} from "src/interfaces/IERC7579Modules.sol"; /** * @title TimelockCancellationRaceTest - * @notice BTT tests for the TimelockPolicy cancellation and grace period fix (TOB-KERNEL-21) + * @notice BTT tests for the TimelockPolicy cancellation logic (TOB-KERNEL-21) * @dev This test suite verifies that: * 1. Cancelled proposals cannot be executed - * 2. Grace period prevents race conditions between cancellation and execution - * 3. The owner can cancel during grace period before public execution + * 2. The delay period prevents immediate execution + * 3. The owner/guardian can cancel during the delay period before execution */ contract TimelockCancellationRaceTest is Test { TimelockPolicy public timelockPolicy; @@ -22,7 +22,7 @@ contract TimelockCancellationRaceTest is Test { uint48 constant DELAY = 1 days; uint48 constant EXPIRATION_PERIOD = 1 days; - uint48 constant GRACE_PERIOD = 1 hours; + address constant GUARDIAN_ADDR = address(0); bytes32 public policyId; @@ -36,7 +36,7 @@ contract TimelockCancellationRaceTest is Test { // Install policy for WALLET vm.startPrank(WALLET); - timelockPolicy.onInstall(abi.encodePacked(policyId, abi.encode(DELAY, EXPIRATION_PERIOD, GRACE_PERIOD))); + timelockPolicy.onInstall(abi.encodePacked(policyId, abi.encode(DELAY, EXPIRATION_PERIOD, GUARDIAN_ADDR))); vm.stopPrank(); } @@ -100,7 +100,7 @@ contract TimelockCancellationRaceTest is Test { _createProposal(TEST_CALLDATA, TEST_NONCE); // Verify proposal is pending before cancellation - (TimelockPolicy.ProposalStatus statusBefore,,,) = + (TimelockPolicy.ProposalStatus statusBefore,,) = timelockPolicy.getProposal(WALLET, TEST_CALLDATA, TEST_NONCE, policyId, WALLET); assertEq(uint256(statusBefore), uint256(TimelockPolicy.ProposalStatus.Pending), "Proposal should be pending"); @@ -113,7 +113,7 @@ contract TimelockCancellationRaceTest is Test { _cancelProposal(TEST_CALLDATA, TEST_NONCE); // Verify: it should set proposal status to Cancelled - (TimelockPolicy.ProposalStatus statusAfter,,,) = + (TimelockPolicy.ProposalStatus statusAfter,,) = timelockPolicy.getProposal(WALLET, TEST_CALLDATA, TEST_NONCE, policyId, WALLET); assertEq(uint256(statusAfter), uint256(TimelockPolicy.ProposalStatus.Cancelled), "Proposal should be cancelled"); @@ -146,8 +146,8 @@ contract TimelockCancellationRaceTest is Test { // Setup: Create a proposal _createProposal(TEST_CALLDATA, TEST_NONCE); - // Fast forward past delay AND grace period - vm.warp(block.timestamp + DELAY + GRACE_PERIOD + 1); + // Fast forward past delay + vm.warp(block.timestamp + DELAY + 1); // Execute the proposal PackedUserOperation memory userOp = _createUserOp(TEST_CALLDATA, TEST_NONCE); @@ -156,7 +156,7 @@ contract TimelockCancellationRaceTest is Test { assertFalse(validationResult == 1, "Execution should succeed"); // Verify proposal is executed - (TimelockPolicy.ProposalStatus status,,,) = + (TimelockPolicy.ProposalStatus status,,) = timelockPolicy.getProposal(WALLET, TEST_CALLDATA, TEST_NONCE, policyId, WALLET); assertEq(uint256(status), uint256(TimelockPolicy.ProposalStatus.Executed), "Proposal should be executed"); @@ -186,8 +186,8 @@ contract TimelockCancellationRaceTest is Test { // Setup: Create a proposal _createProposal(TEST_CALLDATA, TEST_NONCE); - // Fast forward past delay and grace period (to make it executable normally) - vm.warp(block.timestamp + DELAY + GRACE_PERIOD + 1); + // Fast forward past delay (to make it executable normally) + vm.warp(block.timestamp + DELAY + 1); // Cancel in the same block as execution attempt _cancelProposal(TEST_CALLDATA, TEST_NONCE); @@ -205,8 +205,8 @@ contract TimelockCancellationRaceTest is Test { // Setup: Create a proposal _createProposal(TEST_CALLDATA, TEST_NONCE); - // Fast forward past delay and grace period - vm.warp(block.timestamp + DELAY + GRACE_PERIOD + 1); + // Fast forward past delay + vm.warp(block.timestamp + DELAY + 1); // Cancel the proposal _cancelProposal(TEST_CALLDATA, TEST_NONCE); @@ -234,7 +234,8 @@ contract TimelockCancellationRaceTest is Test { // Note: Cancelled proposals persist. Attempting to create via no-op UserOp // for the same calldata/nonce returns SIG_VALIDATION_FAILED. - bytes memory sig = abi.encodePacked(bytes32(TEST_CALLDATA.length), TEST_CALLDATA, bytes32(TEST_NONCE), bytes1(0x00)); + bytes memory sig = + abi.encodePacked(bytes32(TEST_CALLDATA.length), TEST_CALLDATA, bytes32(TEST_NONCE), bytes1(0x00)); PackedUserOperation memory retryOp = PackedUserOperation({ sender: WALLET, nonce: 0, @@ -254,136 +255,128 @@ contract TimelockCancellationRaceTest is Test { uint256 newNonce = TEST_NONCE + 1; _createProposal(TEST_CALLDATA, newNonce); - // Fast forward past delay and grace period - vm.warp(block.timestamp + DELAY + GRACE_PERIOD + 1); + // Fast forward past delay + vm.warp(block.timestamp + DELAY + 1); // Execute the new proposal PackedUserOperation memory userOp = _createUserOp(TEST_CALLDATA, newNonce); vm.prank(WALLET); uint256 validationResult = timelockPolicy.checkUserOpPolicy(policyId, userOp); - // Verify: it should allow execution of the new proposal after grace period + // Verify: it should allow execution of the new proposal after delay assertFalse(validationResult == 1, "New proposal should be executable"); // Verify status is executed - (TimelockPolicy.ProposalStatus status,,,) = + (TimelockPolicy.ProposalStatus status,,) = timelockPolicy.getProposal(WALLET, TEST_CALLDATA, newNonce, policyId, WALLET); assertEq(uint256(status), uint256(TimelockPolicy.ProposalStatus.Executed), "New proposal should be executed"); } - // ==================== whenExecutingAProposalDuringTheGracePeriod ==================== + // ==================== whenExecutingAProposalAfterDelay ==================== - modifier whenExecutingAProposalDuringTheGracePeriod() { + modifier whenExecutingAProposalAfterDelay() { _; } - function test_GivenTheTimelockDelayHasPassedButGracePeriodHasNot() - external - whenExecutingAProposalDuringTheGracePeriod - { + function test_GivenTheDelayHasPassed() external whenExecutingAProposalAfterDelay { // Setup: Create a proposal uint256 startTime = block.timestamp; _createProposal(TEST_CALLDATA, TEST_NONCE); - // Get expected timing - note: packed validAfter uses graceEnd - (,uint256 validAfter, uint256 graceEnd, uint256 validUntil) = + // Get expected timing + (, uint256 validAfter, uint256 validUntil) = timelockPolicy.getProposal(WALLET, TEST_CALLDATA, TEST_NONCE, policyId, WALLET); - // Fast forward past delay but NOT past grace period + // Fast forward past delay vm.warp(startTime + DELAY + 1); - // Verify we are in the grace period window + // Verify we are past validAfter assertTrue(block.timestamp > validAfter, "Should be past validAfter"); - assertTrue(block.timestamp < graceEnd, "Should be before graceEnd"); assertTrue(block.timestamp < validUntil, "Should be before validUntil"); - // Action: Try to execute + // Action: Execute PackedUserOperation memory userOp = _createUserOp(TEST_CALLDATA, TEST_NONCE); vm.prank(WALLET); uint256 validationResult = timelockPolicy.checkUserOpPolicy(policyId, userOp); - // Verify: it should return validation data with graceEnd as validAfter + // Verify: it should return validation data with validAfter matching proposal assertFalse(validationResult == 1, "Should not return failure"); uint48 returnedValidAfter = _extractValidAfter(validationResult); uint48 returnedValidUntil = _extractValidUntil(validationResult); - // The returned validAfter is graceEnd (not validAfter) - // This is the key fix - prevents execution during grace period - assertEq(returnedValidAfter, uint48(graceEnd), "validAfter should be graceEnd"); + assertEq(returnedValidAfter, uint48(validAfter), "validAfter should match proposal"); assertEq(returnedValidUntil, uint48(validUntil), "validUntil should match proposal expiration"); - // Note: The bundler/EntryPoint would reject execution during grace period - // because block.timestamp < returnedValidAfter (graceEnd) + // Verify: it should set proposal status to Executed + (TimelockPolicy.ProposalStatus status,,) = + timelockPolicy.getProposal(WALLET, TEST_CALLDATA, TEST_NONCE, policyId, WALLET); + assertEq(uint256(status), uint256(TimelockPolicy.ProposalStatus.Executed), "Proposal should be executed"); } - function test_GivenTheGracePeriodHasPassed() external whenExecutingAProposalDuringTheGracePeriod { + function test_GivenDelayHasNotPassed() external whenExecutingAProposalAfterDelay { // Setup: Create a proposal uint256 startTime = block.timestamp; _createProposal(TEST_CALLDATA, TEST_NONCE); // Get timing info - (,uint256 validAfter,, uint256 validUntil) = + (, uint256 validAfter, uint256 validUntil) = timelockPolicy.getProposal(WALLET, TEST_CALLDATA, TEST_NONCE, policyId, WALLET); - // Fast forward past delay AND grace period - vm.warp(startTime + DELAY + GRACE_PERIOD + 1); + // Warp halfway through delay (not past validAfter) + vm.warp(startTime + DELAY / 2); - // Verify we are past the grace period - assertTrue(block.timestamp > validAfter, "Should be past graceEnd"); - assertTrue(block.timestamp < validUntil, "Should be before validUntil"); + // Verify we haven't passed validAfter + assertTrue(block.timestamp < validAfter, "Should be before validAfter"); - // Action: Execute + // Action: Try to execute PackedUserOperation memory userOp = _createUserOp(TEST_CALLDATA, TEST_NONCE); vm.prank(WALLET); uint256 validationResult = timelockPolicy.checkUserOpPolicy(policyId, userOp); - // Verify: it should return validation data allowing execution + // Verify: validation returns packed data (not failure) but validAfter is in the future assertFalse(validationResult == 1, "Should not return failure"); uint48 returnedValidAfter = _extractValidAfter(validationResult); - assertTrue(block.timestamp >= returnedValidAfter, "Should be past validAfter for execution"); - - // Verify: it should set proposal status to Executed - (TimelockPolicy.ProposalStatus status,,,) = - timelockPolicy.getProposal(WALLET, TEST_CALLDATA, TEST_NONCE, policyId, WALLET); - assertEq(uint256(status), uint256(TimelockPolicy.ProposalStatus.Executed), "Proposal should be executed"); + assertTrue(block.timestamp < returnedValidAfter, "Should be before validAfter for bundler rejection"); } - // ==================== whenTheOwnerCancelsDuringGracePeriod ==================== + // ==================== whenTheOwnerCancelsDuringDelayPeriod ==================== - modifier whenTheOwnerCancelsDuringGracePeriod() { + modifier whenTheOwnerCancelsDuringDelayPeriod() { _; } - function test_GivenTheProposalIsStillPending() external whenTheOwnerCancelsDuringGracePeriod { + function test_GivenTheProposalIsStillPending() external whenTheOwnerCancelsDuringDelayPeriod { // Setup: Create a proposal uint256 startTime = block.timestamp; _createProposal(TEST_CALLDATA, TEST_NONCE); - // Fast forward to grace period (past delay, but before validUntil) - vm.warp(startTime + DELAY + 1); + // Fast forward into delay period (before validAfter) + vm.warp(startTime + DELAY / 2); // Verify proposal is still pending - (TimelockPolicy.ProposalStatus statusBefore,,,) = + (TimelockPolicy.ProposalStatus statusBefore,,) = timelockPolicy.getProposal(WALLET, TEST_CALLDATA, TEST_NONCE, policyId, WALLET); - assertEq(uint256(statusBefore), uint256(TimelockPolicy.ProposalStatus.Pending), "Proposal should still be pending"); + assertEq( + uint256(statusBefore), uint256(TimelockPolicy.ProposalStatus.Pending), "Proposal should still be pending" + ); - // Action: Owner cancels during grace period + // Action: Owner cancels during delay period _cancelProposal(TEST_CALLDATA, TEST_NONCE); // Verify: it should successfully cancel the proposal - (TimelockPolicy.ProposalStatus statusAfter,,,) = + (TimelockPolicy.ProposalStatus statusAfter,,) = timelockPolicy.getProposal(WALLET, TEST_CALLDATA, TEST_NONCE, policyId, WALLET); assertEq(uint256(statusAfter), uint256(TimelockPolicy.ProposalStatus.Cancelled), "Proposal should be cancelled"); } - function test_GivenAnExecutionAttemptIsPendingInTheMempool() external whenTheOwnerCancelsDuringGracePeriod { + function test_GivenAnExecutionAttemptIsPendingInTheMempool() external whenTheOwnerCancelsDuringDelayPeriod { // Setup: Create a proposal uint256 startTime = block.timestamp; _createProposal(TEST_CALLDATA, TEST_NONCE); - // Fast forward to grace period + // Fast forward past delay vm.warp(startTime + DELAY + 1); // Simulate scenario where both cancellation and execution happen in same block @@ -401,7 +394,7 @@ contract TimelockCancellationRaceTest is Test { assertEq(validationResult, 1, "Execution should fail because cancellation won the race"); // Verify proposal remains cancelled - (TimelockPolicy.ProposalStatus status,,,) = + (TimelockPolicy.ProposalStatus status,,) = timelockPolicy.getProposal(WALLET, TEST_CALLDATA, TEST_NONCE, policyId, WALLET); assertEq(uint256(status), uint256(TimelockPolicy.ProposalStatus.Cancelled), "Proposal should remain cancelled"); } @@ -441,23 +434,24 @@ contract TimelockCancellationRaceTest is Test { vm.stopPrank(); } - // ==================== whenCreatingANewProposalAfterGracePeriod ==================== + // ==================== whenCreatingANewProposalAfterExpiration ==================== - modifier whenCreatingANewProposalAfterGracePeriod() { + modifier whenCreatingANewProposalAfterExpiration() { _; } - function test_GivenTheOriginalProposalWasCancelled() external whenCreatingANewProposalAfterGracePeriod { + function test_GivenTheOriginalProposalWasCancelled() external whenCreatingANewProposalAfterExpiration { // Setup: Create and cancel a proposal _createProposal(TEST_CALLDATA, TEST_NONCE); _cancelProposal(TEST_CALLDATA, TEST_NONCE); - // Fast forward past when grace period would have ended - vm.warp(block.timestamp + DELAY + GRACE_PERIOD + EXPIRATION_PERIOD + 1); + // Fast forward past when the proposal would have expired + vm.warp(block.timestamp + DELAY + EXPIRATION_PERIOD + 1); // Action & Verify: Attempting to create via no-op UserOp returns SIG_VALIDATION_FAILED // because cancelled proposals persist in storage - bytes memory sig = abi.encodePacked(bytes32(TEST_CALLDATA.length), TEST_CALLDATA, bytes32(TEST_NONCE), bytes1(0x00)); + bytes memory sig = + abi.encodePacked(bytes32(TEST_CALLDATA.length), TEST_CALLDATA, bytes32(TEST_NONCE), bytes1(0x00)); PackedUserOperation memory noopOp = PackedUserOperation({ sender: WALLET, nonce: 0, @@ -474,12 +468,12 @@ contract TimelockCancellationRaceTest is Test { assertEq(result, 1, "Should return SIG_VALIDATION_FAILED because cancelled proposals persist"); } - function test_GivenTheOriginalProposalWasExecuted() external whenCreatingANewProposalAfterGracePeriod { + function test_GivenTheOriginalProposalWasExecuted() external whenCreatingANewProposalAfterExpiration { // Setup: Create a proposal _createProposal(TEST_CALLDATA, TEST_NONCE); - // Fast forward past delay and grace period - vm.warp(block.timestamp + DELAY + GRACE_PERIOD + 1); + // Fast forward past delay + vm.warp(block.timestamp + DELAY + 1); // Execute the proposal PackedUserOperation memory userOp = _createUserOp(TEST_CALLDATA, TEST_NONCE); @@ -491,7 +485,8 @@ contract TimelockCancellationRaceTest is Test { // Action & Verify: Attempting to create via no-op UserOp returns SIG_VALIDATION_FAILED // because executed proposals persist in storage - bytes memory sig = abi.encodePacked(bytes32(TEST_CALLDATA.length), TEST_CALLDATA, bytes32(TEST_NONCE), bytes1(0x00)); + bytes memory sig = + abi.encodePacked(bytes32(TEST_CALLDATA.length), TEST_CALLDATA, bytes32(TEST_NONCE), bytes1(0x00)); PackedUserOperation memory noopOp = PackedUserOperation({ sender: WALLET, nonce: 0, @@ -508,13 +503,13 @@ contract TimelockCancellationRaceTest is Test { assertEq(result, 1, "Should return SIG_VALIDATION_FAILED because executed proposals persist"); } - // ==================== whenValidatingGracePeriodTiming ==================== + // ==================== whenValidatingProposalTiming ==================== - modifier whenValidatingGracePeriodTiming() { + modifier whenValidatingProposalTiming() { _; } - function test_GivenDelayIs1DayAndGracePeriodIs1Hour() external whenValidatingGracePeriodTiming { + function test_GivenDelayIs1DayAndExpirationIs1Day() external whenValidatingProposalTiming { // Setup: Record start time uint256 startTime = block.timestamp; @@ -522,26 +517,26 @@ contract TimelockCancellationRaceTest is Test { _createProposal(TEST_CALLDATA, TEST_NONCE); // Get proposal timing - (TimelockPolicy.ProposalStatus status, uint256 validAfter,, uint256 validUntil) = + (TimelockPolicy.ProposalStatus status, uint256 validAfter, uint256 validUntil) = timelockPolicy.getProposal(WALLET, TEST_CALLDATA, TEST_NONCE, policyId, WALLET); // Verify: it should set validAfter to current time plus delay assertEq(validAfter, startTime + DELAY, "validAfter should be startTime + delay"); - // Verify: it should set validUntil correctly (validAfter + grace + expiration) - assertEq(validUntil, validAfter + GRACE_PERIOD + EXPIRATION_PERIOD, "validUntil should be validAfter + gracePeriod + expirationPeriod"); + // Verify: it should set validUntil correctly (validAfter + expirationPeriod) + assertEq(validUntil, validAfter + EXPIRATION_PERIOD, "validUntil should be validAfter + expirationPeriod"); } - function test_GivenExecutionValidationDataIsReturned() external whenValidatingGracePeriodTiming { + function test_GivenExecutionValidationDataIsReturned() external whenValidatingProposalTiming { // Setup: Create a proposal uint256 startTime = block.timestamp; _createProposal(TEST_CALLDATA, TEST_NONCE); - // Get expected timing - note: packed validAfter uses graceEnd, not validAfter - (,uint256 expectedValidAfter, uint256 expectedGraceEnd, uint256 expectedValidUntil) = + // Get expected timing + (, uint256 expectedValidAfter, uint256 expectedValidUntil) = timelockPolicy.getProposal(WALLET, TEST_CALLDATA, TEST_NONCE, policyId, WALLET); - // Fast forward just past delay but still in grace period + // Fast forward past delay vm.warp(startTime + DELAY + 1); // Action: Get validation data by calling checkUserOpPolicy @@ -553,8 +548,8 @@ contract TimelockCancellationRaceTest is Test { uint48 packedValidAfter = _extractValidAfter(validationResult); uint48 packedValidUntil = _extractValidUntil(validationResult); - // Verify: it should pack graceEnd as validAfter (execution allowed after grace period) - assertEq(packedValidAfter, uint48(expectedGraceEnd), "Packed validAfter should match proposal graceEnd"); + // Verify: it should pack validAfter from the proposal + assertEq(packedValidAfter, uint48(expectedValidAfter), "Packed validAfter should match proposal validAfter"); // Verify: it should pack validUntil as expiration time assertEq(packedValidUntil, uint48(expectedValidUntil), "Packed validUntil should match proposal expiration"); @@ -567,7 +562,7 @@ contract TimelockCancellationRaceTest is Test { _createProposal(TEST_CALLDATA, TEST_NONCE); // Fast forward past expiration - vm.warp(block.timestamp + DELAY + GRACE_PERIOD + EXPIRATION_PERIOD + 1); + vm.warp(block.timestamp + DELAY + EXPIRATION_PERIOD + 1); // Action: Try to execute PackedUserOperation memory userOp = _createUserOp(TEST_CALLDATA, TEST_NONCE); @@ -593,7 +588,8 @@ contract TimelockCancellationRaceTest is Test { address nonInitializedAccount = address(0xDEAD); // Try to create proposal via no-op UserOp on non-initialized account - bytes memory sig = abi.encodePacked(bytes32(TEST_CALLDATA.length), TEST_CALLDATA, bytes32(TEST_NONCE), bytes1(0x00)); + bytes memory sig = + abi.encodePacked(bytes32(TEST_CALLDATA.length), TEST_CALLDATA, bytes32(TEST_NONCE), bytes1(0x00)); PackedUserOperation memory noopOp = PackedUserOperation({ sender: nonInitializedAccount, nonce: 0, diff --git a/test/btt/TimelockEpochValidation.t.sol b/test/btt/TimelockEpochValidation.t.sol index b5ca7c9..48ff8b7 100644 --- a/test/btt/TimelockEpochValidation.t.sol +++ b/test/btt/TimelockEpochValidation.t.sol @@ -19,7 +19,7 @@ contract TimelockEpochValidationTest is Test { uint48 constant DELAY = 1 days; uint48 constant EXPIRATION_PERIOD = 1 days; - uint48 constant GRACE_PERIOD = 1 hours; + address constant GUARDIAN_ADDR = address(0); bytes32 constant POLICY_ID_1 = keccak256("POLICY_ID_1"); bytes32 constant POLICY_ID_2 = keccak256("POLICY_ID_2"); @@ -31,7 +31,7 @@ contract TimelockEpochValidationTest is Test { } function _installData() internal pure returns (bytes memory) { - return abi.encode(DELAY, EXPIRATION_PERIOD, GRACE_PERIOD); + return abi.encode(DELAY, EXPIRATION_PERIOD, GUARDIAN_ADDR); } function _installPolicy(address wallet, bytes32 policyId) internal { @@ -99,7 +99,7 @@ contract TimelockEpochValidationTest is Test { assertEq(epochAfter, 1, "Epoch should be 1 after first install"); // it should initialize the policy config - (uint48 delay, uint48 expirationPeriod, uint48 gracePeriod_, bool initialized) = + (uint48 delay, uint48 expirationPeriod, address guardian_, bool initialized) = timelockPolicy.timelockConfig(POLICY_ID_1, WALLET); assertTrue(initialized, "Policy should be initialized"); assertEq(delay, DELAY, "Delay should match"); @@ -166,19 +166,13 @@ contract TimelockEpochValidationTest is Test { // it should store the current epoch in the proposal // it should be in Pending status with timing set bytes32 userOpKey = timelockPolicy.computeUserOpKey(WALLET, callData, nonce); - ( - TimelockPolicy.ProposalStatus status, - uint48 validAfter, - uint48 graceEnd, - uint48 validUntil, - uint256 proposalEpoch - ) = timelockPolicy.proposals(userOpKey, POLICY_ID_1, WALLET); + (TimelockPolicy.ProposalStatus status, uint48 validAfter, uint48 validUntil, uint256 proposalEpoch) = + timelockPolicy.proposals(userOpKey, POLICY_ID_1, WALLET); assertEq(uint256(status), uint256(TimelockPolicy.ProposalStatus.Pending), "Proposal should be Pending"); assertEq(proposalEpoch, 1, "Proposal epoch should match current epoch (1)"); assertEq(validAfter, block.timestamp + DELAY, "validAfter should be correct"); - assertEq(graceEnd, block.timestamp + DELAY + GRACE_PERIOD, "graceEnd should be correct"); - assertEq(validUntil, block.timestamp + DELAY + GRACE_PERIOD + EXPIRATION_PERIOD, "validUntil should be correct"); + assertEq(validUntil, block.timestamp + DELAY + EXPIRATION_PERIOD, "validUntil should be correct"); } function test_GivenCreatingViaNoOpUserOp() external whenCreatingAProposal { @@ -193,7 +187,7 @@ contract TimelockEpochValidationTest is Test { // it should record the epoch at creation time (proposal is Pending) bytes32 userOpKey = timelockPolicy.computeUserOpKey(WALLET, callData, nonce); - (TimelockPolicy.ProposalStatus status,,,, uint256 proposalEpoch) = + (TimelockPolicy.ProposalStatus status,,, uint256 proposalEpoch) = timelockPolicy.proposals(userOpKey, POLICY_ID_1, WALLET); assertEq(uint256(status), uint256(TimelockPolicy.ProposalStatus.Pending), "Should be Pending"); @@ -236,7 +230,7 @@ contract TimelockEpochValidationTest is Test { // it should mark proposal as executed bytes32 userOpKey = timelockPolicy.computeUserOpKey(WALLET, callData, nonce); - (TimelockPolicy.ProposalStatus status,,,,) = timelockPolicy.proposals(userOpKey, POLICY_ID_1, WALLET); + (TimelockPolicy.ProposalStatus status,,,) = timelockPolicy.proposals(userOpKey, POLICY_ID_1, WALLET); assertEq(uint256(status), uint256(TimelockPolicy.ProposalStatus.Executed), "Proposal should be executed"); } @@ -267,7 +261,7 @@ contract TimelockEpochValidationTest is Test { // it should not mark proposal as executed (still Pending from epoch 1) bytes32 userOpKey = timelockPolicy.computeUserOpKey(WALLET, callData, nonce); - (TimelockPolicy.ProposalStatus status,,,,) = timelockPolicy.proposals(userOpKey, POLICY_ID_1, WALLET); + (TimelockPolicy.ProposalStatus status,,,) = timelockPolicy.proposals(userOpKey, POLICY_ID_1, WALLET); assertEq(uint256(status), uint256(TimelockPolicy.ProposalStatus.Pending), "Proposal should still be Pending"); } @@ -281,7 +275,7 @@ contract TimelockEpochValidationTest is Test { _createProposal(WALLET, POLICY_ID_1, callData, nonce); bytes32 userOpKey = timelockPolicy.computeUserOpKey(WALLET, callData, nonce); - (,,,, uint256 proposalEpoch) = timelockPolicy.proposals(userOpKey, POLICY_ID_1, WALLET); + (,,, uint256 proposalEpoch) = timelockPolicy.proposals(userOpKey, POLICY_ID_1, WALLET); assertEq(proposalEpoch, 1, "Proposal should be in epoch 1"); // Warp and do multiple reinstalls to get to epoch 3 @@ -303,7 +297,7 @@ contract TimelockEpochValidationTest is Test { assertEq(validationResult, SIG_VALIDATION_FAILED_UINT, "Should reject stale proposal"); // it should leave proposal status unchanged - (TimelockPolicy.ProposalStatus statusAfter,,,,) = timelockPolicy.proposals(userOpKey, POLICY_ID_1, WALLET); + (TimelockPolicy.ProposalStatus statusAfter,,,) = timelockPolicy.proposals(userOpKey, POLICY_ID_1, WALLET); assertEq( uint256(statusAfter), uint256(TimelockPolicy.ProposalStatus.Pending), @@ -386,7 +380,7 @@ contract TimelockEpochValidationTest is Test { _createProposal(WALLET, POLICY_ID_1, newCallData, newNonce); bytes32 newUserOpKey = timelockPolicy.computeUserOpKey(WALLET, newCallData, newNonce); - (TimelockPolicy.ProposalStatus status,,,, uint256 newProposalEpoch) = + (TimelockPolicy.ProposalStatus status,,, uint256 newProposalEpoch) = timelockPolicy.proposals(newUserOpKey, POLICY_ID_1, WALLET); assertEq(uint256(status), uint256(TimelockPolicy.ProposalStatus.Pending), "New proposal should be Pending"); diff --git a/test/btt/TimelockSignaturePolicy.t.sol b/test/btt/TimelockSignaturePolicy.t.sol index 9b405a4..c5d0289 100644 --- a/test/btt/TimelockSignaturePolicy.t.sol +++ b/test/btt/TimelockSignaturePolicy.t.sol @@ -16,7 +16,7 @@ contract TimelockSignaturePolicyTest is Test { uint48 constant DELAY = 1 days; uint48 constant EXPIRATION_PERIOD = 1 days; - uint48 constant GRACE_PERIOD = 1 hours; + address constant GUARDIAN_ADDR = address(0); bytes32 public policyId; bytes32 public testHash; @@ -29,7 +29,7 @@ contract TimelockSignaturePolicyTest is Test { /// @notice Helper to install the policy for a wallet function _installPolicy(address wallet) internal { - bytes memory installData = abi.encode(DELAY, EXPIRATION_PERIOD, GRACE_PERIOD); + bytes memory installData = abi.encode(DELAY, EXPIRATION_PERIOD, GUARDIAN_ADDR); vm.prank(wallet); timelockPolicy.onInstall(abi.encodePacked(policyId, installData)); } diff --git a/test/integration/TimelockEntryPoint.t.sol b/test/integration/TimelockEntryPoint.t.sol index df87545..e72852c 100644 --- a/test/integration/TimelockEntryPoint.t.sol +++ b/test/integration/TimelockEntryPoint.t.sol @@ -6,6 +6,7 @@ import {IEntryPoint} from "account-abstraction/interfaces/IEntryPoint.sol"; import {PackedUserOperation} from "account-abstraction/interfaces/PackedUserOperation.sol"; import {IAccountExecute} from "account-abstraction/interfaces/IAccountExecute.sol"; import {IERC7579Execution} from "openzeppelin-contracts/contracts/interfaces/draft-IERC7579.sol"; +import {LibERC7579} from "solady/accounts/LibERC7579.sol"; import {EntryPointLib} from "../utils/EntryPointLib.sol"; import {MockTimelockAccount} from "../utils/MockTimelockAccount.sol"; import {TimelockPolicy} from "../../src/policies/TimelockPolicy.sol"; @@ -22,7 +23,7 @@ contract TimelockEntryPointTest is Test { bytes32 public constant POLICY_ID = bytes32(uint256(1)); uint48 public constant DELAY = 1 hours; uint48 public constant EXPIRATION = 1 days; - uint48 public constant GRACE_PERIOD = 30 minutes; + address public constant GUARDIAN = address(0); address payable constant BENEFICIARY = payable(address(0xbeeF)); address constant BUNDLER = address(0xba5ed); @@ -37,23 +38,15 @@ contract TimelockEntryPointTest is Test { // Install timelock policy (must come from the account) vm.prank(address(account)); - policy.onInstall(abi.encode(POLICY_ID, DELAY, EXPIRATION, GRACE_PERIOD)); + policy.onInstall(abi.encode(POLICY_ID, DELAY, EXPIRATION, GUARDIAN)); } // ============ Helpers ============ /// @dev Build proposal-creation signature: [callDataLen(32)][callData][proposalNonce(32)][0x00] - function _proposalSig(bytes memory proposalCallData, uint256 proposalNonce) - internal - pure - returns (bytes memory) - { - return abi.encodePacked( - bytes32(proposalCallData.length), - proposalCallData, - bytes32(proposalNonce), - bytes1(0x00) - ); + function _proposalSig(bytes memory proposalCallData, uint256 proposalNonce) internal pure returns (bytes memory) { + return + abi.encodePacked(bytes32(proposalCallData.length), proposalCallData, bytes32(proposalNonce), bytes1(0x00)); } /// @dev Build a no-op UserOp for proposal creation with configurable calldata format. @@ -149,13 +142,12 @@ contract TimelockEntryPointTest is Test { _submitOp(_buildCreationOp(proposalCallData, proposalNonce, epNonce)); // Verify proposal was stored - (TimelockPolicy.ProposalStatus status, uint256 validAfter, uint256 graceEnd, uint256 validUntil) = + (TimelockPolicy.ProposalStatus status, uint256 validAfter, uint256 validUntil) = policy.getProposal(address(account), proposalCallData, proposalNonce, POLICY_ID, address(account)); assertEq(uint256(status), uint256(TimelockPolicy.ProposalStatus.Pending)); assertEq(validAfter, block.timestamp + DELAY); - assertEq(graceEnd, block.timestamp + DELAY + GRACE_PERIOD); - assertEq(validUntil, block.timestamp + DELAY + GRACE_PERIOD + EXPIRATION); + assertEq(validUntil, block.timestamp + DELAY + EXPIRATION); // EntryPoint nonce should have advanced assertEq(_getNonce(0), 1); @@ -170,7 +162,7 @@ contract TimelockEntryPointTest is Test { _submitOp(_buildCreationOp(proposalCallData, proposalNonce, 0)); // Step 2: Warp past delay + grace period - vm.warp(block.timestamp + DELAY + GRACE_PERIOD + 1); + vm.warp(block.timestamp + DELAY + 1); // Step 3: Execute proposal through EntryPoint _submitOp(_buildExecutionOp(proposalCallData, proposalNonce)); @@ -179,7 +171,7 @@ contract TimelockEntryPointTest is Test { assertEq(account.value(), 42); // Verify proposal status is Executed - (TimelockPolicy.ProposalStatus status,,,) = + (TimelockPolicy.ProposalStatus status,,) = policy.getProposal(address(account), proposalCallData, proposalNonce, POLICY_ID, address(account)); assertEq(uint256(status), uint256(TimelockPolicy.ProposalStatus.Executed)); } @@ -193,44 +185,44 @@ contract TimelockEntryPointTest is Test { // ============ 2. Time Window Enforcement ============ - /// @notice EntryPoint rejects execution during the grace period (validAfter not yet reached). - function testEntryPoint_GracePeriodBlocksExecution() public { + /// @notice EntryPoint rejects execution before the delay has passed (validAfter not yet reached). + function testEntryPoint_DelayBlocksExecution() public { bytes memory proposalCallData = abi.encodeCall(MockTimelockAccount.setValue, (42)); uint256 proposalNonce = 1; _submitOp(_buildCreationOp(proposalCallData, proposalNonce, 0)); - // Warp past delay but still within grace period - vm.warp(block.timestamp + DELAY + 1); + // Warp halfway through delay — still blocked + vm.warp(block.timestamp + DELAY / 2); _expectRevertOnOp(_buildExecutionOp(proposalCallData, proposalNonce)); assertEq(account.value(), 0); } - /// @notice Execution at exactly graceEnd timestamp is still rejected (EntryPoint uses <=). - function testEntryPoint_ExecutionAtExactGraceEndIsRejected() public { + /// @notice Execution at exactly validAfter timestamp is still rejected (EntryPoint uses <=). + function testEntryPoint_ExecutionAtExactValidAfterIsRejected() public { bytes memory proposalCallData = abi.encodeCall(MockTimelockAccount.setValue, (42)); uint256 proposalNonce = 1; uint256 creationTime = block.timestamp; _submitOp(_buildCreationOp(proposalCallData, proposalNonce, 0)); - // Warp to exactly graceEnd: EntryPoint checks block.timestamp <= validAfter, so equal is rejected - vm.warp(creationTime + DELAY + GRACE_PERIOD); + // Warp to exactly validAfter: EntryPoint checks block.timestamp <= validAfter, so equal is rejected + vm.warp(creationTime + DELAY); _expectRevertOnOp(_buildExecutionOp(proposalCallData, proposalNonce)); assertEq(account.value(), 0); } - /// @notice Execution at graceEnd + 1 succeeds (first valid timestamp). - function testEntryPoint_ExecutionAtGraceEndPlusOneSucceeds() public { + /// @notice Execution at validAfter + 1 succeeds (first valid timestamp). + function testEntryPoint_ExecutionAtValidAfterPlusOneSucceeds() public { bytes memory proposalCallData = abi.encodeCall(MockTimelockAccount.setValue, (42)); uint256 proposalNonce = 1; uint256 creationTime = block.timestamp; _submitOp(_buildCreationOp(proposalCallData, proposalNonce, 0)); - vm.warp(creationTime + DELAY + GRACE_PERIOD + 1); + vm.warp(creationTime + DELAY + 1); _submitOp(_buildExecutionOp(proposalCallData, proposalNonce)); assertEq(account.value(), 42); @@ -245,7 +237,7 @@ contract TimelockEntryPointTest is Test { _submitOp(_buildCreationOp(proposalCallData, proposalNonce, 0)); // Warp to exactly validUntil: EntryPoint checks block.timestamp > validUntil, so equal is OK - vm.warp(creationTime + DELAY + GRACE_PERIOD + EXPIRATION); + vm.warp(creationTime + DELAY + EXPIRATION); _submitOp(_buildExecutionOp(proposalCallData, proposalNonce)); assertEq(account.value(), 42); @@ -260,7 +252,7 @@ contract TimelockEntryPointTest is Test { _submitOp(_buildCreationOp(proposalCallData, proposalNonce, 0)); // Warp 1 second past validUntil - vm.warp(creationTime + DELAY + GRACE_PERIOD + EXPIRATION + 1); + vm.warp(creationTime + DELAY + EXPIRATION + 1); _expectRevertOnOp(_buildExecutionOp(proposalCallData, proposalNonce)); assertEq(account.value(), 0); @@ -290,32 +282,32 @@ contract TimelockEntryPointTest is Test { vm.prank(address(account)); policy.cancelProposal(POLICY_ID, address(account), proposalCallData, proposalNonce); - vm.warp(block.timestamp + DELAY + GRACE_PERIOD + 1); + vm.warp(block.timestamp + DELAY + 1); _expectRevertOnOp(_buildExecutionOp(proposalCallData, proposalNonce)); assertEq(account.value(), 0); } - /// @notice Owner can cancel during grace period (delay passed but grace hasn't ended). - function testEntryPoint_CancelDuringGracePeriod() public { + /// @notice Owner can cancel during delay period (before validAfter). + function testEntryPoint_CancelDuringDelayPeriod() public { bytes memory proposalCallData = abi.encodeCall(MockTimelockAccount.setValue, (42)); uint256 proposalNonce = 1; _submitOp(_buildCreationOp(proposalCallData, proposalNonce, 0)); - // Warp into the grace period - vm.warp(block.timestamp + DELAY + GRACE_PERIOD / 2); + // Warp into the delay period + vm.warp(block.timestamp + DELAY / 2); // Cancel should succeed vm.prank(address(account)); policy.cancelProposal(POLICY_ID, address(account), proposalCallData, proposalNonce); - (TimelockPolicy.ProposalStatus status,,,) = + (TimelockPolicy.ProposalStatus status,,) = policy.getProposal(address(account), proposalCallData, proposalNonce, POLICY_ID, address(account)); assertEq(uint256(status), uint256(TimelockPolicy.ProposalStatus.Cancelled)); - // Warp past grace period — execution still fails - vm.warp(block.timestamp + GRACE_PERIOD + 1); + // Warp past delay — execution still fails (cancelled) + vm.warp(block.timestamp + DELAY + 1); _expectRevertOnOp(_buildExecutionOp(proposalCallData, proposalNonce)); } @@ -330,7 +322,7 @@ contract TimelockEntryPointTest is Test { vm.prank(address(account)); policy.cancelProposal(POLICY_ID, address(account), proposalCallData, proposalNonce); - (TimelockPolicy.ProposalStatus status,,,) = + (TimelockPolicy.ProposalStatus status,,) = policy.getProposal(address(account), proposalCallData, proposalNonce, POLICY_ID, address(account)); assertEq(uint256(status), uint256(TimelockPolicy.ProposalStatus.Cancelled)); } @@ -345,7 +337,7 @@ contract TimelockEntryPointTest is Test { _createProposal(proposalCallData, proposalNonce); - vm.warp(block.timestamp + DELAY + GRACE_PERIOD + 1); + vm.warp(block.timestamp + DELAY + 1); // First execution succeeds _submitOp(_buildExecutionOp(proposalCallData, proposalNonce)); @@ -357,7 +349,7 @@ contract TimelockEntryPointTest is Test { // We use key=2 to get a fresh nonce that equals proposalNonce... but that doesn't // match the original proposalNonce. The proposal key won't match. // Instead, verify the proposal status is Executed. - (TimelockPolicy.ProposalStatus status,,,) = + (TimelockPolicy.ProposalStatus status,,) = policy.getProposal(address(account), proposalCallData, proposalNonce, POLICY_ID, address(account)); assertEq(uint256(status), uint256(TimelockPolicy.ProposalStatus.Executed)); } @@ -391,10 +383,10 @@ contract TimelockEntryPointTest is Test { // Reinstall (increments epoch) vm.prank(address(account)); - policy.onInstall(abi.encode(POLICY_ID, DELAY, EXPIRATION, GRACE_PERIOD)); + policy.onInstall(abi.encode(POLICY_ID, DELAY, EXPIRATION, GUARDIAN)); // Warp past delay + grace - vm.warp(block.timestamp + DELAY + GRACE_PERIOD + 1); + vm.warp(block.timestamp + DELAY + 1); // Execution fails: proposal epoch doesn't match new epoch _expectRevertOnOp(_buildExecutionOp(proposalCallData, proposalNonce)); @@ -413,13 +405,13 @@ contract TimelockEntryPointTest is Test { vm.prank(address(account)); policy.onUninstall(abi.encode(POLICY_ID, "")); vm.prank(address(account)); - policy.onInstall(abi.encode(POLICY_ID, DELAY, EXPIRATION, GRACE_PERIOD)); + policy.onInstall(abi.encode(POLICY_ID, DELAY, EXPIRATION, GUARDIAN)); // Create a NEW proposal with a different nonce uint256 newProposalNonce = _getNonce(2); // key=2 _createProposal(abi.encodeCall(MockTimelockAccount.setValue, (77)), newProposalNonce); - vm.warp(block.timestamp + DELAY + GRACE_PERIOD + 1); + vm.warp(block.timestamp + DELAY + 1); // New proposal executes fine _submitOp(_buildExecutionOp(abi.encodeCall(MockTimelockAccount.setValue, (77)), newProposalNonce)); @@ -428,22 +420,27 @@ contract TimelockEntryPointTest is Test { // ============ 6. No-Op Calldata Variants ============ - /// @notice Proposal creation with ERC-7579 execute(mode=0x00, "") no-op format. + /// @notice Proposal creation with ERC-7579 execute(CALLTYPE_SINGLE, abi.encodePacked(target, 0)) no-op format. + /// Uses the minimal decodeSingle()-compatible execution data (52 bytes). function testEntryPoint_CreationViaERC7579NoOp() public { bytes memory proposalCallData = abi.encodeCall(MockTimelockAccount.setValue, (42)); uint256 proposalNonce = _getNonce(1); - // ERC-7579 no-op: execute(bytes32(0), "") → selector + mode(32) + offset(32) + len(32) = 100 bytes - bytes memory erc7579Noop = abi.encodeWithSelector(IERC7579Execution.execute.selector, bytes32(0), ""); + // ERC-7579 no-op: execute(singleMode, abi.encodePacked(target, uint256(0))) + // executionCalldata = target(20) + value(32) = 52 bytes, no inner calldata + bytes32 mode = + LibERC7579.encodeMode(LibERC7579.CALLTYPE_SINGLE, LibERC7579.EXECTYPE_DEFAULT, bytes4(0), bytes22(0)); + bytes memory erc7579Noop = + abi.encodeWithSelector(IERC7579Execution.execute.selector, mode, abi.encodePacked(address(0), uint256(0))); _submitOp(_buildCreationOpWithCalldata(erc7579Noop, proposalCallData, proposalNonce, _getNonce(0))); - (TimelockPolicy.ProposalStatus status,,,) = + (TimelockPolicy.ProposalStatus status,,) = policy.getProposal(address(account), proposalCallData, proposalNonce, POLICY_ID, address(account)); assertEq(uint256(status), uint256(TimelockPolicy.ProposalStatus.Pending)); // Verify lifecycle completes - vm.warp(block.timestamp + DELAY + GRACE_PERIOD + 1); + vm.warp(block.timestamp + DELAY + 1); _submitOp(_buildExecutionOp(proposalCallData, proposalNonce)); assertEq(account.value(), 42); } @@ -458,7 +455,7 @@ contract TimelockEntryPointTest is Test { _submitOp(_buildCreationOpWithCalldata(executeUserOpNoop, proposalCallData, proposalNonce, _getNonce(0))); - (TimelockPolicy.ProposalStatus status,,,) = + (TimelockPolicy.ProposalStatus status,,) = policy.getProposal(address(account), proposalCallData, proposalNonce, POLICY_ID, address(account)); assertEq(uint256(status), uint256(TimelockPolicy.ProposalStatus.Pending)); } @@ -468,12 +465,15 @@ contract TimelockEntryPointTest is Test { bytes memory proposalCallData = abi.encodeCall(MockTimelockAccount.setValue, (42)); uint256 proposalNonce = _getNonce(1); - bytes memory erc7579Noop = abi.encodeWithSelector(IERC7579Execution.execute.selector, bytes32(0), ""); + bytes32 mode = + LibERC7579.encodeMode(LibERC7579.CALLTYPE_SINGLE, LibERC7579.EXECTYPE_DEFAULT, bytes4(0), bytes22(0)); + bytes memory erc7579Noop = + abi.encodeWithSelector(IERC7579Execution.execute.selector, mode, abi.encodePacked(address(0), uint256(0))); bytes memory wrappedNoop = abi.encodePacked(IAccountExecute.executeUserOp.selector, erc7579Noop); _submitOp(_buildCreationOpWithCalldata(wrappedNoop, proposalCallData, proposalNonce, _getNonce(0))); - (TimelockPolicy.ProposalStatus status,,,) = + (TimelockPolicy.ProposalStatus status,,) = policy.getProposal(address(account), proposalCallData, proposalNonce, POLICY_ID, address(account)); assertEq(uint256(status), uint256(TimelockPolicy.ProposalStatus.Pending)); } @@ -493,7 +493,7 @@ contract TimelockEntryPointTest is Test { _createProposal(callDataA, nonceA); _createProposal(callDataB, nonceB); - vm.warp(block.timestamp + DELAY + GRACE_PERIOD + 1); + vm.warp(block.timestamp + DELAY + 1); // Execute B first _submitOp(_buildExecutionOp(callDataB, nonceB)); @@ -504,9 +504,9 @@ contract TimelockEntryPointTest is Test { assertEq(account.value(), 10); // Both are Executed - (TimelockPolicy.ProposalStatus statusA,,,) = + (TimelockPolicy.ProposalStatus statusA,,) = policy.getProposal(address(account), callDataA, nonceA, POLICY_ID, address(account)); - (TimelockPolicy.ProposalStatus statusB,,,) = + (TimelockPolicy.ProposalStatus statusB,,) = policy.getProposal(address(account), callDataB, nonceB, POLICY_ID, address(account)); assertEq(uint256(statusA), uint256(TimelockPolicy.ProposalStatus.Executed)); assertEq(uint256(statusB), uint256(TimelockPolicy.ProposalStatus.Executed)); @@ -527,7 +527,7 @@ contract TimelockEntryPointTest is Test { vm.prank(address(account)); policy.cancelProposal(POLICY_ID, address(account), callDataA, nonceA); - vm.warp(block.timestamp + DELAY + GRACE_PERIOD + 1); + vm.warp(block.timestamp + DELAY + 1); // A fails _expectRevertOnOp(_buildExecutionOp(callDataA, nonceA)); @@ -554,30 +554,30 @@ contract TimelockEntryPointTest is Test { // Create A at T0 _createProposal(callDataA, nonceA); - // A's graceEnd = T0 + DELAY + GRACE_PERIOD = 10000 + 3600 + 1800 = 15400 - // A's validUntil = 15400 + EXPIRATION = 15400 + 86400 = 101800 + // A's validAfter = T0 + DELAY = 10000 + 3600 = 13600 + // A's validUntil = 13600 + EXPIRATION = 13600 + 86400 = 100000 // Warp 1 hour, create B at T0 + 1h uint256 T1 = T0 + 1 hours; // 13600 vm.warp(T1); _createProposal(callDataB, nonceB); - // B's graceEnd = T1 + DELAY + GRACE_PERIOD = 13600 + 3600 + 1800 = 19000 - // B's validUntil = 19000 + EXPIRATION = 19000 + 86400 = 105400 + // B's validAfter = T1 + DELAY = 13600 + 3600 = 17200 + // B's validUntil = 17200 + EXPIRATION = 17200 + 86400 = 103600 - // Warp to T0 + DELAY + GRACE_PERIOD + 1 = 15401 - // A's graceEnd (15400) < 15401 → A is executable - // B's graceEnd (19000) > 15401 → B still in grace - vm.warp(T0 + uint256(DELAY) + uint256(GRACE_PERIOD) + 1); + // Warp to T0 + DELAY + 1 = 13601 + // A's validAfter (13600) < 13601 → A is executable + // B's validAfter (17200) > 13601 → B still in delay + vm.warp(T0 + uint256(DELAY) + 1); // A works _submitOp(_buildExecutionOp(callDataA, nonceA)); assertEq(account.value(), 10); - // B still blocked (B's graceEnd = 19000 > 15401) + // B still blocked (B's validAfter = 17200 > 13601) _expectRevertOnOp(_buildExecutionOp(callDataB, nonceB)); - // Warp to B's window: T1 + DELAY + GRACE_PERIOD + 1 = 19001 - vm.warp(T1 + uint256(DELAY) + uint256(GRACE_PERIOD) + 1); + // Warp to B's window: T1 + DELAY + 1 = 17201 + vm.warp(T1 + uint256(DELAY) + 1); _submitOp(_buildExecutionOp(callDataB, nonceB)); assertEq(account.value(), 20); } @@ -600,9 +600,9 @@ contract TimelockEntryPointTest is Test { _submitOps(ops); // Both proposals should exist - (TimelockPolicy.ProposalStatus statusA,,,) = + (TimelockPolicy.ProposalStatus statusA,,) = policy.getProposal(address(account), callDataA, nonceA, POLICY_ID, address(account)); - (TimelockPolicy.ProposalStatus statusB,,,) = + (TimelockPolicy.ProposalStatus statusB,,) = policy.getProposal(address(account), callDataB, nonceB, POLICY_ID, address(account)); assertEq(uint256(statusA), uint256(TimelockPolicy.ProposalStatus.Pending)); assertEq(uint256(statusB), uint256(TimelockPolicy.ProposalStatus.Pending)); @@ -619,7 +619,7 @@ contract TimelockEntryPointTest is Test { _createProposal(callDataA, nonceA); _createProposal(callDataB, nonceB); - vm.warp(block.timestamp + DELAY + GRACE_PERIOD + 1); + vm.warp(block.timestamp + DELAY + 1); PackedUserOperation[] memory ops = new PackedUserOperation[](2); ops[0] = _buildExecutionOp(callDataA, nonceA); @@ -630,9 +630,9 @@ contract TimelockEntryPointTest is Test { // Last one wins for the value, both should be Executed assertEq(account.value(), 20); - (TimelockPolicy.ProposalStatus statusA,,,) = + (TimelockPolicy.ProposalStatus statusA,,) = policy.getProposal(address(account), callDataA, nonceA, POLICY_ID, address(account)); - (TimelockPolicy.ProposalStatus statusB,,,) = + (TimelockPolicy.ProposalStatus statusB,,) = policy.getProposal(address(account), callDataB, nonceB, POLICY_ID, address(account)); assertEq(uint256(statusA), uint256(TimelockPolicy.ProposalStatus.Executed)); assertEq(uint256(statusB), uint256(TimelockPolicy.ProposalStatus.Executed)); @@ -650,7 +650,7 @@ contract TimelockEntryPointTest is Test { // Create using key=0 _submitOp(_buildCreationOp(proposalCallData, proposalNonce, _getNonce(0))); - vm.warp(block.timestamp + DELAY + GRACE_PERIOD + 1); + vm.warp(block.timestamp + DELAY + 1); // Execute using key=5 (nonce matches proposalNonce) _submitOp(_buildExecutionOp(proposalCallData, proposalNonce)); @@ -665,7 +665,7 @@ contract TimelockEntryPointTest is Test { MockTimelockAccount account2 = new MockTimelockAccount(entryPoint, policy, POLICY_ID); vm.deal(address(account2), 10 ether); vm.prank(address(account2)); - policy.onInstall(abi.encode(POLICY_ID, DELAY, EXPIRATION, GRACE_PERIOD)); + policy.onInstall(abi.encode(POLICY_ID, DELAY, EXPIRATION, GUARDIAN)); bytes memory proposalCallData = abi.encodeCall(MockTimelockAccount.setValue, (42)); uint256 proposalNonce1 = entryPoint.getNonce(address(account), 1); @@ -688,7 +688,7 @@ contract TimelockEntryPointTest is Test { }); _submitOp(op2); - vm.warp(block.timestamp + DELAY + GRACE_PERIOD + 1); + vm.warp(block.timestamp + DELAY + 1); // Execute account 1 _submitOp(_buildExecutionOp(proposalCallData, proposalNonce1)); @@ -721,7 +721,7 @@ contract TimelockEntryPointTest is Test { // sig = [len=0 (32 bytes)] + [nonce (32 bytes)] + [0x00 (1 byte)] = 65 bytes total ✓ _createProposal(proposalCallData, proposalNonce); - (TimelockPolicy.ProposalStatus status,,,) = + (TimelockPolicy.ProposalStatus status,,) = policy.getProposal(address(account), proposalCallData, proposalNonce, POLICY_ID, address(account)); assertEq(uint256(status), uint256(TimelockPolicy.ProposalStatus.Pending)); } @@ -737,7 +737,7 @@ contract TimelockEntryPointTest is Test { _createProposal(largeCallData, proposalNonce); - (TimelockPolicy.ProposalStatus status,,,) = + (TimelockPolicy.ProposalStatus status,,) = policy.getProposal(address(account), largeCallData, proposalNonce, POLICY_ID, address(account)); assertEq(uint256(status), uint256(TimelockPolicy.ProposalStatus.Pending)); } @@ -751,7 +751,7 @@ contract TimelockEntryPointTest is Test { bytes32 expectedKey = policy.computeUserOpKey(address(account), proposalCallData, proposalNonce); uint256 expectedValidAfter = block.timestamp + DELAY; - uint256 expectedValidUntil = block.timestamp + DELAY + GRACE_PERIOD + EXPIRATION; + uint256 expectedValidUntil = block.timestamp + DELAY + EXPIRATION; vm.expectEmit(true, true, true, true); emit TimelockPolicy.ProposalCreated( @@ -768,7 +768,7 @@ contract TimelockEntryPointTest is Test { _createProposal(proposalCallData, proposalNonce); - vm.warp(block.timestamp + DELAY + GRACE_PERIOD + 1); + vm.warp(block.timestamp + DELAY + 1); bytes32 expectedKey = policy.computeUserOpKey(address(account), proposalCallData, proposalNonce); @@ -797,7 +797,7 @@ contract TimelockEntryPointTest is Test { _submitOp(_buildCreationOp(cd2, pNonce2, 1)); assertEq(_getNonce(0), 2); - vm.warp(block.timestamp + DELAY + GRACE_PERIOD + 1); + vm.warp(block.timestamp + DELAY + 1); // Op 3: execution of first proposal (key=1) _submitOp(_buildExecutionOp(cd, pNonce)); @@ -839,34 +839,25 @@ contract TimelockEntryPointTest is Test { assertTrue(BENEFICIARY.balance > balBefore); } - // ============ 15. Full Grace Period Race-Condition Scenario ============ + // ============ 15. Guardian Cancellation Scenario ============ - /// @notice Simulate the race condition the grace period is designed to prevent: + /// @notice Simulate the guardian cancellation scenario: /// 1. Session key creates proposal - /// 2. Delay passes, session key submits execution - /// 3. Owner sees it and cancels during grace period - /// 4. Execution fails because EntryPoint rejects (validAfter = graceEnd) - function testEntryPoint_GracePeriodRaceCondition() public { + /// 2. Guardian (or owner) cancels before delay passes + /// 3. Execution fails because proposal is cancelled + function testEntryPoint_GuardianCancellationScenario() public { bytes memory proposalCallData = abi.encodeCall(MockTimelockAccount.setValue, (999)); uint256 proposalNonce = _getNonce(1); // Step 1: Session key creates proposal _createProposal(proposalCallData, proposalNonce); - // Step 2: Warp to delay + 1 second (within grace period) - vm.warp(block.timestamp + DELAY + 1); - - // Step 3: Session key tries to execute but EntryPoint blocks it - // (validAfter = graceEnd which is in the future) - _expectRevertOnOp(_buildExecutionOp(proposalCallData, proposalNonce)); - assertEq(account.value(), 0); - - // Step 4: Owner cancels during grace period + // Step 2: Owner cancels during delay period vm.prank(address(account)); policy.cancelProposal(POLICY_ID, address(account), proposalCallData, proposalNonce); - // Step 5: Even after grace period, execution fails (cancelled) - vm.warp(block.timestamp + GRACE_PERIOD + 1); + // Step 3: Even after delay passes, execution fails (cancelled) + vm.warp(block.timestamp + DELAY + 1); _expectRevertOnOp(_buildExecutionOp(proposalCallData, proposalNonce)); assertEq(account.value(), 0); } @@ -918,19 +909,19 @@ contract TimelockEntryPointTest is Test { uint256 nonce2 = _getNonce(2); _createProposal(proposalCallData, nonce2); - (TimelockPolicy.ProposalStatus status,,,) = + (TimelockPolicy.ProposalStatus status,,) = policy.getProposal(address(account), proposalCallData, nonce2, POLICY_ID, address(account)); assertEq(uint256(status), uint256(TimelockPolicy.ProposalStatus.Pending)); // Execute the new proposal - vm.warp(block.timestamp + DELAY + GRACE_PERIOD + 1); + vm.warp(block.timestamp + DELAY + 1); _submitOp(_buildExecutionOp(proposalCallData, nonce2)); assertEq(account.value(), 42); } // ============ 19. Exact Boundary: Delay Not Passed ============ - /// @notice At exactly delay (no grace period overlap), execution is still blocked. + /// @notice At exactly validAfter (= t0 + DELAY), execution is still blocked (EntryPoint uses <=). function testEntryPoint_AtExactDelayStillBlocked() public { bytes memory proposalCallData = abi.encodeCall(MockTimelockAccount.setValue, (42)); uint256 proposalNonce = _getNonce(1); @@ -938,8 +929,7 @@ contract TimelockEntryPointTest is Test { uint256 t0 = block.timestamp; _createProposal(proposalCallData, proposalNonce); - // At exactly validAfter (= t0 + DELAY): this is start of grace period, not end - // graceEnd = t0 + DELAY + GRACE_PERIOD, so block.timestamp <= graceEnd + // At exactly validAfter (= t0 + DELAY): EntryPoint checks block.timestamp <= validAfter vm.warp(t0 + DELAY); _expectRevertOnOp(_buildExecutionOp(proposalCallData, proposalNonce)); } @@ -957,14 +947,14 @@ contract TimelockEntryPointTest is Test { _createProposal(proposalCallData, nonceB); // Both exist - (TimelockPolicy.ProposalStatus statusA,,,) = + (TimelockPolicy.ProposalStatus statusA,,) = policy.getProposal(address(account), proposalCallData, nonceA, POLICY_ID, address(account)); - (TimelockPolicy.ProposalStatus statusB,,,) = + (TimelockPolicy.ProposalStatus statusB,,) = policy.getProposal(address(account), proposalCallData, nonceB, POLICY_ID, address(account)); assertEq(uint256(statusA), uint256(TimelockPolicy.ProposalStatus.Pending)); assertEq(uint256(statusB), uint256(TimelockPolicy.ProposalStatus.Pending)); - vm.warp(block.timestamp + DELAY + GRACE_PERIOD + 1); + vm.warp(block.timestamp + DELAY + 1); // Execute A, cancel B _submitOp(_buildExecutionOp(proposalCallData, nonceA)); @@ -973,9 +963,99 @@ contract TimelockEntryPointTest is Test { vm.prank(address(account)); policy.cancelProposal(POLICY_ID, address(account), proposalCallData, nonceB); - (statusA,,,) = policy.getProposal(address(account), proposalCallData, nonceA, POLICY_ID, address(account)); - (statusB,,,) = policy.getProposal(address(account), proposalCallData, nonceB, POLICY_ID, address(account)); + (statusA,,) = policy.getProposal(address(account), proposalCallData, nonceA, POLICY_ID, address(account)); + (statusB,,) = policy.getProposal(address(account), proposalCallData, nonceB, POLICY_ID, address(account)); assertEq(uint256(statusA), uint256(TimelockPolicy.ProposalStatus.Executed)); assertEq(uint256(statusB), uint256(TimelockPolicy.ProposalStatus.Cancelled)); } + + // ============ 21. Guardian Cancel Direct Call Prevents EntryPoint Execution ============ + + /// @notice Guardian cancels proposal via direct call, then execution UserOp through EntryPoint fails + function testEntryPoint_GuardianDirectCancelBlocksEntryPointExecution() public { + // Setup: Create a new account with a real guardian + address guardian = makeAddr("guardian"); + bytes32 guardianPolicyId = bytes32(uint256(100)); + + // Deploy new account and policy with guardian + MockTimelockAccount accountWithGuardian = new MockTimelockAccount(entryPoint, policy, guardianPolicyId); + vm.deal(address(accountWithGuardian), 100 ether); + + // Install policy with guardian + vm.prank(address(accountWithGuardian)); + policy.onInstall(abi.encode(guardianPolicyId, DELAY, EXPIRATION, guardian)); + + // Step 1: Create proposal via EntryPoint + bytes memory proposalCallData = abi.encodeCall(MockTimelockAccount.setValue, (777)); + uint256 proposalNonce = entryPoint.getNonce(address(accountWithGuardian), 1); // key=1 for execution + uint256 creationNonce = entryPoint.getNonce(address(accountWithGuardian), 0); // key=0 for creation + + PackedUserOperation memory creationOp = PackedUserOperation({ + sender: address(accountWithGuardian), + nonce: creationNonce, + initCode: "", + callData: "", // no-op calldata + accountGasLimits: bytes32(abi.encodePacked(uint128(500_000), uint128(500_000))), + preVerificationGas: 100_000, + gasFees: bytes32(abi.encodePacked(uint128(1 gwei), uint128(1 gwei))), + paymasterAndData: "", + signature: _proposalSig(proposalCallData, proposalNonce) + }); + + PackedUserOperation[] memory ops = new PackedUserOperation[](1); + ops[0] = creationOp; + vm.prank(BUNDLER, BUNDLER); + entryPoint.handleOps(ops, BENEFICIARY); + + // Verify proposal was created + (TimelockPolicy.ProposalStatus statusBefore,,) = policy.getProposal( + address(accountWithGuardian), + proposalCallData, + proposalNonce, + guardianPolicyId, + address(accountWithGuardian) + ); + assertEq(uint256(statusBefore), uint256(TimelockPolicy.ProposalStatus.Pending), "Proposal should be pending"); + + // Step 2: Guardian calls cancelProposal directly (not through EntryPoint) + vm.prank(guardian); + policy.cancelProposal(guardianPolicyId, address(accountWithGuardian), proposalCallData, proposalNonce); + + // Verify proposal is cancelled + (TimelockPolicy.ProposalStatus statusAfterCancel,,) = policy.getProposal( + address(accountWithGuardian), + proposalCallData, + proposalNonce, + guardianPolicyId, + address(accountWithGuardian) + ); + assertEq( + uint256(statusAfterCancel), uint256(TimelockPolicy.ProposalStatus.Cancelled), "Proposal should be cancelled" + ); + + // Step 3: Warp past delay and try to execute via EntryPoint — should fail + vm.warp(block.timestamp + DELAY + 1); + + PackedUserOperation memory executionOp = PackedUserOperation({ + sender: address(accountWithGuardian), + nonce: proposalNonce, + initCode: "", + callData: proposalCallData, + accountGasLimits: bytes32(abi.encodePacked(uint128(500_000), uint128(500_000))), + preVerificationGas: 100_000, + gasFees: bytes32(abi.encodePacked(uint128(1 gwei), uint128(1 gwei))), + paymasterAndData: "", + signature: "" // signature irrelevant for execution path + }); + + // Execution should revert because proposal is cancelled + PackedUserOperation[] memory execOps = new PackedUserOperation[](1); + execOps[0] = executionOp; + vm.prank(BUNDLER, BUNDLER); + vm.expectRevert(); + entryPoint.handleOps(execOps, BENEFICIARY); + + // Verify state was NOT changed + assertEq(accountWithGuardian.value(), 0, "Value should remain 0 after cancelled execution"); + } } diff --git a/test/utils/MockTimelockAccount.sol b/test/utils/MockTimelockAccount.sol index ba17609..fc20b6a 100644 --- a/test/utils/MockTimelockAccount.sol +++ b/test/utils/MockTimelockAccount.sol @@ -2,20 +2,28 @@ pragma solidity ^0.8.0; import {IAccount} from "account-abstraction/interfaces/IAccount.sol"; +import {IAccountExecute} from "account-abstraction/interfaces/IAccountExecute.sol"; import {PackedUserOperation} from "account-abstraction/interfaces/PackedUserOperation.sol"; import {IEntryPoint} from "account-abstraction/interfaces/IEntryPoint.sol"; +import {LibERC7579} from "solady/accounts/LibERC7579.sol"; import {TimelockPolicy} from "../../src/policies/TimelockPolicy.sol"; /// @title MockTimelockAccount -/// @notice Minimal IAccount that delegates validation to TimelockPolicy. -/// Used for integration testing with the real EntryPoint. -contract MockTimelockAccount is IAccount { +/// @notice Minimal ERC-4337 + ERC-7579 account that delegates validation to TimelockPolicy. +/// Implements execute() (ERC-7579 single call via Solady's LibERC7579) and +/// executeUserOp() (ERC-4337) so no-op detection tests exercise realistic execution paths. +contract MockTimelockAccount is IAccount, IAccountExecute { + using LibERC7579 for bytes32; + using LibERC7579 for bytes; + IEntryPoint public immutable entryPoint; TimelockPolicy public immutable policy; bytes32 public immutable policyId; uint256 public value; + error UnsupportedCallType(); + constructor(IEntryPoint _entryPoint, TimelockPolicy _policy, bytes32 _policyId) { entryPoint = _entryPoint; policy = _policy; @@ -36,6 +44,43 @@ contract MockTimelockAccount is IAccount { return policy.checkUserOpPolicy(policyId, userOp); } + /// @notice ERC-7579 execute — only supports single call (CALLTYPE_SINGLE). + /// Uses Solady's LibERC7579.decodeSingle() for the packed format. + function execute(bytes32 mode, bytes calldata executionCalldata) external payable { + require(msg.sender == address(entryPoint) || msg.sender == address(this), "only entrypoint or self"); + + bytes1 callType = mode.getCallType(); + if (callType != LibERC7579.CALLTYPE_SINGLE) revert UnsupportedCallType(); + + // Empty executionCalldata = true no-op (nothing to decode or call) + if (executionCalldata.length == 0) return; + + (address target, uint256 val, bytes calldata data) = executionCalldata.decodeSingle(); + + (bool ok, bytes memory ret) = target.call{value: val}(data); + if (!ok) { + assembly { + revert(add(ret, 0x20), mload(ret)) + } + } + } + + /// @notice ERC-4337 executeUserOp — EntryPoint calls this when callData starts with executeUserOp selector. + /// Extracts inner calldata from userOp.callData[4:] and self-calls. + function executeUserOp(PackedUserOperation calldata userOp, bytes32) external { + require(msg.sender == address(entryPoint), "only entrypoint"); + + bytes calldata innerCalldata = userOp.callData[4:]; + if (innerCalldata.length == 0) return; // executeUserOp with no inner data = no-op + + (bool ok, bytes memory ret) = address(this).call(innerCalldata); + if (!ok) { + assembly { + revert(add(ret, 0x20), mload(ret)) + } + } + } + function setValue(uint256 _value) external { value = _value; }