From 0578d2a6377651b760d579e7af80b9d5e11a6c05 Mon Sep 17 00:00:00 2001 From: taek Date: Mon, 2 Feb 2026 19:20:01 +0900 Subject: [PATCH 01/25] fix(TimelockPolicy): enforce timelock for ERC-1271 signatures --- src/policies/TimelockPolicy.sol | 119 +++++++++++++++++++++++++++++++- test/TimelockPolicy.t.sol | 62 ++++++++++++++++- test/base/PolicyTestBase.sol | 2 +- 3 files changed, 178 insertions(+), 5 deletions(-) diff --git a/src/policies/TimelockPolicy.sol b/src/policies/TimelockPolicy.sol index ce86996..71dd639 100644 --- a/src/policies/TimelockPolicy.sol +++ b/src/policies/TimelockPolicy.sol @@ -48,6 +48,9 @@ contract TimelockPolicy is PolicyBase, IStatelessValidator, IStatelessValidatorW // userOpKey = keccak256(abi.encode(account, keccak256(callData), nonce)) mapping(bytes32 => mapping(bytes32 => mapping(address => Proposal))) public proposals; + // Storage for ERC-1271 signature proposals: hash => id => wallet => proposal + mapping(bytes32 => mapping(bytes32 => mapping(address => Proposal))) public signatureProposals; + event ProposalCreated( address indexed wallet, bytes32 indexed id, bytes32 indexed proposalHash, uint256 validAfter, uint256 validUntil ); @@ -56,6 +59,14 @@ contract TimelockPolicy is PolicyBase, IStatelessValidator, IStatelessValidatorW event ProposalCancelled(address indexed wallet, bytes32 indexed id, bytes32 indexed proposalHash); + event SignatureProposalCreated( + address indexed wallet, bytes32 indexed id, bytes32 indexed hash, uint256 validAfter, uint256 validUntil + ); + + event SignatureProposalExecuted(address indexed wallet, bytes32 indexed id, bytes32 indexed hash); + + event SignatureProposalCancelled(address indexed wallet, bytes32 indexed id, bytes32 indexed hash); + event TimelockConfigUpdated(address indexed wallet, bytes32 indexed id, uint256 delay, uint256 expirationPeriod); error InvalidDelay(); @@ -166,6 +177,57 @@ contract TimelockPolicy is PolicyBase, IStatelessValidator, IStatelessValidatorW emit ProposalCancelled(account, id, userOpKey); } + /** + * @notice Create a proposal for time-delayed ERC-1271 signature + * @dev Anyone can create a proposal - the timelock delay provides the security + * @param id The policy ID + * @param account The account address + * @param hash The hash that will be signed + */ + function createSignatureProposal(bytes32 id, address account, bytes32 hash) external { + TimelockConfig storage config = timelockConfig[id][account]; + if (!config.initialized) revert IModule.NotInitialized(account); + + // Calculate proposal timing + uint48 validAfter = uint48(block.timestamp) + config.delay; + uint48 validUntil = validAfter + config.expirationPeriod; + + // Check proposal doesn't already exist + if (signatureProposals[hash][id][account].status != ProposalStatus.None) { + revert ProposalAlreadyExists(); + } + + // Create proposal + signatureProposals[hash][id][account] = + Proposal({status: ProposalStatus.Pending, validAfter: validAfter, validUntil: validUntil}); + + emit SignatureProposalCreated(account, id, hash, validAfter, validUntil); + } + + /** + * @notice Cancel a pending signature proposal + * @dev Only the account itself can cancel proposals to prevent griefing + * @param id The policy ID + * @param account The account address + * @param hash The hash of the signature proposal + */ + function cancelSignatureProposal(bytes32 id, address account, bytes32 hash) external { + // Only the account itself can cancel its own proposals + if (msg.sender != account) revert OnlyAccount(); + + TimelockConfig storage config = timelockConfig[id][account]; + if (!config.initialized) revert IModule.NotInitialized(account); + + Proposal storage proposal = signatureProposals[hash][id][account]; + if (proposal.status != ProposalStatus.Pending) { + revert ProposalNotPending(); + } + + proposal.status = ProposalStatus.Cancelled; + + emit SignatureProposalCancelled(account, id, hash); + } + /** * @notice Check user operation against timelock policy * @dev Called by the smart account during validation phase @@ -427,7 +489,7 @@ contract TimelockPolicy is PolicyBase, IStatelessValidator, IStatelessValidatorW /** * @notice Internal function to validate signature policy - * @dev Shared logic for both installed and stateless validator modes + * @dev Enforces timelock for ERC-1271 signatures - requires a valid proposal */ function _validateSignaturePolicy(bytes32 id, address account, bytes32 hash, bytes calldata sig) internal @@ -437,11 +499,44 @@ contract TimelockPolicy is PolicyBase, IStatelessValidator, IStatelessValidatorW TimelockConfig storage config = timelockConfig[id][account]; if (!config.initialized) return ERC1271_INVALID; - // For signature validation, we're more permissive - // Timelock is primarily for userOp execution + // Check if there's a valid signature proposal for this hash + Proposal storage proposal = signatureProposals[hash][id][account]; + + // Proposal must exist and be pending + if (proposal.status != ProposalStatus.Pending) { + return ERC1271_INVALID; + } + + // Check timing constraints + if (block.timestamp < proposal.validAfter) { + return ERC1271_INVALID; // Timelock not passed + } + + if (block.timestamp > proposal.validUntil) { + return ERC1271_INVALID; // Proposal expired + } + return ERC1271_MAGICVALUE; } + /** + * @notice Mark a signature proposal as executed (called after successful signature validation) + * @dev This should be called by the account after ERC-1271 validation succeeds + * @param id The policy ID + * @param hash The hash of the signature + */ + function markSignatureProposalExecuted(bytes32 id, bytes32 hash) external { + Proposal storage proposal = signatureProposals[hash][id][msg.sender]; + + if (proposal.status != ProposalStatus.Pending) { + revert ProposalNotPending(); + } + + proposal.status = ProposalStatus.Executed; + + emit SignatureProposalExecuted(msg.sender, id, hash); + } + /** * @notice Get proposal details * @param account The account address @@ -473,4 +568,22 @@ contract TimelockPolicy is PolicyBase, IStatelessValidator, IStatelessValidatorW function computeUserOpKey(address account, bytes calldata callData, uint256 nonce) external pure returns (bytes32) { return keccak256(abi.encode(account, keccak256(callData), nonce)); } + + /** + * @notice Get signature proposal details + * @param hash The hash being signed + * @param id The policy ID + * @param wallet The wallet address + * @return status The proposal status + * @return validAfter When the proposal becomes valid + * @return validUntil When the proposal expires + */ + function getSignatureProposal(bytes32 hash, bytes32 id, address wallet) + external + view + returns (ProposalStatus status, uint256 validAfter, uint256 validUntil) + { + Proposal storage proposal = signatureProposals[hash][id][wallet]; + return (proposal.status, proposal.validAfter, proposal.validUntil); + } } diff --git a/test/TimelockPolicy.t.sol b/test/TimelockPolicy.t.sol index 55d7541..142bd8e 100644 --- a/test/TimelockPolicy.t.sol +++ b/test/TimelockPolicy.t.sol @@ -184,7 +184,28 @@ contract TimelockPolicyTest is PolicyTestBase, StatelessValidatorTestBase, State assertEq(validationResult, 1); } - // Override signature policy test because TimelockPolicy always passes for installed accounts + // Override signature policy test because TimelockPolicy requires a valid proposal + function testPolicyCheckSignaturePolicySuccess() public payable override { + TimelockPolicy policyModule = TimelockPolicy(address(module)); + vm.startPrank(WALLET); + policyModule.onInstall(abi.encodePacked(policyId(), installData())); + vm.stopPrank(); + + bytes32 testHash = keccak256(abi.encodePacked("TEST_HASH")); + (address sender, bytes memory sigData) = validSignatureData(testHash); + + // Create a signature proposal first + policyModule.createSignatureProposal(policyId(), WALLET, testHash); + + // Fast forward past the delay + vm.warp(block.timestamp + delay + 1); + + vm.startPrank(WALLET); + uint256 result = policyModule.checkSignaturePolicy(policyId(), sender, testHash, sigData); + vm.stopPrank(); + assertEq(result, 0); + } + function testPolicyCheckSignaturePolicyFail() public payable override { TimelockPolicy policyModule = TimelockPolicy(address(module)); @@ -202,6 +223,45 @@ contract TimelockPolicyTest is PolicyTestBase, StatelessValidatorTestBase, State assertFalse(result == 0); } + function testPolicyCheckSignaturePolicyFailNoProposal() public payable { + TimelockPolicy policyModule = TimelockPolicy(address(module)); + vm.startPrank(WALLET); + policyModule.onInstall(abi.encodePacked(policyId(), installData())); + vm.stopPrank(); + + bytes32 testHash = keccak256(abi.encodePacked("TEST_HASH")); + (address sender, bytes memory sigData) = validSignatureData(testHash); + + // Try to validate signature without creating a proposal first + vm.startPrank(WALLET); + uint256 result = policyModule.checkSignaturePolicy(policyId(), sender, testHash, sigData); + vm.stopPrank(); + + // Should fail because no proposal exists + assertFalse(result == 0); + } + + function testPolicyCheckSignaturePolicyFailTimelockNotPassed() public payable { + TimelockPolicy policyModule = TimelockPolicy(address(module)); + vm.startPrank(WALLET); + policyModule.onInstall(abi.encodePacked(policyId(), installData())); + vm.stopPrank(); + + bytes32 testHash = keccak256(abi.encodePacked("TEST_HASH")); + (address sender, bytes memory sigData) = validSignatureData(testHash); + + // Create a signature proposal + policyModule.createSignatureProposal(policyId(), WALLET, testHash); + + // Don't fast forward - timelock hasn't passed + vm.startPrank(WALLET); + uint256 result = policyModule.checkSignaturePolicy(policyId(), sender, testHash, sigData); + vm.stopPrank(); + + // Should fail because timelock hasn't passed + assertFalse(result == 0); + } + // Additional TimelockPolicy-specific tests function testCreateProposal() public { diff --git a/test/base/PolicyTestBase.sol b/test/base/PolicyTestBase.sol index ca693a2..2daf0f7 100644 --- a/test/base/PolicyTestBase.sol +++ b/test/base/PolicyTestBase.sol @@ -98,7 +98,7 @@ abstract contract PolicyTestBase is ModuleTestBase { assertFalse(validationResult == 0); } - function testPolicyCheckSignaturePolicySuccess() public payable { + function testPolicyCheckSignaturePolicySuccess() public payable virtual { IPolicy policyModule = IPolicy(address(module)); vm.startPrank(WALLET); policyModule.onInstall(abi.encodePacked(policyId(), installData())); From b2f5abc5b50787c14960ecb31c5a7a016c355aa2 Mon Sep 17 00:00:00 2001 From: taek Date: Mon, 2 Feb 2026 19:26:45 +0900 Subject: [PATCH 02/25] fix(TimelockPolicy): invalidate stale proposals on reinstall --- src/policies/TimelockPolicy.sol | 19 +++++++++++++---- test/TimelockPolicy.t.sol | 36 +++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 4 deletions(-) diff --git a/src/policies/TimelockPolicy.sol b/src/policies/TimelockPolicy.sol index ce86996..4cd9b22 100644 --- a/src/policies/TimelockPolicy.sol +++ b/src/policies/TimelockPolicy.sol @@ -39,11 +39,15 @@ contract TimelockPolicy is PolicyBase, IStatelessValidator, IStatelessValidatorW ProposalStatus status; uint48 validAfter; // Timestamp when proposal becomes executable uint48 validUntil; // Timestamp when proposal expires + uint256 epoch; // Epoch when proposal was created } // Storage: id => wallet => config mapping(bytes32 => mapping(address => TimelockConfig)) public timelockConfig; + // Storage: id => wallet => epoch (persists across uninstall/reinstall) + mapping(bytes32 => mapping(address => uint256)) public currentEpoch; + // Storage: userOpKey => id => wallet => proposal // userOpKey = keccak256(abi.encode(account, keccak256(callData), nonce)) mapping(bytes32 => mapping(bytes32 => mapping(address => Proposal))) public proposals; @@ -66,6 +70,7 @@ contract TimelockPolicy is PolicyBase, IStatelessValidator, IStatelessValidatorW error ProposalExpired(uint256 validUntil, uint256 currentTime); error ProposalNotPending(); error OnlyAccount(); + error ProposalFromPreviousEpoch(); /** * @notice Install the timelock policy @@ -81,6 +86,9 @@ contract TimelockPolicy is PolicyBase, IStatelessValidator, IStatelessValidatorW if (delay == 0) revert InvalidDelay(); if (expirationPeriod == 0) revert InvalidExpirationPeriod(); + // Increment epoch to invalidate any proposals from previous installations + currentEpoch[id][msg.sender]++; + timelockConfig[id][msg.sender] = TimelockConfig({delay: delay, expirationPeriod: expirationPeriod, initialized: true}); @@ -131,9 +139,9 @@ contract TimelockPolicy is PolicyBase, IStatelessValidator, IStatelessValidatorW revert ProposalAlreadyExists(); } - // Create proposal (stored by userOpKey) + // Create proposal (stored by userOpKey) with current epoch proposals[userOpKey][id][account] = - Proposal({status: ProposalStatus.Pending, validAfter: validAfter, validUntil: validUntil}); + Proposal({status: ProposalStatus.Pending, validAfter: validAfter, validUntil: validUntil, epoch: currentEpoch[id][account]}); emit ProposalCreated(account, id, userOpKey, validAfter, validUntil); } @@ -219,9 +227,9 @@ contract TimelockPolicy is PolicyBase, IStatelessValidator, IStatelessValidatorW return SIG_VALIDATION_FAILED_UINT; // Proposal already exists } - // Create proposal + // Create proposal with current epoch proposals[userOpKey][id][account] = - Proposal({status: ProposalStatus.Pending, validAfter: validAfter, validUntil: validUntil}); + Proposal({status: ProposalStatus.Pending, validAfter: validAfter, validUntil: validUntil, epoch: currentEpoch[id][account]}); emit ProposalCreated(account, id, userOpKey, validAfter, validUntil); @@ -244,6 +252,9 @@ contract TimelockPolicy is PolicyBase, IStatelessValidator, IStatelessValidatorW // Check proposal exists and is pending if (proposal.status != ProposalStatus.Pending) return SIG_VALIDATION_FAILED_UINT; + // Check proposal is from current epoch (not a stale proposal from previous installation) + if (proposal.epoch != currentEpoch[id][account]) return SIG_VALIDATION_FAILED_UINT; + // Mark as executed proposal.status = ProposalStatus.Executed; diff --git a/test/TimelockPolicy.t.sol b/test/TimelockPolicy.t.sol index 55d7541..128890c 100644 --- a/test/TimelockPolicy.t.sol +++ b/test/TimelockPolicy.t.sol @@ -293,4 +293,40 @@ contract TimelockPolicyTest is PolicyTestBase, StatelessValidatorTestBase, State assertEq(uint256(status), uint256(TimelockPolicy.ProposalStatus.Pending)); } + + // Test that stale proposals from previous installations cannot be executed + function testStaleProposalNotExecutableAfterReinstall() public { + TimelockPolicy policyModule = TimelockPolicy(address(module)); + vm.startPrank(WALLET); + policyModule.onInstall(abi.encodePacked(policyId(), installData())); + vm.stopPrank(); + + PackedUserOperation memory userOp = validUserOp(); + + // Create a proposal + vm.startPrank(WALLET); + policyModule.createProposal(policyId(), WALLET, userOp.callData, userOp.nonce); + vm.stopPrank(); + + // Fast forward past delay + vm.warp(block.timestamp + delay + 1); + + // Uninstall the policy + vm.startPrank(WALLET); + policyModule.onUninstall(abi.encodePacked(policyId(), "")); + vm.stopPrank(); + + // Reinstall the policy + vm.startPrank(WALLET); + policyModule.onInstall(abi.encodePacked(policyId(), installData())); + vm.stopPrank(); + + // Try to execute the stale proposal - should fail + vm.startPrank(WALLET); + uint256 validationResult = policyModule.checkUserOpPolicy(policyId(), userOp); + vm.stopPrank(); + + // Should fail (return 1 = SIG_VALIDATION_FAILED_UINT) because proposal is from previous epoch + assertEq(validationResult, 1); + } } From 14cb24f3c961fd9988042eeb11545776945919e8 Mon Sep 17 00:00:00 2001 From: taek Date: Mon, 2 Feb 2026 19:44:22 +0900 Subject: [PATCH 03/25] Fix dead proposal creation code --- src/policies/TimelockPolicy.sol | 5 +++-- test/TimelockPolicy.t.sol | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/policies/TimelockPolicy.sol b/src/policies/TimelockPolicy.sol index ce86996..7bf0d83 100644 --- a/src/policies/TimelockPolicy.sol +++ b/src/policies/TimelockPolicy.sol @@ -225,8 +225,9 @@ contract TimelockPolicy is PolicyBase, IStatelessValidator, IStatelessValidatorW emit ProposalCreated(account, id, userOpKey, validAfter, validUntil); - // Return failure to prevent execution (this was just proposal creation) - return SIG_VALIDATION_FAILED_UINT; + // Return success with validUntil=0 to persist storage but prevent execution + // EntryPoint will reject because block.timestamp > 0 (validUntil) + return _packValidationData(0, 0); } /** diff --git a/test/TimelockPolicy.t.sol b/test/TimelockPolicy.t.sol index 55d7541..41ab52c 100644 --- a/test/TimelockPolicy.t.sol +++ b/test/TimelockPolicy.t.sol @@ -284,8 +284,9 @@ contract TimelockPolicyTest is PolicyTestBase, StatelessValidatorTestBase, State uint256 result = policyModule.checkUserOpPolicy(policyId(), userOp); vm.stopPrank(); - // Should return failure (1) because this was proposal creation, not execution - assertEq(result, 1); + // Returns success with validUntil=0 (expired) - state persists but execution fails + // This allows proposal creation via UserOp without external caller + assertEq(result, 0); // Verify proposal was created (TimelockPolicy.ProposalStatus status,,) = From 2f88be1ad819e866e41b88f13b60a0887316d627 Mon Sep 17 00:00:00 2001 From: taek Date: Mon, 2 Feb 2026 23:25:32 +0900 Subject: [PATCH 04/25] test: add BTT tests for TimelockEpochValidation --- test/btt/TimelockEpochValidation.t.sol | 521 +++++++++++++++++++++++++ test/btt/TimelockEpochValidation.tree | 75 ++++ 2 files changed, 596 insertions(+) create mode 100644 test/btt/TimelockEpochValidation.t.sol create mode 100644 test/btt/TimelockEpochValidation.tree diff --git a/test/btt/TimelockEpochValidation.t.sol b/test/btt/TimelockEpochValidation.t.sol new file mode 100644 index 0000000..6abe651 --- /dev/null +++ b/test/btt/TimelockEpochValidation.t.sol @@ -0,0 +1,521 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.20; + +import {Test} from "forge-std/Test.sol"; +import {TimelockPolicy} from "src/policies/TimelockPolicy.sol"; +import {PackedUserOperation} from "account-abstraction/interfaces/PackedUserOperation.sol"; + +/** + * @title TimelockEpochValidationTest + * @notice BTT tests for the epoch-based validation fix in TimelockPolicy + * @dev Tests the fix for TOB-KERNEL-2: Stale proposals persisting across reinstalls + */ +contract TimelockEpochValidationTest is Test { + TimelockPolicy public timelockPolicy; + + address constant WALLET = address(0x1234); + address constant WALLET2 = address(0x5678); + address constant ATTACKER = address(0xBAD); + + uint48 constant DELAY = 1 days; + uint48 constant EXPIRATION_PERIOD = 1 days; + + bytes32 constant POLICY_ID_1 = keccak256("POLICY_ID_1"); + bytes32 constant POLICY_ID_2 = keccak256("POLICY_ID_2"); + + uint256 constant SIG_VALIDATION_FAILED_UINT = 1; + + function setUp() public { + timelockPolicy = new TimelockPolicy(); + } + + function _installData() internal pure returns (bytes memory) { + return abi.encode(DELAY, EXPIRATION_PERIOD); + } + + function _installPolicy(address wallet, bytes32 policyId) internal { + vm.prank(wallet); + timelockPolicy.onInstall(abi.encodePacked(policyId, _installData())); + } + + function _uninstallPolicy(address wallet, bytes32 policyId) internal { + vm.prank(wallet); + timelockPolicy.onUninstall(abi.encodePacked(policyId, "")); + } + + function _createProposal(address wallet, bytes32 policyId, bytes memory callData, uint256 nonce) internal { + vm.prank(wallet); + timelockPolicy.createProposal(policyId, wallet, callData, nonce); + } + + function _createUserOp(address sender, bytes memory callData, uint256 nonce) + internal + pure + returns (PackedUserOperation memory) + { + return PackedUserOperation({ + sender: sender, + nonce: nonce, + initCode: "", + callData: callData, + accountGasLimits: bytes32(abi.encodePacked(uint128(100000), uint128(200000))), + preVerificationGas: 0, + gasFees: bytes32(abi.encodePacked(uint128(1), uint128(1))), + paymasterAndData: "", + signature: "" + }); + } + + // ==================== Installing the Policy ==================== + + modifier whenInstallingThePolicy() { + _; + } + + function test_GivenItIsTheFirstInstallation() external whenInstallingThePolicy { + // Check epoch before installation + uint256 epochBefore = timelockPolicy.currentEpoch(POLICY_ID_1, WALLET); + assertEq(epochBefore, 0, "Epoch should be 0 before first install"); + + // Install the policy + _installPolicy(WALLET, POLICY_ID_1); + + // it should set currentEpoch to 1 + uint256 epochAfter = timelockPolicy.currentEpoch(POLICY_ID_1, WALLET); + assertEq(epochAfter, 1, "Epoch should be 1 after first install"); + + // it should initialize the policy config + (uint48 delay, uint48 expirationPeriod, bool initialized) = + timelockPolicy.timelockConfig(POLICY_ID_1, WALLET); + assertTrue(initialized, "Policy should be initialized"); + assertEq(delay, DELAY, "Delay should match"); + assertEq(expirationPeriod, EXPIRATION_PERIOD, "Expiration period should match"); + } + + function test_GivenThePolicyWasPreviouslyUninstalled() external whenInstallingThePolicy { + // First installation + _installPolicy(WALLET, POLICY_ID_1); + uint256 epochAfterFirstInstall = timelockPolicy.currentEpoch(POLICY_ID_1, WALLET); + assertEq(epochAfterFirstInstall, 1, "Epoch should be 1 after first install"); + + // Uninstall + _uninstallPolicy(WALLET, POLICY_ID_1); + + // Reinstall + _installPolicy(WALLET, POLICY_ID_1); + + // it should increment the epoch counter + // it should set currentEpoch to previous epoch plus 1 + uint256 epochAfterReinstall = timelockPolicy.currentEpoch(POLICY_ID_1, WALLET); + assertEq(epochAfterReinstall, 2, "Epoch should be 2 after reinstall"); + } + + function test_GivenMultipleReinstallCyclesOccur() external whenInstallingThePolicy { + // First installation + _installPolicy(WALLET, POLICY_ID_1); + assertEq(timelockPolicy.currentEpoch(POLICY_ID_1, WALLET), 1, "Epoch should be 1"); + + // First reinstall cycle + _uninstallPolicy(WALLET, POLICY_ID_1); + _installPolicy(WALLET, POLICY_ID_1); + + // it should increment epoch on each install + assertEq(timelockPolicy.currentEpoch(POLICY_ID_1, WALLET), 2, "Epoch should be 2"); + + // Second reinstall cycle + _uninstallPolicy(WALLET, POLICY_ID_1); + _installPolicy(WALLET, POLICY_ID_1); + assertEq(timelockPolicy.currentEpoch(POLICY_ID_1, WALLET), 3, "Epoch should be 3"); + + // Third reinstall cycle + _uninstallPolicy(WALLET, POLICY_ID_1); + _installPolicy(WALLET, POLICY_ID_1); + + // it should track epoch correctly after 3 reinstalls + assertEq(timelockPolicy.currentEpoch(POLICY_ID_1, WALLET), 4, "Epoch should be 4 after 3 reinstalls"); + } + + // ==================== Creating a Proposal ==================== + + modifier whenCreatingAProposal() { + _; + } + + function test_GivenThePolicyIsInstalled() external whenCreatingAProposal { + _installPolicy(WALLET, POLICY_ID_1); + + bytes memory callData = hex"1234"; + uint256 nonce = 1; + + _createProposal(WALLET, POLICY_ID_1, callData, nonce); + + // it should store the current epoch in the proposal + // it should use the epoch from currentEpoch mapping + bytes32 userOpKey = timelockPolicy.computeUserOpKey(WALLET, callData, nonce); + ( + 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(validUntil, block.timestamp + DELAY + EXPIRATION_PERIOD, "validUntil should be correct"); + } + + function test_GivenCreatingViaCreateProposalFunction() external whenCreatingAProposal { + _installPolicy(WALLET, POLICY_ID_1); + + bytes memory callData = hex"5678"; + uint256 nonce = 42; + + uint256 currentEpoch = timelockPolicy.currentEpoch(POLICY_ID_1, WALLET); + + _createProposal(WALLET, POLICY_ID_1, callData, nonce); + + // it should record the epoch at creation time + bytes32 userOpKey = timelockPolicy.computeUserOpKey(WALLET, callData, nonce); + (,,, uint256 proposalEpoch) = timelockPolicy.proposals(userOpKey, POLICY_ID_1, WALLET); + + assertEq(proposalEpoch, currentEpoch, "Proposal epoch should equal current epoch at creation"); + } + + // ==================== Executing a Proposal ==================== + + modifier whenExecutingAProposal() { + _; + } + + modifier givenTheProposalWasCreatedInTheCurrentEpoch() { + _; + } + + function test_GivenTheTimelockHasPassed() + external + whenExecutingAProposal + givenTheProposalWasCreatedInTheCurrentEpoch + { + _installPolicy(WALLET, POLICY_ID_1); + + bytes memory callData = hex"1234"; + uint256 nonce = 1; + + _createProposal(WALLET, POLICY_ID_1, callData, nonce); + + // Warp past the timelock delay + vm.warp(block.timestamp + DELAY + 1); + + PackedUserOperation memory userOp = _createUserOp(WALLET, callData, nonce); + + vm.prank(WALLET); + uint256 validationResult = timelockPolicy.checkUserOpPolicy(POLICY_ID_1, userOp); + + // it should return success validation data (not SIG_VALIDATION_FAILED_UINT) + assertTrue(validationResult != SIG_VALIDATION_FAILED_UINT, "Validation should succeed"); + + // it should mark proposal as executed + bytes32 userOpKey = timelockPolicy.computeUserOpKey(WALLET, callData, nonce); + (TimelockPolicy.ProposalStatus status,,,) = timelockPolicy.proposals(userOpKey, POLICY_ID_1, WALLET); + assertEq(uint256(status), uint256(TimelockPolicy.ProposalStatus.Executed), "Proposal should be executed"); + } + + function test_GivenTheProposalWasCreatedBeforeUninstallAndReinstall() external whenExecutingAProposal { + _installPolicy(WALLET, POLICY_ID_1); + + bytes memory callData = hex"1234"; + uint256 nonce = 1; + + // Create proposal in epoch 1 + _createProposal(WALLET, POLICY_ID_1, callData, nonce); + + // Warp past the timelock delay + vm.warp(block.timestamp + DELAY + 1); + + // Uninstall and reinstall (epoch becomes 2) + _uninstallPolicy(WALLET, POLICY_ID_1); + _installPolicy(WALLET, POLICY_ID_1); + + // Try to execute the stale proposal + PackedUserOperation memory userOp = _createUserOp(WALLET, callData, nonce); + + vm.prank(WALLET); + uint256 validationResult = timelockPolicy.checkUserOpPolicy(POLICY_ID_1, userOp); + + // it should return SIG_VALIDATION_FAILED + assertEq(validationResult, SIG_VALIDATION_FAILED_UINT, "Stale proposal should fail validation"); + + // it should not mark proposal as executed + bytes32 userOpKey = timelockPolicy.computeUserOpKey(WALLET, callData, nonce); + (TimelockPolicy.ProposalStatus status,,,) = timelockPolicy.proposals(userOpKey, POLICY_ID_1, WALLET); + assertEq(uint256(status), uint256(TimelockPolicy.ProposalStatus.Pending), "Proposal should still be pending"); + } + + function test_GivenTheProposalEpochDoesNotMatchCurrentEpoch() external whenExecutingAProposal { + _installPolicy(WALLET, POLICY_ID_1); + + bytes memory callData = hex"abcd"; + uint256 nonce = 5; + + // Create proposal in epoch 1 + _createProposal(WALLET, POLICY_ID_1, callData, nonce); + + bytes32 userOpKey = timelockPolicy.computeUserOpKey(WALLET, callData, nonce); + (,,, 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 + vm.warp(block.timestamp + DELAY + 1); + _uninstallPolicy(WALLET, POLICY_ID_1); + _installPolicy(WALLET, POLICY_ID_1); + _uninstallPolicy(WALLET, POLICY_ID_1); + _installPolicy(WALLET, POLICY_ID_1); + + assertEq(timelockPolicy.currentEpoch(POLICY_ID_1, WALLET), 3, "Current epoch should be 3"); + + // Try to execute + PackedUserOperation memory userOp = _createUserOp(WALLET, callData, nonce); + + vm.prank(WALLET); + uint256 validationResult = timelockPolicy.checkUserOpPolicy(POLICY_ID_1, userOp); + + // it should reject the stale proposal + 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); + assertEq( + uint256(statusAfter), + uint256(TimelockPolicy.ProposalStatus.Pending), + "Proposal status should remain pending" + ); + } + + // ==================== Uninstalling and Reinstalling ==================== + + modifier whenUninstallingAndReinstallingThePolicy() { + _; + } + + modifier givenAProposalExistsBeforeUninstall() { + _; + } + + function test_WhenThePolicyIsUninstalled() + external + whenUninstallingAndReinstallingThePolicy + givenAProposalExistsBeforeUninstall + { + _installPolicy(WALLET, POLICY_ID_1); + + bytes memory callData = hex"1234"; + uint256 nonce = 1; + + _createProposal(WALLET, POLICY_ID_1, callData, nonce); + + uint256 epochBeforeUninstall = timelockPolicy.currentEpoch(POLICY_ID_1, WALLET); + + // Uninstall + _uninstallPolicy(WALLET, POLICY_ID_1); + + // it should delete the policy config + (,, bool initialized) = timelockPolicy.timelockConfig(POLICY_ID_1, WALLET); + assertFalse(initialized, "Policy config should be deleted"); + + // it should preserve the epoch counter + uint256 epochAfterUninstall = timelockPolicy.currentEpoch(POLICY_ID_1, WALLET); + assertEq(epochAfterUninstall, epochBeforeUninstall, "Epoch should be preserved after uninstall"); + } + + function test_WhenThePolicyIsReinstalled() + external + whenUninstallingAndReinstallingThePolicy + givenAProposalExistsBeforeUninstall + { + _installPolicy(WALLET, POLICY_ID_1); + + bytes memory callData = hex"1234"; + uint256 nonce = 1; + + _createProposal(WALLET, POLICY_ID_1, callData, nonce); + + uint256 epochBeforeUninstall = timelockPolicy.currentEpoch(POLICY_ID_1, WALLET); + + _uninstallPolicy(WALLET, POLICY_ID_1); + + // Reinstall + _installPolicy(WALLET, POLICY_ID_1); + + // it should increment the epoch + uint256 epochAfterReinstall = timelockPolicy.currentEpoch(POLICY_ID_1, WALLET); + assertEq(epochAfterReinstall, epochBeforeUninstall + 1, "Epoch should increment on reinstall"); + + // it should invalidate old proposals implicitly + vm.warp(block.timestamp + DELAY + 1); + PackedUserOperation memory userOp = _createUserOp(WALLET, callData, nonce); + + vm.prank(WALLET); + uint256 validationResult = timelockPolicy.checkUserOpPolicy(POLICY_ID_1, userOp); + assertEq(validationResult, SIG_VALIDATION_FAILED_UINT, "Old proposal should be invalid"); + + // it should allow new proposals with new epoch + bytes memory newCallData = hex"5678"; + uint256 newNonce = 2; + + _createProposal(WALLET, POLICY_ID_1, newCallData, newNonce); + + bytes32 newUserOpKey = timelockPolicy.computeUserOpKey(WALLET, newCallData, newNonce); + (TimelockPolicy.ProposalStatus status,,, uint256 newProposalEpoch) = + timelockPolicy.proposals(newUserOpKey, POLICY_ID_1, WALLET); + + assertEq(uint256(status), uint256(TimelockPolicy.ProposalStatus.Pending), "New proposal should be created"); + assertEq(newProposalEpoch, epochAfterReinstall, "New proposal should have current epoch"); + } + + // ==================== Epoch Storage Per Policy ID ==================== + + modifier whenEpochIsStoredPerPolicyID() { + _; + } + + function test_GivenTwoDifferentPolicyIDsForSameWallet() external whenEpochIsStoredPerPolicyID { + // Install policy 1 + _installPolicy(WALLET, POLICY_ID_1); + assertEq(timelockPolicy.currentEpoch(POLICY_ID_1, WALLET), 1, "Policy 1 should be epoch 1"); + + // Install policy 2 + _installPolicy(WALLET, POLICY_ID_2); + assertEq(timelockPolicy.currentEpoch(POLICY_ID_2, WALLET), 1, "Policy 2 should be epoch 1"); + + // Reinstall policy 1 twice + _uninstallPolicy(WALLET, POLICY_ID_1); + _installPolicy(WALLET, POLICY_ID_1); + _uninstallPolicy(WALLET, POLICY_ID_1); + _installPolicy(WALLET, POLICY_ID_1); + + // it should track separate epochs for each policy ID + assertEq(timelockPolicy.currentEpoch(POLICY_ID_1, WALLET), 3, "Policy 1 should be epoch 3"); + + // it should not cross contaminate epochs + assertEq(timelockPolicy.currentEpoch(POLICY_ID_2, WALLET), 1, "Policy 2 should still be epoch 1"); + } + + function test_GivenSamePolicyIDForDifferentWallets() external whenEpochIsStoredPerPolicyID { + // Install for wallet 1 + _installPolicy(WALLET, POLICY_ID_1); + assertEq(timelockPolicy.currentEpoch(POLICY_ID_1, WALLET), 1, "Wallet 1 should be epoch 1"); + + // Install for wallet 2 + _installPolicy(WALLET2, POLICY_ID_1); + assertEq(timelockPolicy.currentEpoch(POLICY_ID_1, WALLET2), 1, "Wallet 2 should be epoch 1"); + + // Reinstall for wallet 1 multiple times + _uninstallPolicy(WALLET, POLICY_ID_1); + _installPolicy(WALLET, POLICY_ID_1); + _uninstallPolicy(WALLET, POLICY_ID_1); + _installPolicy(WALLET, POLICY_ID_1); + + // it should track separate epochs for each wallet + assertEq(timelockPolicy.currentEpoch(POLICY_ID_1, WALLET), 3, "Wallet 1 should be epoch 3"); + + // it should not affect other wallets epoch on reinstall + assertEq(timelockPolicy.currentEpoch(POLICY_ID_1, WALLET2), 1, "Wallet 2 should still be epoch 1"); + } + + // ==================== Full Attack Scenario ==================== + + modifier whenVerifyingFullAttackScenario() { + _; + } + + function test_WhenAttackerTriesToExecuteOldProposal() external whenVerifyingFullAttackScenario { + // User installs policy + _installPolicy(WALLET, POLICY_ID_1); + assertEq(timelockPolicy.currentEpoch(POLICY_ID_1, WALLET), 1, "Should start at epoch 1"); + + // Attacker creates malicious proposal + bytes memory maliciousCallData = hex"deadbeef"; + uint256 maliciousNonce = 666; + + vm.prank(ATTACKER); + timelockPolicy.createProposal(POLICY_ID_1, WALLET, maliciousCallData, maliciousNonce); + + // Time passes, timelock expires + vm.warp(block.timestamp + DELAY + 1); + + // User decides to uninstall and reinstall + _uninstallPolicy(WALLET, POLICY_ID_1); + _installPolicy(WALLET, POLICY_ID_1); + + assertEq(timelockPolicy.currentEpoch(POLICY_ID_1, WALLET), 2, "Should be at epoch 2"); + + // Attacker tries to execute the old proposal + PackedUserOperation memory maliciousUserOp = _createUserOp(WALLET, maliciousCallData, maliciousNonce); + + vm.prank(WALLET); + uint256 validationResult = timelockPolicy.checkUserOpPolicy(POLICY_ID_1, maliciousUserOp); + + // it should fail due to epoch mismatch + assertEq(validationResult, SIG_VALIDATION_FAILED_UINT, "Attack should be thwarted by epoch check"); + } + + function test_WhenUserCreatesNewProposalAfterReinstall() external whenVerifyingFullAttackScenario { + // User installs policy + _installPolicy(WALLET, POLICY_ID_1); + + // User uninstalls and reinstalls + _uninstallPolicy(WALLET, POLICY_ID_1); + _installPolicy(WALLET, POLICY_ID_1); + + // User creates legitimate proposal with new epoch + bytes memory callData = hex"abcd"; + uint256 userNonce = 200; + _createProposal(WALLET, POLICY_ID_1, callData, userNonce); + + vm.warp(block.timestamp + DELAY + 1); + + PackedUserOperation memory userOp = _createUserOp(WALLET, callData, userNonce); + + vm.prank(WALLET); + uint256 validationResult = timelockPolicy.checkUserOpPolicy(POLICY_ID_1, userOp); + + // it should succeed with new epoch + assertTrue(validationResult != SIG_VALIDATION_FAILED_UINT, "User's new proposal should succeed"); + } + + function test_WhenOneUserReinstallsDoesNotAffectOther() external whenVerifyingFullAttackScenario { + // Both users install policy + _installPolicy(WALLET, POLICY_ID_1); + _installPolicy(WALLET2, POLICY_ID_1); + + // Both users create proposals + bytes memory callData1 = hex"1111"; + bytes memory callData2 = hex"2222"; + + _createProposal(WALLET, POLICY_ID_1, callData1, 1); + _createProposal(WALLET2, POLICY_ID_1, callData2, 1); + + vm.warp(block.timestamp + DELAY + 1); + + // WALLET reinstalls + _uninstallPolicy(WALLET, POLICY_ID_1); + _installPolicy(WALLET, POLICY_ID_1); + + // it should only affect that users epoch + assertEq(timelockPolicy.currentEpoch(POLICY_ID_1, WALLET), 2, "WALLET should be epoch 2"); + assertEq(timelockPolicy.currentEpoch(POLICY_ID_1, WALLET2), 1, "WALLET2 should still be epoch 1"); + + // WALLET's old proposal should fail + PackedUserOperation memory userOp1 = _createUserOp(WALLET, callData1, 1); + vm.prank(WALLET); + uint256 result1 = timelockPolicy.checkUserOpPolicy(POLICY_ID_1, userOp1); + assertEq(result1, SIG_VALIDATION_FAILED_UINT, "WALLET's old proposal should fail"); + + // it should not affect other users proposals + PackedUserOperation memory userOp2 = _createUserOp(WALLET2, callData2, 1); + vm.prank(WALLET2); + uint256 result2 = timelockPolicy.checkUserOpPolicy(POLICY_ID_1, userOp2); + assertTrue(result2 != SIG_VALIDATION_FAILED_UINT, "WALLET2's proposal should still work"); + } +} diff --git a/test/btt/TimelockEpochValidation.tree b/test/btt/TimelockEpochValidation.tree new file mode 100644 index 0000000..db1132f --- /dev/null +++ b/test/btt/TimelockEpochValidation.tree @@ -0,0 +1,75 @@ +TimelockEpochValidationTest +├── when installing the policy +│ ├── given it is the first installation +│ │ ├── it should set currentEpoch to 1 +│ │ └── it should initialize the policy config +│ ├── given the policy was previously uninstalled +│ │ ├── it should increment the epoch counter +│ │ └── it should set currentEpoch to previous epoch plus 1 +│ └── given multiple reinstall cycles occur +│ ├── it should increment epoch on each install +│ └── it should track epoch correctly after 3 reinstalls +├── when creating a proposal +│ ├── given the policy is installed +│ │ ├── it should store the current epoch in the proposal +│ │ └── it should use the epoch from currentEpoch mapping +│ └── given creating via createProposal function +│ └── it should record the epoch at creation time +├── when executing a proposal +│ ├── given the proposal was created in the current epoch +│ │ ├── given the timelock has passed +│ │ │ ├── it should return success validation data +│ │ │ └── it should mark proposal as executed +│ │ └── given the proposal is pending +│ │ └── it should pass epoch validation +│ ├── given the proposal was created before uninstall and reinstall +│ │ ├── it should return SIG_VALIDATION_FAILED +│ │ └── it should not mark proposal as executed +│ └── given the proposal epoch does not match current epoch +│ ├── it should reject the stale proposal +│ └── it should leave proposal status unchanged +├── when uninstalling and reinstalling the policy +│ ├── given a proposal exists before uninstall +│ │ ├── when the policy is uninstalled +│ │ │ ├── it should delete the policy config +│ │ │ └── it should preserve the epoch counter +│ │ └── when the policy is reinstalled +│ │ ├── it should increment the epoch +│ │ ├── it should invalidate old proposals implicitly +│ │ └── it should allow new proposals with new epoch +│ ├── given multiple proposals exist before uninstall +│ │ └── when reinstalled and proposals executed +│ │ ├── it should reject all old proposals +│ │ └── it should accept new proposals created after reinstall +│ └── given an attacker tries to execute stale proposal +│ ├── when proposal was created in epoch 1 and current epoch is 2 +│ │ └── it should fail validation +│ └── when proposal was created in epoch 2 and current epoch is 5 +│ └── it should fail validation +├── when epoch is stored per policy ID +│ ├── given two different policy IDs for same wallet +│ │ ├── it should track separate epochs for each policy ID +│ │ └── it should not cross contaminate epochs +│ └── given same policy ID for different wallets +│ ├── it should track separate epochs for each wallet +│ └── it should not affect other wallets epoch on reinstall +├── when creating new proposals after reinstall +│ ├── given old proposal exists with old epoch +│ │ ├── it should allow creating new proposal with same parameters +│ │ └── it should store new proposal with current epoch +│ └── given the new proposal is executed +│ ├── it should succeed because epoch matches +│ └── it should mark new proposal as executed +└── when verifying full attack scenario + ├── given attacker creates proposal before user uninstalls + │ ├── when user uninstalls and reinstalls + │ │ ├── when attacker tries to execute old proposal + │ │ │ └── it should fail due to epoch mismatch + │ │ └── when user creates new proposal with same calldata + │ │ └── it should succeed with new epoch + │ └── when timelock passes but policy was reinstalled + │ └── it should still reject the stale proposal + └── given multiple users with same policy + └── when one user reinstalls + ├── it should only affect that users epoch + └── it should not affect other users proposals From d6c7871be0744e9b9403e564fe105b83594b9316 Mon Sep 17 00:00:00 2001 From: taek Date: Mon, 2 Feb 2026 23:45:25 +0900 Subject: [PATCH 05/25] test: add BTT tests for TimelockSignaturePolicy --- test/btt/TimelockSignaturePolicy.t.sol | 309 +++++++++++++++++++++++++ test/btt/TimelockSignaturePolicy.tree | 25 ++ 2 files changed, 334 insertions(+) create mode 100644 test/btt/TimelockSignaturePolicy.t.sol create mode 100644 test/btt/TimelockSignaturePolicy.tree diff --git a/test/btt/TimelockSignaturePolicy.t.sol b/test/btt/TimelockSignaturePolicy.t.sol new file mode 100644 index 0000000..7ad460d --- /dev/null +++ b/test/btt/TimelockSignaturePolicy.t.sol @@ -0,0 +1,309 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.20; + +import {Test} from "forge-std/Test.sol"; +import {TimelockPolicy} from "src/policies/TimelockPolicy.sol"; +import {IModule} from "src/interfaces/IERC7579Modules.sol"; +import {ERC1271_MAGICVALUE, ERC1271_INVALID} from "src/types/Constants.sol"; + +/** + * @title TimelockSignaturePolicyTest + * @notice BTT tests for ERC-1271 signature validation with timelock enforcement + * @dev Tests the fix that prevents bypassing timelock via ERC-1271 signatures + */ +contract TimelockSignaturePolicyTest is Test { + TimelockPolicy public timelockPolicy; + + address constant WALLET = address(0x1234); + address constant OTHER_ACCOUNT = address(0x5678); + + uint48 constant DELAY = 1 days; + uint48 constant EXPIRATION_PERIOD = 1 days; + + bytes32 public policyId; + bytes32 public testHash; + + function setUp() public { + timelockPolicy = new TimelockPolicy(); + policyId = keccak256(abi.encodePacked("POLICY_ID_1")); + testHash = keccak256(abi.encodePacked("TEST_HASH_TO_SIGN")); + } + + /// @notice Helper to install the policy for a wallet + function _installPolicy(address wallet) internal { + bytes memory installData = abi.encode(DELAY, EXPIRATION_PERIOD); + vm.prank(wallet); + timelockPolicy.onInstall(abi.encodePacked(policyId, installData)); + } + + /// @notice Helper to create a signature proposal + function _createSignatureProposal(address wallet, bytes32 hash) internal { + timelockPolicy.createSignatureProposal(policyId, wallet, hash); + } + + // ============================================================ + // Test: when policy is not installed + // ============================================================ + + function test_WhenPolicyIsNotInstalled() external { + // it should return ERC1271_INVALID for signature validation + + // Do NOT install the policy for WALLET + + // Try to validate a signature without installation + vm.prank(WALLET); + uint256 result = timelockPolicy.checkSignaturePolicy(policyId, address(0), testHash, ""); + + // Should return 1 (failure) because policy is not initialized + assertEq(result, 1, "Should return validation failure when policy not installed"); + } + + // ============================================================ + // Test: when creating signature proposal without initialization + // ============================================================ + + function test_WhenCreatingSignatureProposalWithoutInitialization() external { + // it should revert with NotInitialized + + // Do NOT install the policy + + // Attempt to create a signature proposal should revert + vm.expectRevert(abi.encodeWithSelector(IModule.NotInitialized.selector, WALLET)); + timelockPolicy.createSignatureProposal(policyId, WALLET, testHash); + } + + // ============================================================ + // Test: when creating signature proposal that already exists + // ============================================================ + + function test_WhenCreatingSignatureProposalThatAlreadyExists() external { + // it should revert with ProposalAlreadyExists + + // Install policy + _installPolicy(WALLET); + + // Create first signature proposal + _createSignatureProposal(WALLET, testHash); + + // Attempt to create the same proposal again should revert + vm.expectRevert(TimelockPolicy.ProposalAlreadyExists.selector); + _createSignatureProposal(WALLET, testHash); + } + + // ============================================================ + // Test: when creating signature proposal successfully + // ============================================================ + + function test_WhenCreatingSignatureProposalSuccessfully() external { + // it should store the proposal with Pending status + // it should set validAfter to timestamp plus delay + // it should set validUntil to validAfter plus expiration + + // Install policy + _installPolicy(WALLET); + + uint256 currentTimestamp = block.timestamp; + + // Create signature proposal + _createSignatureProposal(WALLET, testHash); + + // Verify proposal details + (TimelockPolicy.ProposalStatus status, uint256 validAfter, uint256 validUntil) = + timelockPolicy.getSignatureProposal(testHash, policyId, WALLET); + + // Check status is Pending + assertEq( + uint256(status), + uint256(TimelockPolicy.ProposalStatus.Pending), + "Proposal status should be Pending" + ); + + // Check validAfter is timestamp + delay + assertEq( + validAfter, + currentTimestamp + DELAY, + "validAfter should be current timestamp plus delay" + ); + + // Check validUntil is validAfter + expiration + assertEq( + validUntil, + currentTimestamp + DELAY + EXPIRATION_PERIOD, + "validUntil should be validAfter plus expiration period" + ); + } + + // ============================================================ + // Test: when cancelling signature proposal as non-account + // ============================================================ + + function test_WhenCancellingSignatureProposalAsNon_account() external { + // it should revert with OnlyAccount + + // Install policy + _installPolicy(WALLET); + + // Create signature proposal + _createSignatureProposal(WALLET, testHash); + + // Attempt to cancel from a different account should revert + vm.prank(OTHER_ACCOUNT); + vm.expectRevert(TimelockPolicy.OnlyAccount.selector); + timelockPolicy.cancelSignatureProposal(policyId, WALLET, testHash); + } + + // ============================================================ + // Test: when cancelling signature proposal successfully + // ============================================================ + + function test_WhenCancellingSignatureProposalSuccessfully() external { + // it should set the proposal status to Cancelled + + // Install policy + _installPolicy(WALLET); + + // Create signature proposal + _createSignatureProposal(WALLET, testHash); + + // Verify it is pending first + (TimelockPolicy.ProposalStatus statusBefore,,) = + timelockPolicy.getSignatureProposal(testHash, policyId, WALLET); + assertEq( + uint256(statusBefore), + uint256(TimelockPolicy.ProposalStatus.Pending), + "Proposal should be Pending before cancellation" + ); + + // Cancel the proposal as the account owner + vm.prank(WALLET); + timelockPolicy.cancelSignatureProposal(policyId, WALLET, testHash); + + // Verify status is now Cancelled + (TimelockPolicy.ProposalStatus statusAfter,,) = + timelockPolicy.getSignatureProposal(testHash, policyId, WALLET); + assertEq( + uint256(statusAfter), + uint256(TimelockPolicy.ProposalStatus.Cancelled), + "Proposal status should be Cancelled after cancellation" + ); + } + + // ============================================================ + // Test: when checking signature without proposal + // ============================================================ + + function test_WhenCheckingSignatureWithoutProposal() external { + // it should return validation failure + + // Install policy + _installPolicy(WALLET); + + // Do NOT create a signature proposal + + // Try to validate signature + vm.prank(WALLET); + uint256 result = timelockPolicy.checkSignaturePolicy(policyId, address(0), testHash, ""); + + // Should return 1 (failure) because no proposal exists + assertEq(result, 1, "Should return validation failure when no proposal exists"); + } + + // ============================================================ + // Test: when checking signature before timelock passes + // ============================================================ + + function test_WhenCheckingSignatureBeforeTimelockPasses() external { + // it should return validation failure + + // Install policy + _installPolicy(WALLET); + + // Create signature proposal + _createSignatureProposal(WALLET, testHash); + + // Do NOT warp time - we are still in the pending period + + // Try to validate signature immediately + vm.prank(WALLET); + uint256 result = timelockPolicy.checkSignaturePolicy(policyId, address(0), testHash, ""); + + // Should return 1 (failure) because timelock has not passed + assertEq(result, 1, "Should return validation failure before timelock passes"); + } + + // ============================================================ + // Test: when checking signature after timelock passes + // ============================================================ + + function test_WhenCheckingSignatureAfterTimelockPasses() external { + // it should return validation success + + // Install policy + _installPolicy(WALLET); + + // Create signature proposal + _createSignatureProposal(WALLET, testHash); + + // Warp time past the delay but before expiration + vm.warp(block.timestamp + DELAY + 1); + + // Validate signature + vm.prank(WALLET); + uint256 result = timelockPolicy.checkSignaturePolicy(policyId, address(0), testHash, ""); + + // Should return 0 (success) because timelock has passed and proposal is valid + assertEq(result, 0, "Should return validation success after timelock passes"); + } + + // ============================================================ + // Test: when checking signature after expiration + // ============================================================ + + function test_WhenCheckingSignatureAfterExpiration() external { + // it should return validation failure + + // Install policy + _installPolicy(WALLET); + + // Create signature proposal + _createSignatureProposal(WALLET, testHash); + + // Warp time past the expiration (delay + expiration + 1 second) + vm.warp(block.timestamp + DELAY + EXPIRATION_PERIOD + 1); + + // Try to validate signature after expiration + vm.prank(WALLET); + uint256 result = timelockPolicy.checkSignaturePolicy(policyId, address(0), testHash, ""); + + // Should return 1 (failure) because proposal has expired + assertEq(result, 1, "Should return validation failure after expiration"); + } + + // ============================================================ + // Test: when checking signature for cancelled proposal + // ============================================================ + + function test_WhenCheckingSignatureForCancelledProposal() external { + // it should return validation failure + + // Install policy + _installPolicy(WALLET); + + // Create signature proposal + _createSignatureProposal(WALLET, testHash); + + // Cancel the proposal + vm.prank(WALLET); + timelockPolicy.cancelSignatureProposal(policyId, WALLET, testHash); + + // Warp time past the delay (would normally be valid) + vm.warp(block.timestamp + DELAY + 1); + + // Try to validate signature for cancelled proposal + vm.prank(WALLET); + uint256 result = timelockPolicy.checkSignaturePolicy(policyId, address(0), testHash, ""); + + // Should return 1 (failure) because proposal is cancelled + assertEq(result, 1, "Should return validation failure for cancelled proposal"); + } +} diff --git a/test/btt/TimelockSignaturePolicy.tree b/test/btt/TimelockSignaturePolicy.tree new file mode 100644 index 0000000..ede0f9e --- /dev/null +++ b/test/btt/TimelockSignaturePolicy.tree @@ -0,0 +1,25 @@ +TimelockSignaturePolicyTest +├── when policy is not installed +│ └── it should return ERC1271_INVALID for signature validation +├── when creating signature proposal without initialization +│ └── it should revert with NotInitialized +├── when creating signature proposal that already exists +│ └── it should revert with ProposalAlreadyExists +├── when creating signature proposal successfully +│ ├── it should store the proposal with Pending status +│ ├── it should set validAfter to timestamp plus delay +│ └── it should set validUntil to validAfter plus expiration +├── when cancelling signature proposal as non-account +│ └── it should revert with OnlyAccount +├── when cancelling signature proposal successfully +│ └── it should set the proposal status to Cancelled +├── when checking signature without proposal +│ └── it should return validation failure +├── when checking signature before timelock passes +│ └── it should return validation failure +├── when checking signature after timelock passes +│ └── it should return validation success +├── when checking signature after expiration +│ └── it should return validation failure +└── when checking signature for cancelled proposal + └── it should return validation failure From baa9fa6dc25a33c35fe3e7ccebd7a6dabd77a5ac Mon Sep 17 00:00:00 2001 From: taek Date: Tue, 3 Feb 2026 00:01:12 +0900 Subject: [PATCH 06/25] test: add comprehensive BTT tests for TOB-KERNEL-1 fix --- src/policies/TimelockPolicy.sol | 4 +- test/TimelockPolicy.t.sol | 2 +- test/btt/TimelockProposalCreation.t.sol | 1089 +++++++++++++++++++++++ test/btt/TimelockProposalCreation.tree | 146 +++ 4 files changed, 1238 insertions(+), 3 deletions(-) create mode 100644 test/btt/TimelockProposalCreation.t.sol create mode 100644 test/btt/TimelockProposalCreation.tree diff --git a/src/policies/TimelockPolicy.sol b/src/policies/TimelockPolicy.sol index 7bf0d83..b5c489d 100644 --- a/src/policies/TimelockPolicy.sol +++ b/src/policies/TimelockPolicy.sol @@ -225,8 +225,8 @@ contract TimelockPolicy is PolicyBase, IStatelessValidator, IStatelessValidatorW emit ProposalCreated(account, id, userOpKey, validAfter, validUntil); - // Return success with validUntil=0 to persist storage but prevent execution - // EntryPoint will reject because block.timestamp > 0 (validUntil) + // Return success (validationData = 0) to allow the proposal creation to persist + // EntryPoint treats validationData == 0 as valid (no time range check) return _packValidationData(0, 0); } diff --git a/test/TimelockPolicy.t.sol b/test/TimelockPolicy.t.sol index 41ab52c..fda141e 100644 --- a/test/TimelockPolicy.t.sol +++ b/test/TimelockPolicy.t.sol @@ -284,7 +284,7 @@ contract TimelockPolicyTest is PolicyTestBase, StatelessValidatorTestBase, State uint256 result = policyModule.checkUserOpPolicy(policyId(), userOp); vm.stopPrank(); - // Returns success with validUntil=0 (expired) - state persists but execution fails + // Returns success (validationData = 0) - valid indefinitely per ERC-4337 // This allows proposal creation via UserOp without external caller assertEq(result, 0); diff --git a/test/btt/TimelockProposalCreation.t.sol b/test/btt/TimelockProposalCreation.t.sol new file mode 100644 index 0000000..0c1ec89 --- /dev/null +++ b/test/btt/TimelockProposalCreation.t.sol @@ -0,0 +1,1089 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import {Test} from "forge-std/Test.sol"; +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 {IModule} from "../../src/interfaces/IERC7579Modules.sol"; +import { + MODULE_TYPE_POLICY, + MODULE_TYPE_STATELESS_VALIDATOR, + MODULE_TYPE_STATELESS_VALIDATOR_WITH_SENDER +} from "../../src/types/Constants.sol"; + +/** + * @title TimelockProposalCreationTest + * @notice BTT tests for TimelockPolicy contract including TOB-KERNEL-1 security fix + * @dev Tests achieve 100% coverage and verify the fix returns validationData=0 for proposal creation + */ +contract TimelockProposalCreationTest is Test { + TimelockPolicy public timelockPolicy; + + address public constant WALLET = address(0x1234); + address public constant ATTACKER = address(0xdead); + bytes32 public constant POLICY_ID = bytes32(uint256(1)); + + uint48 public constant DELAY = 1 hours; + uint48 public constant EXPIRATION = 1 days; + + uint256 public constant SIG_VALIDATION_FAILED = 1; + + function setUp() public { + timelockPolicy = new TimelockPolicy(); + + // Install policy for WALLET + bytes memory installData = abi.encode(POLICY_ID, DELAY, EXPIRATION); + vm.prank(WALLET); + timelockPolicy.onInstall(installData); + } + + // ============ Helper Functions ============ + + function _createNoopUserOp(address sender, bytes memory signature) + internal + pure + returns (PackedUserOperation memory) + { + return PackedUserOperation({ + sender: sender, + nonce: 0, + initCode: "", + callData: "", + accountGasLimits: bytes32(abi.encodePacked(uint128(100000), uint128(200000))), + preVerificationGas: 0, + gasFees: bytes32(abi.encodePacked(uint128(1), uint128(1))), + paymasterAndData: "", + signature: signature + }); + } + + function _createUserOpWithCalldata(address sender, bytes memory callData, uint256 nonce, bytes memory signature) + internal + pure + returns (PackedUserOperation memory) + { + return PackedUserOperation({ + sender: sender, + nonce: nonce, + initCode: "", + callData: callData, + accountGasLimits: bytes32(abi.encodePacked(uint128(100000), uint128(200000))), + preVerificationGas: 0, + gasFees: bytes32(abi.encodePacked(uint128(1), uint128(1))), + paymasterAndData: "", + signature: signature + }); + } + + function _createProposalSignature(bytes memory proposalCallData, uint256 proposalNonce) + internal + pure + returns (bytes memory) + { + return + abi.encodePacked(bytes32(proposalCallData.length), proposalCallData, bytes32(proposalNonce), bytes1(0x00)); + } + + function _packValidationData(uint48 validAfter, uint48 validUntil) internal pure returns (uint256) { + return uint256(validAfter) << 208 | uint256(validUntil) << 160; + } + + // ============ onInstall Tests ============ + + modifier whenCallingOnInstall() { + _; + } + + function test_GivenDelayAndExpirationAreValid() external whenCallingOnInstall { + // it should store the config + // it should emit TimelockConfigUpdated + address newWallet = address(0x5555); + bytes32 newId = bytes32(uint256(2)); + + vm.expectEmit(true, true, true, true); + emit TimelockPolicy.TimelockConfigUpdated(newWallet, newId, 2 hours, 2 days); + + bytes memory installData = abi.encode(newId, uint48(2 hours), uint48(2 days)); + vm.prank(newWallet); + timelockPolicy.onInstall(installData); + + (uint48 delay, uint48 expiration, bool initialized) = timelockPolicy.timelockConfig(newId, newWallet); + assertEq(delay, 2 hours, "Delay should be stored"); + assertEq(expiration, 2 days, "Expiration 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); + vm.prank(WALLET); + vm.expectRevert(abi.encodeWithSelector(IModule.AlreadyInitialized.selector, WALLET)); + timelockPolicy.onInstall(installData); + } + + function test_GivenDelayIsZero() external whenCallingOnInstall { + // it should revert with InvalidDelay + address newWallet = address(0x6666); + bytes memory installData = abi.encode(POLICY_ID, uint48(0), EXPIRATION); + vm.prank(newWallet); + vm.expectRevert(TimelockPolicy.InvalidDelay.selector); + timelockPolicy.onInstall(installData); + } + + function test_GivenExpirationIsZero() external whenCallingOnInstall { + // it should revert with InvalidExpirationPeriod + address newWallet = address(0x7777); + bytes memory installData = abi.encode(POLICY_ID, DELAY, uint48(0)); + vm.prank(newWallet); + vm.expectRevert(TimelockPolicy.InvalidExpirationPeriod.selector); + timelockPolicy.onInstall(installData); + } + + // ============ onUninstall Tests ============ + + modifier whenCallingOnUninstall() { + _; + } + + function test_GivenInitialized() external whenCallingOnUninstall { + // it should clear the config + vm.prank(WALLET); + timelockPolicy.onUninstall(abi.encode(POLICY_ID)); + + (,, bool initialized) = timelockPolicy.timelockConfig(POLICY_ID, WALLET); + assertFalse(initialized, "Config should be cleared"); + } + + function test_GivenNotInitialized() external whenCallingOnUninstall { + // it should revert with NotInitialized + address newWallet = address(0x8888); + vm.prank(newWallet); + vm.expectRevert(abi.encodeWithSelector(IModule.NotInitialized.selector, newWallet)); + timelockPolicy.onUninstall(abi.encode(POLICY_ID)); + } + + // ============ isModuleType Tests ============ + + modifier whenCallingIsModuleType() { + _; + } + + function test_GivenTypeIsPolicy() external whenCallingIsModuleType { + // it should return true + assertTrue(timelockPolicy.isModuleType(MODULE_TYPE_POLICY), "Should support policy type"); + } + + function test_GivenTypeIsStatelessValidator() external whenCallingIsModuleType { + // it should return true + assertTrue(timelockPolicy.isModuleType(MODULE_TYPE_STATELESS_VALIDATOR), "Should support stateless validator"); + } + + function test_GivenTypeIsStatelessValidatorWithSender() external whenCallingIsModuleType { + // it should return true + assertTrue( + timelockPolicy.isModuleType(MODULE_TYPE_STATELESS_VALIDATOR_WITH_SENDER), + "Should support stateless validator with sender" + ); + } + + function test_GivenTypeIsInvalid() external whenCallingIsModuleType { + // it should return false + assertFalse(timelockPolicy.isModuleType(999), "Should not support invalid type"); + } + + // ============ createProposal Tests ============ + + modifier whenCallingCreateProposal() { + _; + } + + function test_GivenConfigIsInitializedAndProposalDoesNotExist() external whenCallingCreateProposal { + // it should store the proposal with pending status + // it should set correct validAfter and validUntil + // it should emit ProposalCreated + bytes memory callData = abi.encodeWithSelector(IERC7579Execution.execute.selector, bytes32(0), "test"); + uint256 nonce = 100; + + uint256 createTime = block.timestamp; + bytes32 expectedKey = keccak256(abi.encode(WALLET, keccak256(callData), nonce)); + + vm.expectEmit(true, true, true, true); + emit TimelockPolicy.ProposalCreated( + WALLET, POLICY_ID, expectedKey, uint48(createTime) + DELAY, uint48(createTime) + DELAY + EXPIRATION + ); + + timelockPolicy.createProposal(POLICY_ID, WALLET, callData, nonce); + + (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, createTime + DELAY, "validAfter should be timestamp + delay"); + assertEq(validUntil, createTime + DELAY + EXPIRATION, "validUntil should be validAfter + expiration"); + } + + function test_GivenNotInitialized_WhenCallingCreateProposal() external whenCallingCreateProposal { + // it should revert with NotInitialized + address uninitWallet = address(0x9999); + bytes memory callData = abi.encodeWithSelector(IERC7579Execution.execute.selector, bytes32(0), "test"); + + vm.expectRevert(abi.encodeWithSelector(IModule.NotInitialized.selector, uninitWallet)); + timelockPolicy.createProposal(POLICY_ID, uninitWallet, callData, 0); + } + + function test_GivenProposalAlreadyExists() external whenCallingCreateProposal { + // it should revert with ProposalAlreadyExists + bytes memory callData = abi.encodeWithSelector(IERC7579Execution.execute.selector, bytes32(0), "test"); + uint256 nonce = 200; + + timelockPolicy.createProposal(POLICY_ID, WALLET, callData, nonce); + + vm.expectRevert(TimelockPolicy.ProposalAlreadyExists.selector); + timelockPolicy.createProposal(POLICY_ID, WALLET, callData, nonce); + } + + // ============ cancelProposal Tests ============ + + modifier whenCallingCancelProposal() { + _; + } + + function test_GivenCallerIsAccountAndProposalIsPending() external whenCallingCancelProposal { + // it should set status to cancelled + // it should emit ProposalCancelled + bytes memory callData = abi.encodeWithSelector(IERC7579Execution.execute.selector, bytes32(0), "test"); + uint256 nonce = 300; + + timelockPolicy.createProposal(POLICY_ID, WALLET, callData, nonce); + + bytes32 expectedKey = keccak256(abi.encode(WALLET, keccak256(callData), nonce)); + + vm.expectEmit(true, true, true, true); + emit TimelockPolicy.ProposalCancelled(WALLET, POLICY_ID, expectedKey); + + 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), "Status should be Cancelled"); + } + + function test_GivenCallerIsNotAccount() external whenCallingCancelProposal { + // it should revert with OnlyAccount + bytes memory callData = abi.encodeWithSelector(IERC7579Execution.execute.selector, bytes32(0), "test"); + uint256 nonce = 400; + + timelockPolicy.createProposal(POLICY_ID, WALLET, callData, nonce); + + vm.prank(ATTACKER); + vm.expectRevert(TimelockPolicy.OnlyAccount.selector); + timelockPolicy.cancelProposal(POLICY_ID, WALLET, callData, nonce); + } + + function test_GivenNotInitialized_WhenCallingCancelProposal() external whenCallingCancelProposal { + // it should revert with NotInitialized + address uninitWallet = address(0xaaaa); + bytes memory callData = abi.encodeWithSelector(IERC7579Execution.execute.selector, bytes32(0), "test"); + + vm.prank(uninitWallet); + vm.expectRevert(abi.encodeWithSelector(IModule.NotInitialized.selector, uninitWallet)); + timelockPolicy.cancelProposal(POLICY_ID, uninitWallet, callData, 0); + } + + function test_GivenProposalDoesNotExist() external whenCallingCancelProposal { + // it should revert with ProposalNotPending + bytes memory callData = abi.encodeWithSelector(IERC7579Execution.execute.selector, bytes32(0), "test"); + uint256 nonce = 500; + + vm.prank(WALLET); + vm.expectRevert(TimelockPolicy.ProposalNotPending.selector); + timelockPolicy.cancelProposal(POLICY_ID, WALLET, callData, nonce); + } + + function test_GivenProposalIsAlreadyCancelled() external whenCallingCancelProposal { + // it should revert with ProposalNotPending + bytes memory callData = abi.encodeWithSelector(IERC7579Execution.execute.selector, bytes32(0), "test"); + uint256 nonce = 600; + + timelockPolicy.createProposal(POLICY_ID, WALLET, callData, nonce); + + vm.prank(WALLET); + timelockPolicy.cancelProposal(POLICY_ID, WALLET, callData, nonce); + + vm.prank(WALLET); + vm.expectRevert(TimelockPolicy.ProposalNotPending.selector); + timelockPolicy.cancelProposal(POLICY_ID, WALLET, callData, nonce); + } + + // ============ checkUserOpPolicy - Proposal Creation Tests ============ + + modifier whenCallingCheckUserOpPolicyToCreateProposal() { + _; + } + + function test_GivenNoopCalldataAndValidSignature() external whenCallingCheckUserOpPolicyToCreateProposal { + // it should create the proposal + // it should return zero for state persistence + // it should emit ProposalCreated + bytes memory proposalCallData = abi.encodeWithSelector(IERC7579Execution.execute.selector, bytes32(0), "test"); + uint256 proposalNonce = 700; + bytes memory sig = _createProposalSignature(proposalCallData, proposalNonce); + + PackedUserOperation memory userOp = _createNoopUserOp(WALLET, sig); + + bytes32 expectedKey = keccak256(abi.encode(WALLET, keccak256(proposalCallData), proposalNonce)); + + vm.expectEmit(true, true, true, true); + emit TimelockPolicy.ProposalCreated( + WALLET, + POLICY_ID, + expectedKey, + uint48(block.timestamp) + DELAY, + uint48(block.timestamp) + DELAY + EXPIRATION + ); + + vm.prank(WALLET); + uint256 result = timelockPolicy.checkUserOpPolicy(POLICY_ID, userOp); + + // TOB-KERNEL-1 FIX: Should return 0 for state persistence + assertEq(result, 0, "Should return 0 for state persistence (TOB-KERNEL-1 fix)"); + + (TimelockPolicy.ProposalStatus status,,) = + timelockPolicy.getProposal(WALLET, proposalCallData, proposalNonce, POLICY_ID, WALLET); + assertEq(uint256(status), uint256(TimelockPolicy.ProposalStatus.Pending), "Proposal should be created"); + } + + function test_GivenNoopCalldataAndSignatureShorterThan65Bytes() + external + whenCallingCheckUserOpPolicyToCreateProposal + { + // it should return SIG_VALIDATION_FAILED + bytes memory shortSig = new bytes(64); + PackedUserOperation memory userOp = _createNoopUserOp(WALLET, shortSig); + + vm.prank(WALLET); + uint256 result = timelockPolicy.checkUserOpPolicy(POLICY_ID, userOp); + + assertEq(result, SIG_VALIDATION_FAILED, "Should fail with short signature"); + } + + function test_GivenNoopCalldataAndSignatureClaimsMoreDataThanAvailable() + external + whenCallingCheckUserOpPolicyToCreateProposal + { + // it should return SIG_VALIDATION_FAILED + bytes memory badSig = abi.encodePacked( + bytes32(uint256(1000)), // claims 1000 bytes of calldata + bytes32(0), // nonce + bytes1(0x00) // only 65 bytes total, not enough for claimed calldata + ); + + PackedUserOperation memory userOp = _createNoopUserOp(WALLET, badSig); + + vm.prank(WALLET); + uint256 result = timelockPolicy.checkUserOpPolicy(POLICY_ID, userOp); + + assertEq(result, SIG_VALIDATION_FAILED, "Should fail when signature claims more data than available"); + } + + function test_GivenNoopCalldataAndProposalAlreadyExists() external whenCallingCheckUserOpPolicyToCreateProposal { + // it should return SIG_VALIDATION_FAILED + bytes memory proposalCallData = abi.encodeWithSelector(IERC7579Execution.execute.selector, bytes32(0), "test"); + uint256 proposalNonce = 800; + bytes memory sig = _createProposalSignature(proposalCallData, proposalNonce); + + PackedUserOperation memory userOp = _createNoopUserOp(WALLET, sig); + + // First creation should succeed + vm.prank(WALLET); + timelockPolicy.checkUserOpPolicy(POLICY_ID, userOp); + + // Second creation should fail + vm.prank(WALLET); + uint256 result = timelockPolicy.checkUserOpPolicy(POLICY_ID, userOp); + + assertEq(result, SIG_VALIDATION_FAILED, "Should fail for duplicate proposal"); + } + + // ============ checkUserOpPolicy - Proposal Execution Tests ============ + + modifier whenCallingCheckUserOpPolicyToExecuteProposal() { + _; + } + + function test_GivenProposalIsPendingAndTimelockPassed() external whenCallingCheckUserOpPolicyToExecuteProposal { + // it should mark proposal as executed + // it should return packed validation data with timing + // it should emit ProposalExecuted + bytes memory proposalCallData = abi.encodeWithSelector(IERC7579Execution.execute.selector, bytes32(0), "action"); + uint256 proposalNonce = 900; + + uint256 createTime = block.timestamp; + timelockPolicy.createProposal(POLICY_ID, WALLET, proposalCallData, proposalNonce); + + // Get the actual stored proposal values + (, uint256 storedValidAfter, uint256 storedValidUntil) = + timelockPolicy.getProposal(WALLET, proposalCallData, proposalNonce, POLICY_ID, WALLET); + + vm.warp(block.timestamp + DELAY + 1); + + PackedUserOperation memory executeOp = _createUserOpWithCalldata(WALLET, proposalCallData, proposalNonce, ""); + + bytes32 expectedKey = keccak256(abi.encode(WALLET, keccak256(proposalCallData), proposalNonce)); + + vm.expectEmit(true, true, true, true); + emit TimelockPolicy.ProposalExecuted(WALLET, POLICY_ID, expectedKey); + + vm.prank(WALLET); + uint256 result = timelockPolicy.checkUserOpPolicy(POLICY_ID, executeOp); + + // Extract validAfter and validUntil from packed data + uint48 validAfter = uint48(result >> 208); + uint48 validUntil = uint48(result >> 160); + assertEq(validAfter, storedValidAfter, "validAfter should match proposal"); + assertEq(validUntil, storedValidUntil, "validUntil should match proposal"); + + (TimelockPolicy.ProposalStatus status,,) = + timelockPolicy.getProposal(WALLET, proposalCallData, proposalNonce, POLICY_ID, WALLET); + assertEq(uint256(status), uint256(TimelockPolicy.ProposalStatus.Executed), "Proposal should be executed"); + } + + function test_GivenNoProposalExists() external whenCallingCheckUserOpPolicyToExecuteProposal { + // it should return SIG_VALIDATION_FAILED + bytes memory callData = abi.encodeWithSelector(IERC7579Execution.execute.selector, bytes32(0), "attack"); + PackedUserOperation memory userOp = _createUserOpWithCalldata(WALLET, callData, 0, ""); + + vm.prank(WALLET); + uint256 result = timelockPolicy.checkUserOpPolicy(POLICY_ID, userOp); + + assertEq(result, SIG_VALIDATION_FAILED, "Should fail without proposal"); + } + + function test_GivenProposalIsCancelled() external whenCallingCheckUserOpPolicyToExecuteProposal { + // it should return SIG_VALIDATION_FAILED + bytes memory callData = abi.encodeWithSelector(IERC7579Execution.execute.selector, bytes32(0), "test"); + uint256 nonce = 1000; + + timelockPolicy.createProposal(POLICY_ID, WALLET, callData, nonce); + + vm.prank(WALLET); + timelockPolicy.cancelProposal(POLICY_ID, WALLET, callData, nonce); + + PackedUserOperation memory userOp = _createUserOpWithCalldata(WALLET, callData, nonce, ""); + + vm.prank(WALLET); + uint256 result = timelockPolicy.checkUserOpPolicy(POLICY_ID, userOp); + + assertEq(result, SIG_VALIDATION_FAILED, "Should fail for cancelled proposal"); + } + + function test_GivenProposalIsAlreadyExecuted() external whenCallingCheckUserOpPolicyToExecuteProposal { + // it should return SIG_VALIDATION_FAILED + bytes memory callData = abi.encodeWithSelector(IERC7579Execution.execute.selector, bytes32(0), "test"); + uint256 nonce = 1100; + + timelockPolicy.createProposal(POLICY_ID, WALLET, callData, nonce); + + vm.warp(block.timestamp + DELAY + 1); + + PackedUserOperation memory userOp = _createUserOpWithCalldata(WALLET, callData, nonce, ""); + + // First execution + vm.prank(WALLET); + timelockPolicy.checkUserOpPolicy(POLICY_ID, userOp); + + // Second execution attempt + vm.prank(WALLET); + uint256 result = timelockPolicy.checkUserOpPolicy(POLICY_ID, userOp); + + assertEq(result, SIG_VALIDATION_FAILED, "Should fail for already executed proposal"); + } + + // ============ checkUserOpPolicy - Not Initialized ============ + + function test_WhenCallingCheckUserOpPolicyWithoutInitialization() external { + // it should return SIG_VALIDATION_FAILED + address uninitWallet = address(0xbbbb); + bytes memory sig = _createProposalSignature("test", 0); + PackedUserOperation memory userOp = _createNoopUserOp(uninitWallet, sig); + + vm.prank(uninitWallet); + uint256 result = timelockPolicy.checkUserOpPolicy(POLICY_ID, userOp); + + assertEq(result, SIG_VALIDATION_FAILED, "Should fail when not initialized"); + } + + // ============ checkSignaturePolicy Tests ============ + + modifier whenCallingCheckSignaturePolicy() { + _; + } + + function test_GivenInitialized_WhenCallingCheckSignaturePolicy() external whenCallingCheckSignaturePolicy { + // it should return zero + vm.prank(WALLET); + uint256 result = timelockPolicy.checkSignaturePolicy(POLICY_ID, address(0), bytes32(0), ""); + assertEq(result, 0, "Should return 0 when initialized"); + } + + function test_GivenNotInitialized_WhenCallingCheckSignaturePolicy() external whenCallingCheckSignaturePolicy { + // it should return one + address uninitWallet = address(0xcccc); + vm.prank(uninitWallet); + uint256 result = timelockPolicy.checkSignaturePolicy(POLICY_ID, address(0), bytes32(0), ""); + assertEq(result, 1, "Should return 1 when not initialized"); + } + + // ============ validateSignatureWithData Tests ============ + + modifier whenCallingValidateSignatureWithData() { + _; + } + + function test_GivenDelayAndExpirationAreNonzero() external whenCallingValidateSignatureWithData { + // it should return true + bytes memory data = abi.encode(uint48(1 hours), uint48(1 days)); + bool result = timelockPolicy.validateSignatureWithData(bytes32(0), "", data); + assertTrue(result, "Should return true for valid data"); + } + + function test_GivenDelayIsZero_WhenCallingValidateSignatureWithData() + external + whenCallingValidateSignatureWithData + { + // it should return false + bytes memory data = abi.encode(uint48(0), uint48(1 days)); + bool result = timelockPolicy.validateSignatureWithData(bytes32(0), "", data); + assertFalse(result, "Should return false for zero delay"); + } + + function test_GivenExpirationIsZero_WhenCallingValidateSignatureWithData() + external + whenCallingValidateSignatureWithData + { + // it should return false + bytes memory data = abi.encode(uint48(1 hours), uint48(0)); + bool result = timelockPolicy.validateSignatureWithData(bytes32(0), "", data); + assertFalse(result, "Should return false for zero expiration"); + } + + // ============ validateSignatureWithDataWithSender Tests ============ + + modifier whenCallingValidateSignatureWithDataWithSender() { + _; + } + + function test_GivenDelayAndExpirationAreNonzero_WhenCallingValidateSignatureWithDataWithSender() + external + whenCallingValidateSignatureWithDataWithSender + { + // it should return true + bytes memory data = abi.encode(uint48(1 hours), uint48(1 days)); + bool result = timelockPolicy.validateSignatureWithDataWithSender(address(0), bytes32(0), "", data); + assertTrue(result, "Should return true for valid data"); + } + + function test_GivenDelayIsZero_WhenCallingValidateSignatureWithDataWithSender() + external + whenCallingValidateSignatureWithDataWithSender + { + // it should return false + bytes memory data = abi.encode(uint48(0), uint48(1 days)); + bool result = timelockPolicy.validateSignatureWithDataWithSender(address(0), bytes32(0), "", data); + assertFalse(result, "Should return false for zero delay"); + } + + function test_GivenExpirationIsZero_WhenCallingValidateSignatureWithDataWithSender() + external + whenCallingValidateSignatureWithDataWithSender + { + // it should return false + bytes memory data = abi.encode(uint48(1 hours), uint48(0)); + bool result = timelockPolicy.validateSignatureWithDataWithSender(address(0), bytes32(0), "", data); + assertFalse(result, "Should return false for zero expiration"); + } + + // ============ getProposal Tests ============ + + modifier whenCallingGetProposal() { + _; + } + + function test_GivenProposalExists() external whenCallingGetProposal { + // it should return status validAfter and validUntil + bytes memory callData = abi.encodeWithSelector(IERC7579Execution.execute.selector, bytes32(0), "test"); + uint256 nonce = 1200; + + uint256 createTime = block.timestamp; + timelockPolicy.createProposal(POLICY_ID, WALLET, callData, nonce); + + (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, createTime + DELAY, "validAfter should be correct"); + assertEq(validUntil, createTime + 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 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(validUntil, 0, "validUntil should be 0"); + } + + // ============ computeUserOpKey Tests ============ + + function test_WhenCallingComputeUserOpKey() external { + // it should match manual keccak256 computation + bytes memory callData = abi.encodeWithSelector(IERC7579Execution.execute.selector, bytes32(0), "test"); + uint256 nonce = 1300; + + bytes32 expected = keccak256(abi.encode(WALLET, keccak256(callData), nonce)); + bytes32 result = timelockPolicy.computeUserOpKey(WALLET, callData, nonce); + + assertEq(result, expected, "computeUserOpKey should match manual computation"); + } + + // ============ _isNoOpCalldata Tests ============ + + modifier whenDetectingNoopCalldata() { + _; + } + + function test_GivenCalldataIsEmpty() external whenDetectingNoopCalldata { + // it should be detected as noop + bytes memory sig = _createProposalSignature("test", 0); + PackedUserOperation memory userOp = _createNoopUserOp(WALLET, sig); + + vm.prank(WALLET); + uint256 result = timelockPolicy.checkUserOpPolicy(POLICY_ID, userOp); + + // If it's a no-op, it goes to creation path and returns 0 + assertEq(result, 0, "Empty calldata should be detected as noop"); + } + + function test_GivenCalldataIsShorterThan4Bytes() external whenDetectingNoopCalldata { + // it should not be detected as noop + bytes memory shortCalldata = hex"aabb"; + PackedUserOperation memory userOp = _createUserOpWithCalldata(WALLET, shortCalldata, 0, ""); + + vm.prank(WALLET); + uint256 result = timelockPolicy.checkUserOpPolicy(POLICY_ID, userOp); + + // Not a no-op, goes to execution path, no proposal exists + assertEq(result, SIG_VALIDATION_FAILED, "Short calldata should not be noop"); + } + + function test_GivenSelectorIsUnrecognized() external whenDetectingNoopCalldata { + // it should not be detected as noop + bytes memory unknownCalldata = abi.encodeWithSelector(bytes4(0xdeadbeef), "test"); + PackedUserOperation memory userOp = _createUserOpWithCalldata(WALLET, unknownCalldata, 0, ""); + + vm.prank(WALLET); + uint256 result = timelockPolicy.checkUserOpPolicy(POLICY_ID, userOp); + + assertEq(result, SIG_VALIDATION_FAILED, "Unknown selector should not be noop"); + } + + // ============ _isNoOpERC7579Execute Tests ============ + + modifier whenDetectingERC7579ExecuteNoop() { + _; + } + + function test_GivenTargetIsSelfAndValueIsZeroAndInnerCalldataIsEmpty() external whenDetectingERC7579ExecuteNoop { + // it should be detected as noop + bytes memory callData = abi.encodePacked( + IERC7579Execution.execute.selector, + bytes32(0), // mode + bytes32(uint256(32)), // offset + bytes32(uint256(84)), // execDataLength (20+32+32) + bytes20(WALLET), // target = self + bytes32(uint256(0)), // value = 0 + bytes32(uint256(0)) // innerCalldataLength = 0 + ); + + bytes memory sig = _createProposalSignature("proposal", 0); + PackedUserOperation memory userOp = _createUserOpWithCalldata(WALLET, callData, 0, sig); + + vm.prank(WALLET); + uint256 result = timelockPolicy.checkUserOpPolicy(POLICY_ID, userOp); + + assertEq(result, 0, "ERC7579 execute to self with zero value should be noop"); + } + + function test_GivenTargetIsZeroAddressAndValueIsZeroAndInnerCalldataIsEmpty() + external + whenDetectingERC7579ExecuteNoop + { + // it should be detected as noop + bytes memory callData = abi.encodePacked( + IERC7579Execution.execute.selector, + bytes32(0), // mode + bytes32(uint256(32)), // offset + bytes32(uint256(84)), // execDataLength + bytes20(address(0)), // target = address(0) + bytes32(uint256(0)), // value = 0 + bytes32(uint256(0)) // innerCalldataLength = 0 + ); + + bytes memory sig = _createProposalSignature("proposal", 1); + PackedUserOperation memory userOp = _createUserOpWithCalldata(WALLET, callData, 0, sig); + + vm.prank(WALLET); + uint256 result = timelockPolicy.checkUserOpPolicy(POLICY_ID, userOp); + + assertEq(result, 0, "ERC7579 execute to zero address with zero value should be noop"); + } + + function test_GivenCalldataIsShorterThan68Bytes() external whenDetectingERC7579ExecuteNoop { + // it should not be detected as noop + bytes memory shortCallData = abi.encodeWithSelector(IERC7579Execution.execute.selector, bytes32(0)); + + PackedUserOperation memory userOp = _createUserOpWithCalldata(WALLET, shortCallData, 0, ""); + + vm.prank(WALLET); + uint256 result = timelockPolicy.checkUserOpPolicy(POLICY_ID, userOp); + + assertEq(result, SIG_VALIDATION_FAILED, "Too short for offset should not be noop"); + } + + function test_GivenOffsetIsNot32() external whenDetectingERC7579ExecuteNoop { + // it should not be detected as noop + bytes memory callData = abi.encodePacked( + IERC7579Execution.execute.selector, + bytes32(0), // mode + bytes32(uint256(64)), // wrong offset (should be 32) + bytes32(uint256(52)), // length + WALLET, + uint256(0), + uint256(0) + ); + + PackedUserOperation memory userOp = _createUserOpWithCalldata(WALLET, callData, 0, ""); + + vm.prank(WALLET); + uint256 result = timelockPolicy.checkUserOpPolicy(POLICY_ID, userOp); + + assertEq(result, SIG_VALIDATION_FAILED, "Wrong offset should not be noop"); + } + + function test_GivenCalldataIsShorterThan100Bytes() external whenDetectingERC7579ExecuteNoop { + // it should not be detected as noop + bytes memory callData = abi.encodePacked( + IERC7579Execution.execute.selector, + bytes32(0), // mode + bytes32(uint256(32)) // offset + // missing length and data + ); + + PackedUserOperation memory userOp = _createUserOpWithCalldata(WALLET, callData, 0, ""); + + vm.prank(WALLET); + uint256 result = timelockPolicy.checkUserOpPolicy(POLICY_ID, userOp); + + assertEq(result, SIG_VALIDATION_FAILED, "Too short for length should not be noop"); + } + + function test_GivenExecDataLengthIsLessThan52() external whenDetectingERC7579ExecuteNoop { + // it should not be detected as noop + bytes memory callData = abi.encodePacked( + IERC7579Execution.execute.selector, + bytes32(0), // mode + bytes32(uint256(32)), // offset + bytes32(uint256(20)) // length only 20 (need at least 52) + ); + + PackedUserOperation memory userOp = _createUserOpWithCalldata(WALLET, callData, 0, ""); + + vm.prank(WALLET); + uint256 result = timelockPolicy.checkUserOpPolicy(POLICY_ID, userOp); + + assertEq(result, SIG_VALIDATION_FAILED, "Exec data too short should not be noop"); + } + + function test_GivenTargetIsNotSelfOrZero() external whenDetectingERC7579ExecuteNoop { + // it should not be detected as noop + bytes memory executionCalldata = abi.encodePacked(ATTACKER, uint256(0), uint256(0)); + + bytes memory callData = + abi.encodeWithSelector(IERC7579Execution.execute.selector, bytes32(0), executionCalldata); + + PackedUserOperation memory userOp = _createUserOpWithCalldata(WALLET, callData, 0, ""); + + vm.prank(WALLET); + uint256 result = timelockPolicy.checkUserOpPolicy(POLICY_ID, userOp); + + assertEq(result, SIG_VALIDATION_FAILED, "Wrong target should not be noop"); + } + + function test_GivenValueIsNonzero() external whenDetectingERC7579ExecuteNoop { + // it should not be detected as noop + bytes memory executionCalldata = abi.encodePacked(WALLET, uint256(1 ether), uint256(0)); + + bytes memory callData = + abi.encodeWithSelector(IERC7579Execution.execute.selector, bytes32(0), executionCalldata); + + PackedUserOperation memory userOp = _createUserOpWithCalldata(WALLET, callData, 0, ""); + + vm.prank(WALLET); + uint256 result = timelockPolicy.checkUserOpPolicy(POLICY_ID, userOp); + + assertEq(result, SIG_VALIDATION_FAILED, "Non-zero value should not be noop"); + } + + function test_GivenCalldataIsShorterThan184Bytes() external whenDetectingERC7579ExecuteNoop { + // it should not be detected as noop + bytes memory callData = abi.encodePacked( + IERC7579Execution.execute.selector, + bytes32(0), // mode + bytes32(uint256(32)), // offset + bytes32(uint256(52)), // execDataLength (exactly 52, no room for inner calldata length) + WALLET, // 20 bytes + uint256(0) // 32 bytes = 52 total + ); + + PackedUserOperation memory userOp = _createUserOpWithCalldata(WALLET, callData, 0, ""); + + vm.prank(WALLET); + uint256 result = timelockPolicy.checkUserOpPolicy(POLICY_ID, userOp); + + assertEq(result, SIG_VALIDATION_FAILED, "Too short for inner calldata length should not be noop"); + } + + function test_GivenInnerCalldataLengthIsNonzero() external whenDetectingERC7579ExecuteNoop { + // it should not be detected as noop + bytes memory executionCalldata = abi.encodePacked(WALLET, uint256(0), uint256(10)); + + bytes memory callData = + abi.encodeWithSelector(IERC7579Execution.execute.selector, bytes32(0), executionCalldata); + + PackedUserOperation memory userOp = _createUserOpWithCalldata(WALLET, callData, 0, ""); + + vm.prank(WALLET); + uint256 result = timelockPolicy.checkUserOpPolicy(POLICY_ID, userOp); + + assertEq(result, SIG_VALIDATION_FAILED, "Non-empty inner calldata should not be noop"); + } + + // ============ _isNoOpExecuteUserOp Tests ============ + + modifier whenDetectingExecuteUserOpNoop() { + _; + } + + function test_GivenUserOpDataIsEmpty() external whenDetectingExecuteUserOpNoop { + // it should be detected as noop + bytes memory callData = abi.encodePacked( + IAccountExecute.executeUserOp.selector, + bytes32(uint256(32)), // offset to userOp + bytes32(0), // userOpHash + bytes32(uint256(0)) // userOp length = 0 + ); + + bytes memory sig = _createProposalSignature("proposal", 2); + PackedUserOperation memory userOp = _createUserOpWithCalldata(WALLET, callData, 0, sig); + + vm.prank(WALLET); + uint256 result = timelockPolicy.checkUserOpPolicy(POLICY_ID, userOp); + + assertEq(result, 0, "executeUserOp with empty userOp should be noop"); + } + + function test_GivenCalldataIsShorterThan100Bytes_WhenDetectingExecuteUserOpNoop() + external + whenDetectingExecuteUserOpNoop + { + // it should not be detected as noop + bytes memory callData = abi.encodePacked(IAccountExecute.executeUserOp.selector, bytes32(uint256(32))); + + PackedUserOperation memory userOp = _createUserOpWithCalldata(WALLET, callData, 0, ""); + + vm.prank(WALLET); + uint256 result = timelockPolicy.checkUserOpPolicy(POLICY_ID, userOp); + + assertEq(result, SIG_VALIDATION_FAILED, "Too short executeUserOp should not be noop"); + } + + function test_GivenOffsetIsNot32_WhenDetectingExecuteUserOpNoop() external whenDetectingExecuteUserOpNoop { + // it should not be detected as noop + bytes memory callData = abi.encodePacked( + IAccountExecute.executeUserOp.selector, + bytes32(uint256(64)), // wrong offset + bytes32(0), + bytes32(uint256(0)) + ); + + PackedUserOperation memory userOp = _createUserOpWithCalldata(WALLET, callData, 0, ""); + + vm.prank(WALLET); + uint256 result = timelockPolicy.checkUserOpPolicy(POLICY_ID, userOp); + + assertEq(result, SIG_VALIDATION_FAILED, "Wrong offset in executeUserOp should not be noop"); + } + + function test_GivenUserOpLengthIsNonzero() external whenDetectingExecuteUserOpNoop { + // it should not be detected as noop + bytes memory callData = abi.encodePacked( + IAccountExecute.executeUserOp.selector, + bytes32(uint256(32)), + bytes32(0), + bytes32(uint256(10)) // non-empty userOp + ); + + PackedUserOperation memory userOp = _createUserOpWithCalldata(WALLET, callData, 0, ""); + + vm.prank(WALLET); + uint256 result = timelockPolicy.checkUserOpPolicy(POLICY_ID, userOp); + + assertEq(result, SIG_VALIDATION_FAILED, "Non-empty userOp should not be noop"); + } + + // ============ _packValidationData Tests ============ + + function test_WhenPackingValidationData() external { + // it should correctly pack validAfter and validUntil + // Test the packing by creating a proposal and checking the returned validation data + bytes memory callData = abi.encodeWithSelector(IERC7579Execution.execute.selector, bytes32(0), "test"); + uint256 nonce = 1400; + + timelockPolicy.createProposal(POLICY_ID, WALLET, callData, nonce); + + // Get the actual stored proposal values + (, uint256 storedValidAfter, uint256 storedValidUntil) = + timelockPolicy.getProposal(WALLET, callData, nonce, POLICY_ID, WALLET); + + vm.warp(block.timestamp + DELAY + 1); + + PackedUserOperation memory userOp = _createUserOpWithCalldata(WALLET, callData, nonce, ""); + + vm.prank(WALLET); + uint256 result = timelockPolicy.checkUserOpPolicy(POLICY_ID, userOp); + + uint256 expectedPacked = _packValidationData(uint48(storedValidAfter), uint48(storedValidUntil)); + + assertEq(result, expectedPacked, "Packed validation data should match expected"); + } + + // ============ TOB-KERNEL-1 Security Tests ============ + + modifier whenTestingTOBKERNEL1SecurityFix() { + _; + } + + function test_GivenAttackerTriesToExecuteWithoutProposal() external whenTestingTOBKERNEL1SecurityFix { + // it should return SIG_VALIDATION_FAILED + // Security: Attacker cannot execute arbitrary operations without first creating a proposal + bytes memory maliciousCalldata = + abi.encodeWithSelector(IERC7579Execution.execute.selector, bytes32(0), "steal_funds"); + + PackedUserOperation memory attackOp = _createUserOpWithCalldata(WALLET, maliciousCalldata, 0, ""); + + vm.prank(WALLET); + uint256 result = timelockPolicy.checkUserOpPolicy(POLICY_ID, attackOp); + + assertEq(result, SIG_VALIDATION_FAILED, "Attack without proposal should fail"); + } + + function test_GivenAttackerTriesToExecuteImmediatelyAfterCreation() external whenTestingTOBKERNEL1SecurityFix { + // it should return packed data with future validAfter + // Security: Even if attacker creates a proposal, they cannot execute immediately + // The validAfter will be in the future, causing EntryPoint to reject + bytes memory callData = abi.encodeWithSelector(IERC7579Execution.execute.selector, bytes32(0), "action"); + uint256 nonce = 1500; + + uint256 createTime = block.timestamp; + timelockPolicy.createProposal(POLICY_ID, WALLET, callData, nonce); + + // Try to execute immediately (before timelock) + PackedUserOperation memory executeOp = _createUserOpWithCalldata(WALLET, callData, nonce, ""); + + vm.prank(WALLET); + uint256 result = timelockPolicy.checkUserOpPolicy(POLICY_ID, executeOp); + + // Extract validAfter - it should be in the future + uint48 validAfter = uint48(result >> 208); + assertGt(validAfter, block.timestamp, "validAfter should be in the future"); + assertEq(validAfter, uint48(createTime) + DELAY, "validAfter should be createTime + DELAY"); + } + + function test_GivenAttackerTriesToReexecuteAUsedProposal() external whenTestingTOBKERNEL1SecurityFix { + // it should return SIG_VALIDATION_FAILED + // Security: Attacker cannot replay an already executed proposal + bytes memory callData = abi.encodeWithSelector(IERC7579Execution.execute.selector, bytes32(0), "action"); + uint256 nonce = 1600; + + timelockPolicy.createProposal(POLICY_ID, WALLET, callData, nonce); + + vm.warp(block.timestamp + DELAY + 1); + + PackedUserOperation memory executeOp = _createUserOpWithCalldata(WALLET, callData, nonce, ""); + + // First execution - should succeed + vm.prank(WALLET); + uint256 firstResult = timelockPolicy.checkUserOpPolicy(POLICY_ID, executeOp); + assertNotEq(firstResult, SIG_VALIDATION_FAILED, "First execution should succeed"); + + // Verify proposal is marked as executed + (TimelockPolicy.ProposalStatus status,,) = + timelockPolicy.getProposal(WALLET, callData, nonce, POLICY_ID, WALLET); + assertEq(uint256(status), uint256(TimelockPolicy.ProposalStatus.Executed), "Should be executed"); + + // Second execution attempt - should fail + vm.prank(WALLET); + uint256 secondResult = timelockPolicy.checkUserOpPolicy(POLICY_ID, executeOp); + assertEq(secondResult, SIG_VALIDATION_FAILED, "Re-execution should fail"); + } + + function test_GivenProposalCreationReturnsZero() external whenTestingTOBKERNEL1SecurityFix { + // it should allow state to persist via EntryPoint + // This is the core TOB-KERNEL-1 fix: proposal creation returns 0 instead of 1 + // so EntryPoint doesn't revert and state persists + bytes memory proposalCallData = abi.encodeWithSelector(IERC7579Execution.execute.selector, bytes32(0), "test"); + uint256 proposalNonce = 1700; + bytes memory sig = _createProposalSignature(proposalCallData, proposalNonce); + + PackedUserOperation memory userOp = _createNoopUserOp(WALLET, sig); + + vm.prank(WALLET); + uint256 result = timelockPolicy.checkUserOpPolicy(POLICY_ID, userOp); + + // Key assertion: result must be 0 for EntryPoint to not revert + assertEq(result, 0, "TOB-KERNEL-1 FIX: Proposal creation MUST return 0 for state persistence"); + + // Verify the proposal was actually created and persisted + (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"); + assertGt(validAfter, 0, "validAfter should be set"); + assertGt(validUntil, validAfter, "validUntil should be after validAfter"); + } + + function test_GivenAttackerTriesToCancelAnotherAccountsProposal() external whenTestingTOBKERNEL1SecurityFix { + // it should revert with OnlyAccount + // Security: Only the account owner can cancel their proposals + bytes memory callData = abi.encodeWithSelector(IERC7579Execution.execute.selector, bytes32(0), "test"); + uint256 nonce = 1800; + + timelockPolicy.createProposal(POLICY_ID, WALLET, callData, nonce); + + vm.prank(ATTACKER); + vm.expectRevert(TimelockPolicy.OnlyAccount.selector); + timelockPolicy.cancelProposal(POLICY_ID, WALLET, callData, nonce); + + // Verify proposal is still pending + (TimelockPolicy.ProposalStatus status,,) = + timelockPolicy.getProposal(WALLET, callData, nonce, POLICY_ID, WALLET); + assertEq(uint256(status), uint256(TimelockPolicy.ProposalStatus.Pending), "Proposal should still be pending"); + } +} diff --git a/test/btt/TimelockProposalCreation.tree b/test/btt/TimelockProposalCreation.tree new file mode 100644 index 0000000..d94ed5f --- /dev/null +++ b/test/btt/TimelockProposalCreation.tree @@ -0,0 +1,146 @@ +TimelockProposalCreationTest +├── when calling onInstall +│ ├── given delay and expiration are valid +│ │ ├── it should store the config +│ │ └── it should emit TimelockConfigUpdated +│ ├── given already initialized +│ │ └── it should revert with AlreadyInitialized +│ ├── given delay is zero +│ │ └── it should revert with InvalidDelay +│ └── given expiration is zero +│ └── it should revert with InvalidExpirationPeriod +├── when calling onUninstall +│ ├── given initialized +│ │ └── it should clear the config +│ └── given not initialized +│ └── it should revert with NotInitialized +├── when calling isModuleType +│ ├── given type is policy +│ │ └── it should return true +│ ├── given type is stateless validator +│ │ └── it should return true +│ ├── given type is stateless validator with sender +│ │ └── it should return true +│ └── given type is invalid +│ └── it should return false +├── when calling createProposal +│ ├── given config is initialized and proposal does not exist +│ │ ├── it should store the proposal with pending status +│ │ ├── it should set correct validAfter and validUntil +│ │ └── it should emit ProposalCreated +│ ├── given not initialized +│ │ └── it should revert with NotInitialized +│ └── given proposal already exists +│ └── it should revert with ProposalAlreadyExists +├── when calling cancelProposal +│ ├── given caller is account and proposal is pending +│ │ ├── it should set status to cancelled +│ │ └── it should emit ProposalCancelled +│ ├── given caller is not account +│ │ └── it should revert with OnlyAccount +│ ├── given not initialized +│ │ └── it should revert with NotInitialized +│ ├── given proposal does not exist +│ │ └── it should revert with ProposalNotPending +│ └── given proposal is already cancelled +│ └── it should revert with ProposalNotPending +├── when calling checkUserOpPolicy to create proposal +│ ├── given noop calldata and valid signature +│ │ ├── it should create the proposal +│ │ ├── it should return zero for state persistence +│ │ └── it should emit ProposalCreated +│ ├── given noop calldata and signature shorter than 65 bytes +│ │ └── it should return SIG_VALIDATION_FAILED +│ ├── given noop calldata and signature claims more data than available +│ │ └── it should return SIG_VALIDATION_FAILED +│ └── given noop calldata and proposal already exists +│ └── it should return SIG_VALIDATION_FAILED +├── when calling checkUserOpPolicy to execute proposal +│ ├── given proposal is pending and timelock passed +│ │ ├── it should mark proposal as executed +│ │ ├── it should return packed validation data with timing +│ │ └── it should emit ProposalExecuted +│ ├── given no proposal exists +│ │ └── it should return SIG_VALIDATION_FAILED +│ ├── given proposal is cancelled +│ │ └── it should return SIG_VALIDATION_FAILED +│ └── given proposal is already executed +│ └── it should return SIG_VALIDATION_FAILED +├── when calling checkUserOpPolicy without initialization +│ └── it should return SIG_VALIDATION_FAILED +├── when calling checkSignaturePolicy +│ ├── given initialized +│ │ └── it should return zero +│ └── given not initialized +│ └── it should return one +├── when calling validateSignatureWithData +│ ├── given delay and expiration are nonzero +│ │ └── it should return true +│ ├── given delay is zero +│ │ └── it should return false +│ └── given expiration is zero +│ └── it should return false +├── when calling validateSignatureWithDataWithSender +│ ├── given delay and expiration are nonzero +│ │ └── it should return true +│ ├── given delay is zero +│ │ └── it should return false +│ └── given expiration is zero +│ └── it should return false +├── when calling getProposal +│ ├── given proposal exists +│ │ └── it should return status validAfter and validUntil +│ └── given proposal does not exist +│ └── it should return None status and zeros +├── when calling computeUserOpKey +│ └── it should match manual keccak256 computation +├── when detecting noop calldata +│ ├── given calldata is empty +│ │ └── it should be detected as noop +│ ├── given calldata is shorter than 4 bytes +│ │ └── it should not be detected as noop +│ └── given selector is unrecognized +│ └── it should not be detected as noop +├── when detecting ERC7579 execute noop +│ ├── given target is self and value is zero and inner calldata is empty +│ │ └── it should be detected as noop +│ ├── given target is zero address and value is zero and inner calldata is empty +│ │ └── it should be detected as noop +│ ├── given calldata is shorter than 68 bytes +│ │ └── it should not be detected as noop +│ ├── given offset is not 32 +│ │ └── it should not be detected as noop +│ ├── given calldata is shorter than 100 bytes +│ │ └── it should not be detected as noop +│ ├── given exec data length is less than 52 +│ │ └── it should not be detected as noop +│ ├── given target is not self or zero +│ │ └── it should not be detected as noop +│ ├── given value is nonzero +│ │ └── it should not be detected as noop +│ ├── given calldata is shorter than 184 bytes +│ │ └── it should not be detected as noop +│ └── given inner calldata length is nonzero +│ └── it should not be detected as noop +├── when detecting executeUserOp noop +│ ├── given userOp data is empty +│ │ └── it should be detected as noop +│ ├── given calldata is shorter than 100 bytes +│ │ └── it should not be detected as noop +│ ├── given offset is not 32 +│ │ └── it should not be detected as noop +│ └── given userOp length is nonzero +│ └── it should not be detected as noop +├── when packing validation data +│ └── it should correctly pack validAfter and validUntil +└── when testing TOB KERNEL 1 security fix + ├── given attacker tries to execute without proposal + │ └── it should return SIG_VALIDATION_FAILED + ├── given attacker tries to execute immediately after creation + │ └── it should return packed data with future validAfter + ├── given attacker tries to reexecute a used proposal + │ └── it should return SIG_VALIDATION_FAILED + ├── given proposal creation returns zero + │ └── it should allow state to persist via EntryPoint + └── given attacker tries to cancel another accounts proposal + └── it should revert with OnlyAccount From ca7137b9d1fe0236d6a5e27774a80cd0634ad3e5 Mon Sep 17 00:00:00 2001 From: taek Date: Tue, 3 Feb 2026 01:06:57 +0900 Subject: [PATCH 07/25] update --- ...kProposalCreation.t.sol => Timelock.t.sol} | 36 ++++++++----------- ...ockProposalCreation.tree => Timelock.tree} | 4 +-- 2 files changed, 16 insertions(+), 24 deletions(-) rename test/btt/{TimelockProposalCreation.t.sol => Timelock.t.sol} (96%) rename test/btt/{TimelockProposalCreation.tree => Timelock.tree} (98%) diff --git a/test/btt/TimelockProposalCreation.t.sol b/test/btt/Timelock.t.sol similarity index 96% rename from test/btt/TimelockProposalCreation.t.sol rename to test/btt/Timelock.t.sol index 0c1ec89..227d3f7 100644 --- a/test/btt/TimelockProposalCreation.t.sol +++ b/test/btt/Timelock.t.sol @@ -14,11 +14,10 @@ import { } from "../../src/types/Constants.sol"; /** - * @title TimelockProposalCreationTest - * @notice BTT tests for TimelockPolicy contract including TOB-KERNEL-1 security fix - * @dev Tests achieve 100% coverage and verify the fix returns validationData=0 for proposal creation + * @title TimelockTest + * @notice BTT tests for TimelockPolicy contract */ -contract TimelockProposalCreationTest is Test { +contract TimelockTest is Test { TimelockPolicy public timelockPolicy; address public constant WALLET = address(0x1234); @@ -348,8 +347,8 @@ contract TimelockProposalCreationTest is Test { vm.prank(WALLET); uint256 result = timelockPolicy.checkUserOpPolicy(POLICY_ID, userOp); - // TOB-KERNEL-1 FIX: Should return 0 for state persistence - assertEq(result, 0, "Should return 0 for state persistence (TOB-KERNEL-1 fix)"); + // Proposal creation must return 0 for state persistence + assertEq(result, 0, "Should return 0 for state persistence"); (TimelockPolicy.ProposalStatus status,,) = timelockPolicy.getProposal(WALLET, proposalCallData, proposalNonce, POLICY_ID, WALLET); @@ -974,15 +973,14 @@ contract TimelockProposalCreationTest is Test { assertEq(result, expectedPacked, "Packed validation data should match expected"); } - // ============ TOB-KERNEL-1 Security Tests ============ + // ============ Security Tests ============ - modifier whenTestingTOBKERNEL1SecurityFix() { + modifier whenTestingSecurityScenarios() { _; } - function test_GivenAttackerTriesToExecuteWithoutProposal() external whenTestingTOBKERNEL1SecurityFix { + function test_GivenAttackerTriesToExecuteWithoutProposal() external whenTestingSecurityScenarios { // it should return SIG_VALIDATION_FAILED - // Security: Attacker cannot execute arbitrary operations without first creating a proposal bytes memory maliciousCalldata = abi.encodeWithSelector(IERC7579Execution.execute.selector, bytes32(0), "steal_funds"); @@ -994,10 +992,8 @@ contract TimelockProposalCreationTest is Test { assertEq(result, SIG_VALIDATION_FAILED, "Attack without proposal should fail"); } - function test_GivenAttackerTriesToExecuteImmediatelyAfterCreation() external whenTestingTOBKERNEL1SecurityFix { + function test_GivenAttackerTriesToExecuteImmediatelyAfterCreation() external whenTestingSecurityScenarios { // it should return packed data with future validAfter - // Security: Even if attacker creates a proposal, they cannot execute immediately - // The validAfter will be in the future, causing EntryPoint to reject bytes memory callData = abi.encodeWithSelector(IERC7579Execution.execute.selector, bytes32(0), "action"); uint256 nonce = 1500; @@ -1016,9 +1012,8 @@ contract TimelockProposalCreationTest is Test { assertEq(validAfter, uint48(createTime) + DELAY, "validAfter should be createTime + DELAY"); } - function test_GivenAttackerTriesToReexecuteAUsedProposal() external whenTestingTOBKERNEL1SecurityFix { + function test_GivenAttackerTriesToReexecuteAUsedProposal() external whenTestingSecurityScenarios { // it should return SIG_VALIDATION_FAILED - // Security: Attacker cannot replay an already executed proposal bytes memory callData = abi.encodeWithSelector(IERC7579Execution.execute.selector, bytes32(0), "action"); uint256 nonce = 1600; @@ -1044,10 +1039,8 @@ contract TimelockProposalCreationTest is Test { assertEq(secondResult, SIG_VALIDATION_FAILED, "Re-execution should fail"); } - function test_GivenProposalCreationReturnsZero() external whenTestingTOBKERNEL1SecurityFix { + function test_GivenProposalCreationReturnsZero() external whenTestingSecurityScenarios { // it should allow state to persist via EntryPoint - // This is the core TOB-KERNEL-1 fix: proposal creation returns 0 instead of 1 - // so EntryPoint doesn't revert and state persists bytes memory proposalCallData = abi.encodeWithSelector(IERC7579Execution.execute.selector, bytes32(0), "test"); uint256 proposalNonce = 1700; bytes memory sig = _createProposalSignature(proposalCallData, proposalNonce); @@ -1057,8 +1050,8 @@ contract TimelockProposalCreationTest is Test { vm.prank(WALLET); uint256 result = timelockPolicy.checkUserOpPolicy(POLICY_ID, userOp); - // Key assertion: result must be 0 for EntryPoint to not revert - assertEq(result, 0, "TOB-KERNEL-1 FIX: Proposal creation MUST return 0 for state persistence"); + // Result must be 0 for EntryPoint to not revert + 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 validUntil) = @@ -1069,9 +1062,8 @@ contract TimelockProposalCreationTest is Test { assertGt(validUntil, validAfter, "validUntil should be after validAfter"); } - function test_GivenAttackerTriesToCancelAnotherAccountsProposal() external whenTestingTOBKERNEL1SecurityFix { + function test_GivenAttackerTriesToCancelAnotherAccountsProposal() external whenTestingSecurityScenarios { // it should revert with OnlyAccount - // Security: Only the account owner can cancel their proposals bytes memory callData = abi.encodeWithSelector(IERC7579Execution.execute.selector, bytes32(0), "test"); uint256 nonce = 1800; diff --git a/test/btt/TimelockProposalCreation.tree b/test/btt/Timelock.tree similarity index 98% rename from test/btt/TimelockProposalCreation.tree rename to test/btt/Timelock.tree index d94ed5f..6d6f7a9 100644 --- a/test/btt/TimelockProposalCreation.tree +++ b/test/btt/Timelock.tree @@ -1,4 +1,4 @@ -TimelockProposalCreationTest +TimelockTest ├── when calling onInstall │ ├── given delay and expiration are valid │ │ ├── it should store the config @@ -133,7 +133,7 @@ TimelockProposalCreationTest │ └── it should not be detected as noop ├── when packing validation data │ └── it should correctly pack validAfter and validUntil -└── when testing TOB KERNEL 1 security fix +└── when testing security scenarios ├── given attacker tries to execute without proposal │ └── it should return SIG_VALIDATION_FAILED ├── given attacker tries to execute immediately after creation From 97ded5e6ff3927f757a2524fd2aa1a8112f11624 Mon Sep 17 00:00:00 2001 From: taek Date: Tue, 3 Feb 2026 02:08:10 +0900 Subject: [PATCH 08/25] fix: timelockpolicy does not accept erc1271 --- src/policies/TimelockPolicy.sol | 157 +--------- test/TimelockPolicy.t.sol | 102 +++---- test/base/StatelessValidatorTestBase.sol | 2 +- .../StatelessValidatorWithSenderTestBase.sol | 2 +- test/btt/TimelockSignaturePolicy.t.sol | 273 ++---------------- test/btt/TimelockSignaturePolicy.tree | 32 +- 6 files changed, 84 insertions(+), 484 deletions(-) diff --git a/src/policies/TimelockPolicy.sol b/src/policies/TimelockPolicy.sol index 71dd639..b68551f 100644 --- a/src/policies/TimelockPolicy.sol +++ b/src/policies/TimelockPolicy.sol @@ -10,10 +10,7 @@ import { MODULE_TYPE_POLICY, MODULE_TYPE_STATELESS_VALIDATOR, MODULE_TYPE_STATELESS_VALIDATOR_WITH_SENDER, - SIG_VALIDATION_SUCCESS_UINT, - SIG_VALIDATION_FAILED_UINT, - ERC1271_MAGICVALUE, - ERC1271_INVALID + SIG_VALIDATION_FAILED_UINT } from "src/types/Constants.sol"; /** @@ -48,9 +45,6 @@ contract TimelockPolicy is PolicyBase, IStatelessValidator, IStatelessValidatorW // userOpKey = keccak256(abi.encode(account, keccak256(callData), nonce)) mapping(bytes32 => mapping(bytes32 => mapping(address => Proposal))) public proposals; - // Storage for ERC-1271 signature proposals: hash => id => wallet => proposal - mapping(bytes32 => mapping(bytes32 => mapping(address => Proposal))) public signatureProposals; - event ProposalCreated( address indexed wallet, bytes32 indexed id, bytes32 indexed proposalHash, uint256 validAfter, uint256 validUntil ); @@ -59,14 +53,6 @@ contract TimelockPolicy is PolicyBase, IStatelessValidator, IStatelessValidatorW event ProposalCancelled(address indexed wallet, bytes32 indexed id, bytes32 indexed proposalHash); - event SignatureProposalCreated( - address indexed wallet, bytes32 indexed id, bytes32 indexed hash, uint256 validAfter, uint256 validUntil - ); - - event SignatureProposalExecuted(address indexed wallet, bytes32 indexed id, bytes32 indexed hash); - - event SignatureProposalCancelled(address indexed wallet, bytes32 indexed id, bytes32 indexed hash); - event TimelockConfigUpdated(address indexed wallet, bytes32 indexed id, uint256 delay, uint256 expirationPeriod); error InvalidDelay(); @@ -177,57 +163,6 @@ contract TimelockPolicy is PolicyBase, IStatelessValidator, IStatelessValidatorW emit ProposalCancelled(account, id, userOpKey); } - /** - * @notice Create a proposal for time-delayed ERC-1271 signature - * @dev Anyone can create a proposal - the timelock delay provides the security - * @param id The policy ID - * @param account The account address - * @param hash The hash that will be signed - */ - function createSignatureProposal(bytes32 id, address account, bytes32 hash) external { - TimelockConfig storage config = timelockConfig[id][account]; - if (!config.initialized) revert IModule.NotInitialized(account); - - // Calculate proposal timing - uint48 validAfter = uint48(block.timestamp) + config.delay; - uint48 validUntil = validAfter + config.expirationPeriod; - - // Check proposal doesn't already exist - if (signatureProposals[hash][id][account].status != ProposalStatus.None) { - revert ProposalAlreadyExists(); - } - - // Create proposal - signatureProposals[hash][id][account] = - Proposal({status: ProposalStatus.Pending, validAfter: validAfter, validUntil: validUntil}); - - emit SignatureProposalCreated(account, id, hash, validAfter, validUntil); - } - - /** - * @notice Cancel a pending signature proposal - * @dev Only the account itself can cancel proposals to prevent griefing - * @param id The policy ID - * @param account The account address - * @param hash The hash of the signature proposal - */ - function cancelSignatureProposal(bytes32 id, address account, bytes32 hash) external { - // Only the account itself can cancel its own proposals - if (msg.sender != account) revert OnlyAccount(); - - TimelockConfig storage config = timelockConfig[id][account]; - if (!config.initialized) revert IModule.NotInitialized(account); - - Proposal storage proposal = signatureProposals[hash][id][account]; - if (proposal.status != ProposalStatus.Pending) { - revert ProposalNotPending(); - } - - proposal.status = ProposalStatus.Cancelled; - - emit SignatureProposalCancelled(account, id, hash); - } - /** * @notice Check user operation against timelock policy * @dev Called by the smart account during validation phase @@ -396,7 +331,7 @@ contract TimelockPolicy is PolicyBase, IStatelessValidator, IStatelessValidatorW * @notice Check if executeUserOp call is a no-op * @dev Valid: executeUserOp("", bytes32) */ - function _isNoOpExecuteUserOp(bytes calldata callData) internal view returns (bool) { + function _isNoOpExecuteUserOp(bytes calldata callData) internal pure returns (bool) { // executeUserOp(bytes calldata userOp, bytes32 userOpHash) // Format: 4 (selector) + 32 (userOp offset) + 32 (userOpHash) + 32 (userOp length) + userOp data if (callData.length < 100) return false; @@ -430,37 +365,33 @@ contract TimelockPolicy is PolicyBase, IStatelessValidator, IStatelessValidatorW /** * @notice Check signature against timelock policy (for ERC-1271) - * @param id The policy ID - * @return validationData 0 if valid, 1 if invalid + * @dev TimelockPolicy does not support ERC-1271 signature validation - always reverts */ - function checkSignaturePolicy(bytes32 id, address, bytes32 hash, bytes calldata sig) + function checkSignaturePolicy(bytes32, address, bytes32, bytes calldata) external - view + pure override returns (uint256) { - bytes4 result = _validateSignaturePolicy(id, msg.sender, hash, sig); - return result == ERC1271_MAGICVALUE ? 0 : 1; + revert("TimelockPolicy: signature validation not supported"); } - function validateSignatureWithData(bytes32, bytes calldata, bytes calldata data) + function validateSignatureWithData(bytes32, bytes calldata, bytes calldata) external pure override(IStatelessValidator) returns (bool) { - (uint48 delay, uint48 expirationPeriod) = abi.decode(data, (uint48, uint48)); - return delay != 0 && expirationPeriod != 0; + revert("TimelockPolicy: stateless signature validation not supported"); } - function validateSignatureWithDataWithSender(address, bytes32, bytes calldata, bytes calldata data) + function validateSignatureWithDataWithSender(address, bytes32, bytes calldata, bytes calldata) external pure override(IStatelessValidatorWithSender) returns (bool) { - (uint48 delay, uint48 expirationPeriod) = abi.decode(data, (uint48, uint48)); - return delay != 0 && expirationPeriod != 0; + revert("TimelockPolicy: stateless signature validation not supported"); } // ==================== Internal Shared Logic ==================== @@ -487,56 +418,6 @@ contract TimelockPolicy is PolicyBase, IStatelessValidator, IStatelessValidatorW return _handleProposalExecutionInternal(id, userOp, account); } - /** - * @notice Internal function to validate signature policy - * @dev Enforces timelock for ERC-1271 signatures - requires a valid proposal - */ - function _validateSignaturePolicy(bytes32 id, address account, bytes32 hash, bytes calldata sig) - internal - view - returns (bytes4) - { - TimelockConfig storage config = timelockConfig[id][account]; - if (!config.initialized) return ERC1271_INVALID; - - // Check if there's a valid signature proposal for this hash - Proposal storage proposal = signatureProposals[hash][id][account]; - - // Proposal must exist and be pending - if (proposal.status != ProposalStatus.Pending) { - return ERC1271_INVALID; - } - - // Check timing constraints - if (block.timestamp < proposal.validAfter) { - return ERC1271_INVALID; // Timelock not passed - } - - if (block.timestamp > proposal.validUntil) { - return ERC1271_INVALID; // Proposal expired - } - - return ERC1271_MAGICVALUE; - } - - /** - * @notice Mark a signature proposal as executed (called after successful signature validation) - * @dev This should be called by the account after ERC-1271 validation succeeds - * @param id The policy ID - * @param hash The hash of the signature - */ - function markSignatureProposalExecuted(bytes32 id, bytes32 hash) external { - Proposal storage proposal = signatureProposals[hash][id][msg.sender]; - - if (proposal.status != ProposalStatus.Pending) { - revert ProposalNotPending(); - } - - proposal.status = ProposalStatus.Executed; - - emit SignatureProposalExecuted(msg.sender, id, hash); - } - /** * @notice Get proposal details * @param account The account address @@ -568,22 +449,4 @@ contract TimelockPolicy is PolicyBase, IStatelessValidator, IStatelessValidatorW function computeUserOpKey(address account, bytes calldata callData, uint256 nonce) external pure returns (bytes32) { return keccak256(abi.encode(account, keccak256(callData), nonce)); } - - /** - * @notice Get signature proposal details - * @param hash The hash being signed - * @param id The policy ID - * @param wallet The wallet address - * @return status The proposal status - * @return validAfter When the proposal becomes valid - * @return validUntil When the proposal expires - */ - function getSignatureProposal(bytes32 hash, bytes32 id, address wallet) - external - view - returns (ProposalStatus status, uint256 validAfter, uint256 validUntil) - { - Proposal storage proposal = signatureProposals[hash][id][wallet]; - return (proposal.status, proposal.validAfter, proposal.validUntil); - } } diff --git a/test/TimelockPolicy.t.sol b/test/TimelockPolicy.t.sol index 142bd8e..ef98680 100644 --- a/test/TimelockPolicy.t.sol +++ b/test/TimelockPolicy.t.sol @@ -107,21 +107,33 @@ contract TimelockPolicyTest is PolicyTestBase, StatelessValidatorTestBase, State return statelessValidationSignature(bytes32(0), valid); } - // Override stateless validator tests to use proper data parameter + // Override stateless validator tests - TimelockPolicy reverts for stateless validation function testStatlessValidatorFail() external override { IStatelessValidator validatorModule = IStatelessValidator(address(module)); bytes32 message = keccak256(abi.encodePacked("TEST_MESSAGE")); (, bytes memory sig) = statelessValidationSignature(message, false); - // For TimelockPolicy, validation fails if delay or expirationPeriod is 0 - bytes memory invalidData = abi.encode(uint48(0), uint48(0)); + bytes memory data = abi.encode(uint48(0), uint48(0)); vm.startPrank(WALLET); - bool result = validatorModule.validateSignatureWithData(message, sig, invalidData); + vm.expectRevert("TimelockPolicy: stateless signature validation not supported"); + validatorModule.validateSignatureWithData(message, sig, data); vm.stopPrank(); + } + + function testStatelessValidatorSuccess() external override { + IStatelessValidator validatorModule = IStatelessValidator(address(module)); + + bytes32 message = keccak256(abi.encodePacked("TEST_MESSAGE")); + (, bytes memory sig) = statelessValidationSignature(message, true); + + bytes memory validData = abi.encode(delay, expirationPeriod); - assertFalse(result); + vm.startPrank(WALLET); + vm.expectRevert("TimelockPolicy: stateless signature validation not supported"); + validatorModule.validateSignatureWithData(message, sig, validData); + vm.stopPrank(); } function testStatelessValidatorWithSenderFail() external override { @@ -130,14 +142,26 @@ contract TimelockPolicyTest is PolicyTestBase, StatelessValidatorTestBase, State bytes32 message = keccak256(abi.encodePacked("TEST_MESSAGE")); (address caller, bytes memory sig) = statelessValidationSignatureWithSender(message, false); - // For TimelockPolicy, validation fails if delay or expirationPeriod is 0 - bytes memory invalidData = abi.encode(uint48(0), uint48(0)); + bytes memory data = abi.encode(uint48(0), uint48(0)); vm.startPrank(WALLET); - bool result = validatorModule.validateSignatureWithDataWithSender(caller, message, sig, invalidData); + vm.expectRevert("TimelockPolicy: stateless signature validation not supported"); + validatorModule.validateSignatureWithDataWithSender(caller, message, sig, data); vm.stopPrank(); + } + + function testStatelessValidatorWithSenderSuccess() external override { + IStatelessValidatorWithSender validatorModule = IStatelessValidatorWithSender(address(module)); + + bytes32 message = keccak256(abi.encodePacked("TEST_MESSAGE")); + (address caller, bytes memory sig) = statelessValidationSignatureWithSender(message, true); + + bytes memory validData = abi.encode(delay, expirationPeriod); - assertFalse(result); + vm.startPrank(WALLET); + vm.expectRevert("TimelockPolicy: stateless signature validation not supported"); + validatorModule.validateSignatureWithDataWithSender(caller, message, sig, validData); + vm.stopPrank(); } // Override the checkUserOpPolicy tests because TimelockPolicy has special behavior @@ -184,7 +208,7 @@ contract TimelockPolicyTest is PolicyTestBase, StatelessValidatorTestBase, State assertEq(validationResult, 1); } - // Override signature policy test because TimelockPolicy requires a valid proposal + // Override signature policy tests - TimelockPolicy reverts for signature validation function testPolicyCheckSignaturePolicySuccess() public payable override { TimelockPolicy policyModule = TimelockPolicy(address(module)); vm.startPrank(WALLET); @@ -194,72 +218,22 @@ contract TimelockPolicyTest is PolicyTestBase, StatelessValidatorTestBase, State bytes32 testHash = keccak256(abi.encodePacked("TEST_HASH")); (address sender, bytes memory sigData) = validSignatureData(testHash); - // Create a signature proposal first - policyModule.createSignatureProposal(policyId(), WALLET, testHash); - - // Fast forward past the delay - vm.warp(block.timestamp + delay + 1); - vm.startPrank(WALLET); - uint256 result = policyModule.checkSignaturePolicy(policyId(), sender, testHash, sigData); + vm.expectRevert("TimelockPolicy: signature validation not supported"); + policyModule.checkSignaturePolicy(policyId(), sender, testHash, sigData); vm.stopPrank(); - assertEq(result, 0); } function testPolicyCheckSignaturePolicyFail() public payable override { TimelockPolicy policyModule = TimelockPolicy(address(module)); - // Don't install for this wallet - address nonInstalledWallet = address(0xBEEF); - bytes32 testHash = keccak256(abi.encodePacked("TEST_HASH")); (address sender, bytes memory sigData) = invalidSignatureData(testHash); - vm.startPrank(nonInstalledWallet); - uint256 result = policyModule.checkSignaturePolicy(policyId(), sender, testHash, sigData); - vm.stopPrank(); - - // Should fail for non-installed account - assertFalse(result == 0); - } - - function testPolicyCheckSignaturePolicyFailNoProposal() public payable { - TimelockPolicy policyModule = TimelockPolicy(address(module)); - vm.startPrank(WALLET); - policyModule.onInstall(abi.encodePacked(policyId(), installData())); - vm.stopPrank(); - - bytes32 testHash = keccak256(abi.encodePacked("TEST_HASH")); - (address sender, bytes memory sigData) = validSignatureData(testHash); - - // Try to validate signature without creating a proposal first - vm.startPrank(WALLET); - uint256 result = policyModule.checkSignaturePolicy(policyId(), sender, testHash, sigData); - vm.stopPrank(); - - // Should fail because no proposal exists - assertFalse(result == 0); - } - - function testPolicyCheckSignaturePolicyFailTimelockNotPassed() public payable { - TimelockPolicy policyModule = TimelockPolicy(address(module)); - vm.startPrank(WALLET); - policyModule.onInstall(abi.encodePacked(policyId(), installData())); - vm.stopPrank(); - - bytes32 testHash = keccak256(abi.encodePacked("TEST_HASH")); - (address sender, bytes memory sigData) = validSignatureData(testHash); - - // Create a signature proposal - policyModule.createSignatureProposal(policyId(), WALLET, testHash); - - // Don't fast forward - timelock hasn't passed vm.startPrank(WALLET); - uint256 result = policyModule.checkSignaturePolicy(policyId(), sender, testHash, sigData); + vm.expectRevert("TimelockPolicy: signature validation not supported"); + policyModule.checkSignaturePolicy(policyId(), sender, testHash, sigData); vm.stopPrank(); - - // Should fail because timelock hasn't passed - assertFalse(result == 0); } // Additional TimelockPolicy-specific tests diff --git a/test/base/StatelessValidatorTestBase.sol b/test/base/StatelessValidatorTestBase.sol index 48d8239..08bf240 100644 --- a/test/base/StatelessValidatorTestBase.sol +++ b/test/base/StatelessValidatorTestBase.sol @@ -21,7 +21,7 @@ abstract contract StatelessValidatorTestBase is ModuleTestBase { assertTrue(result); } - function testStatelessValidatorSuccess() external { + function testStatelessValidatorSuccess() external virtual { IStatelessValidator validatorModule = IStatelessValidator(address(module)); bytes32 message = keccak256(abi.encodePacked("TEST_MESSAGE")); diff --git a/test/base/StatelessValidatorWithSenderTestBase.sol b/test/base/StatelessValidatorWithSenderTestBase.sol index 920b1b2..5a05801 100644 --- a/test/base/StatelessValidatorWithSenderTestBase.sol +++ b/test/base/StatelessValidatorWithSenderTestBase.sol @@ -21,7 +21,7 @@ abstract contract StatelessValidatorWithSenderTestBase is ModuleTestBase { assertTrue(result); } - function testStatelessValidatorWithSenderSuccess() external { + function testStatelessValidatorWithSenderSuccess() external virtual { IStatelessValidatorWithSender validatorModule = IStatelessValidatorWithSender(address(module)); bytes32 message = keccak256(abi.encodePacked("TEST_MESSAGE")); diff --git a/test/btt/TimelockSignaturePolicy.t.sol b/test/btt/TimelockSignaturePolicy.t.sol index 7ad460d..0df7676 100644 --- a/test/btt/TimelockSignaturePolicy.t.sol +++ b/test/btt/TimelockSignaturePolicy.t.sol @@ -3,19 +3,16 @@ pragma solidity ^0.8.20; import {Test} from "forge-std/Test.sol"; import {TimelockPolicy} from "src/policies/TimelockPolicy.sol"; -import {IModule} from "src/interfaces/IERC7579Modules.sol"; -import {ERC1271_MAGICVALUE, ERC1271_INVALID} from "src/types/Constants.sol"; /** * @title TimelockSignaturePolicyTest - * @notice BTT tests for ERC-1271 signature validation with timelock enforcement - * @dev Tests the fix that prevents bypassing timelock via ERC-1271 signatures + * @notice BTT tests for ERC-1271 signature validation with timelock + * @dev Tests that TimelockPolicy disables ERC-1271 signature validation (always reverts) */ contract TimelockSignaturePolicyTest is Test { TimelockPolicy public timelockPolicy; address constant WALLET = address(0x1234); - address constant OTHER_ACCOUNT = address(0x5678); uint48 constant DELAY = 1 days; uint48 constant EXPIRATION_PERIOD = 1 days; @@ -36,274 +33,56 @@ contract TimelockSignaturePolicyTest is Test { timelockPolicy.onInstall(abi.encodePacked(policyId, installData)); } - /// @notice Helper to create a signature proposal - function _createSignatureProposal(address wallet, bytes32 hash) internal { - timelockPolicy.createSignatureProposal(policyId, wallet, hash); - } - - // ============================================================ - // Test: when policy is not installed - // ============================================================ - - function test_WhenPolicyIsNotInstalled() external { - // it should return ERC1271_INVALID for signature validation - - // Do NOT install the policy for WALLET - - // Try to validate a signature without installation - vm.prank(WALLET); - uint256 result = timelockPolicy.checkSignaturePolicy(policyId, address(0), testHash, ""); - - // Should return 1 (failure) because policy is not initialized - assertEq(result, 1, "Should return validation failure when policy not installed"); - } - - // ============================================================ - // Test: when creating signature proposal without initialization - // ============================================================ - - function test_WhenCreatingSignatureProposalWithoutInitialization() external { - // it should revert with NotInitialized - - // Do NOT install the policy - - // Attempt to create a signature proposal should revert - vm.expectRevert(abi.encodeWithSelector(IModule.NotInitialized.selector, WALLET)); - timelockPolicy.createSignatureProposal(policyId, WALLET, testHash); - } - - // ============================================================ - // Test: when creating signature proposal that already exists - // ============================================================ - - function test_WhenCreatingSignatureProposalThatAlreadyExists() external { - // it should revert with ProposalAlreadyExists - - // Install policy - _installPolicy(WALLET); - - // Create first signature proposal - _createSignatureProposal(WALLET, testHash); - - // Attempt to create the same proposal again should revert - vm.expectRevert(TimelockPolicy.ProposalAlreadyExists.selector); - _createSignatureProposal(WALLET, testHash); - } - - // ============================================================ - // Test: when creating signature proposal successfully - // ============================================================ - - function test_WhenCreatingSignatureProposalSuccessfully() external { - // it should store the proposal with Pending status - // it should set validAfter to timestamp plus delay - // it should set validUntil to validAfter plus expiration - - // Install policy - _installPolicy(WALLET); - - uint256 currentTimestamp = block.timestamp; - - // Create signature proposal - _createSignatureProposal(WALLET, testHash); - - // Verify proposal details - (TimelockPolicy.ProposalStatus status, uint256 validAfter, uint256 validUntil) = - timelockPolicy.getSignatureProposal(testHash, policyId, WALLET); - - // Check status is Pending - assertEq( - uint256(status), - uint256(TimelockPolicy.ProposalStatus.Pending), - "Proposal status should be Pending" - ); - - // Check validAfter is timestamp + delay - assertEq( - validAfter, - currentTimestamp + DELAY, - "validAfter should be current timestamp plus delay" - ); - - // Check validUntil is validAfter + expiration - assertEq( - validUntil, - currentTimestamp + DELAY + EXPIRATION_PERIOD, - "validUntil should be validAfter plus expiration period" - ); - } - - // ============================================================ - // Test: when cancelling signature proposal as non-account - // ============================================================ - - function test_WhenCancellingSignatureProposalAsNon_account() external { - // it should revert with OnlyAccount - - // Install policy - _installPolicy(WALLET); - - // Create signature proposal - _createSignatureProposal(WALLET, testHash); - - // Attempt to cancel from a different account should revert - vm.prank(OTHER_ACCOUNT); - vm.expectRevert(TimelockPolicy.OnlyAccount.selector); - timelockPolicy.cancelSignatureProposal(policyId, WALLET, testHash); - } - - // ============================================================ - // Test: when cancelling signature proposal successfully - // ============================================================ - - function test_WhenCancellingSignatureProposalSuccessfully() external { - // it should set the proposal status to Cancelled - - // Install policy - _installPolicy(WALLET); - - // Create signature proposal - _createSignatureProposal(WALLET, testHash); - - // Verify it is pending first - (TimelockPolicy.ProposalStatus statusBefore,,) = - timelockPolicy.getSignatureProposal(testHash, policyId, WALLET); - assertEq( - uint256(statusBefore), - uint256(TimelockPolicy.ProposalStatus.Pending), - "Proposal should be Pending before cancellation" - ); - - // Cancel the proposal as the account owner - vm.prank(WALLET); - timelockPolicy.cancelSignatureProposal(policyId, WALLET, testHash); - - // Verify status is now Cancelled - (TimelockPolicy.ProposalStatus statusAfter,,) = - timelockPolicy.getSignatureProposal(testHash, policyId, WALLET); - assertEq( - uint256(statusAfter), - uint256(TimelockPolicy.ProposalStatus.Cancelled), - "Proposal status should be Cancelled after cancellation" - ); - } - - // ============================================================ - // Test: when checking signature without proposal - // ============================================================ - - function test_WhenCheckingSignatureWithoutProposal() external { - // it should return validation failure - - // Install policy - _installPolicy(WALLET); - - // Do NOT create a signature proposal - - // Try to validate signature - vm.prank(WALLET); - uint256 result = timelockPolicy.checkSignaturePolicy(policyId, address(0), testHash, ""); - - // Should return 1 (failure) because no proposal exists - assertEq(result, 1, "Should return validation failure when no proposal exists"); - } - // ============================================================ - // Test: when checking signature before timelock passes + // Test: checkSignaturePolicy always reverts // ============================================================ - function test_WhenCheckingSignatureBeforeTimelockPasses() external { - // it should return validation failure + function test_WhenCheckingSignaturePolicy() external { + // it should revert because signature validation is not supported // Install policy _installPolicy(WALLET); - // Create signature proposal - _createSignatureProposal(WALLET, testHash); - - // Do NOT warp time - we are still in the pending period - - // Try to validate signature immediately + // Try to validate a signature - should always revert vm.prank(WALLET); - uint256 result = timelockPolicy.checkSignaturePolicy(policyId, address(0), testHash, ""); - - // Should return 1 (failure) because timelock has not passed - assertEq(result, 1, "Should return validation failure before timelock passes"); + vm.expectRevert("TimelockPolicy: signature validation not supported"); + timelockPolicy.checkSignaturePolicy(policyId, address(0), testHash, ""); } - // ============================================================ - // Test: when checking signature after timelock passes - // ============================================================ - - function test_WhenCheckingSignatureAfterTimelockPasses() external { - // it should return validation success + function test_WhenCheckingSignaturePolicyWithoutInstall() external { + // it should revert because signature validation is not supported - // Install policy - _installPolicy(WALLET); - - // Create signature proposal - _createSignatureProposal(WALLET, testHash); - - // Warp time past the delay but before expiration - vm.warp(block.timestamp + DELAY + 1); + // Do NOT install the policy - // Validate signature + // Try to validate a signature - should always revert vm.prank(WALLET); - uint256 result = timelockPolicy.checkSignaturePolicy(policyId, address(0), testHash, ""); - - // Should return 0 (success) because timelock has passed and proposal is valid - assertEq(result, 0, "Should return validation success after timelock passes"); + vm.expectRevert("TimelockPolicy: signature validation not supported"); + timelockPolicy.checkSignaturePolicy(policyId, address(0), testHash, ""); } // ============================================================ - // Test: when checking signature after expiration + // Test: validateSignatureWithData always reverts // ============================================================ - function test_WhenCheckingSignatureAfterExpiration() external { - // it should return validation failure - - // Install policy - _installPolicy(WALLET); + function test_WhenValidatingSignatureWithData() external { + // it should revert because stateless signature validation is not supported - // Create signature proposal - _createSignatureProposal(WALLET, testHash); + bytes memory data = abi.encode(DELAY, EXPIRATION_PERIOD); - // Warp time past the expiration (delay + expiration + 1 second) - vm.warp(block.timestamp + DELAY + EXPIRATION_PERIOD + 1); - - // Try to validate signature after expiration - vm.prank(WALLET); - uint256 result = timelockPolicy.checkSignaturePolicy(policyId, address(0), testHash, ""); - - // Should return 1 (failure) because proposal has expired - assertEq(result, 1, "Should return validation failure after expiration"); + vm.expectRevert("TimelockPolicy: stateless signature validation not supported"); + timelockPolicy.validateSignatureWithData(testHash, "", data); } // ============================================================ - // Test: when checking signature for cancelled proposal + // Test: validateSignatureWithDataWithSender always reverts // ============================================================ - function test_WhenCheckingSignatureForCancelledProposal() external { - // it should return validation failure + function test_WhenValidatingSignatureWithDataWithSender() external { + // it should revert because stateless signature validation is not supported - // Install policy - _installPolicy(WALLET); - - // Create signature proposal - _createSignatureProposal(WALLET, testHash); - - // Cancel the proposal - vm.prank(WALLET); - timelockPolicy.cancelSignatureProposal(policyId, WALLET, testHash); - - // Warp time past the delay (would normally be valid) - vm.warp(block.timestamp + DELAY + 1); - - // Try to validate signature for cancelled proposal - vm.prank(WALLET); - uint256 result = timelockPolicy.checkSignaturePolicy(policyId, address(0), testHash, ""); + bytes memory data = abi.encode(DELAY, EXPIRATION_PERIOD); - // Should return 1 (failure) because proposal is cancelled - assertEq(result, 1, "Should return validation failure for cancelled proposal"); + vm.expectRevert("TimelockPolicy: stateless signature validation not supported"); + timelockPolicy.validateSignatureWithDataWithSender(WALLET, testHash, "", data); } } diff --git a/test/btt/TimelockSignaturePolicy.tree b/test/btt/TimelockSignaturePolicy.tree index ede0f9e..9a21572 100644 --- a/test/btt/TimelockSignaturePolicy.tree +++ b/test/btt/TimelockSignaturePolicy.tree @@ -1,25 +1,9 @@ TimelockSignaturePolicyTest -├── when policy is not installed -│ └── it should return ERC1271_INVALID for signature validation -├── when creating signature proposal without initialization -│ └── it should revert with NotInitialized -├── when creating signature proposal that already exists -│ └── it should revert with ProposalAlreadyExists -├── when creating signature proposal successfully -│ ├── it should store the proposal with Pending status -│ ├── it should set validAfter to timestamp plus delay -│ └── it should set validUntil to validAfter plus expiration -├── when cancelling signature proposal as non-account -│ └── it should revert with OnlyAccount -├── when cancelling signature proposal successfully -│ └── it should set the proposal status to Cancelled -├── when checking signature without proposal -│ └── it should return validation failure -├── when checking signature before timelock passes -│ └── it should return validation failure -├── when checking signature after timelock passes -│ └── it should return validation success -├── when checking signature after expiration -│ └── it should return validation failure -└── when checking signature for cancelled proposal - └── it should return validation failure +├── when checking signature policy +│ └── it should revert because signature validation is not supported +├── when checking signature policy without install +│ └── it should revert because signature validation is not supported +├── when validating signature with data +│ └── it should revert because stateless signature validation is not supported +└── when validating signature with data with sender + └── it should revert because stateless signature validation is not supported From 37da3aa34cb3d2b61766b71ec4fe7123cfb9356a Mon Sep 17 00:00:00 2001 From: taek Date: Fri, 6 Feb 2026 20:31:25 +0900 Subject: [PATCH 09/25] fix(TimelockPolicy): add upper bounds for delay and expirationPeriod --- src/policies/TimelockPolicy.sol | 5 ++++ test/btt/Timelock.t.sol | 48 ++++++++++++++++----------------- 2 files changed, 29 insertions(+), 24 deletions(-) diff --git a/src/policies/TimelockPolicy.sol b/src/policies/TimelockPolicy.sol index a951695..0d422f1 100644 --- a/src/policies/TimelockPolicy.sol +++ b/src/policies/TimelockPolicy.sol @@ -68,6 +68,7 @@ contract TimelockPolicy is PolicyBase, IStatelessValidator, IStatelessValidatorW error ProposalNotPending(); error OnlyAccount(); error ProposalFromPreviousEpoch(); + error ParametersTooLarge(); /** * @notice Install the timelock policy @@ -82,6 +83,10 @@ contract TimelockPolicy is PolicyBase, IStatelessValidator, IStatelessValidatorW if (delay == 0) revert InvalidDelay(); if (expirationPeriod == 0) revert InvalidExpirationPeriod(); + // Prevent uint48 overflow in createProposal: uint48(block.timestamp) + delay + expirationPeriod + if (uint256(delay) + uint256(expirationPeriod) > type(uint48).max - block.timestamp) { + revert ParametersTooLarge(); + } // Increment epoch to invalidate any proposals from previous installations currentEpoch[id][msg.sender]++; diff --git a/test/btt/Timelock.t.sol b/test/btt/Timelock.t.sol index 227d3f7..7036b1e 100644 --- a/test/btt/Timelock.t.sol +++ b/test/btt/Timelock.t.sol @@ -522,18 +522,18 @@ contract TimelockTest is Test { } function test_GivenInitialized_WhenCallingCheckSignaturePolicy() external whenCallingCheckSignaturePolicy { - // it should return zero + // it should revert (TOB-KERNEL-20: signature validation not supported) vm.prank(WALLET); - uint256 result = timelockPolicy.checkSignaturePolicy(POLICY_ID, address(0), bytes32(0), ""); - assertEq(result, 0, "Should return 0 when initialized"); + vm.expectRevert("TimelockPolicy: signature validation not supported"); + timelockPolicy.checkSignaturePolicy(POLICY_ID, address(0), bytes32(0), ""); } function test_GivenNotInitialized_WhenCallingCheckSignaturePolicy() external whenCallingCheckSignaturePolicy { - // it should return one + // it should revert (TOB-KERNEL-20: signature validation not supported) address uninitWallet = address(0xcccc); vm.prank(uninitWallet); - uint256 result = timelockPolicy.checkSignaturePolicy(POLICY_ID, address(0), bytes32(0), ""); - assertEq(result, 1, "Should return 1 when not initialized"); + vm.expectRevert("TimelockPolicy: signature validation not supported"); + timelockPolicy.checkSignaturePolicy(POLICY_ID, address(0), bytes32(0), ""); } // ============ validateSignatureWithData Tests ============ @@ -543,30 +543,30 @@ contract TimelockTest is Test { } function test_GivenDelayAndExpirationAreNonzero() external whenCallingValidateSignatureWithData { - // it should return true + // it should revert (TOB-KERNEL-20: stateless signature validation not supported) bytes memory data = abi.encode(uint48(1 hours), uint48(1 days)); - bool result = timelockPolicy.validateSignatureWithData(bytes32(0), "", data); - assertTrue(result, "Should return true for valid data"); + vm.expectRevert("TimelockPolicy: stateless signature validation not supported"); + timelockPolicy.validateSignatureWithData(bytes32(0), "", data); } function test_GivenDelayIsZero_WhenCallingValidateSignatureWithData() external whenCallingValidateSignatureWithData { - // it should return false + // it should revert (TOB-KERNEL-20: stateless signature validation not supported) bytes memory data = abi.encode(uint48(0), uint48(1 days)); - bool result = timelockPolicy.validateSignatureWithData(bytes32(0), "", data); - assertFalse(result, "Should return false for zero delay"); + vm.expectRevert("TimelockPolicy: stateless signature validation not supported"); + timelockPolicy.validateSignatureWithData(bytes32(0), "", data); } function test_GivenExpirationIsZero_WhenCallingValidateSignatureWithData() external whenCallingValidateSignatureWithData { - // it should return false + // it should revert (TOB-KERNEL-20: stateless signature validation not supported) bytes memory data = abi.encode(uint48(1 hours), uint48(0)); - bool result = timelockPolicy.validateSignatureWithData(bytes32(0), "", data); - assertFalse(result, "Should return false for zero expiration"); + vm.expectRevert("TimelockPolicy: stateless signature validation not supported"); + timelockPolicy.validateSignatureWithData(bytes32(0), "", data); } // ============ validateSignatureWithDataWithSender Tests ============ @@ -579,30 +579,30 @@ contract TimelockTest is Test { external whenCallingValidateSignatureWithDataWithSender { - // it should return true + // it should revert (TOB-KERNEL-20: stateless signature validation not supported) bytes memory data = abi.encode(uint48(1 hours), uint48(1 days)); - bool result = timelockPolicy.validateSignatureWithDataWithSender(address(0), bytes32(0), "", data); - assertTrue(result, "Should return true for valid data"); + vm.expectRevert("TimelockPolicy: stateless signature validation not supported"); + timelockPolicy.validateSignatureWithDataWithSender(address(0), bytes32(0), "", data); } function test_GivenDelayIsZero_WhenCallingValidateSignatureWithDataWithSender() external whenCallingValidateSignatureWithDataWithSender { - // it should return false + // it should revert (TOB-KERNEL-20: stateless signature validation not supported) bytes memory data = abi.encode(uint48(0), uint48(1 days)); - bool result = timelockPolicy.validateSignatureWithDataWithSender(address(0), bytes32(0), "", data); - assertFalse(result, "Should return false for zero delay"); + vm.expectRevert("TimelockPolicy: stateless signature validation not supported"); + timelockPolicy.validateSignatureWithDataWithSender(address(0), bytes32(0), "", data); } function test_GivenExpirationIsZero_WhenCallingValidateSignatureWithDataWithSender() external whenCallingValidateSignatureWithDataWithSender { - // it should return false + // it should revert (TOB-KERNEL-20: stateless signature validation not supported) bytes memory data = abi.encode(uint48(1 hours), uint48(0)); - bool result = timelockPolicy.validateSignatureWithDataWithSender(address(0), bytes32(0), "", data); - assertFalse(result, "Should return false for zero expiration"); + vm.expectRevert("TimelockPolicy: stateless signature validation not supported"); + timelockPolicy.validateSignatureWithDataWithSender(address(0), bytes32(0), "", data); } // ============ getProposal Tests ============ From ecb76309e34b4f7a396a71e3db8b4119c8d8be83 Mon Sep 17 00:00:00 2001 From: taek Date: Fri, 6 Feb 2026 20:36:27 +0900 Subject: [PATCH 10/25] fix(TimelockPolicy): correct ERC-7579 no-op detection encoding --- src/policies/TimelockPolicy.sol | 32 +++++--------- test/btt/Timelock.t.sol | 76 +++++++++++---------------------- 2 files changed, 35 insertions(+), 73 deletions(-) diff --git a/src/policies/TimelockPolicy.sol b/src/policies/TimelockPolicy.sol index a951695..764493b 100644 --- a/src/policies/TimelockPolicy.sol +++ b/src/policies/TimelockPolicy.sol @@ -299,20 +299,22 @@ contract TimelockPolicy is PolicyBase, IStatelessValidator, IStatelessValidatorW */ function _isNoOpERC7579Execute(bytes calldata callData) internal view returns (bool) { // execute(bytes32 mode, bytes calldata executionCalldata) - // Need: 4 (selector) + 32 (mode) + 32 (offset) + 32 (length) + data - if (callData.length < 68) return false; + // ABI layout: 4 (selector) + 32 (mode) + 32 (offset) + 32 (length) + data + if (callData.length < 100) return false; - // Decode the offset to executionCalldata (should be 32) + // Offset to executionCalldata: 2 head slots (mode + offset) = 64 uint256 offset = uint256(bytes32(callData[36:68])); - if (offset != 32) return false; + if (offset != 64) return false; // Decode the length of executionCalldata - if (callData.length < 100) return false; uint256 execDataLength = uint256(bytes32(callData[68:100])); - // For single execution mode, executionCalldata format is: - // target (20 bytes) + value (32 bytes) + calldata (variable) - if (execDataLength < 52) return false; + // ERC-7579 single execution uses compact format (no length prefix): + // executionCalldata = abi.encodePacked(target, value, calldata) + // target (20 bytes) + value (32 bytes) = 52 bytes with no inner calldata + if (execDataLength != 52) return false; + + if (callData.length < 152) return false; // Extract target address (first 20 bytes of executionCalldata) address target = address(bytes20(callData[100:120])); @@ -324,19 +326,7 @@ contract TimelockPolicy is PolicyBase, IStatelessValidator, IStatelessValidatorW uint256 value = uint256(bytes32(callData[120:152])); // Value must be 0 - if (value != 0) return false; - - // Check calldata length (remaining bytes should indicate empty calldata) - // executionCalldata = target(20) + value(32) + calldataLength(32) + calldata - if (callData.length < 184) { - // If we don't have enough for calldata length field, it's malformed - return false; - } - - uint256 innerCalldataLength = uint256(bytes32(callData[152:184])); - - // Inner calldata must be empty - return innerCalldataLength == 0; + return value == 0; } /** diff --git a/test/btt/Timelock.t.sol b/test/btt/Timelock.t.sol index 227d3f7..2b63848 100644 --- a/test/btt/Timelock.t.sol +++ b/test/btt/Timelock.t.sol @@ -701,15 +701,11 @@ contract TimelockTest is Test { function test_GivenTargetIsSelfAndValueIsZeroAndInnerCalldataIsEmpty() external whenDetectingERC7579ExecuteNoop { // it should be detected as noop - bytes memory callData = abi.encodePacked( - IERC7579Execution.execute.selector, - bytes32(0), // mode - bytes32(uint256(32)), // offset - bytes32(uint256(84)), // execDataLength (20+32+32) - bytes20(WALLET), // target = self - bytes32(uint256(0)), // value = 0 - bytes32(uint256(0)) // innerCalldataLength = 0 - ); + // ERC-7579 compact format: abi.encodePacked(target, value) = 52 bytes + bytes memory executionCalldata = abi.encodePacked(bytes20(WALLET), uint256(0)); + + bytes memory callData = + abi.encodeWithSelector(IERC7579Execution.execute.selector, bytes32(0), executionCalldata); bytes memory sig = _createProposalSignature("proposal", 0); PackedUserOperation memory userOp = _createUserOpWithCalldata(WALLET, callData, 0, sig); @@ -725,15 +721,11 @@ contract TimelockTest is Test { whenDetectingERC7579ExecuteNoop { // it should be detected as noop - bytes memory callData = abi.encodePacked( - IERC7579Execution.execute.selector, - bytes32(0), // mode - bytes32(uint256(32)), // offset - bytes32(uint256(84)), // execDataLength - bytes20(address(0)), // target = address(0) - bytes32(uint256(0)), // value = 0 - bytes32(uint256(0)) // innerCalldataLength = 0 - ); + // ERC-7579 compact format: abi.encodePacked(target, value) = 52 bytes + bytes memory executionCalldata = abi.encodePacked(bytes20(address(0)), uint256(0)); + + bytes memory callData = + abi.encodeWithSelector(IERC7579Execution.execute.selector, bytes32(0), executionCalldata); bytes memory sig = _createProposalSignature("proposal", 1); PackedUserOperation memory userOp = _createUserOpWithCalldata(WALLET, callData, 0, sig); @@ -756,15 +748,14 @@ contract TimelockTest is Test { assertEq(result, SIG_VALIDATION_FAILED, "Too short for offset should not be noop"); } - function test_GivenOffsetIsNot32() external whenDetectingERC7579ExecuteNoop { + function test_GivenOffsetIsNot64() external whenDetectingERC7579ExecuteNoop { // it should not be detected as noop bytes memory callData = abi.encodePacked( IERC7579Execution.execute.selector, bytes32(0), // mode - bytes32(uint256(64)), // wrong offset (should be 32) + bytes32(uint256(32)), // wrong offset (should be 64) bytes32(uint256(52)), // length - WALLET, - uint256(0), + bytes20(WALLET), uint256(0) ); @@ -781,7 +772,7 @@ contract TimelockTest is Test { bytes memory callData = abi.encodePacked( IERC7579Execution.execute.selector, bytes32(0), // mode - bytes32(uint256(32)) // offset + bytes32(uint256(64)) // offset // missing length and data ); @@ -793,13 +784,13 @@ contract TimelockTest is Test { assertEq(result, SIG_VALIDATION_FAILED, "Too short for length should not be noop"); } - function test_GivenExecDataLengthIsLessThan52() external whenDetectingERC7579ExecuteNoop { - // it should not be detected as noop + function test_GivenExecDataLengthIsNot52() external whenDetectingERC7579ExecuteNoop { + // it should not be detected as noop (length 20 != 52) bytes memory callData = abi.encodePacked( IERC7579Execution.execute.selector, bytes32(0), // mode - bytes32(uint256(32)), // offset - bytes32(uint256(20)) // length only 20 (need at least 52) + bytes32(uint256(64)), // offset + bytes32(uint256(20)) // length only 20 (must be exactly 52) ); PackedUserOperation memory userOp = _createUserOpWithCalldata(WALLET, callData, 0, ""); @@ -807,12 +798,12 @@ contract TimelockTest is Test { vm.prank(WALLET); uint256 result = timelockPolicy.checkUserOpPolicy(POLICY_ID, userOp); - assertEq(result, SIG_VALIDATION_FAILED, "Exec data too short should not be noop"); + assertEq(result, SIG_VALIDATION_FAILED, "Exec data length != 52 should not be noop"); } function test_GivenTargetIsNotSelfOrZero() external whenDetectingERC7579ExecuteNoop { // it should not be detected as noop - bytes memory executionCalldata = abi.encodePacked(ATTACKER, uint256(0), uint256(0)); + bytes memory executionCalldata = abi.encodePacked(bytes20(ATTACKER), uint256(0)); bytes memory callData = abi.encodeWithSelector(IERC7579Execution.execute.selector, bytes32(0), executionCalldata); @@ -827,7 +818,7 @@ contract TimelockTest is Test { function test_GivenValueIsNonzero() external whenDetectingERC7579ExecuteNoop { // it should not be detected as noop - bytes memory executionCalldata = abi.encodePacked(WALLET, uint256(1 ether), uint256(0)); + bytes memory executionCalldata = abi.encodePacked(bytes20(WALLET), uint256(1 ether)); bytes memory callData = abi.encodeWithSelector(IERC7579Execution.execute.selector, bytes32(0), executionCalldata); @@ -840,28 +831,9 @@ contract TimelockTest is Test { assertEq(result, SIG_VALIDATION_FAILED, "Non-zero value should not be noop"); } - function test_GivenCalldataIsShorterThan184Bytes() external whenDetectingERC7579ExecuteNoop { - // it should not be detected as noop - bytes memory callData = abi.encodePacked( - IERC7579Execution.execute.selector, - bytes32(0), // mode - bytes32(uint256(32)), // offset - bytes32(uint256(52)), // execDataLength (exactly 52, no room for inner calldata length) - WALLET, // 20 bytes - uint256(0) // 32 bytes = 52 total - ); - - PackedUserOperation memory userOp = _createUserOpWithCalldata(WALLET, callData, 0, ""); - - vm.prank(WALLET); - uint256 result = timelockPolicy.checkUserOpPolicy(POLICY_ID, userOp); - - assertEq(result, SIG_VALIDATION_FAILED, "Too short for inner calldata length should not be noop"); - } - - function test_GivenInnerCalldataLengthIsNonzero() external whenDetectingERC7579ExecuteNoop { - // it should not be detected as noop - bytes memory executionCalldata = abi.encodePacked(WALLET, uint256(0), uint256(10)); + function test_GivenExecDataLengthGreaterThan52() external whenDetectingERC7579ExecuteNoop { + // it should not be detected as noop (has inner calldata) + bytes memory executionCalldata = abi.encodePacked(bytes20(WALLET), uint256(0), hex"deadbeef"); bytes memory callData = abi.encodeWithSelector(IERC7579Execution.execute.selector, bytes32(0), executionCalldata); From df553e7f0fdba4d2d0cd000e38650968518c1cdf Mon Sep 17 00:00:00 2001 From: taek Date: Mon, 2 Feb 2026 19:24:00 +0900 Subject: [PATCH 11/25] fix(TimelockPolicy): add grace period to prevent race conditions --- src/policies/TimelockPolicy.sol | 49 +++++++++++++------- test/TimelockPolicy.t.sol | 82 +++++++++++++++++++++++++++++---- 2 files changed, 105 insertions(+), 26 deletions(-) diff --git a/src/policies/TimelockPolicy.sol b/src/policies/TimelockPolicy.sol index f1340ca..1e3bfef 100644 --- a/src/policies/TimelockPolicy.sol +++ b/src/policies/TimelockPolicy.sol @@ -29,12 +29,14 @@ 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 bool initialized; } struct Proposal { ProposalStatus status; - uint48 validAfter; // Timestamp when proposal becomes executable + uint48 validAfter; // Timestamp when timelock passes (grace period starts) + uint48 graceEnd; // Timestamp when grace period ends (public execution allowed) uint48 validUntil; // Timestamp when proposal expires uint256 epoch; // Epoch when proposal was created } @@ -57,10 +59,13 @@ 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); + event TimelockConfigUpdated( + address indexed wallet, bytes32 indexed id, uint256 delay, uint256 expirationPeriod, uint256 gracePeriod + ); error InvalidDelay(); error InvalidExpirationPeriod(); + error InvalidGracePeriod(); error ProposalNotFound(); error ProposalAlreadyExists(); error TimelockNotExpired(uint256 validAfter, uint256 currentTime); @@ -72,10 +77,10 @@ contract TimelockPolicy is PolicyBase, IStatelessValidator, IStatelessValidatorW /** * @notice Install the timelock policy - * @param _data Encoded: (uint48 delay, uint48 expirationPeriod) + * @param _data Encoded: (uint48 delay, uint48 expirationPeriod, uint48 gracePeriod) */ function _policyOninstall(bytes32 id, bytes calldata _data) internal override { - (uint48 delay, uint48 expirationPeriod) = abi.decode(_data, (uint48, uint48)); + (uint48 delay, uint48 expirationPeriod, uint48 gracePeriod) = abi.decode(_data, (uint48, uint48, uint48)); if (timelockConfig[id][msg.sender].initialized) { revert IModule.AlreadyInitialized(msg.sender); @@ -83,8 +88,9 @@ contract TimelockPolicy is PolicyBase, IStatelessValidator, IStatelessValidatorW if (delay == 0) revert InvalidDelay(); if (expirationPeriod == 0) revert InvalidExpirationPeriod(); - // Prevent uint48 overflow in createProposal: uint48(block.timestamp) + delay + expirationPeriod - if (uint256(delay) + uint256(expirationPeriod) > type(uint48).max - block.timestamp) { + if (gracePeriod == 0) revert InvalidGracePeriod(); + // Prevent uint48 overflow in createProposal: uint48(block.timestamp) + delay + gracePeriod + expirationPeriod + if (uint256(delay) + uint256(gracePeriod) + uint256(expirationPeriod) > type(uint48).max - block.timestamp) { revert ParametersTooLarge(); } @@ -92,9 +98,9 @@ contract TimelockPolicy is PolicyBase, IStatelessValidator, IStatelessValidatorW currentEpoch[id][msg.sender]++; timelockConfig[id][msg.sender] = - TimelockConfig({delay: delay, expirationPeriod: expirationPeriod, initialized: true}); + TimelockConfig({delay: delay, expirationPeriod: expirationPeriod, gracePeriod: gracePeriod, initialized: true}); - emit TimelockConfigUpdated(msg.sender, id, delay, expirationPeriod); + emit TimelockConfigUpdated(msg.sender, id, delay, expirationPeriod, gracePeriod); } /** @@ -130,8 +136,12 @@ contract TimelockPolicy is PolicyBase, IStatelessValidator, IStatelessValidatorW if (!config.initialized) revert IModule.NotInitialized(account); // Calculate proposal timing + // validAfter: when timelock passes (grace period starts) + // graceEnd: when grace period ends (public execution allowed) + // validUntil: when proposal expires uint48 validAfter = uint48(block.timestamp) + config.delay; - uint48 validUntil = validAfter + config.expirationPeriod; + uint48 graceEnd = validAfter + config.gracePeriod; + uint48 validUntil = graceEnd + config.expirationPeriod; // Create userOp key for storage lookup bytes32 userOpKey = keccak256(abi.encode(account, keccak256(callData), nonce)); @@ -143,7 +153,7 @@ contract TimelockPolicy is PolicyBase, IStatelessValidator, IStatelessValidatorW // Create proposal (stored by userOpKey) with current epoch proposals[userOpKey][id][account] = - Proposal({status: ProposalStatus.Pending, validAfter: validAfter, validUntil: validUntil, epoch: currentEpoch[id][account]}); + Proposal({status: ProposalStatus.Pending, validAfter: validAfter, graceEnd: graceEnd, validUntil: validUntil, epoch: currentEpoch[id][account]}); emit ProposalCreated(account, id, userOpKey, validAfter, validUntil); } @@ -219,7 +229,8 @@ contract TimelockPolicy is PolicyBase, IStatelessValidator, IStatelessValidatorW // Calculate proposal timing uint48 validAfter = uint48(block.timestamp) + config.delay; - uint48 validUntil = validAfter + config.expirationPeriod; + uint48 graceEnd = validAfter + config.gracePeriod; + uint48 validUntil = graceEnd + 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)); @@ -231,7 +242,7 @@ contract TimelockPolicy is PolicyBase, IStatelessValidator, IStatelessValidatorW // Create proposal with current epoch proposals[userOpKey][id][account] = - Proposal({status: ProposalStatus.Pending, validAfter: validAfter, validUntil: validUntil, epoch: currentEpoch[id][account]}); + Proposal({status: ProposalStatus.Pending, validAfter: validAfter, graceEnd: graceEnd, validUntil: validUntil, epoch: currentEpoch[id][account]}); emit ProposalCreated(account, id, userOpKey, validAfter, validUntil); @@ -242,6 +253,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. */ function _handleProposalExecutionInternal(bytes32 id, PackedUserOperation calldata userOp, address account) internal @@ -263,8 +276,9 @@ contract TimelockPolicy is PolicyBase, IStatelessValidator, IStatelessValidatorW emit ProposalExecuted(account, id, userOpKey); - // Return validAfter and validUntil for EntryPoint to validate timing - return _packValidationData(proposal.validAfter, proposal.validUntil); + // 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); } /** @@ -433,17 +447,18 @@ 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 proposal becomes valid + * @return validAfter When the timelock passes (grace period starts) + * @return graceEnd When the grace period ends (public execution allowed) * @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 validUntil) + returns (ProposalStatus status, uint256 validAfter, uint256 graceEnd, uint256 validUntil) { bytes32 userOpKey = keccak256(abi.encode(account, keccak256(callData), nonce)); Proposal storage proposal = proposals[userOpKey][id][wallet]; - return (proposal.status, proposal.validAfter, proposal.validUntil); + return (proposal.status, proposal.validAfter, proposal.graceEnd, proposal.validUntil); } /** diff --git a/test/TimelockPolicy.t.sol b/test/TimelockPolicy.t.sol index 2df90c2..a12ae09 100644 --- a/test/TimelockPolicy.t.sol +++ b/test/TimelockPolicy.t.sol @@ -11,6 +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; function deployModule() internal virtual override returns (IModule) { return new TimelockPolicy(); @@ -19,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); + return abi.encode(delay, expirationPeriod, gracePeriod); } function validUserOp() internal view virtual override returns (PackedUserOperation memory) { @@ -114,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)); + bytes memory data = abi.encode(uint48(0), uint48(0), uint48(0)); vm.startPrank(WALLET); vm.expectRevert("TimelockPolicy: stateless signature validation not supported"); @@ -142,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)); + bytes memory data = abi.encode(uint48(0), uint48(0), uint48(0)); vm.startPrank(WALLET); vm.expectRevert("TimelockPolicy: stateless signature validation not supported"); @@ -178,8 +179,8 @@ contract TimelockPolicyTest is PolicyTestBase, StatelessValidatorTestBase, State policyModule.createProposal(policyId(), WALLET, userOp.callData, userOp.nonce); vm.stopPrank(); - // Fast forward past the delay - vm.warp(block.timestamp + delay + 1); + // Fast forward past the delay AND grace period + vm.warp(block.timestamp + delay + gracePeriod + 1); // Now execute the proposal vm.startPrank(WALLET); @@ -252,12 +253,13 @@ contract TimelockPolicyTest is PolicyTestBase, StatelessValidatorTestBase, State vm.stopPrank(); // Verify proposal was created - (TimelockPolicy.ProposalStatus status, uint256 validAfter, uint256 validUntil) = + (TimelockPolicy.ProposalStatus status, uint256 validAfter, uint256 graceEnd, uint256 validUntil) = policyModule.getProposal(WALLET, callData, nonce, policyId(), WALLET); assertEq(uint256(status), uint256(TimelockPolicy.ProposalStatus.Pending)); assertEq(validAfter, block.timestamp + delay); - assertEq(validUntil, block.timestamp + delay + expirationPeriod); + assertEq(graceEnd, block.timestamp + delay + gracePeriod); + assertEq(validUntil, block.timestamp + delay + gracePeriod + expirationPeriod); } function testCancelProposal() public { @@ -280,7 +282,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)); } @@ -323,7 +325,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)); @@ -364,4 +366,66 @@ contract TimelockPolicyTest is PolicyTestBase, StatelessValidatorTestBase, State // Should fail (return 1 = SIG_VALIDATION_FAILED_UINT) because proposal is from previous epoch assertEq(validationResult, 1); } + + // Test that execution cannot happen during grace period (race condition prevention) + function testExecutionBlockedDuringGracePeriod() public { + TimelockPolicy policyModule = TimelockPolicy(address(module)); + vm.startPrank(WALLET); + policyModule.onInstall(abi.encodePacked(policyId(), installData())); + vm.stopPrank(); + + PackedUserOperation memory userOp = validUserOp(); + + // Create a proposal + vm.startPrank(WALLET); + policyModule.createProposal(policyId(), WALLET, userOp.callData, userOp.nonce); + vm.stopPrank(); + + // Fast forward past delay but NOT past grace period + vm.warp(block.timestamp + delay + 1); + + // Try to 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 + 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)); + } + + // Test that owner can still cancel during grace period + function testCancelDuringGracePeriod() public { + TimelockPolicy policyModule = TimelockPolicy(address(module)); + vm.startPrank(WALLET); + policyModule.onInstall(abi.encodePacked(policyId(), installData())); + vm.stopPrank(); + + bytes memory callData = hex"1234"; + uint256 nonce = 1; + + // Create proposal + vm.startPrank(WALLET); + policyModule.createProposal(policyId(), WALLET, callData, nonce); + vm.stopPrank(); + + // Fast forward past delay but still in grace period + vm.warp(block.timestamp + delay + 1); + + // Cancel proposal (should still work during grace 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); + assertEq(uint256(status), uint256(TimelockPolicy.ProposalStatus.Cancelled)); + } } From 7709731bc3ba2851ad7f9b9a1176eb3f9cb1fbc8 Mon Sep 17 00:00:00 2001 From: taek Date: Mon, 2 Feb 2026 23:42:08 +0900 Subject: [PATCH 12/25] test: add BTT tests for TimelockCancellationRace --- test/btt/Timelock.t.sol | 61 +-- test/btt/TimelockCancellationRace.t.sol | 553 ++++++++++++++++++++++++ test/btt/TimelockCancellationRace.tree | 51 +++ test/btt/TimelockEpochValidation.t.sol | 23 +- test/btt/TimelockSignaturePolicy.t.sol | 3 +- 5 files changed, 652 insertions(+), 39 deletions(-) create mode 100644 test/btt/TimelockCancellationRace.t.sol create mode 100644 test/btt/TimelockCancellationRace.tree diff --git a/test/btt/Timelock.t.sol b/test/btt/Timelock.t.sol index 04ec804..b44b850 100644 --- a/test/btt/Timelock.t.sol +++ b/test/btt/Timelock.t.sol @@ -26,6 +26,7 @@ contract TimelockTest is Test { uint48 public constant DELAY = 1 hours; uint48 public constant EXPIRATION = 1 days; + uint48 public constant GRACE_PERIOD = 30 minutes; uint256 public constant SIG_VALIDATION_FAILED = 1; @@ -33,7 +34,7 @@ contract TimelockTest is Test { timelockPolicy = new TimelockPolicy(); // Install policy for WALLET - bytes memory installData = abi.encode(POLICY_ID, DELAY, EXPIRATION); + bytes memory installData = abi.encode(POLICY_ID, DELAY, EXPIRATION, GRACE_PERIOD); vm.prank(WALLET); timelockPolicy.onInstall(installData); } @@ -102,13 +103,13 @@ contract TimelockTest is Test { bytes32 newId = bytes32(uint256(2)); vm.expectEmit(true, true, true, true); - emit TimelockPolicy.TimelockConfigUpdated(newWallet, newId, 2 hours, 2 days); + emit TimelockPolicy.TimelockConfigUpdated(newWallet, newId, 2 hours, 2 days, 30 minutes); - bytes memory installData = abi.encode(newId, uint48(2 hours), uint48(2 days)); + bytes memory installData = abi.encode(newId, uint48(2 hours), uint48(2 days), uint48(30 minutes)); vm.prank(newWallet); timelockPolicy.onInstall(installData); - (uint48 delay, uint48 expiration, bool initialized) = timelockPolicy.timelockConfig(newId, newWallet); + (uint48 delay, uint48 expiration, uint48 gracePeriod_, bool initialized) = timelockPolicy.timelockConfig(newId, newWallet); assertEq(delay, 2 hours, "Delay should be stored"); assertEq(expiration, 2 days, "Expiration should be stored"); assertTrue(initialized, "Should be initialized"); @@ -116,7 +117,7 @@ contract TimelockTest is Test { function test_GivenAlreadyInitialized() external whenCallingOnInstall { // it should revert with AlreadyInitialized - bytes memory installData = abi.encode(POLICY_ID, DELAY, EXPIRATION); + bytes memory installData = abi.encode(POLICY_ID, DELAY, EXPIRATION, GRACE_PERIOD); vm.prank(WALLET); vm.expectRevert(abi.encodeWithSelector(IModule.AlreadyInitialized.selector, WALLET)); timelockPolicy.onInstall(installData); @@ -125,7 +126,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); + bytes memory installData = abi.encode(POLICY_ID, uint48(0), EXPIRATION, GRACE_PERIOD); vm.prank(newWallet); vm.expectRevert(TimelockPolicy.InvalidDelay.selector); timelockPolicy.onInstall(installData); @@ -134,7 +135,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)); + bytes memory installData = abi.encode(POLICY_ID, DELAY, uint48(0), GRACE_PERIOD); vm.prank(newWallet); vm.expectRevert(TimelockPolicy.InvalidExpirationPeriod.selector); timelockPolicy.onInstall(installData); @@ -151,7 +152,7 @@ contract TimelockTest is Test { vm.prank(WALLET); timelockPolicy.onUninstall(abi.encode(POLICY_ID)); - (,, bool initialized) = timelockPolicy.timelockConfig(POLICY_ID, WALLET); + (,,, bool initialized) = timelockPolicy.timelockConfig(POLICY_ID, WALLET); assertFalse(initialized, "Config should be cleared"); } @@ -210,17 +211,18 @@ contract TimelockTest is Test { vm.expectEmit(true, true, true, true); emit TimelockPolicy.ProposalCreated( - WALLET, POLICY_ID, expectedKey, uint48(createTime) + DELAY, uint48(createTime) + DELAY + EXPIRATION + WALLET, POLICY_ID, expectedKey, uint48(createTime) + DELAY, uint48(createTime) + DELAY + GRACE_PERIOD + EXPIRATION ); timelockPolicy.createProposal(POLICY_ID, WALLET, callData, nonce); - (TimelockPolicy.ProposalStatus status, uint256 validAfter, uint256 validUntil) = + (TimelockPolicy.ProposalStatus status, uint256 validAfter, uint256 graceEnd, uint256 validUntil) = timelockPolicy.getProposal(WALLET, callData, nonce, POLICY_ID, WALLET); assertEq(uint256(status), uint256(TimelockPolicy.ProposalStatus.Pending), "Status should be Pending"); assertEq(validAfter, createTime + DELAY, "validAfter should be timestamp + delay"); - assertEq(validUntil, createTime + DELAY + EXPIRATION, "validUntil should be validAfter + expiration"); + assertEq(graceEnd, createTime + DELAY + GRACE_PERIOD, "graceEnd should be validAfter + gracePeriod"); + assertEq(validUntil, createTime + DELAY + GRACE_PERIOD + EXPIRATION, "validUntil should be graceEnd + expiration"); } function test_GivenNotInitialized_WhenCallingCreateProposal() external whenCallingCreateProposal { @@ -265,7 +267,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"); } @@ -341,7 +343,7 @@ contract TimelockTest is Test { POLICY_ID, expectedKey, uint48(block.timestamp) + DELAY, - uint48(block.timestamp) + DELAY + EXPIRATION + uint48(block.timestamp) + DELAY + GRACE_PERIOD + EXPIRATION ); vm.prank(WALLET); @@ -350,7 +352,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 created"); } @@ -424,7 +426,7 @@ contract TimelockTest is Test { timelockPolicy.createProposal(POLICY_ID, WALLET, proposalCallData, proposalNonce); // Get the actual stored proposal values - (, uint256 storedValidAfter, uint256 storedValidUntil) = + (, uint256 storedValidAfter, uint256 storedGraceEnd, uint256 storedValidUntil) = timelockPolicy.getProposal(WALLET, proposalCallData, proposalNonce, POLICY_ID, WALLET); vm.warp(block.timestamp + DELAY + 1); @@ -440,12 +442,13 @@ 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, storedValidAfter, "validAfter should match proposal"); + assertEq(validAfter, storedGraceEnd, "validAfter in packed data should match graceEnd"); 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"); } @@ -612,30 +615,32 @@ contract TimelockTest is Test { } function test_GivenProposalExists() external whenCallingGetProposal { - // it should return status validAfter and validUntil + // it should return status validAfter graceEnd and validUntil bytes memory callData = abi.encodeWithSelector(IERC7579Execution.execute.selector, bytes32(0), "test"); uint256 nonce = 1200; uint256 createTime = block.timestamp; timelockPolicy.createProposal(POLICY_ID, WALLET, callData, nonce); - (TimelockPolicy.ProposalStatus status, uint256 validAfter, uint256 validUntil) = + (TimelockPolicy.ProposalStatus status, uint256 validAfter, uint256 graceEnd, uint256 validUntil) = timelockPolicy.getProposal(WALLET, callData, nonce, POLICY_ID, WALLET); assertEq(uint256(status), uint256(TimelockPolicy.ProposalStatus.Pending), "Status should be Pending"); assertEq(validAfter, createTime + DELAY, "validAfter should be correct"); - assertEq(validUntil, createTime + DELAY + EXPIRATION, "validUntil should be correct"); + assertEq(graceEnd, createTime + DELAY + GRACE_PERIOD, "graceEnd should be correct"); + assertEq(validUntil, createTime + DELAY + GRACE_PERIOD + 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 validUntil) = + (TimelockPolicy.ProposalStatus status, uint256 validAfter, uint256 graceEnd, 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"); } @@ -930,7 +935,7 @@ contract TimelockTest is Test { timelockPolicy.createProposal(POLICY_ID, WALLET, callData, nonce); // Get the actual stored proposal values - (, uint256 storedValidAfter, uint256 storedValidUntil) = + (, uint256 storedValidAfter, uint256 storedGraceEnd, uint256 storedValidUntil) = timelockPolicy.getProposal(WALLET, callData, nonce, POLICY_ID, WALLET); vm.warp(block.timestamp + DELAY + 1); @@ -940,7 +945,7 @@ contract TimelockTest is Test { vm.prank(WALLET); uint256 result = timelockPolicy.checkUserOpPolicy(POLICY_ID, userOp); - uint256 expectedPacked = _packValidationData(uint48(storedValidAfter), uint48(storedValidUntil)); + uint256 expectedPacked = _packValidationData(uint48(storedGraceEnd), uint48(storedValidUntil)); assertEq(result, expectedPacked, "Packed validation data should match expected"); } @@ -978,10 +983,10 @@ contract TimelockTest is Test { vm.prank(WALLET); uint256 result = timelockPolicy.checkUserOpPolicy(POLICY_ID, executeOp); - // Extract validAfter - it should be in the future + // Extract validAfter from packed data - it's actually graceEnd, should be in the future uint48 validAfter = uint48(result >> 208); assertGt(validAfter, block.timestamp, "validAfter should be in the future"); - assertEq(validAfter, uint48(createTime) + DELAY, "validAfter should be createTime + DELAY"); + assertEq(validAfter, uint48(createTime) + DELAY + GRACE_PERIOD, "validAfter should be createTime + DELAY + GRACE_PERIOD (graceEnd)"); } function test_GivenAttackerTriesToReexecuteAUsedProposal() external whenTestingSecurityScenarios { @@ -1001,7 +1006,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"); @@ -1026,7 +1031,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 validUntil) = + (TimelockPolicy.ProposalStatus status, uint256 validAfter, uint256 graceEnd, uint256 validUntil) = timelockPolicy.getProposal(WALLET, proposalCallData, proposalNonce, POLICY_ID, WALLET); assertEq(uint256(status), uint256(TimelockPolicy.ProposalStatus.Pending), "Proposal state should persist"); @@ -1046,7 +1051,7 @@ 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"); } diff --git a/test/btt/TimelockCancellationRace.t.sol b/test/btt/TimelockCancellationRace.t.sol new file mode 100644 index 0000000..5c305a0 --- /dev/null +++ b/test/btt/TimelockCancellationRace.t.sol @@ -0,0 +1,553 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.20; + +import {Test} from "forge-std/Test.sol"; +import {TimelockPolicy} from "src/policies/TimelockPolicy.sol"; +import {PackedUserOperation} from "account-abstraction/interfaces/PackedUserOperation.sol"; +import {IModule} from "src/interfaces/IERC7579Modules.sol"; + +/** + * @title TimelockCancellationRaceTest + * @notice BTT tests for the TimelockPolicy cancellation and grace period fix (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 + */ +contract TimelockCancellationRaceTest is Test { + TimelockPolicy public timelockPolicy; + + address constant WALLET = address(0x1234); + address constant ATTACKER = address(0xBAD); + + uint48 constant DELAY = 1 days; + uint48 constant EXPIRATION_PERIOD = 1 days; + uint48 constant GRACE_PERIOD = 1 hours; + + bytes32 public policyId; + + // Test calldata and nonce for proposals + bytes constant TEST_CALLDATA = hex"1234abcd"; + uint256 constant TEST_NONCE = 1; + + function setUp() public { + timelockPolicy = new TimelockPolicy(); + policyId = keccak256(abi.encodePacked("POLICY_ID_1")); + + // Install policy for WALLET + vm.startPrank(WALLET); + timelockPolicy.onInstall(abi.encodePacked(policyId, abi.encode(DELAY, EXPIRATION_PERIOD, GRACE_PERIOD))); + vm.stopPrank(); + } + + // Helper function to create a proposal + function _createProposal(bytes memory callData, uint256 nonce) internal { + vm.prank(WALLET); + timelockPolicy.createProposal(policyId, WALLET, callData, nonce); + } + + // Helper function to cancel a proposal + function _cancelProposal(bytes memory callData, uint256 nonce) internal { + vm.prank(WALLET); + timelockPolicy.cancelProposal(policyId, WALLET, callData, nonce); + } + + // Helper function to create a userOp for execution + function _createUserOp(bytes memory callData, uint256 nonce) internal pure returns (PackedUserOperation memory) { + return PackedUserOperation({ + sender: WALLET, + nonce: nonce, + initCode: "", + callData: callData, + accountGasLimits: bytes32(abi.encodePacked(uint128(100000), uint128(200000))), + preVerificationGas: 0, + gasFees: bytes32(abi.encodePacked(uint128(1), uint128(1))), + paymasterAndData: "", + signature: "" + }); + } + + // Helper to extract validAfter from packed validation data + function _extractValidAfter(uint256 validationData) internal pure returns (uint48) { + return uint48(validationData >> 208); + } + + // Helper to extract validUntil from packed validation data + function _extractValidUntil(uint256 validationData) internal pure returns (uint48) { + return uint48(validationData >> 160); + } + + // ==================== whenCancellingAProposal ==================== + + modifier whenCancellingAProposal() { + _; + } + + function test_GivenTheProposalIsPending() external whenCancellingAProposal { + // Setup: Create a pending proposal + _createProposal(TEST_CALLDATA, TEST_NONCE); + + // Verify proposal is pending before cancellation + (TimelockPolicy.ProposalStatus statusBefore,,,) = + timelockPolicy.getProposal(WALLET, TEST_CALLDATA, TEST_NONCE, policyId, WALLET); + assertEq(uint256(statusBefore), uint256(TimelockPolicy.ProposalStatus.Pending), "Proposal should be pending"); + + // Action: Cancel the proposal and expect event + bytes32 expectedUserOpKey = timelockPolicy.computeUserOpKey(WALLET, TEST_CALLDATA, TEST_NONCE); + + vm.expectEmit(true, true, true, true); + emit TimelockPolicy.ProposalCancelled(WALLET, policyId, expectedUserOpKey); + + _cancelProposal(TEST_CALLDATA, TEST_NONCE); + + // Verify: it should set proposal status to Cancelled + (TimelockPolicy.ProposalStatus statusAfter,,,) = + timelockPolicy.getProposal(WALLET, TEST_CALLDATA, TEST_NONCE, policyId, WALLET); + assertEq(uint256(statusAfter), uint256(TimelockPolicy.ProposalStatus.Cancelled), "Proposal should be cancelled"); + + // Verify: it should prevent execution via checkUserOpPolicy returning failure + PackedUserOperation memory userOp = _createUserOp(TEST_CALLDATA, TEST_NONCE); + vm.prank(WALLET); + uint256 validationResult = timelockPolicy.checkUserOpPolicy(policyId, userOp); + assertEq(validationResult, 1, "Execution should fail for cancelled proposal (SIG_VALIDATION_FAILED)"); + } + + function test_GivenTheProposalDoesNotExist() external whenCancellingAProposal { + // Action & Verify: it should revert with ProposalNotPending + vm.prank(WALLET); + vm.expectRevert(TimelockPolicy.ProposalNotPending.selector); + timelockPolicy.cancelProposal(policyId, WALLET, TEST_CALLDATA, TEST_NONCE); + } + + function test_GivenTheProposalIsAlreadyCancelled() external whenCancellingAProposal { + // Setup: Create and cancel a proposal + _createProposal(TEST_CALLDATA, TEST_NONCE); + _cancelProposal(TEST_CALLDATA, TEST_NONCE); + + // Action & Verify: it should revert with ProposalNotPending + vm.prank(WALLET); + vm.expectRevert(TimelockPolicy.ProposalNotPending.selector); + timelockPolicy.cancelProposal(policyId, WALLET, TEST_CALLDATA, TEST_NONCE); + } + + function test_GivenTheProposalIsAlreadyExecuted() external whenCancellingAProposal { + // Setup: Create a proposal + _createProposal(TEST_CALLDATA, TEST_NONCE); + + // Fast forward past delay AND grace period + vm.warp(block.timestamp + DELAY + GRACE_PERIOD + 1); + + // Execute the proposal + PackedUserOperation memory userOp = _createUserOp(TEST_CALLDATA, TEST_NONCE); + vm.prank(WALLET); + uint256 validationResult = timelockPolicy.checkUserOpPolicy(policyId, userOp); + assertFalse(validationResult == 1, "Execution should succeed"); + + // Verify proposal is executed + (TimelockPolicy.ProposalStatus status,,,) = + timelockPolicy.getProposal(WALLET, TEST_CALLDATA, TEST_NONCE, policyId, WALLET); + assertEq(uint256(status), uint256(TimelockPolicy.ProposalStatus.Executed), "Proposal should be executed"); + + // Action & Verify: it should revert with ProposalNotPending + vm.prank(WALLET); + vm.expectRevert(TimelockPolicy.ProposalNotPending.selector); + timelockPolicy.cancelProposal(policyId, WALLET, TEST_CALLDATA, TEST_NONCE); + } + + function test_GivenTheCallerIsNotTheAccount() external whenCancellingAProposal { + // Setup: Create a proposal + _createProposal(TEST_CALLDATA, TEST_NONCE); + + // Action & Verify: it should revert with OnlyAccount when attacker tries to cancel + vm.prank(ATTACKER); + vm.expectRevert(TimelockPolicy.OnlyAccount.selector); + timelockPolicy.cancelProposal(policyId, WALLET, TEST_CALLDATA, TEST_NONCE); + } + + // ==================== whenExecutingAProposalAfterCancellation ==================== + + modifier whenExecutingAProposalAfterCancellation() { + _; + } + + function test_GivenTheProposalWasJustCancelledInTheSameBlock() external whenExecutingAProposalAfterCancellation { + // 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); + + // Cancel in the same block as execution attempt + _cancelProposal(TEST_CALLDATA, TEST_NONCE); + + // Action: Try to execute in the same block + PackedUserOperation memory userOp = _createUserOp(TEST_CALLDATA, TEST_NONCE); + vm.prank(WALLET); + uint256 validationResult = timelockPolicy.checkUserOpPolicy(policyId, userOp); + + // Verify: it should return SIG_VALIDATION_FAILED because status is Cancelled + assertEq(validationResult, 1, "Should return SIG_VALIDATION_FAILED for cancelled proposal"); + } + + function test_GivenTheCancellationHappenedInAPreviousBlock() external whenExecutingAProposalAfterCancellation { + // Setup: Create a proposal + _createProposal(TEST_CALLDATA, TEST_NONCE); + + // Fast forward past delay and grace period + vm.warp(block.timestamp + DELAY + GRACE_PERIOD + 1); + + // Cancel the proposal + _cancelProposal(TEST_CALLDATA, TEST_NONCE); + + // Move to next block + vm.warp(block.timestamp + 1); + vm.roll(block.number + 1); + + // Action: Try to execute in a later block + PackedUserOperation memory userOp = _createUserOp(TEST_CALLDATA, TEST_NONCE); + vm.prank(WALLET); + uint256 validationResult = timelockPolicy.checkUserOpPolicy(policyId, userOp); + + // Verify: it should return SIG_VALIDATION_FAILED because status is Cancelled + assertEq(validationResult, 1, "Should return SIG_VALIDATION_FAILED for cancelled proposal"); + } + + function test_GivenANewProposalIsCreatedForTheSameCalldataAfterCancellation() + external + whenExecutingAProposalAfterCancellation + { + // Setup: Create and cancel a proposal + _createProposal(TEST_CALLDATA, TEST_NONCE); + _cancelProposal(TEST_CALLDATA, TEST_NONCE); + + // Note: The current implementation does not allow creating a new proposal + // for the same calldata/nonce because cancelled proposals persist + // This test verifies this behavior + + // Action & Verify: Attempting to create a new proposal with same params should fail + vm.prank(WALLET); + vm.expectRevert(TimelockPolicy.ProposalAlreadyExists.selector); + timelockPolicy.createProposal(policyId, WALLET, TEST_CALLDATA, TEST_NONCE); + + // However, a proposal with different nonce should work + uint256 newNonce = TEST_NONCE + 1; + _createProposal(TEST_CALLDATA, newNonce); + + // Fast forward past delay and grace period + vm.warp(block.timestamp + DELAY + GRACE_PERIOD + 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 + assertFalse(validationResult == 1, "New proposal should be executable"); + + // Verify status is executed + (TimelockPolicy.ProposalStatus status,,,) = + timelockPolicy.getProposal(WALLET, TEST_CALLDATA, newNonce, policyId, WALLET); + assertEq(uint256(status), uint256(TimelockPolicy.ProposalStatus.Executed), "New proposal should be executed"); + } + + // ==================== whenExecutingAProposalDuringTheGracePeriod ==================== + + modifier whenExecutingAProposalDuringTheGracePeriod() { + _; + } + + function test_GivenTheTimelockDelayHasPassedButGracePeriodHasNot() + external + whenExecutingAProposalDuringTheGracePeriod + { + // 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) = + timelockPolicy.getProposal(WALLET, TEST_CALLDATA, TEST_NONCE, policyId, WALLET); + + // Fast forward past delay but NOT past grace period + vm.warp(startTime + DELAY + 1); + + // Verify we are in the grace period window + 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 + 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 + 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(returnedValidUntil, uint48(validUntil), "validUntil should match proposal expiration"); + + // Note: The bundler/EntryPoint would reject execution during grace period + // because block.timestamp < returnedValidAfter (graceEnd) + } + + function test_GivenTheGracePeriodHasPassed() external whenExecutingAProposalDuringTheGracePeriod { + // Setup: Create a proposal + uint256 startTime = block.timestamp; + _createProposal(TEST_CALLDATA, TEST_NONCE); + + // Get timing info + (,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); + + // Verify we are past the grace period + assertTrue(block.timestamp > validAfter, "Should be past graceEnd"); + assertTrue(block.timestamp < validUntil, "Should be before validUntil"); + + // 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 allowing execution + 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"); + } + + // ==================== whenTheOwnerCancelsDuringGracePeriod ==================== + + modifier whenTheOwnerCancelsDuringGracePeriod() { + _; + } + + function test_GivenTheProposalIsStillPending() external whenTheOwnerCancelsDuringGracePeriod { + // 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); + + // Verify proposal is still pending + (TimelockPolicy.ProposalStatus statusBefore,,,) = + timelockPolicy.getProposal(WALLET, TEST_CALLDATA, TEST_NONCE, policyId, WALLET); + assertEq(uint256(statusBefore), uint256(TimelockPolicy.ProposalStatus.Pending), "Proposal should still be pending"); + + // Action: Owner cancels during grace period + _cancelProposal(TEST_CALLDATA, TEST_NONCE); + + // Verify: it should successfully cancel the proposal + (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 { + // Setup: Create a proposal + uint256 startTime = block.timestamp; + _createProposal(TEST_CALLDATA, TEST_NONCE); + + // Fast forward to grace period + vm.warp(startTime + DELAY + 1); + + // Simulate scenario where both cancellation and execution happen in same block + // but cancellation is processed first (wins the race) + + // Owner cancels first + _cancelProposal(TEST_CALLDATA, TEST_NONCE); + + // Then execution attempt comes in the same block + PackedUserOperation memory userOp = _createUserOp(TEST_CALLDATA, TEST_NONCE); + vm.prank(WALLET); + uint256 validationResult = timelockPolicy.checkUserOpPolicy(policyId, userOp); + + // Verify: it should allow cancellation to win the race (execution fails) + assertEq(validationResult, 1, "Execution should fail because cancellation won the race"); + + // Verify proposal remains cancelled + (TimelockPolicy.ProposalStatus status,,,) = + timelockPolicy.getProposal(WALLET, TEST_CALLDATA, TEST_NONCE, policyId, WALLET); + assertEq(uint256(status), uint256(TimelockPolicy.ProposalStatus.Cancelled), "Proposal should remain cancelled"); + } + + // ==================== whenAttemptingMultipleCancellations ==================== + + modifier whenAttemptingMultipleCancellations() { + _; + } + + function test_GivenTheProposalWasJustCancelled() external whenAttemptingMultipleCancellations { + // Setup: Create and cancel a proposal + _createProposal(TEST_CALLDATA, TEST_NONCE); + _cancelProposal(TEST_CALLDATA, TEST_NONCE); + + // Move to next block + vm.warp(block.timestamp + 1); + vm.roll(block.number + 1); + + // Action & Verify: it should revert with ProposalNotPending on second attempt + vm.prank(WALLET); + vm.expectRevert(TimelockPolicy.ProposalNotPending.selector); + timelockPolicy.cancelProposal(policyId, WALLET, TEST_CALLDATA, TEST_NONCE); + } + + function test_GivenTryingToCancelTwiceInTheSameTransaction() external whenAttemptingMultipleCancellations { + // Setup: Create a proposal + _createProposal(TEST_CALLDATA, TEST_NONCE); + + // Action: First cancellation succeeds + vm.startPrank(WALLET); + timelockPolicy.cancelProposal(policyId, WALLET, TEST_CALLDATA, TEST_NONCE); + + // Verify: it should revert with ProposalNotPending on second call + vm.expectRevert(TimelockPolicy.ProposalNotPending.selector); + timelockPolicy.cancelProposal(policyId, WALLET, TEST_CALLDATA, TEST_NONCE); + vm.stopPrank(); + } + + // ==================== whenCreatingANewProposalAfterGracePeriod ==================== + + modifier whenCreatingANewProposalAfterGracePeriod() { + _; + } + + function test_GivenTheOriginalProposalWasCancelled() external whenCreatingANewProposalAfterGracePeriod { + // 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); + + // Action & Verify: it should revert with ProposalAlreadyExists because cancelled proposals persist + vm.prank(WALLET); + vm.expectRevert(TimelockPolicy.ProposalAlreadyExists.selector); + timelockPolicy.createProposal(policyId, WALLET, TEST_CALLDATA, TEST_NONCE); + } + + function test_GivenTheOriginalProposalWasExecuted() external whenCreatingANewProposalAfterGracePeriod { + // Setup: Create a proposal + _createProposal(TEST_CALLDATA, TEST_NONCE); + + // Fast forward past delay and grace period + vm.warp(block.timestamp + DELAY + GRACE_PERIOD + 1); + + // Execute the proposal + PackedUserOperation memory userOp = _createUserOp(TEST_CALLDATA, TEST_NONCE); + vm.prank(WALLET); + timelockPolicy.checkUserOpPolicy(policyId, userOp); + + // Fast forward more + vm.warp(block.timestamp + EXPIRATION_PERIOD + 1); + + // Action & Verify: it should revert with ProposalAlreadyExists because executed proposals persist + vm.prank(WALLET); + vm.expectRevert(TimelockPolicy.ProposalAlreadyExists.selector); + timelockPolicy.createProposal(policyId, WALLET, TEST_CALLDATA, TEST_NONCE); + } + + // ==================== whenValidatingGracePeriodTiming ==================== + + modifier whenValidatingGracePeriodTiming() { + _; + } + + function test_GivenDelayIs1DayAndGracePeriodIs1Hour() external whenValidatingGracePeriodTiming { + // Setup: Record start time + uint256 startTime = block.timestamp; + + // Action: Create a proposal + _createProposal(TEST_CALLDATA, TEST_NONCE); + + // Get proposal timing + (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"); + } + + function test_GivenExecutionValidationDataIsReturned() external whenValidatingGracePeriodTiming { + // 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) = + timelockPolicy.getProposal(WALLET, TEST_CALLDATA, TEST_NONCE, policyId, WALLET); + + // Fast forward just past delay but still in grace period + vm.warp(startTime + DELAY + 1); + + // Action: Get validation data by calling checkUserOpPolicy + PackedUserOperation memory userOp = _createUserOp(TEST_CALLDATA, TEST_NONCE); + vm.prank(WALLET); + uint256 validationResult = timelockPolicy.checkUserOpPolicy(policyId, userOp); + + // Extract packed values + 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 validUntil as expiration time + assertEq(packedValidUntil, uint48(expectedValidUntil), "Packed validUntil should match proposal expiration"); + } + + // ==================== Additional Edge Case Tests ==================== + + function test_ExecutionFailsForExpiredProposal() external { + // Setup: Create a proposal + _createProposal(TEST_CALLDATA, TEST_NONCE); + + // Fast forward past expiration + vm.warp(block.timestamp + DELAY + GRACE_PERIOD + EXPIRATION_PERIOD + 1); + + // Action: Try to execute + PackedUserOperation memory userOp = _createUserOp(TEST_CALLDATA, TEST_NONCE); + vm.prank(WALLET); + uint256 validationResult = timelockPolicy.checkUserOpPolicy(policyId, userOp); + + // The proposal would be marked as executed in storage, but the validUntil + // returned would be in the past, causing bundler rejection + uint48 packedValidUntil = _extractValidUntil(validationResult); + assertTrue(block.timestamp > packedValidUntil, "Current time should be past validUntil"); + } + + function test_NonInitializedAccountCannotCancelProposal() external { + address nonInitializedAccount = address(0xDEAD); + + // Try to cancel on non-initialized account + vm.prank(nonInitializedAccount); + vm.expectRevert(abi.encodeWithSelector(IModule.NotInitialized.selector, nonInitializedAccount)); + timelockPolicy.cancelProposal(policyId, nonInitializedAccount, TEST_CALLDATA, TEST_NONCE); + } + + function test_NonInitializedAccountCannotCreateProposal() external { + address nonInitializedAccount = address(0xDEAD); + + // Try to create proposal on non-initialized account + vm.prank(nonInitializedAccount); + vm.expectRevert(abi.encodeWithSelector(IModule.NotInitialized.selector, nonInitializedAccount)); + timelockPolicy.createProposal(policyId, nonInitializedAccount, TEST_CALLDATA, TEST_NONCE); + } +} diff --git a/test/btt/TimelockCancellationRace.tree b/test/btt/TimelockCancellationRace.tree new file mode 100644 index 0000000..14ec819 --- /dev/null +++ b/test/btt/TimelockCancellationRace.tree @@ -0,0 +1,51 @@ +TimelockCancellationRaceTest +├── when cancelling a proposal +│ ├── given the proposal is pending +│ │ ├── it should set proposal status to Cancelled +│ │ ├── it should emit ProposalCancelled event +│ │ └── it should prevent execution via checkUserOpPolicy returning failure +│ ├── given the proposal does not exist +│ │ └── it should revert with ProposalNotPending +│ ├── given the proposal is already cancelled +│ │ └── it should revert with ProposalNotPending +│ ├── given the proposal is already executed +│ │ └── it should revert with ProposalNotPending +│ └── given the caller is not the account +│ └── it should revert with OnlyAccount +├── when executing a proposal after cancellation +│ ├── given the proposal was just cancelled in the same block +│ │ └── it should return SIG_VALIDATION_FAILED because status is Cancelled +│ ├── given the cancellation happened in a previous block +│ │ └── it should return SIG_VALIDATION_FAILED because status is Cancelled +│ └── given a new proposal is created for the same calldata after cancellation +│ └── it should allow execution of the new proposal after grace period +├── when executing a proposal during the grace period +│ ├── given the timelock delay has passed but grace period has not +│ │ ├── it should return validation data with graceEnd as validAfter +│ │ └── it should prevent immediate execution via bundler rejection +│ └── given the grace period has passed +│ ├── it should return validation data allowing execution +│ └── it should set proposal status to Executed +├── when the owner cancels during grace period +│ ├── given the proposal is still pending +│ │ └── it should successfully cancel the proposal +│ └── given an execution attempt is pending in the mempool +│ └── it should allow cancellation to win the race +├── when attempting multiple cancellations +│ ├── given the proposal was just cancelled +│ │ └── it should revert with ProposalNotPending on second attempt +│ └── given trying to cancel twice in the same transaction +│ └── it should revert with ProposalNotPending on second call +├── when creating a new proposal after grace period +│ ├── given the original proposal was cancelled +│ │ └── it should revert with ProposalAlreadyExists because cancelled proposals persist +│ └── given the original proposal was executed +│ └── it should revert with ProposalAlreadyExists because executed proposals persist +└── when validating grace period timing + ├── given delay is 1 day and gracePeriod is 1 hour + │ ├── it should set validAfter to current time plus delay + │ ├── it should set graceEnd to validAfter plus gracePeriod + │ └── it should set validUntil to graceEnd plus expirationPeriod + └── given execution validation data is returned + ├── it should pack graceEnd not validAfter as execution start time + └── it should pack validUntil as expiration time diff --git a/test/btt/TimelockEpochValidation.t.sol b/test/btt/TimelockEpochValidation.t.sol index 6abe651..fd894e4 100644 --- a/test/btt/TimelockEpochValidation.t.sol +++ b/test/btt/TimelockEpochValidation.t.sol @@ -19,6 +19,7 @@ contract TimelockEpochValidationTest is Test { uint48 constant DELAY = 1 days; uint48 constant EXPIRATION_PERIOD = 1 days; + uint48 constant GRACE_PERIOD = 1 hours; bytes32 constant POLICY_ID_1 = keccak256("POLICY_ID_1"); bytes32 constant POLICY_ID_2 = keccak256("POLICY_ID_2"); @@ -30,7 +31,7 @@ contract TimelockEpochValidationTest is Test { } function _installData() internal pure returns (bytes memory) { - return abi.encode(DELAY, EXPIRATION_PERIOD); + return abi.encode(DELAY, EXPIRATION_PERIOD, GRACE_PERIOD); } function _installPolicy(address wallet, bytes32 policyId) internal { @@ -85,7 +86,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, bool initialized) = + (uint48 delay, uint48 expirationPeriod, uint48 gracePeriod_, bool initialized) = timelockPolicy.timelockConfig(POLICY_ID_1, WALLET); assertTrue(initialized, "Policy should be initialized"); assertEq(delay, DELAY, "Delay should match"); @@ -155,6 +156,7 @@ contract TimelockEpochValidationTest is Test { ( TimelockPolicy.ProposalStatus status, uint48 validAfter, + uint48 graceEnd, uint48 validUntil, uint256 proposalEpoch ) = timelockPolicy.proposals(userOpKey, POLICY_ID_1, WALLET); @@ -162,7 +164,8 @@ contract TimelockEpochValidationTest is Test { 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(validUntil, block.timestamp + DELAY + EXPIRATION_PERIOD, "validUntil 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"); } function test_GivenCreatingViaCreateProposalFunction() external whenCreatingAProposal { @@ -177,7 +180,7 @@ contract TimelockEpochValidationTest is Test { // it should record the epoch at creation time 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, currentEpoch, "Proposal epoch should equal current epoch at creation"); } @@ -217,7 +220,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"); } @@ -248,7 +251,7 @@ contract TimelockEpochValidationTest is Test { // it should not 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.Pending), "Proposal should still be pending"); } @@ -262,7 +265,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 @@ -284,7 +287,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), @@ -320,7 +323,7 @@ contract TimelockEpochValidationTest is Test { _uninstallPolicy(WALLET, POLICY_ID_1); // it should delete the policy config - (,, bool initialized) = timelockPolicy.timelockConfig(POLICY_ID_1, WALLET); + (,,, bool initialized) = timelockPolicy.timelockConfig(POLICY_ID_1, WALLET); assertFalse(initialized, "Policy config should be deleted"); // it should preserve the epoch counter @@ -366,7 +369,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 created"); diff --git a/test/btt/TimelockSignaturePolicy.t.sol b/test/btt/TimelockSignaturePolicy.t.sol index 0df7676..9b405a4 100644 --- a/test/btt/TimelockSignaturePolicy.t.sol +++ b/test/btt/TimelockSignaturePolicy.t.sol @@ -16,6 +16,7 @@ contract TimelockSignaturePolicyTest is Test { uint48 constant DELAY = 1 days; uint48 constant EXPIRATION_PERIOD = 1 days; + uint48 constant GRACE_PERIOD = 1 hours; bytes32 public policyId; bytes32 public testHash; @@ -28,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); + bytes memory installData = abi.encode(DELAY, EXPIRATION_PERIOD, GRACE_PERIOD); vm.prank(wallet); timelockPolicy.onInstall(abi.encodePacked(policyId, installData)); } From 17f78f7874ed650b575e952c6fe5634aab668e2d Mon Sep 17 00:00:00 2001 From: taek Date: Mon, 9 Feb 2026 13:18:03 +0900 Subject: [PATCH 13/25] fix(TimelockPolicy): make permissionless proposals inert until session key approval --- src/policies/TimelockPolicy.sol | 78 +++++---- test/TimelockPolicy.t.sol | 57 +++++-- test/btt/Timelock.t.sol | 226 ++++++++++++++++++++----- test/btt/TimelockEpochValidation.t.sol | 66 ++++++-- 4 files changed, 329 insertions(+), 98 deletions(-) diff --git a/src/policies/TimelockPolicy.sol b/src/policies/TimelockPolicy.sol index f1340ca..7fdf64d 100644 --- a/src/policies/TimelockPolicy.sol +++ b/src/policies/TimelockPolicy.sol @@ -21,7 +21,8 @@ import { contract TimelockPolicy is PolicyBase, IStatelessValidator, IStatelessValidatorWithSender { enum ProposalStatus { None, // Proposal doesn't exist - Pending, // Proposal created, waiting for timelock + Proposed, // Proposal created but not yet approved (inert, no clock) + Pending, // Proposal approved, clock started, waiting for timelock Executed, // Proposal executed Cancelled // Proposal cancelled } @@ -53,6 +54,10 @@ contract TimelockPolicy is PolicyBase, IStatelessValidator, IStatelessValidatorW address indexed wallet, bytes32 indexed id, bytes32 indexed proposalHash, uint256 validAfter, uint256 validUntil ); + event ProposalApproved( + address indexed wallet, bytes32 indexed id, bytes32 indexed proposalHash, uint256 validAfter, uint256 validUntil + ); + event ProposalExecuted(address indexed wallet, bytes32 indexed id, bytes32 indexed proposalHash); event ProposalCancelled(address indexed wallet, bytes32 indexed id, bytes32 indexed proposalHash); @@ -119,7 +124,9 @@ contract TimelockPolicy is PolicyBase, IStatelessValidator, IStatelessValidatorW /** * @notice Create a proposal for time-delayed execution - * @dev Anyone can create a proposal - the timelock delay provides the security + * @dev Anyone can create a proposal. The proposal is inert until approved by the + * session key holder via a no-op UserOp. The timelock clock does not start + * until approval, so spam proposals can be safely ignored. * @param id The policy ID * @param account The account address * @param callData The calldata for the future operation @@ -129,10 +136,6 @@ contract TimelockPolicy is PolicyBase, IStatelessValidator, IStatelessValidatorW TimelockConfig storage config = timelockConfig[id][account]; if (!config.initialized) revert IModule.NotInitialized(account); - // Calculate proposal timing - uint48 validAfter = uint48(block.timestamp) + config.delay; - uint48 validUntil = validAfter + config.expirationPeriod; - // Create userOp key for storage lookup bytes32 userOpKey = keccak256(abi.encode(account, keccak256(callData), nonce)); @@ -141,11 +144,11 @@ contract TimelockPolicy is PolicyBase, IStatelessValidator, IStatelessValidatorW revert ProposalAlreadyExists(); } - // Create proposal (stored by userOpKey) with current epoch + // Create INERT proposal — clock does NOT start until approved via UserOp proposals[userOpKey][id][account] = - Proposal({status: ProposalStatus.Pending, validAfter: validAfter, validUntil: validUntil, epoch: currentEpoch[id][account]}); + Proposal({status: ProposalStatus.Proposed, validAfter: 0, validUntil: 0, epoch: currentEpoch[id][account]}); - emit ProposalCreated(account, id, userOpKey, validAfter, validUntil); + emit ProposalCreated(account, id, userOpKey, 0, 0); } /** @@ -167,7 +170,7 @@ contract TimelockPolicy is PolicyBase, IStatelessValidator, IStatelessValidatorW bytes32 userOpKey = keccak256(abi.encode(account, keccak256(callData), nonce)); Proposal storage proposal = proposals[userOpKey][id][account]; - if (proposal.status != ProposalStatus.Pending) { + if (proposal.status != ProposalStatus.Pending && proposal.status != ProposalStatus.Proposed) { revert ProposalNotPending(); } @@ -197,10 +200,13 @@ contract TimelockPolicy is PolicyBase, IStatelessValidator, IStatelessValidatorW } /** - * @notice Handle proposal creation from userOp - * @dev Signature format: [callDataLength(32)][callData][nonce(32)][remaining sig data] + * @notice Handle proposal approval (or create+approve) from userOp + * @dev Called when the session key holder submits a no-op UserOp. + * If a matching inert proposal exists (Proposed status), approves it and starts the clock. + * If no proposal exists, creates and approves in one step. + * Signature format: [callDataLength(32)][callData][nonce(32)][remaining sig data] */ - function _handleProposalCreationInternal( + function _handleProposalApprovalInternal( bytes32 id, PackedUserOperation calldata userOp, TimelockConfig storage config, @@ -217,27 +223,40 @@ contract TimelockPolicy is PolicyBase, IStatelessValidator, IStatelessValidatorW bytes calldata proposalCallData = sig[32:32 + callDataLength]; uint256 proposalNonce = uint256(bytes32(sig[32 + callDataLength:64 + callDataLength])); - // Calculate proposal timing + // Calculate proposal timing (clock starts NOW) uint48 validAfter = uint48(block.timestamp) + config.delay; 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)); - // Check proposal doesn't already exist - if (proposals[userOpKey][id][account].status != ProposalStatus.None) { - return SIG_VALIDATION_FAILED_UINT; // Proposal already exists - } - - // Create proposal with current epoch - proposals[userOpKey][id][account] = - Proposal({status: ProposalStatus.Pending, validAfter: validAfter, validUntil: validUntil, epoch: currentEpoch[id][account]}); - - emit ProposalCreated(account, id, userOpKey, validAfter, validUntil); + Proposal storage proposal = proposals[userOpKey][id][account]; - // Return success (validationData = 0) to allow the proposal creation to persist - // EntryPoint treats validationData == 0 as valid (no time range check) - return _packValidationData(0, 0); + if (proposal.status == ProposalStatus.Proposed) { + // Approve existing inert proposal — start the clock + if (proposal.epoch != currentEpoch[id][account]) return SIG_VALIDATION_FAILED_UINT; + + proposal.status = ProposalStatus.Pending; + proposal.validAfter = validAfter; + proposal.validUntil = validUntil; + + emit ProposalApproved(account, id, userOpKey, validAfter, validUntil); + return _packValidationData(0, 0); + } else if (proposal.status == ProposalStatus.None) { + // Create + approve in one step (session key holder creating directly) + 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); + } else { + // Proposal exists in wrong state (Pending, Executed, Cancelled) + return SIG_VALIDATION_FAILED_UINT; + } } /** @@ -414,11 +433,10 @@ 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 + // Check if this is a proposal approval (or create+approve) request // Criteria: calldata is a no-op AND signature has proposal data (length >= 65) if (_isNoOpCalldata(userOp.callData) && sig.length >= 65) { - // This is a proposal creation request - return _handleProposalCreationInternal(id, userOp, config, sig, account); + return _handleProposalApprovalInternal(id, userOp, config, sig, account); } // Otherwise, this is a proposal execution request diff --git a/test/TimelockPolicy.t.sol b/test/TimelockPolicy.t.sol index 2df90c2..71423c5 100644 --- a/test/TimelockPolicy.t.sol +++ b/test/TimelockPolicy.t.sol @@ -173,11 +173,30 @@ contract TimelockPolicyTest is PolicyTestBase, StatelessValidatorTestBase, State PackedUserOperation memory userOp = validUserOp(); - // First create a proposal + // First create a proposal (inert) vm.startPrank(WALLET); policyModule.createProposal(policyId(), WALLET, userOp.callData, userOp.nonce); vm.stopPrank(); + // Approve the proposal via no-op UserOp (starts the clock) + bytes memory sig = abi.encodePacked( + bytes32(userOp.callData.length), userOp.callData, bytes32(userOp.nonce), bytes1(0x00) + ); + PackedUserOperation memory approveOp = PackedUserOperation({ + sender: WALLET, + nonce: 0, + initCode: "", + callData: "", + accountGasLimits: bytes32(abi.encodePacked(uint128(100000), uint128(200000))), + preVerificationGas: 0, + gasFees: bytes32(abi.encodePacked(uint128(1), uint128(1))), + paymasterAndData: "", + signature: sig + }); + vm.startPrank(WALLET); + policyModule.checkUserOpPolicy(policyId(), approveOp); + vm.stopPrank(); + // Fast forward past the delay vm.warp(block.timestamp + delay + 1); @@ -251,13 +270,13 @@ contract TimelockPolicyTest is PolicyTestBase, StatelessValidatorTestBase, State policyModule.createProposal(policyId(), WALLET, callData, nonce); vm.stopPrank(); - // Verify proposal was created + // Verify proposal was created as inert (Proposed) (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(validUntil, block.timestamp + delay + expirationPeriod); + assertEq(uint256(status), uint256(TimelockPolicy.ProposalStatus.Proposed)); + assertEq(validAfter, 0); + assertEq(validUntil, 0); } function testCancelProposal() public { @@ -291,7 +310,7 @@ contract TimelockPolicyTest is PolicyTestBase, StatelessValidatorTestBase, State policyModule.onInstall(abi.encodePacked(policyId(), installData())); vm.stopPrank(); - // Create a proposal via checkUserOpPolicy with no-op calldata + // Create+approve a proposal via checkUserOpPolicy with no-op calldata bytes memory proposalCallData = hex"1234"; uint256 proposalNonce = 1; @@ -318,11 +337,10 @@ contract TimelockPolicyTest is PolicyTestBase, StatelessValidatorTestBase, State uint256 result = policyModule.checkUserOpPolicy(policyId(), userOp); vm.stopPrank(); - // Returns success (validationData = 0) - valid indefinitely per ERC-4337 - // This allows proposal creation via UserOp without external caller + // Returns success (validationData = 0) for state persistence assertEq(result, 0); - // Verify proposal was created + // Verify proposal was created+approved (Pending with timing) (TimelockPolicy.ProposalStatus status,,) = policyModule.getProposal(WALLET, proposalCallData, proposalNonce, policyId(), WALLET); @@ -338,11 +356,30 @@ contract TimelockPolicyTest is PolicyTestBase, StatelessValidatorTestBase, State PackedUserOperation memory userOp = validUserOp(); - // Create a proposal + // Create and approve a proposal vm.startPrank(WALLET); policyModule.createProposal(policyId(), WALLET, userOp.callData, userOp.nonce); vm.stopPrank(); + // Approve via no-op UserOp + bytes memory sig = abi.encodePacked( + bytes32(userOp.callData.length), userOp.callData, bytes32(userOp.nonce), bytes1(0x00) + ); + PackedUserOperation memory approveOp = PackedUserOperation({ + sender: WALLET, + nonce: 0, + initCode: "", + callData: "", + accountGasLimits: bytes32(abi.encodePacked(uint128(100000), uint128(200000))), + preVerificationGas: 0, + gasFees: bytes32(abi.encodePacked(uint128(1), uint128(1))), + paymasterAndData: "", + signature: sig + }); + vm.startPrank(WALLET); + policyModule.checkUserOpPolicy(policyId(), approveOp); + vm.stopPrank(); + // Fast forward past delay vm.warp(block.timestamp + delay + 1); diff --git a/test/btt/Timelock.t.sol b/test/btt/Timelock.t.sol index 04ec804..f48f842 100644 --- a/test/btt/Timelock.t.sol +++ b/test/btt/Timelock.t.sol @@ -199,28 +199,25 @@ contract TimelockTest is Test { } function test_GivenConfigIsInitializedAndProposalDoesNotExist() external whenCallingCreateProposal { - // it should store the proposal with pending status - // it should set correct validAfter and validUntil - // it should emit ProposalCreated + // it should store the proposal with Proposed (inert) status + // it should NOT set timing (validAfter = 0, validUntil = 0) + // it should emit ProposalCreated with zero timing bytes memory callData = abi.encodeWithSelector(IERC7579Execution.execute.selector, bytes32(0), "test"); uint256 nonce = 100; - uint256 createTime = block.timestamp; bytes32 expectedKey = keccak256(abi.encode(WALLET, keccak256(callData), nonce)); vm.expectEmit(true, true, true, true); - emit TimelockPolicy.ProposalCreated( - WALLET, POLICY_ID, expectedKey, uint48(createTime) + DELAY, uint48(createTime) + DELAY + EXPIRATION - ); + emit TimelockPolicy.ProposalCreated(WALLET, POLICY_ID, expectedKey, 0, 0); timelockPolicy.createProposal(POLICY_ID, WALLET, callData, nonce); (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, createTime + DELAY, "validAfter should be timestamp + delay"); - assertEq(validUntil, createTime + DELAY + EXPIRATION, "validUntil should be validAfter + expiration"); + assertEq(uint256(status), uint256(TimelockPolicy.ProposalStatus.Proposed), "Status should be Proposed"); + assertEq(validAfter, 0, "validAfter should be 0 (inert)"); + assertEq(validUntil, 0, "validUntil should be 0 (inert)"); } function test_GivenNotInitialized_WhenCallingCreateProposal() external whenCallingCreateProposal { @@ -249,8 +246,8 @@ contract TimelockTest is Test { _; } - function test_GivenCallerIsAccountAndProposalIsPending() external whenCallingCancelProposal { - // it should set status to cancelled + function test_GivenCallerIsAccountAndProposalIsProposed() external whenCallingCancelProposal { + // it should set status to cancelled (cancelling an inert Proposed proposal) // it should emit ProposalCancelled bytes memory callData = abi.encodeWithSelector(IERC7579Execution.execute.selector, bytes32(0), "test"); uint256 nonce = 300; @@ -270,6 +267,31 @@ contract TimelockTest is Test { assertEq(uint256(status), uint256(TimelockPolicy.ProposalStatus.Cancelled), "Status should be Cancelled"); } + function test_GivenCallerIsAccountAndProposalIsPending() external whenCallingCancelProposal { + // it should set status to cancelled (cancelling an approved Pending proposal) + // it should emit ProposalCancelled + bytes memory callData = abi.encodeWithSelector(IERC7579Execution.execute.selector, bytes32(0), "test"); + uint256 nonce = 301; + + // Create and approve via UserOp to get Pending status + bytes memory sig = _createProposalSignature(callData, nonce); + PackedUserOperation memory userOp = _createNoopUserOp(WALLET, sig); + vm.prank(WALLET); + timelockPolicy.checkUserOpPolicy(POLICY_ID, userOp); + + bytes32 expectedKey = keccak256(abi.encode(WALLET, keccak256(callData), nonce)); + + vm.expectEmit(true, true, true, true); + emit TimelockPolicy.ProposalCancelled(WALLET, POLICY_ID, expectedKey); + + 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), "Status should be Cancelled"); + } + function test_GivenCallerIsNotAccount() external whenCallingCancelProposal { // it should revert with OnlyAccount bytes memory callData = abi.encodeWithSelector(IERC7579Execution.execute.selector, bytes32(0), "test"); @@ -324,9 +346,9 @@ contract TimelockTest is Test { } function test_GivenNoopCalldataAndValidSignature() external whenCallingCheckUserOpPolicyToCreateProposal { - // it should create the proposal + // it should create+approve the proposal in one step (no prior external proposal) // it should return zero for state persistence - // it should emit ProposalCreated + // it should emit ProposalCreated with timing bytes memory proposalCallData = abi.encodeWithSelector(IERC7579Execution.execute.selector, bytes32(0), "test"); uint256 proposalNonce = 700; bytes memory sig = _createProposalSignature(proposalCallData, proposalNonce); @@ -352,7 +374,7 @@ contract TimelockTest is Test { (TimelockPolicy.ProposalStatus status,,) = timelockPolicy.getProposal(WALLET, proposalCallData, proposalNonce, POLICY_ID, WALLET); - assertEq(uint256(status), uint256(TimelockPolicy.ProposalStatus.Pending), "Proposal should be created"); + assertEq(uint256(status), uint256(TimelockPolicy.ProposalStatus.Pending), "Proposal should be Pending (created+approved)"); } function test_GivenNoopCalldataAndSignatureShorterThan65Bytes() @@ -388,23 +410,23 @@ contract TimelockTest is Test { assertEq(result, SIG_VALIDATION_FAILED, "Should fail when signature claims more data than available"); } - function test_GivenNoopCalldataAndProposalAlreadyExists() external whenCallingCheckUserOpPolicyToCreateProposal { - // it should return SIG_VALIDATION_FAILED + function test_GivenNoopCalldataAndProposalAlreadyPending() external whenCallingCheckUserOpPolicyToCreateProposal { + // it should return SIG_VALIDATION_FAILED when proposal is already Pending bytes memory proposalCallData = abi.encodeWithSelector(IERC7579Execution.execute.selector, bytes32(0), "test"); uint256 proposalNonce = 800; bytes memory sig = _createProposalSignature(proposalCallData, proposalNonce); PackedUserOperation memory userOp = _createNoopUserOp(WALLET, sig); - // First creation should succeed + // First call: create+approve → Pending vm.prank(WALLET); timelockPolicy.checkUserOpPolicy(POLICY_ID, userOp); - // Second creation should fail + // Second call: already Pending → should fail vm.prank(WALLET); uint256 result = timelockPolicy.checkUserOpPolicy(POLICY_ID, userOp); - assertEq(result, SIG_VALIDATION_FAILED, "Should fail for duplicate proposal"); + assertEq(result, SIG_VALIDATION_FAILED, "Should fail for already Pending proposal"); } // ============ checkUserOpPolicy - Proposal Execution Tests ============ @@ -420,10 +442,16 @@ contract TimelockTest is Test { bytes memory proposalCallData = abi.encodeWithSelector(IERC7579Execution.execute.selector, bytes32(0), "action"); uint256 proposalNonce = 900; - uint256 createTime = block.timestamp; + // Create inert proposal timelockPolicy.createProposal(POLICY_ID, WALLET, proposalCallData, proposalNonce); - // Get the actual stored proposal values + // Approve the proposal via no-op UserOp (starts the clock) + bytes memory sig = _createProposalSignature(proposalCallData, proposalNonce); + PackedUserOperation memory approveOp = _createNoopUserOp(WALLET, sig); + vm.prank(WALLET); + timelockPolicy.checkUserOpPolicy(POLICY_ID, approveOp); + + // Get the actual stored proposal values (now has timing) (, uint256 storedValidAfter, uint256 storedValidUntil) = timelockPolicy.getProposal(WALLET, proposalCallData, proposalNonce, POLICY_ID, WALLET); @@ -484,7 +512,11 @@ contract TimelockTest is Test { bytes memory callData = abi.encodeWithSelector(IERC7579Execution.execute.selector, bytes32(0), "test"); uint256 nonce = 1100; - timelockPolicy.createProposal(POLICY_ID, WALLET, callData, nonce); + // Create and approve via UserOp + bytes memory sig = _createProposalSignature(callData, nonce); + PackedUserOperation memory approveOp = _createNoopUserOp(WALLET, sig); + vm.prank(WALLET); + timelockPolicy.checkUserOpPolicy(POLICY_ID, approveOp); vm.warp(block.timestamp + DELAY + 1); @@ -616,15 +648,14 @@ contract TimelockTest is Test { bytes memory callData = abi.encodeWithSelector(IERC7579Execution.execute.selector, bytes32(0), "test"); uint256 nonce = 1200; - uint256 createTime = block.timestamp; timelockPolicy.createProposal(POLICY_ID, WALLET, callData, nonce); (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, createTime + DELAY, "validAfter should be correct"); - assertEq(validUntil, createTime + DELAY + EXPIRATION, "validUntil should be correct"); + assertEq(uint256(status), uint256(TimelockPolicy.ProposalStatus.Proposed), "Status should be Proposed"); + assertEq(validAfter, 0, "validAfter should be 0 (inert)"); + assertEq(validUntil, 0, "validUntil should be 0 (inert)"); } function test_GivenProposalDoesNotExist_WhenCallingGetProposal() external whenCallingGetProposal { @@ -923,11 +954,15 @@ contract TimelockTest is Test { function test_WhenPackingValidationData() external { // it should correctly pack validAfter and validUntil - // Test the packing by creating a proposal and checking the returned validation data + // Test the packing by creating+approving a proposal and checking the returned validation data bytes memory callData = abi.encodeWithSelector(IERC7579Execution.execute.selector, bytes32(0), "test"); uint256 nonce = 1400; - timelockPolicy.createProposal(POLICY_ID, WALLET, callData, nonce); + // Create and approve via UserOp + bytes memory sig = _createProposalSignature(callData, nonce); + PackedUserOperation memory approveOp = _createNoopUserOp(WALLET, sig); + vm.prank(WALLET); + timelockPolicy.checkUserOpPolicy(POLICY_ID, approveOp); // Get the actual stored proposal values (, uint256 storedValidAfter, uint256 storedValidUntil) = @@ -964,24 +999,20 @@ contract TimelockTest is Test { assertEq(result, SIG_VALIDATION_FAILED, "Attack without proposal should fail"); } - function test_GivenAttackerTriesToExecuteImmediatelyAfterCreation() external whenTestingSecurityScenarios { - // it should return packed data with future validAfter + function test_GivenAttackerTriesToExecuteUnapprovedProposal() external whenTestingSecurityScenarios { + // it should return SIG_VALIDATION_FAILED because proposal is inert (Proposed, not Pending) bytes memory callData = abi.encodeWithSelector(IERC7579Execution.execute.selector, bytes32(0), "action"); uint256 nonce = 1500; - uint256 createTime = block.timestamp; timelockPolicy.createProposal(POLICY_ID, WALLET, callData, nonce); - // Try to execute immediately (before timelock) + // Try to execute without approval PackedUserOperation memory executeOp = _createUserOpWithCalldata(WALLET, callData, nonce, ""); vm.prank(WALLET); uint256 result = timelockPolicy.checkUserOpPolicy(POLICY_ID, executeOp); - // Extract validAfter - it should be in the future - uint48 validAfter = uint48(result >> 208); - assertGt(validAfter, block.timestamp, "validAfter should be in the future"); - assertEq(validAfter, uint48(createTime) + DELAY, "validAfter should be createTime + DELAY"); + assertEq(result, SIG_VALIDATION_FAILED, "Unapproved proposal should fail execution"); } function test_GivenAttackerTriesToReexecuteAUsedProposal() external whenTestingSecurityScenarios { @@ -989,7 +1020,11 @@ contract TimelockTest is Test { bytes memory callData = abi.encodeWithSelector(IERC7579Execution.execute.selector, bytes32(0), "action"); uint256 nonce = 1600; - timelockPolicy.createProposal(POLICY_ID, WALLET, callData, nonce); + // Create and approve via UserOp + bytes memory sig = _createProposalSignature(callData, nonce); + PackedUserOperation memory approveOp = _createNoopUserOp(WALLET, sig); + vm.prank(WALLET); + timelockPolicy.checkUserOpPolicy(POLICY_ID, approveOp); vm.warp(block.timestamp + DELAY + 1); @@ -1034,6 +1069,117 @@ contract TimelockTest is Test { assertGt(validUntil, validAfter, "validUntil should be after validAfter"); } + // ============ Proposal Approval Tests ============ + + modifier whenCallingCheckUserOpPolicyToApproveProposal() { + _; + } + + function test_ApproveExistingProposal() external whenCallingCheckUserOpPolicyToApproveProposal { + // it should transition Proposed → Pending and start the clock + bytes memory callData = abi.encodeWithSelector(IERC7579Execution.execute.selector, bytes32(0), "approve_test"); + uint256 nonce = 2000; + + // Create inert proposal externally + timelockPolicy.createProposal(POLICY_ID, WALLET, callData, nonce); + + // Verify it's Proposed with no timing + (TimelockPolicy.ProposalStatus statusBefore, uint256 vaBefore, uint256 vuBefore) = + timelockPolicy.getProposal(WALLET, callData, nonce, POLICY_ID, WALLET); + assertEq(uint256(statusBefore), uint256(TimelockPolicy.ProposalStatus.Proposed), "Should be Proposed"); + assertEq(vaBefore, 0, "validAfter should be 0 before approval"); + assertEq(vuBefore, 0, "validUntil should be 0 before approval"); + + // Approve via no-op UserOp + bytes memory sig = _createProposalSignature(callData, nonce); + PackedUserOperation memory approveOp = _createNoopUserOp(WALLET, sig); + + bytes32 expectedKey = keccak256(abi.encode(WALLET, keccak256(callData), nonce)); + uint48 expectedValidAfter = uint48(block.timestamp) + DELAY; + uint48 expectedValidUntil = expectedValidAfter + EXPIRATION; + + vm.expectEmit(true, true, true, true); + emit TimelockPolicy.ProposalApproved(WALLET, POLICY_ID, expectedKey, expectedValidAfter, expectedValidUntil); + + vm.prank(WALLET); + uint256 result = timelockPolicy.checkUserOpPolicy(POLICY_ID, approveOp); + + assertEq(result, 0, "Approval should return 0 for state persistence"); + + // Verify it's now Pending with timing set + (TimelockPolicy.ProposalStatus statusAfter, uint256 vaAfter, uint256 vuAfter) = + timelockPolicy.getProposal(WALLET, callData, nonce, POLICY_ID, WALLET); + assertEq(uint256(statusAfter), uint256(TimelockPolicy.ProposalStatus.Pending), "Should be Pending after approval"); + assertEq(vaAfter, expectedValidAfter, "validAfter should be set"); + assertEq(vuAfter, expectedValidUntil, "validUntil should be set"); + } + + function test_CannotApproveAlreadyPendingProposal() external whenCallingCheckUserOpPolicyToApproveProposal { + // it should return SIG_VALIDATION_FAILED + bytes memory callData = abi.encodeWithSelector(IERC7579Execution.execute.selector, bytes32(0), "test"); + uint256 nonce = 2100; + + // Create and approve in one step + bytes memory sig = _createProposalSignature(callData, nonce); + PackedUserOperation memory approveOp = _createNoopUserOp(WALLET, sig); + vm.prank(WALLET); + timelockPolicy.checkUserOpPolicy(POLICY_ID, approveOp); + + // Try to approve again + vm.prank(WALLET); + uint256 result = timelockPolicy.checkUserOpPolicy(POLICY_ID, approveOp); + + assertEq(result, SIG_VALIDATION_FAILED, "Should fail for already Pending proposal"); + } + + function test_CannotApproveCancelledProposal() external whenCallingCheckUserOpPolicyToApproveProposal { + // it should return SIG_VALIDATION_FAILED + bytes memory callData = abi.encodeWithSelector(IERC7579Execution.execute.selector, bytes32(0), "test"); + uint256 nonce = 2200; + + // Create and cancel + timelockPolicy.createProposal(POLICY_ID, WALLET, callData, nonce); + vm.prank(WALLET); + timelockPolicy.cancelProposal(POLICY_ID, WALLET, callData, nonce); + + // Try to approve + bytes memory sig = _createProposalSignature(callData, nonce); + PackedUserOperation memory approveOp = _createNoopUserOp(WALLET, sig); + vm.prank(WALLET); + uint256 result = timelockPolicy.checkUserOpPolicy(POLICY_ID, approveOp); + + assertEq(result, SIG_VALIDATION_FAILED, "Should fail for cancelled proposal"); + } + + function test_SpamProposalsAreInert() external whenTestingSecurityScenarios { + // it should demonstrate that spam proposals cannot be executed without approval + bytes memory callData1 = abi.encodeWithSelector(IERC7579Execution.execute.selector, bytes32(0), "approve_usdc"); + bytes memory callData2 = abi.encodeWithSelector(IERC7579Execution.execute.selector, bytes32(0), "approve_weth"); + bytes memory callData3 = abi.encodeWithSelector(IERC7579Execution.execute.selector, bytes32(0), "transfer_all"); + + // Attacker creates many proposals + vm.startPrank(ATTACKER); + timelockPolicy.createProposal(POLICY_ID, WALLET, callData1, 0); + timelockPolicy.createProposal(POLICY_ID, WALLET, callData2, 0); + timelockPolicy.createProposal(POLICY_ID, WALLET, callData3, 0); + vm.stopPrank(); + + // Wait past timelock + vm.warp(block.timestamp + DELAY + 1); + + // None can be executed because they're all in Proposed (inert) status + PackedUserOperation memory op1 = _createUserOpWithCalldata(WALLET, callData1, 0, ""); + PackedUserOperation memory op2 = _createUserOpWithCalldata(WALLET, callData2, 0, ""); + PackedUserOperation memory op3 = _createUserOpWithCalldata(WALLET, callData3, 0, ""); + + vm.prank(WALLET); + assertEq(timelockPolicy.checkUserOpPolicy(POLICY_ID, op1), SIG_VALIDATION_FAILED, "Spam proposal 1 should fail"); + vm.prank(WALLET); + assertEq(timelockPolicy.checkUserOpPolicy(POLICY_ID, op2), SIG_VALIDATION_FAILED, "Spam proposal 2 should fail"); + vm.prank(WALLET); + assertEq(timelockPolicy.checkUserOpPolicy(POLICY_ID, op3), SIG_VALIDATION_FAILED, "Spam proposal 3 should fail"); + } + function test_GivenAttackerTriesToCancelAnotherAccountsProposal() external whenTestingSecurityScenarios { // it should revert with OnlyAccount bytes memory callData = abi.encodeWithSelector(IERC7579Execution.execute.selector, bytes32(0), "test"); @@ -1045,9 +1191,9 @@ contract TimelockTest is Test { vm.expectRevert(TimelockPolicy.OnlyAccount.selector); timelockPolicy.cancelProposal(POLICY_ID, WALLET, callData, nonce); - // Verify proposal is still pending + // Verify proposal is still Proposed (inert) (TimelockPolicy.ProposalStatus status,,) = timelockPolicy.getProposal(WALLET, callData, nonce, POLICY_ID, WALLET); - assertEq(uint256(status), uint256(TimelockPolicy.ProposalStatus.Pending), "Proposal should still be pending"); + assertEq(uint256(status), uint256(TimelockPolicy.ProposalStatus.Proposed), "Proposal should still be Proposed"); } } diff --git a/test/btt/TimelockEpochValidation.t.sol b/test/btt/TimelockEpochValidation.t.sol index 6abe651..9e28484 100644 --- a/test/btt/TimelockEpochValidation.t.sol +++ b/test/btt/TimelockEpochValidation.t.sol @@ -66,6 +66,23 @@ contract TimelockEpochValidationTest is Test { }); } + function _approveProposal(address wallet, bytes32 policyId, bytes memory callData, uint256 nonce) internal { + bytes memory sig = abi.encodePacked(bytes32(callData.length), callData, bytes32(nonce), bytes1(0x00)); + PackedUserOperation memory noopOp = PackedUserOperation({ + sender: wallet, + nonce: 0, + initCode: "", + callData: "", + accountGasLimits: bytes32(abi.encodePacked(uint128(100000), uint128(200000))), + preVerificationGas: 0, + gasFees: bytes32(abi.encodePacked(uint128(1), uint128(1))), + paymasterAndData: "", + signature: sig + }); + vm.prank(wallet); + timelockPolicy.checkUserOpPolicy(policyId, noopOp); + } + // ==================== Installing the Policy ==================== modifier whenInstallingThePolicy() { @@ -150,7 +167,7 @@ contract TimelockEpochValidationTest is Test { _createProposal(WALLET, POLICY_ID_1, callData, nonce); // it should store the current epoch in the proposal - // it should use the epoch from currentEpoch mapping + // it should be in Proposed (inert) status with no timing bytes32 userOpKey = timelockPolicy.computeUserOpKey(WALLET, callData, nonce); ( TimelockPolicy.ProposalStatus status, @@ -159,10 +176,10 @@ contract TimelockEpochValidationTest is Test { uint256 proposalEpoch ) = timelockPolicy.proposals(userOpKey, POLICY_ID_1, WALLET); - assertEq(uint256(status), uint256(TimelockPolicy.ProposalStatus.Pending), "Proposal should be pending"); + assertEq(uint256(status), uint256(TimelockPolicy.ProposalStatus.Proposed), "Proposal should be Proposed (inert)"); assertEq(proposalEpoch, 1, "Proposal epoch should match current epoch (1)"); - assertEq(validAfter, block.timestamp + DELAY, "validAfter should be correct"); - assertEq(validUntil, block.timestamp + DELAY + EXPIRATION_PERIOD, "validUntil should be correct"); + assertEq(validAfter, 0, "validAfter should be 0 (inert)"); + assertEq(validUntil, 0, "validUntil should be 0 (inert)"); } function test_GivenCreatingViaCreateProposalFunction() external whenCreatingAProposal { @@ -171,15 +188,16 @@ contract TimelockEpochValidationTest is Test { bytes memory callData = hex"5678"; uint256 nonce = 42; - uint256 currentEpoch = timelockPolicy.currentEpoch(POLICY_ID_1, WALLET); + uint256 epoch = timelockPolicy.currentEpoch(POLICY_ID_1, WALLET); _createProposal(WALLET, POLICY_ID_1, callData, nonce); - // it should record the epoch at creation time + // it should record the epoch at creation time (proposal is inert) bytes32 userOpKey = timelockPolicy.computeUserOpKey(WALLET, callData, nonce); - (,,, uint256 proposalEpoch) = timelockPolicy.proposals(userOpKey, POLICY_ID_1, WALLET); + (TimelockPolicy.ProposalStatus status,,, uint256 proposalEpoch) = timelockPolicy.proposals(userOpKey, POLICY_ID_1, WALLET); - assertEq(proposalEpoch, currentEpoch, "Proposal epoch should equal current epoch at creation"); + assertEq(uint256(status), uint256(TimelockPolicy.ProposalStatus.Proposed), "Should be Proposed"); + assertEq(proposalEpoch, epoch, "Proposal epoch should equal current epoch at creation"); } // ==================== Executing a Proposal ==================== @@ -204,6 +222,9 @@ contract TimelockEpochValidationTest is Test { _createProposal(WALLET, POLICY_ID_1, callData, nonce); + // Approve the proposal (starts the clock) + _approveProposal(WALLET, POLICY_ID_1, callData, nonce); + // Warp past the timelock delay vm.warp(block.timestamp + DELAY + 1); @@ -227,9 +248,12 @@ contract TimelockEpochValidationTest is Test { bytes memory callData = hex"1234"; uint256 nonce = 1; - // Create proposal in epoch 1 + // Create proposal in epoch 1 (inert) _createProposal(WALLET, POLICY_ID_1, callData, nonce); + // Approve the proposal (starts the clock, epoch 1) + _approveProposal(WALLET, POLICY_ID_1, callData, nonce); + // Warp past the timelock delay vm.warp(block.timestamp + DELAY + 1); @@ -246,10 +270,10 @@ contract TimelockEpochValidationTest is Test { // it should return SIG_VALIDATION_FAILED assertEq(validationResult, SIG_VALIDATION_FAILED_UINT, "Stale proposal should fail validation"); - // it should not mark proposal as executed + // 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); - assertEq(uint256(status), uint256(TimelockPolicy.ProposalStatus.Pending), "Proposal should still be pending"); + assertEq(uint256(status), uint256(TimelockPolicy.ProposalStatus.Pending), "Proposal should still be Pending"); } function test_GivenTheProposalEpochDoesNotMatchCurrentEpoch() external whenExecutingAProposal { @@ -258,8 +282,9 @@ contract TimelockEpochValidationTest is Test { bytes memory callData = hex"abcd"; uint256 nonce = 5; - // Create proposal in epoch 1 + // Create and approve proposal in epoch 1 _createProposal(WALLET, POLICY_ID_1, callData, nonce); + _approveProposal(WALLET, POLICY_ID_1, callData, nonce); bytes32 userOpKey = timelockPolicy.computeUserOpKey(WALLET, callData, nonce); (,,, uint256 proposalEpoch) = timelockPolicy.proposals(userOpKey, POLICY_ID_1, WALLET); @@ -288,7 +313,7 @@ contract TimelockEpochValidationTest is Test { assertEq( uint256(statusAfter), uint256(TimelockPolicy.ProposalStatus.Pending), - "Proposal status should remain pending" + "Proposal status should remain Pending" ); } @@ -338,7 +363,9 @@ contract TimelockEpochValidationTest is Test { bytes memory callData = hex"1234"; uint256 nonce = 1; + // Create and approve in epoch 1 _createProposal(WALLET, POLICY_ID_1, callData, nonce); + _approveProposal(WALLET, POLICY_ID_1, callData, nonce); uint256 epochBeforeUninstall = timelockPolicy.currentEpoch(POLICY_ID_1, WALLET); @@ -359,7 +386,7 @@ contract TimelockEpochValidationTest is Test { uint256 validationResult = timelockPolicy.checkUserOpPolicy(POLICY_ID_1, userOp); assertEq(validationResult, SIG_VALIDATION_FAILED_UINT, "Old proposal should be invalid"); - // it should allow new proposals with new epoch + // it should allow new proposals with new epoch (Proposed status) bytes memory newCallData = hex"5678"; uint256 newNonce = 2; @@ -369,7 +396,7 @@ contract TimelockEpochValidationTest is Test { (TimelockPolicy.ProposalStatus status,,, uint256 newProposalEpoch) = timelockPolicy.proposals(newUserOpKey, POLICY_ID_1, WALLET); - assertEq(uint256(status), uint256(TimelockPolicy.ProposalStatus.Pending), "New proposal should be created"); + assertEq(uint256(status), uint256(TimelockPolicy.ProposalStatus.Proposed), "New proposal should be Proposed"); assertEq(newProposalEpoch, epochAfterReinstall, "New proposal should have current epoch"); } @@ -468,10 +495,11 @@ contract TimelockEpochValidationTest is Test { _uninstallPolicy(WALLET, POLICY_ID_1); _installPolicy(WALLET, POLICY_ID_1); - // User creates legitimate proposal with new epoch + // User creates and approves legitimate proposal with new epoch bytes memory callData = hex"abcd"; uint256 userNonce = 200; _createProposal(WALLET, POLICY_ID_1, callData, userNonce); + _approveProposal(WALLET, POLICY_ID_1, callData, userNonce); vm.warp(block.timestamp + DELAY + 1); @@ -489,12 +517,14 @@ contract TimelockEpochValidationTest is Test { _installPolicy(WALLET, POLICY_ID_1); _installPolicy(WALLET2, POLICY_ID_1); - // Both users create proposals + // Both users create and approve proposals bytes memory callData1 = hex"1111"; bytes memory callData2 = hex"2222"; _createProposal(WALLET, POLICY_ID_1, callData1, 1); + _approveProposal(WALLET, POLICY_ID_1, callData1, 1); _createProposal(WALLET2, POLICY_ID_1, callData2, 1); + _approveProposal(WALLET2, POLICY_ID_1, callData2, 1); vm.warp(block.timestamp + DELAY + 1); @@ -506,7 +536,7 @@ contract TimelockEpochValidationTest is Test { assertEq(timelockPolicy.currentEpoch(POLICY_ID_1, WALLET), 2, "WALLET should be epoch 2"); assertEq(timelockPolicy.currentEpoch(POLICY_ID_1, WALLET2), 1, "WALLET2 should still be epoch 1"); - // WALLET's old proposal should fail + // WALLET's old proposal should fail (epoch mismatch) PackedUserOperation memory userOp1 = _createUserOp(WALLET, callData1, 1); vm.prank(WALLET); uint256 result1 = timelockPolicy.checkUserOpPolicy(POLICY_ID_1, userOp1); From 4b66879afc6f79b1c7c7c84934895dd2e737415a Mon Sep 17 00:00:00 2001 From: taek Date: Mon, 9 Feb 2026 13:19:49 +0900 Subject: [PATCH 14/25] feat(TimelockPolicy): add proposer to ProposalCreated event --- src/policies/TimelockPolicy.sol | 6 +++--- test/btt/Timelock.t.sol | 3 ++- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/policies/TimelockPolicy.sol b/src/policies/TimelockPolicy.sol index 7fdf64d..d261120 100644 --- a/src/policies/TimelockPolicy.sol +++ b/src/policies/TimelockPolicy.sol @@ -51,7 +51,7 @@ contract TimelockPolicy is PolicyBase, IStatelessValidator, IStatelessValidatorW mapping(bytes32 => mapping(bytes32 => mapping(address => Proposal))) public proposals; event ProposalCreated( - address indexed wallet, bytes32 indexed id, bytes32 indexed proposalHash, uint256 validAfter, uint256 validUntil + address indexed wallet, bytes32 indexed id, bytes32 indexed proposalHash, address proposer, uint256 validAfter, uint256 validUntil ); event ProposalApproved( @@ -148,7 +148,7 @@ contract TimelockPolicy is PolicyBase, IStatelessValidator, IStatelessValidatorW proposals[userOpKey][id][account] = Proposal({status: ProposalStatus.Proposed, validAfter: 0, validUntil: 0, epoch: currentEpoch[id][account]}); - emit ProposalCreated(account, id, userOpKey, 0, 0); + emit ProposalCreated(account, id, userOpKey, msg.sender, 0, 0); } /** @@ -251,7 +251,7 @@ contract TimelockPolicy is PolicyBase, IStatelessValidator, IStatelessValidatorW epoch: currentEpoch[id][account] }); - emit ProposalCreated(account, id, userOpKey, validAfter, validUntil); + emit ProposalCreated(account, id, userOpKey, account, validAfter, validUntil); return _packValidationData(0, 0); } else { // Proposal exists in wrong state (Pending, Executed, Cancelled) diff --git a/test/btt/Timelock.t.sol b/test/btt/Timelock.t.sol index f48f842..e52bcd1 100644 --- a/test/btt/Timelock.t.sol +++ b/test/btt/Timelock.t.sol @@ -208,7 +208,7 @@ contract TimelockTest is Test { bytes32 expectedKey = keccak256(abi.encode(WALLET, keccak256(callData), nonce)); vm.expectEmit(true, true, true, true); - emit TimelockPolicy.ProposalCreated(WALLET, POLICY_ID, expectedKey, 0, 0); + emit TimelockPolicy.ProposalCreated(WALLET, POLICY_ID, expectedKey, address(this), 0, 0); timelockPolicy.createProposal(POLICY_ID, WALLET, callData, nonce); @@ -362,6 +362,7 @@ contract TimelockTest is Test { WALLET, POLICY_ID, expectedKey, + WALLET, uint48(block.timestamp) + DELAY, uint48(block.timestamp) + DELAY + EXPIRATION ); From 17d950e221653a743bcf586a7cde5b145178b00b Mon Sep 17 00:00:00 2001 From: taek Date: Mon, 9 Feb 2026 23:24:57 +0900 Subject: [PATCH 15/25] fix(TimelockPolicy): remove createProposal, proposals only via no-op UserOp --- src/policies/TimelockPolicy.sol | 99 ++------- test/TimelockPolicy.t.sol | 79 ++++--- test/btt/Timelock.t.sol | 282 +++++-------------------- test/btt/TimelockEpochValidation.t.sol | 113 ++++------ 4 files changed, 167 insertions(+), 406 deletions(-) diff --git a/src/policies/TimelockPolicy.sol b/src/policies/TimelockPolicy.sol index d261120..4ef4b86 100644 --- a/src/policies/TimelockPolicy.sol +++ b/src/policies/TimelockPolicy.sol @@ -21,8 +21,7 @@ import { contract TimelockPolicy is PolicyBase, IStatelessValidator, IStatelessValidatorWithSender { enum ProposalStatus { None, // Proposal doesn't exist - Proposed, // Proposal created but not yet approved (inert, no clock) - Pending, // Proposal approved, clock started, waiting for timelock + Pending, // Clock started, waiting for timelock Executed, // Proposal executed Cancelled // Proposal cancelled } @@ -51,10 +50,6 @@ contract TimelockPolicy is PolicyBase, IStatelessValidator, IStatelessValidatorW mapping(bytes32 => mapping(bytes32 => mapping(address => Proposal))) public proposals; event ProposalCreated( - address indexed wallet, bytes32 indexed id, bytes32 indexed proposalHash, address proposer, uint256 validAfter, uint256 validUntil - ); - - event ProposalApproved( address indexed wallet, bytes32 indexed id, bytes32 indexed proposalHash, uint256 validAfter, uint256 validUntil ); @@ -67,7 +62,6 @@ contract TimelockPolicy is PolicyBase, IStatelessValidator, IStatelessValidatorW error InvalidDelay(); error InvalidExpirationPeriod(); error ProposalNotFound(); - error ProposalAlreadyExists(); error TimelockNotExpired(uint256 validAfter, uint256 currentTime); error ProposalExpired(uint256 validUntil, uint256 currentTime); error ProposalNotPending(); @@ -122,35 +116,6 @@ contract TimelockPolicy is PolicyBase, IStatelessValidator, IStatelessValidatorW || moduleTypeId == MODULE_TYPE_STATELESS_VALIDATOR_WITH_SENDER; } - /** - * @notice Create a proposal for time-delayed execution - * @dev Anyone can create a proposal. The proposal is inert until approved by the - * session key holder via a no-op UserOp. The timelock clock does not start - * until approval, so spam proposals can be safely ignored. - * @param id The policy ID - * @param account The account address - * @param callData The calldata for the future operation - * @param nonce The nonce for the future operation - */ - function createProposal(bytes32 id, address account, bytes calldata callData, uint256 nonce) external { - TimelockConfig storage config = timelockConfig[id][account]; - if (!config.initialized) revert IModule.NotInitialized(account); - - // Create userOp key for storage lookup - bytes32 userOpKey = keccak256(abi.encode(account, keccak256(callData), nonce)); - - // Check proposal doesn't already exist - if (proposals[userOpKey][id][account].status != ProposalStatus.None) { - revert ProposalAlreadyExists(); - } - - // Create INERT proposal — clock does NOT start until approved via UserOp - proposals[userOpKey][id][account] = - Proposal({status: ProposalStatus.Proposed, validAfter: 0, validUntil: 0, epoch: currentEpoch[id][account]}); - - emit ProposalCreated(account, id, userOpKey, msg.sender, 0, 0); - } - /** * @notice Cancel a pending proposal * @dev Only the account itself can cancel proposals to prevent griefing @@ -170,7 +135,7 @@ contract TimelockPolicy is PolicyBase, IStatelessValidator, IStatelessValidatorW bytes32 userOpKey = keccak256(abi.encode(account, keccak256(callData), nonce)); Proposal storage proposal = proposals[userOpKey][id][account]; - if (proposal.status != ProposalStatus.Pending && proposal.status != ProposalStatus.Proposed) { + if (proposal.status != ProposalStatus.Pending) { revert ProposalNotPending(); } @@ -200,13 +165,12 @@ contract TimelockPolicy is PolicyBase, IStatelessValidator, IStatelessValidatorW } /** - * @notice Handle proposal approval (or create+approve) from userOp - * @dev Called when the session key holder submits a no-op UserOp. - * If a matching inert proposal exists (Proposed status), approves it and starts the clock. - * If no proposal exists, creates and approves in one step. + * @notice Handle proposal creation from a no-op UserOp + * @dev Called when the session key holder submits a no-op UserOp with proposal data in the signature. + * Creates a new Pending proposal with the timelock clock started. * Signature format: [callDataLength(32)][callData][nonce(32)][remaining sig data] */ - function _handleProposalApprovalInternal( + function _handleProposalCreationInternal( bytes32 id, PackedUserOperation calldata userOp, TimelockConfig storage config, @@ -223,40 +187,28 @@ contract TimelockPolicy is PolicyBase, IStatelessValidator, IStatelessValidatorW bytes calldata proposalCallData = sig[32:32 + callDataLength]; uint256 proposalNonce = uint256(bytes32(sig[32 + callDataLength:64 + callDataLength])); - // Calculate proposal timing (clock starts NOW) - uint48 validAfter = uint48(block.timestamp) + config.delay; - 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)); Proposal storage proposal = proposals[userOpKey][id][account]; - if (proposal.status == ProposalStatus.Proposed) { - // Approve existing inert proposal — start the clock - if (proposal.epoch != currentEpoch[id][account]) return SIG_VALIDATION_FAILED_UINT; - - proposal.status = ProposalStatus.Pending; - proposal.validAfter = validAfter; - proposal.validUntil = validUntil; - - emit ProposalApproved(account, id, userOpKey, validAfter, validUntil); - return _packValidationData(0, 0); - } else if (proposal.status == ProposalStatus.None) { - // Create + approve in one step (session key holder creating directly) - proposals[userOpKey][id][account] = Proposal({ - status: ProposalStatus.Pending, - validAfter: validAfter, - validUntil: validUntil, - epoch: currentEpoch[id][account] - }); - - emit ProposalCreated(account, id, userOpKey, account, validAfter, validUntil); - return _packValidationData(0, 0); - } else { - // Proposal exists in wrong state (Pending, Executed, Cancelled) + if (proposal.status != ProposalStatus.None) { return SIG_VALIDATION_FAILED_UINT; } + + // Calculate proposal timing (clock starts NOW) + uint48 validAfter = uint48(block.timestamp) + config.delay; + uint48 validUntil = validAfter + config.expirationPeriod; + + 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); } /** @@ -393,12 +345,7 @@ contract TimelockPolicy is PolicyBase, IStatelessValidator, IStatelessValidatorW * @notice Check signature against timelock policy (for ERC-1271) * @dev TimelockPolicy does not support ERC-1271 signature validation - always reverts */ - function checkSignaturePolicy(bytes32, address, bytes32, bytes calldata) - external - pure - override - returns (uint256) - { + function checkSignaturePolicy(bytes32, address, bytes32, bytes calldata) external pure override returns (uint256) { revert("TimelockPolicy: signature validation not supported"); } @@ -436,7 +383,7 @@ contract TimelockPolicy is PolicyBase, IStatelessValidator, IStatelessValidatorW // Check if this is a proposal approval (or create+approve) request // Criteria: calldata is a no-op AND signature has proposal data (length >= 65) if (_isNoOpCalldata(userOp.callData) && sig.length >= 65) { - return _handleProposalApprovalInternal(id, userOp, config, sig, account); + return _handleProposalCreationInternal(id, userOp, config, sig, account); } // Otherwise, this is a proposal execution request diff --git a/test/TimelockPolicy.t.sol b/test/TimelockPolicy.t.sol index 71423c5..951f6b9 100644 --- a/test/TimelockPolicy.t.sol +++ b/test/TimelockPolicy.t.sol @@ -173,16 +173,10 @@ contract TimelockPolicyTest is PolicyTestBase, StatelessValidatorTestBase, State PackedUserOperation memory userOp = validUserOp(); - // First create a proposal (inert) - vm.startPrank(WALLET); - policyModule.createProposal(policyId(), WALLET, userOp.callData, userOp.nonce); - vm.stopPrank(); - - // Approve the proposal via no-op UserOp (starts the clock) - bytes memory sig = abi.encodePacked( - bytes32(userOp.callData.length), userOp.callData, bytes32(userOp.nonce), bytes1(0x00) - ); - PackedUserOperation memory approveOp = PackedUserOperation({ + // Create proposal via no-op UserOp (creates Pending directly with clock started) + bytes memory sig = + abi.encodePacked(bytes32(userOp.callData.length), userOp.callData, bytes32(userOp.nonce), bytes1(0x00)); + PackedUserOperation memory noopOp = PackedUserOperation({ sender: WALLET, nonce: 0, initCode: "", @@ -194,7 +188,7 @@ contract TimelockPolicyTest is PolicyTestBase, StatelessValidatorTestBase, State signature: sig }); vm.startPrank(WALLET); - policyModule.checkUserOpPolicy(policyId(), approveOp); + policyModule.checkUserOpPolicy(policyId(), noopOp); vm.stopPrank(); // Fast forward past the delay @@ -266,17 +260,33 @@ contract TimelockPolicyTest is PolicyTestBase, StatelessValidatorTestBase, State bytes memory callData = hex"1234"; uint256 nonce = 1; + // Create proposal via no-op UserOp + bytes memory sig = abi.encodePacked(bytes32(callData.length), callData, bytes32(nonce), bytes1(0x00)); + PackedUserOperation memory noopOp = PackedUserOperation({ + sender: WALLET, + nonce: 0, + initCode: "", + callData: "", + accountGasLimits: bytes32(abi.encodePacked(uint128(100000), uint128(200000))), + preVerificationGas: 0, + gasFees: bytes32(abi.encodePacked(uint128(1), uint128(1))), + paymasterAndData: "", + signature: sig + }); + vm.startPrank(WALLET); - policyModule.createProposal(policyId(), WALLET, callData, nonce); + uint256 result = policyModule.checkUserOpPolicy(policyId(), noopOp); vm.stopPrank(); - // Verify proposal was created as inert (Proposed) + assertEq(result, 0); + + // Verify proposal was created as Pending with timing (TimelockPolicy.ProposalStatus status, uint256 validAfter, uint256 validUntil) = policyModule.getProposal(WALLET, callData, nonce, policyId(), WALLET); - assertEq(uint256(status), uint256(TimelockPolicy.ProposalStatus.Proposed)); - assertEq(validAfter, 0); - assertEq(validUntil, 0); + assertEq(uint256(status), uint256(TimelockPolicy.ProposalStatus.Pending)); + assertGt(validAfter, 0); + assertGt(validUntil, validAfter); } function testCancelProposal() public { @@ -288,9 +298,22 @@ contract TimelockPolicyTest is PolicyTestBase, StatelessValidatorTestBase, State bytes memory callData = hex"1234"; uint256 nonce = 1; - // Create proposal + // Create proposal via no-op UserOp + bytes memory sig = abi.encodePacked(bytes32(callData.length), callData, bytes32(nonce), bytes1(0x00)); + PackedUserOperation memory noopOp = PackedUserOperation({ + sender: WALLET, + nonce: 0, + initCode: "", + callData: "", + accountGasLimits: bytes32(abi.encodePacked(uint128(100000), uint128(200000))), + preVerificationGas: 0, + gasFees: bytes32(abi.encodePacked(uint128(1), uint128(1))), + paymasterAndData: "", + signature: sig + }); + vm.startPrank(WALLET); - policyModule.createProposal(policyId(), WALLET, callData, nonce); + policyModule.checkUserOpPolicy(policyId(), noopOp); vm.stopPrank(); // Cancel proposal @@ -310,7 +333,7 @@ contract TimelockPolicyTest is PolicyTestBase, StatelessValidatorTestBase, State policyModule.onInstall(abi.encodePacked(policyId(), installData())); vm.stopPrank(); - // Create+approve a proposal via checkUserOpPolicy with no-op calldata + // Create a proposal via checkUserOpPolicy with no-op calldata bytes memory proposalCallData = hex"1234"; uint256 proposalNonce = 1; @@ -340,7 +363,7 @@ contract TimelockPolicyTest is PolicyTestBase, StatelessValidatorTestBase, State // Returns success (validationData = 0) for state persistence assertEq(result, 0); - // Verify proposal was created+approved (Pending with timing) + // Verify proposal was created as Pending with timing (TimelockPolicy.ProposalStatus status,,) = policyModule.getProposal(WALLET, proposalCallData, proposalNonce, policyId(), WALLET); @@ -356,16 +379,10 @@ contract TimelockPolicyTest is PolicyTestBase, StatelessValidatorTestBase, State PackedUserOperation memory userOp = validUserOp(); - // Create and approve a proposal - vm.startPrank(WALLET); - policyModule.createProposal(policyId(), WALLET, userOp.callData, userOp.nonce); - vm.stopPrank(); - - // Approve via no-op UserOp - bytes memory sig = abi.encodePacked( - bytes32(userOp.callData.length), userOp.callData, bytes32(userOp.nonce), bytes1(0x00) - ); - PackedUserOperation memory approveOp = PackedUserOperation({ + // Create proposal via no-op UserOp (creates Pending directly) + bytes memory sig = + abi.encodePacked(bytes32(userOp.callData.length), userOp.callData, bytes32(userOp.nonce), bytes1(0x00)); + PackedUserOperation memory noopOp = PackedUserOperation({ sender: WALLET, nonce: 0, initCode: "", @@ -377,7 +394,7 @@ contract TimelockPolicyTest is PolicyTestBase, StatelessValidatorTestBase, State signature: sig }); vm.startPrank(WALLET); - policyModule.checkUserOpPolicy(policyId(), approveOp); + policyModule.checkUserOpPolicy(policyId(), noopOp); vm.stopPrank(); // Fast forward past delay diff --git a/test/btt/Timelock.t.sol b/test/btt/Timelock.t.sol index e52bcd1..739ab31 100644 --- a/test/btt/Timelock.t.sol +++ b/test/btt/Timelock.t.sol @@ -192,88 +192,19 @@ contract TimelockTest is Test { assertFalse(timelockPolicy.isModuleType(999), "Should not support invalid type"); } - // ============ createProposal Tests ============ - - modifier whenCallingCreateProposal() { - _; - } - - function test_GivenConfigIsInitializedAndProposalDoesNotExist() external whenCallingCreateProposal { - // it should store the proposal with Proposed (inert) status - // it should NOT set timing (validAfter = 0, validUntil = 0) - // it should emit ProposalCreated with zero timing - bytes memory callData = abi.encodeWithSelector(IERC7579Execution.execute.selector, bytes32(0), "test"); - uint256 nonce = 100; - - bytes32 expectedKey = keccak256(abi.encode(WALLET, keccak256(callData), nonce)); - - vm.expectEmit(true, true, true, true); - emit TimelockPolicy.ProposalCreated(WALLET, POLICY_ID, expectedKey, address(this), 0, 0); - - timelockPolicy.createProposal(POLICY_ID, WALLET, callData, nonce); - - (TimelockPolicy.ProposalStatus status, uint256 validAfter, uint256 validUntil) = - timelockPolicy.getProposal(WALLET, callData, nonce, POLICY_ID, WALLET); - - assertEq(uint256(status), uint256(TimelockPolicy.ProposalStatus.Proposed), "Status should be Proposed"); - assertEq(validAfter, 0, "validAfter should be 0 (inert)"); - assertEq(validUntil, 0, "validUntil should be 0 (inert)"); - } - - function test_GivenNotInitialized_WhenCallingCreateProposal() external whenCallingCreateProposal { - // it should revert with NotInitialized - address uninitWallet = address(0x9999); - bytes memory callData = abi.encodeWithSelector(IERC7579Execution.execute.selector, bytes32(0), "test"); - - vm.expectRevert(abi.encodeWithSelector(IModule.NotInitialized.selector, uninitWallet)); - timelockPolicy.createProposal(POLICY_ID, uninitWallet, callData, 0); - } - - function test_GivenProposalAlreadyExists() external whenCallingCreateProposal { - // it should revert with ProposalAlreadyExists - bytes memory callData = abi.encodeWithSelector(IERC7579Execution.execute.selector, bytes32(0), "test"); - uint256 nonce = 200; - - timelockPolicy.createProposal(POLICY_ID, WALLET, callData, nonce); - - vm.expectRevert(TimelockPolicy.ProposalAlreadyExists.selector); - timelockPolicy.createProposal(POLICY_ID, WALLET, callData, nonce); - } - // ============ cancelProposal Tests ============ modifier whenCallingCancelProposal() { _; } - function test_GivenCallerIsAccountAndProposalIsProposed() external whenCallingCancelProposal { - // it should set status to cancelled (cancelling an inert Proposed proposal) - // it should emit ProposalCancelled - bytes memory callData = abi.encodeWithSelector(IERC7579Execution.execute.selector, bytes32(0), "test"); - uint256 nonce = 300; - - timelockPolicy.createProposal(POLICY_ID, WALLET, callData, nonce); - - bytes32 expectedKey = keccak256(abi.encode(WALLET, keccak256(callData), nonce)); - - vm.expectEmit(true, true, true, true); - emit TimelockPolicy.ProposalCancelled(WALLET, POLICY_ID, expectedKey); - - 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), "Status should be Cancelled"); - } - function test_GivenCallerIsAccountAndProposalIsPending() external whenCallingCancelProposal { - // it should set status to cancelled (cancelling an approved Pending proposal) + // it should set status to cancelled (cancelling a Pending proposal) // it should emit ProposalCancelled bytes memory callData = abi.encodeWithSelector(IERC7579Execution.execute.selector, bytes32(0), "test"); uint256 nonce = 301; - // Create and approve via UserOp to get Pending status + // Create proposal via no-op UserOp to get Pending status bytes memory sig = _createProposalSignature(callData, nonce); PackedUserOperation memory userOp = _createNoopUserOp(WALLET, sig); vm.prank(WALLET); @@ -297,7 +228,11 @@ contract TimelockTest is Test { bytes memory callData = abi.encodeWithSelector(IERC7579Execution.execute.selector, bytes32(0), "test"); uint256 nonce = 400; - timelockPolicy.createProposal(POLICY_ID, WALLET, callData, nonce); + // Create proposal via no-op UserOp + bytes memory sig = _createProposalSignature(callData, nonce); + PackedUserOperation memory noopOp = _createNoopUserOp(WALLET, sig); + vm.prank(WALLET); + timelockPolicy.checkUserOpPolicy(POLICY_ID, noopOp); vm.prank(ATTACKER); vm.expectRevert(TimelockPolicy.OnlyAccount.selector); @@ -329,7 +264,11 @@ contract TimelockTest is Test { bytes memory callData = abi.encodeWithSelector(IERC7579Execution.execute.selector, bytes32(0), "test"); uint256 nonce = 600; - timelockPolicy.createProposal(POLICY_ID, WALLET, callData, nonce); + // Create proposal via no-op UserOp + bytes memory sig = _createProposalSignature(callData, nonce); + PackedUserOperation memory noopOp = _createNoopUserOp(WALLET, sig); + vm.prank(WALLET); + timelockPolicy.checkUserOpPolicy(POLICY_ID, noopOp); vm.prank(WALLET); timelockPolicy.cancelProposal(POLICY_ID, WALLET, callData, nonce); @@ -346,7 +285,7 @@ contract TimelockTest is Test { } function test_GivenNoopCalldataAndValidSignature() external whenCallingCheckUserOpPolicyToCreateProposal { - // it should create+approve the proposal in one step (no prior external proposal) + // it should create the proposal as Pending with clock started // it should return zero for state persistence // it should emit ProposalCreated with timing bytes memory proposalCallData = abi.encodeWithSelector(IERC7579Execution.execute.selector, bytes32(0), "test"); @@ -362,7 +301,6 @@ contract TimelockTest is Test { WALLET, POLICY_ID, expectedKey, - WALLET, uint48(block.timestamp) + DELAY, uint48(block.timestamp) + DELAY + EXPIRATION ); @@ -375,7 +313,7 @@ contract TimelockTest is Test { (TimelockPolicy.ProposalStatus status,,) = timelockPolicy.getProposal(WALLET, proposalCallData, proposalNonce, POLICY_ID, WALLET); - assertEq(uint256(status), uint256(TimelockPolicy.ProposalStatus.Pending), "Proposal should be Pending (created+approved)"); + assertEq(uint256(status), uint256(TimelockPolicy.ProposalStatus.Pending), "Proposal should be Pending"); } function test_GivenNoopCalldataAndSignatureShorterThan65Bytes() @@ -419,11 +357,11 @@ contract TimelockTest is Test { PackedUserOperation memory userOp = _createNoopUserOp(WALLET, sig); - // First call: create+approve → Pending + // First call: create -> Pending vm.prank(WALLET); timelockPolicy.checkUserOpPolicy(POLICY_ID, userOp); - // Second call: already Pending → should fail + // Second call: already Pending -> should fail vm.prank(WALLET); uint256 result = timelockPolicy.checkUserOpPolicy(POLICY_ID, userOp); @@ -443,14 +381,11 @@ contract TimelockTest is Test { bytes memory proposalCallData = abi.encodeWithSelector(IERC7579Execution.execute.selector, bytes32(0), "action"); uint256 proposalNonce = 900; - // Create inert proposal - timelockPolicy.createProposal(POLICY_ID, WALLET, proposalCallData, proposalNonce); - - // Approve the proposal via no-op UserOp (starts the clock) + // Create proposal via no-op UserOp (creates Pending directly) bytes memory sig = _createProposalSignature(proposalCallData, proposalNonce); - PackedUserOperation memory approveOp = _createNoopUserOp(WALLET, sig); + PackedUserOperation memory noopOp = _createNoopUserOp(WALLET, sig); vm.prank(WALLET); - timelockPolicy.checkUserOpPolicy(POLICY_ID, approveOp); + timelockPolicy.checkUserOpPolicy(POLICY_ID, noopOp); // Get the actual stored proposal values (now has timing) (, uint256 storedValidAfter, uint256 storedValidUntil) = @@ -495,7 +430,11 @@ contract TimelockTest is Test { bytes memory callData = abi.encodeWithSelector(IERC7579Execution.execute.selector, bytes32(0), "test"); uint256 nonce = 1000; - timelockPolicy.createProposal(POLICY_ID, WALLET, callData, nonce); + // Create proposal via no-op UserOp + bytes memory sig = _createProposalSignature(callData, nonce); + PackedUserOperation memory noopOp = _createNoopUserOp(WALLET, sig); + vm.prank(WALLET); + timelockPolicy.checkUserOpPolicy(POLICY_ID, noopOp); vm.prank(WALLET); timelockPolicy.cancelProposal(POLICY_ID, WALLET, callData, nonce); @@ -513,11 +452,11 @@ contract TimelockTest is Test { bytes memory callData = abi.encodeWithSelector(IERC7579Execution.execute.selector, bytes32(0), "test"); uint256 nonce = 1100; - // Create and approve via UserOp + // Create proposal via no-op UserOp bytes memory sig = _createProposalSignature(callData, nonce); - PackedUserOperation memory approveOp = _createNoopUserOp(WALLET, sig); + PackedUserOperation memory noopOp = _createNoopUserOp(WALLET, sig); vm.prank(WALLET); - timelockPolicy.checkUserOpPolicy(POLICY_ID, approveOp); + timelockPolicy.checkUserOpPolicy(POLICY_ID, noopOp); vm.warp(block.timestamp + DELAY + 1); @@ -649,14 +588,22 @@ contract TimelockTest is Test { bytes memory callData = abi.encodeWithSelector(IERC7579Execution.execute.selector, bytes32(0), "test"); uint256 nonce = 1200; - timelockPolicy.createProposal(POLICY_ID, WALLET, callData, nonce); + // Create proposal via no-op UserOp (creates Pending with timing) + bytes memory sig = _createProposalSignature(callData, nonce); + PackedUserOperation memory noopOp = _createNoopUserOp(WALLET, sig); + vm.prank(WALLET); + timelockPolicy.checkUserOpPolicy(POLICY_ID, noopOp); (TimelockPolicy.ProposalStatus status, uint256 validAfter, uint256 validUntil) = timelockPolicy.getProposal(WALLET, callData, nonce, POLICY_ID, WALLET); - assertEq(uint256(status), uint256(TimelockPolicy.ProposalStatus.Proposed), "Status should be Proposed"); - assertEq(validAfter, 0, "validAfter should be 0 (inert)"); - assertEq(validUntil, 0, "validUntil should be 0 (inert)"); + assertEq(uint256(status), uint256(TimelockPolicy.ProposalStatus.Pending), "Status should be Pending"); + assertEq(validAfter, uint256(uint48(block.timestamp) + DELAY), "validAfter should be block.timestamp + DELAY"); + assertEq( + validUntil, + uint256(uint48(block.timestamp) + DELAY + EXPIRATION), + "validUntil should be validAfter + EXPIRATION" + ); } function test_GivenProposalDoesNotExist_WhenCallingGetProposal() external whenCallingGetProposal { @@ -955,15 +902,15 @@ contract TimelockTest is Test { function test_WhenPackingValidationData() external { // it should correctly pack validAfter and validUntil - // Test the packing by creating+approving a proposal and checking the returned validation data + // Test the packing by creating a proposal and checking the returned validation data bytes memory callData = abi.encodeWithSelector(IERC7579Execution.execute.selector, bytes32(0), "test"); uint256 nonce = 1400; - // Create and approve via UserOp + // Create proposal via no-op UserOp bytes memory sig = _createProposalSignature(callData, nonce); - PackedUserOperation memory approveOp = _createNoopUserOp(WALLET, sig); + PackedUserOperation memory noopOp = _createNoopUserOp(WALLET, sig); vm.prank(WALLET); - timelockPolicy.checkUserOpPolicy(POLICY_ID, approveOp); + timelockPolicy.checkUserOpPolicy(POLICY_ID, noopOp); // Get the actual stored proposal values (, uint256 storedValidAfter, uint256 storedValidUntil) = @@ -1000,32 +947,16 @@ contract TimelockTest is Test { assertEq(result, SIG_VALIDATION_FAILED, "Attack without proposal should fail"); } - function test_GivenAttackerTriesToExecuteUnapprovedProposal() external whenTestingSecurityScenarios { - // it should return SIG_VALIDATION_FAILED because proposal is inert (Proposed, not Pending) - bytes memory callData = abi.encodeWithSelector(IERC7579Execution.execute.selector, bytes32(0), "action"); - uint256 nonce = 1500; - - timelockPolicy.createProposal(POLICY_ID, WALLET, callData, nonce); - - // Try to execute without approval - PackedUserOperation memory executeOp = _createUserOpWithCalldata(WALLET, callData, nonce, ""); - - vm.prank(WALLET); - uint256 result = timelockPolicy.checkUserOpPolicy(POLICY_ID, executeOp); - - assertEq(result, SIG_VALIDATION_FAILED, "Unapproved proposal should fail execution"); - } - function test_GivenAttackerTriesToReexecuteAUsedProposal() external whenTestingSecurityScenarios { // it should return SIG_VALIDATION_FAILED bytes memory callData = abi.encodeWithSelector(IERC7579Execution.execute.selector, bytes32(0), "action"); uint256 nonce = 1600; - // Create and approve via UserOp + // Create proposal via no-op UserOp bytes memory sig = _createProposalSignature(callData, nonce); - PackedUserOperation memory approveOp = _createNoopUserOp(WALLET, sig); + PackedUserOperation memory noopOp = _createNoopUserOp(WALLET, sig); vm.prank(WALLET); - timelockPolicy.checkUserOpPolicy(POLICY_ID, approveOp); + timelockPolicy.checkUserOpPolicy(POLICY_ID, noopOp); vm.warp(block.timestamp + DELAY + 1); @@ -1070,131 +1001,24 @@ contract TimelockTest is Test { assertGt(validUntil, validAfter, "validUntil should be after validAfter"); } - // ============ Proposal Approval Tests ============ - - modifier whenCallingCheckUserOpPolicyToApproveProposal() { - _; - } - - function test_ApproveExistingProposal() external whenCallingCheckUserOpPolicyToApproveProposal { - // it should transition Proposed → Pending and start the clock - bytes memory callData = abi.encodeWithSelector(IERC7579Execution.execute.selector, bytes32(0), "approve_test"); - uint256 nonce = 2000; - - // Create inert proposal externally - timelockPolicy.createProposal(POLICY_ID, WALLET, callData, nonce); - - // Verify it's Proposed with no timing - (TimelockPolicy.ProposalStatus statusBefore, uint256 vaBefore, uint256 vuBefore) = - timelockPolicy.getProposal(WALLET, callData, nonce, POLICY_ID, WALLET); - assertEq(uint256(statusBefore), uint256(TimelockPolicy.ProposalStatus.Proposed), "Should be Proposed"); - assertEq(vaBefore, 0, "validAfter should be 0 before approval"); - assertEq(vuBefore, 0, "validUntil should be 0 before approval"); - - // Approve via no-op UserOp - bytes memory sig = _createProposalSignature(callData, nonce); - PackedUserOperation memory approveOp = _createNoopUserOp(WALLET, sig); - - bytes32 expectedKey = keccak256(abi.encode(WALLET, keccak256(callData), nonce)); - uint48 expectedValidAfter = uint48(block.timestamp) + DELAY; - uint48 expectedValidUntil = expectedValidAfter + EXPIRATION; - - vm.expectEmit(true, true, true, true); - emit TimelockPolicy.ProposalApproved(WALLET, POLICY_ID, expectedKey, expectedValidAfter, expectedValidUntil); - - vm.prank(WALLET); - uint256 result = timelockPolicy.checkUserOpPolicy(POLICY_ID, approveOp); - - assertEq(result, 0, "Approval should return 0 for state persistence"); - - // Verify it's now Pending with timing set - (TimelockPolicy.ProposalStatus statusAfter, uint256 vaAfter, uint256 vuAfter) = - timelockPolicy.getProposal(WALLET, callData, nonce, POLICY_ID, WALLET); - assertEq(uint256(statusAfter), uint256(TimelockPolicy.ProposalStatus.Pending), "Should be Pending after approval"); - assertEq(vaAfter, expectedValidAfter, "validAfter should be set"); - assertEq(vuAfter, expectedValidUntil, "validUntil should be set"); - } - - function test_CannotApproveAlreadyPendingProposal() external whenCallingCheckUserOpPolicyToApproveProposal { - // it should return SIG_VALIDATION_FAILED - bytes memory callData = abi.encodeWithSelector(IERC7579Execution.execute.selector, bytes32(0), "test"); - uint256 nonce = 2100; - - // Create and approve in one step - bytes memory sig = _createProposalSignature(callData, nonce); - PackedUserOperation memory approveOp = _createNoopUserOp(WALLET, sig); - vm.prank(WALLET); - timelockPolicy.checkUserOpPolicy(POLICY_ID, approveOp); - - // Try to approve again - vm.prank(WALLET); - uint256 result = timelockPolicy.checkUserOpPolicy(POLICY_ID, approveOp); - - assertEq(result, SIG_VALIDATION_FAILED, "Should fail for already Pending proposal"); - } - - function test_CannotApproveCancelledProposal() external whenCallingCheckUserOpPolicyToApproveProposal { - // it should return SIG_VALIDATION_FAILED - bytes memory callData = abi.encodeWithSelector(IERC7579Execution.execute.selector, bytes32(0), "test"); - uint256 nonce = 2200; - - // Create and cancel - timelockPolicy.createProposal(POLICY_ID, WALLET, callData, nonce); - vm.prank(WALLET); - timelockPolicy.cancelProposal(POLICY_ID, WALLET, callData, nonce); - - // Try to approve - bytes memory sig = _createProposalSignature(callData, nonce); - PackedUserOperation memory approveOp = _createNoopUserOp(WALLET, sig); - vm.prank(WALLET); - uint256 result = timelockPolicy.checkUserOpPolicy(POLICY_ID, approveOp); - - assertEq(result, SIG_VALIDATION_FAILED, "Should fail for cancelled proposal"); - } - - function test_SpamProposalsAreInert() external whenTestingSecurityScenarios { - // it should demonstrate that spam proposals cannot be executed without approval - bytes memory callData1 = abi.encodeWithSelector(IERC7579Execution.execute.selector, bytes32(0), "approve_usdc"); - bytes memory callData2 = abi.encodeWithSelector(IERC7579Execution.execute.selector, bytes32(0), "approve_weth"); - bytes memory callData3 = abi.encodeWithSelector(IERC7579Execution.execute.selector, bytes32(0), "transfer_all"); - - // Attacker creates many proposals - vm.startPrank(ATTACKER); - timelockPolicy.createProposal(POLICY_ID, WALLET, callData1, 0); - timelockPolicy.createProposal(POLICY_ID, WALLET, callData2, 0); - timelockPolicy.createProposal(POLICY_ID, WALLET, callData3, 0); - vm.stopPrank(); - - // Wait past timelock - vm.warp(block.timestamp + DELAY + 1); - - // None can be executed because they're all in Proposed (inert) status - PackedUserOperation memory op1 = _createUserOpWithCalldata(WALLET, callData1, 0, ""); - PackedUserOperation memory op2 = _createUserOpWithCalldata(WALLET, callData2, 0, ""); - PackedUserOperation memory op3 = _createUserOpWithCalldata(WALLET, callData3, 0, ""); - - vm.prank(WALLET); - assertEq(timelockPolicy.checkUserOpPolicy(POLICY_ID, op1), SIG_VALIDATION_FAILED, "Spam proposal 1 should fail"); - vm.prank(WALLET); - assertEq(timelockPolicy.checkUserOpPolicy(POLICY_ID, op2), SIG_VALIDATION_FAILED, "Spam proposal 2 should fail"); - vm.prank(WALLET); - assertEq(timelockPolicy.checkUserOpPolicy(POLICY_ID, op3), SIG_VALIDATION_FAILED, "Spam proposal 3 should fail"); - } - function test_GivenAttackerTriesToCancelAnotherAccountsProposal() external whenTestingSecurityScenarios { // it should revert with OnlyAccount bytes memory callData = abi.encodeWithSelector(IERC7579Execution.execute.selector, bytes32(0), "test"); uint256 nonce = 1800; - timelockPolicy.createProposal(POLICY_ID, WALLET, callData, nonce); + // Create proposal via no-op UserOp from WALLET + bytes memory sig = _createProposalSignature(callData, nonce); + PackedUserOperation memory noopOp = _createNoopUserOp(WALLET, sig); + vm.prank(WALLET); + timelockPolicy.checkUserOpPolicy(POLICY_ID, noopOp); vm.prank(ATTACKER); vm.expectRevert(TimelockPolicy.OnlyAccount.selector); timelockPolicy.cancelProposal(POLICY_ID, WALLET, callData, nonce); - // Verify proposal is still Proposed (inert) + // Verify proposal is still Pending (TimelockPolicy.ProposalStatus status,,) = timelockPolicy.getProposal(WALLET, callData, nonce, POLICY_ID, WALLET); - assertEq(uint256(status), uint256(TimelockPolicy.ProposalStatus.Proposed), "Proposal should still be Proposed"); + assertEq(uint256(status), uint256(TimelockPolicy.ProposalStatus.Pending), "Proposal should still be Pending"); } } diff --git a/test/btt/TimelockEpochValidation.t.sol b/test/btt/TimelockEpochValidation.t.sol index 9e28484..92a049a 100644 --- a/test/btt/TimelockEpochValidation.t.sol +++ b/test/btt/TimelockEpochValidation.t.sol @@ -44,8 +44,21 @@ contract TimelockEpochValidationTest is Test { } function _createProposal(address wallet, bytes32 policyId, bytes memory callData, uint256 nonce) internal { + // Create proposal via no-op UserOp (creates Pending directly with clock started) + bytes memory sig = abi.encodePacked(bytes32(callData.length), callData, bytes32(nonce), bytes1(0x00)); + PackedUserOperation memory noopOp = PackedUserOperation({ + sender: wallet, + nonce: 0, + initCode: "", + callData: "", + accountGasLimits: bytes32(abi.encodePacked(uint128(100000), uint128(200000))), + preVerificationGas: 0, + gasFees: bytes32(abi.encodePacked(uint128(1), uint128(1))), + paymasterAndData: "", + signature: sig + }); vm.prank(wallet); - timelockPolicy.createProposal(policyId, wallet, callData, nonce); + timelockPolicy.checkUserOpPolicy(policyId, noopOp); } function _createUserOp(address sender, bytes memory callData, uint256 nonce) @@ -66,23 +79,6 @@ contract TimelockEpochValidationTest is Test { }); } - function _approveProposal(address wallet, bytes32 policyId, bytes memory callData, uint256 nonce) internal { - bytes memory sig = abi.encodePacked(bytes32(callData.length), callData, bytes32(nonce), bytes1(0x00)); - PackedUserOperation memory noopOp = PackedUserOperation({ - sender: wallet, - nonce: 0, - initCode: "", - callData: "", - accountGasLimits: bytes32(abi.encodePacked(uint128(100000), uint128(200000))), - preVerificationGas: 0, - gasFees: bytes32(abi.encodePacked(uint128(1), uint128(1))), - paymasterAndData: "", - signature: sig - }); - vm.prank(wallet); - timelockPolicy.checkUserOpPolicy(policyId, noopOp); - } - // ==================== Installing the Policy ==================== modifier whenInstallingThePolicy() { @@ -102,8 +98,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, bool initialized) = - timelockPolicy.timelockConfig(POLICY_ID_1, WALLET); + (uint48 delay, uint48 expirationPeriod, bool initialized) = timelockPolicy.timelockConfig(POLICY_ID_1, WALLET); assertTrue(initialized, "Policy should be initialized"); assertEq(delay, DELAY, "Delay should match"); assertEq(expirationPeriod, EXPIRATION_PERIOD, "Expiration period should match"); @@ -167,22 +162,18 @@ contract TimelockEpochValidationTest is Test { _createProposal(WALLET, POLICY_ID_1, callData, nonce); // it should store the current epoch in the proposal - // it should be in Proposed (inert) status with no timing + // it should be in Pending status with timing set bytes32 userOpKey = timelockPolicy.computeUserOpKey(WALLET, callData, nonce); - ( - TimelockPolicy.ProposalStatus status, - uint48 validAfter, - uint48 validUntil, - uint256 proposalEpoch - ) = timelockPolicy.proposals(userOpKey, POLICY_ID_1, WALLET); - - assertEq(uint256(status), uint256(TimelockPolicy.ProposalStatus.Proposed), "Proposal should be Proposed (inert)"); + (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, 0, "validAfter should be 0 (inert)"); - assertEq(validUntil, 0, "validUntil should be 0 (inert)"); + assertGt(validAfter, 0, "validAfter should be set"); + assertGt(validUntil, validAfter, "validUntil should be after validAfter"); } - function test_GivenCreatingViaCreateProposalFunction() external whenCreatingAProposal { + function test_GivenCreatingViaNoOpUserOp() external whenCreatingAProposal { _installPolicy(WALLET, POLICY_ID_1); bytes memory callData = hex"5678"; @@ -192,11 +183,12 @@ contract TimelockEpochValidationTest is Test { _createProposal(WALLET, POLICY_ID_1, callData, nonce); - // it should record the epoch at creation time (proposal is inert) + // it should record the epoch at creation time (proposal is Pending) bytes32 userOpKey = timelockPolicy.computeUserOpKey(WALLET, callData, nonce); - (TimelockPolicy.ProposalStatus status,,, uint256 proposalEpoch) = timelockPolicy.proposals(userOpKey, POLICY_ID_1, WALLET); + (TimelockPolicy.ProposalStatus status,,, uint256 proposalEpoch) = + timelockPolicy.proposals(userOpKey, POLICY_ID_1, WALLET); - assertEq(uint256(status), uint256(TimelockPolicy.ProposalStatus.Proposed), "Should be Proposed"); + assertEq(uint256(status), uint256(TimelockPolicy.ProposalStatus.Pending), "Should be Pending"); assertEq(proposalEpoch, epoch, "Proposal epoch should equal current epoch at creation"); } @@ -220,11 +212,9 @@ contract TimelockEpochValidationTest is Test { bytes memory callData = hex"1234"; uint256 nonce = 1; + // Create proposal via no-op UserOp (creates Pending directly with clock started) _createProposal(WALLET, POLICY_ID_1, callData, nonce); - // Approve the proposal (starts the clock) - _approveProposal(WALLET, POLICY_ID_1, callData, nonce); - // Warp past the timelock delay vm.warp(block.timestamp + DELAY + 1); @@ -248,12 +238,9 @@ contract TimelockEpochValidationTest is Test { bytes memory callData = hex"1234"; uint256 nonce = 1; - // Create proposal in epoch 1 (inert) + // Create proposal via no-op UserOp (Pending with clock started, epoch 1) _createProposal(WALLET, POLICY_ID_1, callData, nonce); - // Approve the proposal (starts the clock, epoch 1) - _approveProposal(WALLET, POLICY_ID_1, callData, nonce); - // Warp past the timelock delay vm.warp(block.timestamp + DELAY + 1); @@ -282,9 +269,8 @@ contract TimelockEpochValidationTest is Test { bytes memory callData = hex"abcd"; uint256 nonce = 5; - // Create and approve proposal in epoch 1 + // Create proposal via no-op UserOp in epoch 1 (Pending with clock started) _createProposal(WALLET, POLICY_ID_1, callData, nonce); - _approveProposal(WALLET, POLICY_ID_1, callData, nonce); bytes32 userOpKey = timelockPolicy.computeUserOpKey(WALLET, callData, nonce); (,,, uint256 proposalEpoch) = timelockPolicy.proposals(userOpKey, POLICY_ID_1, WALLET); @@ -363,9 +349,8 @@ contract TimelockEpochValidationTest is Test { bytes memory callData = hex"1234"; uint256 nonce = 1; - // Create and approve in epoch 1 + // Create proposal via no-op UserOp in epoch 1 (Pending with clock started) _createProposal(WALLET, POLICY_ID_1, callData, nonce); - _approveProposal(WALLET, POLICY_ID_1, callData, nonce); uint256 epochBeforeUninstall = timelockPolicy.currentEpoch(POLICY_ID_1, WALLET); @@ -386,7 +371,7 @@ contract TimelockEpochValidationTest is Test { uint256 validationResult = timelockPolicy.checkUserOpPolicy(POLICY_ID_1, userOp); assertEq(validationResult, SIG_VALIDATION_FAILED_UINT, "Old proposal should be invalid"); - // it should allow new proposals with new epoch (Proposed status) + // it should allow new proposals with new epoch (Pending status) bytes memory newCallData = hex"5678"; uint256 newNonce = 2; @@ -396,7 +381,7 @@ contract TimelockEpochValidationTest is Test { (TimelockPolicy.ProposalStatus status,,, uint256 newProposalEpoch) = timelockPolicy.proposals(newUserOpKey, POLICY_ID_1, WALLET); - assertEq(uint256(status), uint256(TimelockPolicy.ProposalStatus.Proposed), "New proposal should be Proposed"); + assertEq(uint256(status), uint256(TimelockPolicy.ProposalStatus.Pending), "New proposal should be Pending"); assertEq(newProposalEpoch, epochAfterReinstall, "New proposal should have current epoch"); } @@ -456,35 +441,26 @@ contract TimelockEpochValidationTest is Test { _; } - function test_WhenAttackerTriesToExecuteOldProposal() external whenVerifyingFullAttackScenario { + function test_WhenAttackerCannotCreateProposalsDirectly() external whenVerifyingFullAttackScenario { // User installs policy _installPolicy(WALLET, POLICY_ID_1); assertEq(timelockPolicy.currentEpoch(POLICY_ID_1, WALLET), 1, "Should start at epoch 1"); - // Attacker creates malicious proposal + // Without createProposal, an attacker cannot create proposals for WALLET. + // The only way to create a proposal is via checkUserOpPolicy which requires + // msg.sender == WALLET (the account itself). So there is no external attack vector. + + // Verify that trying to execute a non-existent proposal fails bytes memory maliciousCallData = hex"deadbeef"; uint256 maliciousNonce = 666; - vm.prank(ATTACKER); - timelockPolicy.createProposal(POLICY_ID_1, WALLET, maliciousCallData, maliciousNonce); - - // Time passes, timelock expires - vm.warp(block.timestamp + DELAY + 1); - - // User decides to uninstall and reinstall - _uninstallPolicy(WALLET, POLICY_ID_1); - _installPolicy(WALLET, POLICY_ID_1); - - assertEq(timelockPolicy.currentEpoch(POLICY_ID_1, WALLET), 2, "Should be at epoch 2"); - - // Attacker tries to execute the old proposal PackedUserOperation memory maliciousUserOp = _createUserOp(WALLET, maliciousCallData, maliciousNonce); vm.prank(WALLET); uint256 validationResult = timelockPolicy.checkUserOpPolicy(POLICY_ID_1, maliciousUserOp); - // it should fail due to epoch mismatch - assertEq(validationResult, SIG_VALIDATION_FAILED_UINT, "Attack should be thwarted by epoch check"); + // it should fail because no proposal exists + assertEq(validationResult, SIG_VALIDATION_FAILED_UINT, "Non-existent proposal should fail"); } function test_WhenUserCreatesNewProposalAfterReinstall() external whenVerifyingFullAttackScenario { @@ -495,11 +471,10 @@ contract TimelockEpochValidationTest is Test { _uninstallPolicy(WALLET, POLICY_ID_1); _installPolicy(WALLET, POLICY_ID_1); - // User creates and approves legitimate proposal with new epoch + // User creates legitimate proposal via no-op UserOp with new epoch bytes memory callData = hex"abcd"; uint256 userNonce = 200; _createProposal(WALLET, POLICY_ID_1, callData, userNonce); - _approveProposal(WALLET, POLICY_ID_1, callData, userNonce); vm.warp(block.timestamp + DELAY + 1); @@ -517,14 +492,12 @@ contract TimelockEpochValidationTest is Test { _installPolicy(WALLET, POLICY_ID_1); _installPolicy(WALLET2, POLICY_ID_1); - // Both users create and approve proposals + // Both users create proposals via no-op UserOp bytes memory callData1 = hex"1111"; bytes memory callData2 = hex"2222"; _createProposal(WALLET, POLICY_ID_1, callData1, 1); - _approveProposal(WALLET, POLICY_ID_1, callData1, 1); _createProposal(WALLET2, POLICY_ID_1, callData2, 1); - _approveProposal(WALLET2, POLICY_ID_1, callData2, 1); vm.warp(block.timestamp + DELAY + 1); From 28ee229897e667aaabc629b3817fa8c130e33e01 Mon Sep 17 00:00:00 2001 From: taek Date: Mon, 9 Feb 2026 23:36:16 +0900 Subject: [PATCH 16/25] fix(TimelockPolicy): remove dead code and add callDataLength overflow guard --- src/policies/TimelockPolicy.sol | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/src/policies/TimelockPolicy.sol b/src/policies/TimelockPolicy.sol index 4ef4b86..56d330e 100644 --- a/src/policies/TimelockPolicy.sol +++ b/src/policies/TimelockPolicy.sol @@ -61,12 +61,8 @@ contract TimelockPolicy is PolicyBase, IStatelessValidator, IStatelessValidatorW error InvalidDelay(); error InvalidExpirationPeriod(); - error ProposalNotFound(); - error TimelockNotExpired(uint256 validAfter, uint256 currentTime); - error ProposalExpired(uint256 validUntil, uint256 currentTime); error ProposalNotPending(); error OnlyAccount(); - error ProposalFromPreviousEpoch(); error ParametersTooLarge(); /** @@ -82,7 +78,7 @@ contract TimelockPolicy is PolicyBase, IStatelessValidator, IStatelessValidatorW if (delay == 0) revert InvalidDelay(); if (expirationPeriod == 0) revert InvalidExpirationPeriod(); - // Prevent uint48 overflow in createProposal: uint48(block.timestamp) + delay + expirationPeriod + // Prevent uint48 overflow: uint48(block.timestamp) + delay + expirationPeriod if (uint256(delay) + uint256(expirationPeriod) > type(uint48).max - block.timestamp) { revert ParametersTooLarge(); } @@ -181,8 +177,8 @@ contract TimelockPolicy is PolicyBase, IStatelessValidator, IStatelessValidatorW // Format: [callDataLength(32 bytes)][callData][nonce(32 bytes)][...] uint256 callDataLength = uint256(bytes32(sig[0:32])); - // Validate signature has enough data - if (sig.length < 64 + callDataLength) return SIG_VALIDATION_FAILED_UINT; + // Validate signature has enough data (check callDataLength first to prevent overflow) + if (callDataLength > sig.length || sig.length < 64 + callDataLength) return SIG_VALIDATION_FAILED_UINT; bytes calldata proposalCallData = sig[32:32 + callDataLength]; uint256 proposalNonce = uint256(bytes32(sig[32 + callDataLength:64 + callDataLength])); @@ -380,7 +376,7 @@ 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 approval (or create+approve) request + // 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) { return _handleProposalCreationInternal(id, userOp, config, sig, account); From 4a83f3f66f8893019123e253ac7dae4f71023a80 Mon Sep 17 00:00:00 2001 From: taek Date: Tue, 10 Feb 2026 00:13:58 +0900 Subject: [PATCH 17/25] fix(TimelockPolicy): add mode check and fix executeUserOp offset in no-op detection --- src/policies/TimelockPolicy.sol | 10 ++++--- test/btt/Timelock.t.sol | 46 ++++++++++++++++++++++++++++----- 2 files changed, 47 insertions(+), 9 deletions(-) diff --git a/src/policies/TimelockPolicy.sol b/src/policies/TimelockPolicy.sol index 88bfcdb..be4103e 100644 --- a/src/policies/TimelockPolicy.sol +++ b/src/policies/TimelockPolicy.sol @@ -281,6 +281,10 @@ contract TimelockPolicy is PolicyBase, IStatelessValidator, IStatelessValidatorW // ABI layout: 4 (selector) + 32 (mode) + 32 (offset) + 32 (length) + data if (callData.length < 100) return false; + // Only accept single call mode (callType = first byte of mode must be 0x00). + // Prevents delegatecall (0xFE) or batch (0x01) payloads from being treated as no-ops. + if (callData[4] != 0x00) return false; + // Offset to executionCalldata: 2 head slots (mode + offset) = 64 uint256 offset = uint256(bytes32(callData[36:68])); if (offset != 64) return false; @@ -314,12 +318,12 @@ contract TimelockPolicy is PolicyBase, IStatelessValidator, IStatelessValidatorW */ function _isNoOpExecuteUserOp(bytes calldata callData) internal pure returns (bool) { // executeUserOp(bytes calldata userOp, bytes32 userOpHash) - // Format: 4 (selector) + 32 (userOp offset) + 32 (userOpHash) + 32 (userOp length) + userOp data + // Format: 4 (selector) + 32 (userOp offset=64) + 32 (userOpHash) + 32 (userOp length) + userOp data if (callData.length < 100) return false; - // Decode offset to userOp data (should be 32) + // Decode offset to userOp data (should be 64: past 2 head slots) uint256 offset = uint256(bytes32(callData[4:36])); - if (offset != 32) return false; + if (offset != 64) return false; // userOpHash is at bytes 36-68 (we don't validate it) diff --git a/test/btt/Timelock.t.sol b/test/btt/Timelock.t.sol index a52af49..ec254a8 100644 --- a/test/btt/Timelock.t.sol +++ b/test/btt/Timelock.t.sol @@ -825,6 +825,40 @@ contract TimelockTest is Test { assertEq(result, SIG_VALIDATION_FAILED, "Non-empty inner calldata should not be noop"); } + function test_GivenModeIsDelegatecall() external whenDetectingERC7579ExecuteNoop { + // it should not be detected as noop (delegatecall mode 0xFE) + // Mode with callType=0xFE (delegatecall) should be rejected + bytes32 delegatecallMode = bytes32(uint256(0xFE) << 248); + bytes memory executionCalldata = abi.encodePacked(bytes20(WALLET), uint256(0)); + + bytes memory callData = + abi.encodeWithSelector(IERC7579Execution.execute.selector, delegatecallMode, executionCalldata); + + bytes memory sig = _createProposalSignature("proposal", 3); + PackedUserOperation memory userOp = _createUserOpWithCalldata(WALLET, callData, 0, sig); + + vm.prank(WALLET); + uint256 result = timelockPolicy.checkUserOpPolicy(POLICY_ID, userOp); + + assertEq(result, SIG_VALIDATION_FAILED, "Delegatecall mode should not be noop"); + } + + function test_GivenModeIsBatch() external whenDetectingERC7579ExecuteNoop { + // it should not be detected as noop (batch mode 0x01) + bytes32 batchMode = bytes32(uint256(0x01) << 248); + bytes memory executionCalldata = abi.encodePacked(bytes20(WALLET), uint256(0)); + + bytes memory callData = + abi.encodeWithSelector(IERC7579Execution.execute.selector, batchMode, executionCalldata); + + PackedUserOperation memory userOp = _createUserOpWithCalldata(WALLET, callData, 0, ""); + + vm.prank(WALLET); + uint256 result = timelockPolicy.checkUserOpPolicy(POLICY_ID, userOp); + + assertEq(result, SIG_VALIDATION_FAILED, "Batch mode should not be noop"); + } + // ============ _isNoOpExecuteUserOp Tests ============ modifier whenDetectingExecuteUserOpNoop() { @@ -833,11 +867,11 @@ contract TimelockTest is Test { function test_GivenUserOpDataIsEmpty() external whenDetectingExecuteUserOpNoop { // it should be detected as noop - bytes memory callData = abi.encodePacked( + // Use encodeWithSelector to guarantee proper ABI encoding + bytes memory callData = abi.encodeWithSelector( IAccountExecute.executeUserOp.selector, - bytes32(uint256(32)), // offset to userOp - bytes32(0), // userOpHash - bytes32(uint256(0)) // userOp length = 0 + "", // empty bytes userOp + bytes32(0) // userOpHash ); bytes memory sig = _createProposalSignature("proposal", 2); @@ -864,11 +898,11 @@ contract TimelockTest is Test { assertEq(result, SIG_VALIDATION_FAILED, "Too short executeUserOp should not be noop"); } - function test_GivenOffsetIsNot32_WhenDetectingExecuteUserOpNoop() external whenDetectingExecuteUserOpNoop { + function test_GivenOffsetIsNot64_WhenDetectingExecuteUserOpNoop() external whenDetectingExecuteUserOpNoop { // it should not be detected as noop bytes memory callData = abi.encodePacked( IAccountExecute.executeUserOp.selector, - bytes32(uint256(64)), // wrong offset + bytes32(uint256(32)), // wrong offset (should be 64) bytes32(0), bytes32(uint256(0)) ); From 810bc68bd431e72c495ed64b9e837b4f0515a6d8 Mon Sep 17 00:00:00 2001 From: taek Date: Tue, 10 Feb 2026 01:22:19 +0900 Subject: [PATCH 18/25] fix(TimelockPolicy): correct ERC-7579 no-op detection encoding --- src/policies/TimelockPolicy.sol | 106 ++++-------- test/btt/Timelock.t.sol | 280 ++++++-------------------------- 2 files changed, 83 insertions(+), 303 deletions(-) diff --git a/src/policies/TimelockPolicy.sol b/src/policies/TimelockPolicy.sol index be4103e..b88d1e1 100644 --- a/src/policies/TimelockPolicy.sol +++ b/src/policies/TimelockPolicy.sol @@ -243,95 +243,49 @@ contract TimelockPolicy is PolicyBase, IStatelessValidator, IStatelessValidatorW /** * @notice Check if calldata is a no-op operation - * @dev Valid no-ops: + * @dev Recognizes 4 forms of no-op: * 1. Empty calldata - * 2. ERC-7579 execute(CALL, self, 0, "") - * 3. ERC-7579 execute(CALL, address(0), 0, "") - * 4. executeUserOp with empty calldata + * 2. ERC-7579 execute(mode=0x00, "") — single-call with empty execution data + * 3. executeUserOp + empty inner calldata (just the 4-byte selector) + * 4. executeUserOp + ERC-7579 execute no-op (selector + form 2) */ - function _isNoOpCalldata(bytes calldata callData) internal view returns (bool) { - // 1. Empty calldata is a no-op - if (callData.length == 0) return true; + function _isNoOpCalldata(bytes calldata callData) internal pure returns (bool) { + uint256 len = callData.length; - // Need at least 4 bytes for selector - if (callData.length < 4) return false; + // Case 1: Empty calldata + if (len == 0) return true; - bytes4 selector = bytes4(callData[0:4]); + // Case 2: ERC-7579 execute with empty execution data + if (_isNoOpERC7579Execute(callData)) return true; - // 2. Check for ERC-7579 execute(bytes32 mode, bytes calldata executionCalldata) - if (selector == IERC7579Execution.execute.selector) { - return _isNoOpERC7579Execute(callData); + // Cases 3 & 4: executeUserOp wrapper + if (len >= 4 && bytes4(callData[0:4]) == IAccountExecute.executeUserOp.selector) { + // Case 3: executeUserOp + empty (just the selector, no inner data) + if (len == 4) return true; + // Case 4: executeUserOp + ERC-7579 execute no-op + if (_isNoOpERC7579Execute(callData[4:])) return true; } - // 3. Check for executeUserOp(PackedUserOperation calldata userOp, bytes32 userOpHash) - if (selector == IAccountExecute.executeUserOp.selector) { - return _isNoOpExecuteUserOp(callData); - } - - // Not a recognized no-op return false; } /** - * @notice Check if ERC-7579 execute call is a no-op - * @dev Valid: execute(CALL, self/address(0), 0, "") + * @notice Check if calldata is an ERC-7579 execute call with empty execution data + * @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 */ - function _isNoOpERC7579Execute(bytes calldata callData) internal view returns (bool) { - // execute(bytes32 mode, bytes calldata executionCalldata) - // ABI layout: 4 (selector) + 32 (mode) + 32 (offset) + 32 (length) + data - if (callData.length < 100) return false; - - // Only accept single call mode (callType = first byte of mode must be 0x00). - // Prevents delegatecall (0xFE) or batch (0x01) payloads from being treated as no-ops. + function _isNoOpERC7579Execute(bytes calldata callData) internal pure returns (bool) { + 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 to executionCalldata: 2 head slots (mode + offset) = 64 - uint256 offset = uint256(bytes32(callData[36:68])); - if (offset != 64) return false; - - // Decode the length of executionCalldata - uint256 execDataLength = uint256(bytes32(callData[68:100])); - - // ERC-7579 single execution uses compact format (no length prefix): - // executionCalldata = abi.encodePacked(target, value, calldata) - // target (20 bytes) + value (32 bytes) = 52 bytes with no inner calldata - if (execDataLength != 52) return false; - - if (callData.length < 152) return false; - - // Extract target address (first 20 bytes of executionCalldata) - address target = address(bytes20(callData[100:120])); - - // Check if target is self or address(0) - if (target != msg.sender && target != address(0)) return false; - - // Extract value (next 32 bytes) - uint256 value = uint256(bytes32(callData[120:152])); - - // Value must be 0 - return value == 0; - } - - /** - * @notice Check if executeUserOp call is a no-op - * @dev Valid: executeUserOp("", bytes32) - */ - function _isNoOpExecuteUserOp(bytes calldata callData) internal pure returns (bool) { - // executeUserOp(bytes calldata userOp, bytes32 userOpHash) - // Format: 4 (selector) + 32 (userOp offset=64) + 32 (userOpHash) + 32 (userOp length) + userOp data - if (callData.length < 100) return false; - - // Decode offset to userOp data (should be 64: past 2 head slots) - uint256 offset = uint256(bytes32(callData[4:36])); - if (offset != 64) return false; - - // userOpHash is at bytes 36-68 (we don't validate it) - - // Decode userOp length - uint256 userOpLength = uint256(bytes32(callData[68:100])); - - // UserOp must be empty - return userOpLength == 0; + // 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; } /** diff --git a/test/btt/Timelock.t.sol b/test/btt/Timelock.t.sol index ec254a8..e51f3a5 100644 --- a/test/btt/Timelock.t.sol +++ b/test/btt/Timelock.t.sol @@ -637,6 +637,7 @@ contract TimelockTest is Test { _; } + // Case 1: Empty calldata function test_GivenCalldataIsEmpty() external whenDetectingNoopCalldata { // it should be detected as noop bytes memory sig = _createProposalSignature("test", 0); @@ -649,193 +650,66 @@ contract TimelockTest is Test { assertEq(result, 0, "Empty calldata should be detected as noop"); } - function test_GivenCalldataIsShorterThan4Bytes() external whenDetectingNoopCalldata { - // it should not be detected as noop - bytes memory shortCalldata = hex"aabb"; - PackedUserOperation memory userOp = _createUserOpWithCalldata(WALLET, shortCalldata, 0, ""); - - vm.prank(WALLET); - uint256 result = timelockPolicy.checkUserOpPolicy(POLICY_ID, userOp); - - // Not a no-op, goes to execution path, no proposal exists - assertEq(result, SIG_VALIDATION_FAILED, "Short calldata should not be noop"); - } - - function test_GivenSelectorIsUnrecognized() external whenDetectingNoopCalldata { - // it should not be detected as noop - bytes memory unknownCalldata = abi.encodeWithSelector(bytes4(0xdeadbeef), "test"); - PackedUserOperation memory userOp = _createUserOpWithCalldata(WALLET, unknownCalldata, 0, ""); - - vm.prank(WALLET); - uint256 result = timelockPolicy.checkUserOpPolicy(POLICY_ID, userOp); - - assertEq(result, SIG_VALIDATION_FAILED, "Unknown selector should not be noop"); - } - - // ============ _isNoOpERC7579Execute Tests ============ - - modifier whenDetectingERC7579ExecuteNoop() { - _; - } - - function test_GivenTargetIsSelfAndValueIsZeroAndInnerCalldataIsEmpty() external whenDetectingERC7579ExecuteNoop { + // Case 2: ERC-7579 execute(mode=0x00, "") — single-call with empty execution data + function test_GivenCalldataIsERC7579ExecuteNoop() external whenDetectingNoopCalldata { // it should be detected as noop - // ERC-7579 compact format: abi.encodePacked(target, value) = 52 bytes - bytes memory executionCalldata = abi.encodePacked(bytes20(WALLET), uint256(0)); - - bytes memory callData = - abi.encodeWithSelector(IERC7579Execution.execute.selector, bytes32(0), executionCalldata); - - bytes memory sig = _createProposalSignature("proposal", 0); - PackedUserOperation memory userOp = _createUserOpWithCalldata(WALLET, callData, 0, sig); + bytes memory noopExecute = abi.encodeWithSelector(IERC7579Execution.execute.selector, bytes32(0), ""); + bytes memory sig = _createProposalSignature("test", 1); + PackedUserOperation memory userOp = _createUserOpWithCalldata(WALLET, noopExecute, 0, sig); vm.prank(WALLET); uint256 result = timelockPolicy.checkUserOpPolicy(POLICY_ID, userOp); - assertEq(result, 0, "ERC7579 execute to self with zero value should be noop"); + assertEq(result, 0, "ERC-7579 execute noop should be detected as noop"); } - function test_GivenTargetIsZeroAddressAndValueIsZeroAndInnerCalldataIsEmpty() - external - whenDetectingERC7579ExecuteNoop - { + // Case 3: executeUserOp + empty inner calldata (just the 4-byte selector) + function test_GivenCalldataIsExecuteUserOpEmpty() external whenDetectingNoopCalldata { // it should be detected as noop - // ERC-7579 compact format: abi.encodePacked(target, value) = 52 bytes - bytes memory executionCalldata = abi.encodePacked(bytes20(address(0)), uint256(0)); - - bytes memory callData = - abi.encodeWithSelector(IERC7579Execution.execute.selector, bytes32(0), executionCalldata); - - bytes memory sig = _createProposalSignature("proposal", 1); - PackedUserOperation memory userOp = _createUserOpWithCalldata(WALLET, callData, 0, sig); + bytes memory executeUserOpNoop = abi.encodePacked(IAccountExecute.executeUserOp.selector); + bytes memory sig = _createProposalSignature("test", 2); + PackedUserOperation memory userOp = _createUserOpWithCalldata(WALLET, executeUserOpNoop, 0, sig); vm.prank(WALLET); uint256 result = timelockPolicy.checkUserOpPolicy(POLICY_ID, userOp); - assertEq(result, 0, "ERC7579 execute to zero address with zero value should be noop"); + assertEq(result, 0, "executeUserOp + empty should be detected as noop"); } - function test_GivenCalldataIsShorterThan68Bytes() external whenDetectingERC7579ExecuteNoop { - // it should not be detected as noop - bytes memory shortCallData = abi.encodeWithSelector(IERC7579Execution.execute.selector, bytes32(0)); - - PackedUserOperation memory userOp = _createUserOpWithCalldata(WALLET, shortCallData, 0, ""); - - vm.prank(WALLET); - uint256 result = timelockPolicy.checkUserOpPolicy(POLICY_ID, userOp); - - assertEq(result, SIG_VALIDATION_FAILED, "Too short for offset should not be noop"); - } - - function test_GivenOffsetIsNot64() external whenDetectingERC7579ExecuteNoop { - // it should not be detected as noop - bytes memory callData = abi.encodePacked( - IERC7579Execution.execute.selector, - bytes32(0), // mode - bytes32(uint256(32)), // wrong offset (should be 64) - bytes32(uint256(52)), // length - bytes20(WALLET), - uint256(0) - ); - - PackedUserOperation memory userOp = _createUserOpWithCalldata(WALLET, callData, 0, ""); - - vm.prank(WALLET); - uint256 result = timelockPolicy.checkUserOpPolicy(POLICY_ID, userOp); - - assertEq(result, SIG_VALIDATION_FAILED, "Wrong offset should not be noop"); - } - - function test_GivenCalldataIsShorterThan100Bytes() external whenDetectingERC7579ExecuteNoop { - // it should not be detected as noop - bytes memory callData = abi.encodePacked( - IERC7579Execution.execute.selector, - bytes32(0), // mode - bytes32(uint256(64)) // offset - // missing length and data - ); - - PackedUserOperation memory userOp = _createUserOpWithCalldata(WALLET, callData, 0, ""); - - vm.prank(WALLET); - uint256 result = timelockPolicy.checkUserOpPolicy(POLICY_ID, userOp); - - assertEq(result, SIG_VALIDATION_FAILED, "Too short for length should not be noop"); - } - - function test_GivenExecDataLengthIsNot52() external whenDetectingERC7579ExecuteNoop { - // it should not be detected as noop (length 20 != 52) - bytes memory callData = abi.encodePacked( - IERC7579Execution.execute.selector, - bytes32(0), // mode - bytes32(uint256(64)), // offset - bytes32(uint256(20)) // length only 20 (must be exactly 52) - ); - - PackedUserOperation memory userOp = _createUserOpWithCalldata(WALLET, callData, 0, ""); - - vm.prank(WALLET); - uint256 result = timelockPolicy.checkUserOpPolicy(POLICY_ID, userOp); - - assertEq(result, SIG_VALIDATION_FAILED, "Exec data length != 52 should not be noop"); - } - - function test_GivenTargetIsNotSelfOrZero() external whenDetectingERC7579ExecuteNoop { - // it should not be detected as noop - bytes memory executionCalldata = abi.encodePacked(bytes20(ATTACKER), uint256(0)); - - bytes memory callData = - abi.encodeWithSelector(IERC7579Execution.execute.selector, bytes32(0), executionCalldata); - - PackedUserOperation memory userOp = _createUserOpWithCalldata(WALLET, callData, 0, ""); + // 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), ""); + bytes memory executeUserOpWrapped = abi.encodePacked(IAccountExecute.executeUserOp.selector, noopExecute); + bytes memory sig = _createProposalSignature("test", 3); + PackedUserOperation memory userOp = _createUserOpWithCalldata(WALLET, executeUserOpWrapped, 0, sig); vm.prank(WALLET); uint256 result = timelockPolicy.checkUserOpPolicy(POLICY_ID, userOp); - assertEq(result, SIG_VALIDATION_FAILED, "Wrong target should not be noop"); + assertEq(result, 0, "executeUserOp + ERC-7579 execute noop should be detected as noop"); } - function test_GivenValueIsNonzero() external whenDetectingERC7579ExecuteNoop { + // Negative: non-empty arbitrary calldata + function test_GivenCalldataIsNonEmpty() external whenDetectingNoopCalldata { // it should not be detected as noop - bytes memory executionCalldata = abi.encodePacked(bytes20(WALLET), uint256(1 ether)); - - bytes memory callData = - abi.encodeWithSelector(IERC7579Execution.execute.selector, bytes32(0), executionCalldata); - - PackedUserOperation memory userOp = _createUserOpWithCalldata(WALLET, callData, 0, ""); + bytes memory nonEmptyCalldata = hex"aabb"; + PackedUserOperation memory userOp = _createUserOpWithCalldata(WALLET, nonEmptyCalldata, 0, ""); vm.prank(WALLET); uint256 result = timelockPolicy.checkUserOpPolicy(POLICY_ID, userOp); - assertEq(result, SIG_VALIDATION_FAILED, "Non-zero value should not be noop"); - } - - function test_GivenExecDataLengthGreaterThan52() external whenDetectingERC7579ExecuteNoop { - // it should not be detected as noop (has inner calldata) - bytes memory executionCalldata = abi.encodePacked(bytes20(WALLET), uint256(0), hex"deadbeef"); - - bytes memory callData = - abi.encodeWithSelector(IERC7579Execution.execute.selector, bytes32(0), executionCalldata); - - PackedUserOperation memory userOp = _createUserOpWithCalldata(WALLET, callData, 0, ""); - - vm.prank(WALLET); - uint256 result = timelockPolicy.checkUserOpPolicy(POLICY_ID, userOp); - - assertEq(result, SIG_VALIDATION_FAILED, "Non-empty inner calldata should not be noop"); + // Not a no-op, goes to execution path, no proposal exists + assertEq(result, SIG_VALIDATION_FAILED, "Non-empty calldata should not be noop"); } - function test_GivenModeIsDelegatecall() external whenDetectingERC7579ExecuteNoop { - // it should not be detected as noop (delegatecall mode 0xFE) - // Mode with callType=0xFE (delegatecall) should be rejected + // 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 executionCalldata = abi.encodePacked(bytes20(WALLET), uint256(0)); - - bytes memory callData = - abi.encodeWithSelector(IERC7579Execution.execute.selector, delegatecallMode, executionCalldata); - - bytes memory sig = _createProposalSignature("proposal", 3); - PackedUserOperation memory userOp = _createUserOpWithCalldata(WALLET, callData, 0, sig); + bytes memory delegatecallExecute = + abi.encodeWithSelector(IERC7579Execution.execute.selector, delegatecallMode, ""); + PackedUserOperation memory userOp = _createUserOpWithCalldata(WALLET, delegatecallExecute, 0, ""); vm.prank(WALLET); uint256 result = timelockPolicy.checkUserOpPolicy(POLICY_ID, userOp); @@ -843,15 +717,12 @@ contract TimelockTest is Test { assertEq(result, SIG_VALIDATION_FAILED, "Delegatecall mode should not be noop"); } - function test_GivenModeIsBatch() external whenDetectingERC7579ExecuteNoop { - // it should not be detected as noop (batch mode 0x01) + // 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 executionCalldata = abi.encodePacked(bytes20(WALLET), uint256(0)); - - bytes memory callData = - abi.encodeWithSelector(IERC7579Execution.execute.selector, batchMode, executionCalldata); - - PackedUserOperation memory userOp = _createUserOpWithCalldata(WALLET, callData, 0, ""); + bytes memory batchExecute = abi.encodeWithSelector(IERC7579Execution.execute.selector, batchMode, ""); + PackedUserOperation memory userOp = _createUserOpWithCalldata(WALLET, batchExecute, 0, ""); vm.prank(WALLET); uint256 result = timelockPolicy.checkUserOpPolicy(POLICY_ID, userOp); @@ -859,77 +730,32 @@ contract TimelockTest is Test { assertEq(result, SIG_VALIDATION_FAILED, "Batch mode should not be noop"); } - // ============ _isNoOpExecuteUserOp Tests ============ - - modifier whenDetectingExecuteUserOpNoop() { - _; - } - - function test_GivenUserOpDataIsEmpty() external whenDetectingExecuteUserOpNoop { - // it should be detected as noop - // Use encodeWithSelector to guarantee proper ABI encoding - bytes memory callData = abi.encodeWithSelector( - IAccountExecute.executeUserOp.selector, - "", // empty bytes userOp - bytes32(0) // userOpHash - ); - - bytes memory sig = _createProposalSignature("proposal", 2); - PackedUserOperation memory userOp = _createUserOpWithCalldata(WALLET, callData, 0, sig); - - vm.prank(WALLET); - uint256 result = timelockPolicy.checkUserOpPolicy(POLICY_ID, userOp); - - assertEq(result, 0, "executeUserOp with empty userOp should be noop"); - } - - function test_GivenCalldataIsShorterThan100Bytes_WhenDetectingExecuteUserOpNoop() - external - whenDetectingExecuteUserOpNoop - { - // it should not be detected as noop - bytes memory callData = abi.encodePacked(IAccountExecute.executeUserOp.selector, bytes32(uint256(32))); - - PackedUserOperation memory userOp = _createUserOpWithCalldata(WALLET, callData, 0, ""); + // Negative: ERC-7579 execute with non-empty execution data + function test_GivenCalldataIsERC7579ExecuteWithData() external whenDetectingNoopCalldata { + // it should not be detected as noop — has execution data + bytes memory executeWithData = + abi.encodeWithSelector(IERC7579Execution.execute.selector, bytes32(0), "some_execution_data"); + PackedUserOperation memory userOp = _createUserOpWithCalldata(WALLET, executeWithData, 0, ""); vm.prank(WALLET); uint256 result = timelockPolicy.checkUserOpPolicy(POLICY_ID, userOp); - assertEq(result, SIG_VALIDATION_FAILED, "Too short executeUserOp should not be noop"); + assertEq(result, SIG_VALIDATION_FAILED, "Execute with data should not be noop"); } - function test_GivenOffsetIsNot64_WhenDetectingExecuteUserOpNoop() external whenDetectingExecuteUserOpNoop { + // Negative: executeUserOp wrapping a delegatecall ERC-7579 execute + function test_GivenCalldataIsExecuteUserOpWithDelegatecall() external whenDetectingNoopCalldata { // it should not be detected as noop - bytes memory callData = abi.encodePacked( - IAccountExecute.executeUserOp.selector, - bytes32(uint256(32)), // wrong offset (should be 64) - bytes32(0), - bytes32(uint256(0)) - ); - - PackedUserOperation memory userOp = _createUserOpWithCalldata(WALLET, callData, 0, ""); - - vm.prank(WALLET); - uint256 result = timelockPolicy.checkUserOpPolicy(POLICY_ID, userOp); - - assertEq(result, SIG_VALIDATION_FAILED, "Wrong offset in executeUserOp should not be noop"); - } - - function test_GivenUserOpLengthIsNonzero() external whenDetectingExecuteUserOpNoop { - // it should not be detected as noop - bytes memory callData = abi.encodePacked( - IAccountExecute.executeUserOp.selector, - bytes32(uint256(32)), - bytes32(0), - bytes32(uint256(10)) // non-empty userOp - ); - - PackedUserOperation memory userOp = _createUserOpWithCalldata(WALLET, callData, 0, ""); + bytes32 delegatecallMode = bytes32(uint256(0xFE) << 248); + bytes memory delegatecallExecute = + abi.encodeWithSelector(IERC7579Execution.execute.selector, delegatecallMode, ""); + bytes memory wrapped = abi.encodePacked(IAccountExecute.executeUserOp.selector, delegatecallExecute); + PackedUserOperation memory userOp = _createUserOpWithCalldata(WALLET, wrapped, 0, ""); vm.prank(WALLET); uint256 result = timelockPolicy.checkUserOpPolicy(POLICY_ID, userOp); - assertEq(result, SIG_VALIDATION_FAILED, "Non-empty userOp should not be noop"); + assertEq(result, SIG_VALIDATION_FAILED, "executeUserOp + delegatecall should not be noop"); } // ============ _packValidationData Tests ============ From d0bbbb19408315bcde4d835a13a95543587b3c65 Mon Sep 17 00:00:00 2001 From: taek Date: Wed, 11 Feb 2026 14:44:25 +0900 Subject: [PATCH 19/25] test: add EntryPoint integration tests for TimelockPolicy --- test/integration/TimelockEntryPoint.t.sol | 981 ++++++++++++++++++++++ test/utils/MockTimelockAccount.sol | 44 + 2 files changed, 1025 insertions(+) create mode 100644 test/integration/TimelockEntryPoint.t.sol create mode 100644 test/utils/MockTimelockAccount.sol diff --git a/test/integration/TimelockEntryPoint.t.sol b/test/integration/TimelockEntryPoint.t.sol new file mode 100644 index 0000000..df87545 --- /dev/null +++ b/test/integration/TimelockEntryPoint.t.sol @@ -0,0 +1,981 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import {Test} from "forge-std/Test.sol"; +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 {EntryPointLib} from "../utils/EntryPointLib.sol"; +import {MockTimelockAccount} from "../utils/MockTimelockAccount.sol"; +import {TimelockPolicy} from "../../src/policies/TimelockPolicy.sol"; + +/// @title TimelockEntryPointTest +/// @notice Integration tests that exercise TimelockPolicy through the real EntryPoint v0.9. +/// Verifies that validAfter/validUntil returned by the policy are correctly enforced +/// by the EntryPoint's time-range validation. +contract TimelockEntryPointTest is Test { + IEntryPoint public entryPoint; + TimelockPolicy public policy; + MockTimelockAccount public account; + + 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 payable constant BENEFICIARY = payable(address(0xbeeF)); + address constant BUNDLER = address(0xba5ed); + + function setUp() public { + entryPoint = EntryPointLib.deploy(); + policy = new TimelockPolicy(); + account = new MockTimelockAccount(entryPoint, policy, POLICY_ID); + + // Fund the account for gas + vm.deal(address(account), 100 ether); + + // Install timelock policy (must come from the account) + vm.prank(address(account)); + policy.onInstall(abi.encode(POLICY_ID, DELAY, EXPIRATION, GRACE_PERIOD)); + } + + // ============ 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) + ); + } + + /// @dev Build a no-op UserOp for proposal creation with configurable calldata format. + function _buildCreationOpWithCalldata( + bytes memory noopCallData, + bytes memory proposalCallData, + uint256 proposalNonce, + uint256 epNonce + ) internal view returns (PackedUserOperation memory) { + return PackedUserOperation({ + sender: address(account), + nonce: epNonce, + initCode: "", + callData: noopCallData, + 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) + }); + } + + /// @dev Build a no-op UserOp for proposal creation (empty calldata). + function _buildCreationOp(bytes memory proposalCallData, uint256 proposalNonce, uint256 epNonce) + internal + view + returns (PackedUserOperation memory) + { + return _buildCreationOpWithCalldata("", proposalCallData, proposalNonce, epNonce); + } + + /// @dev Build an execution UserOp. The nonce must match the proposalNonce used at creation time. + function _buildExecutionOp(bytes memory callData, uint256 nonce) + internal + view + returns (PackedUserOperation memory) + { + return PackedUserOperation({ + sender: address(account), + nonce: nonce, + initCode: "", + callData: 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: "" // signature content irrelevant for execution path + }); + } + + function _submitOp(PackedUserOperation memory op) internal { + PackedUserOperation[] memory ops = new PackedUserOperation[](1); + ops[0] = op; + // EntryPoint requires msg.sender == tx.origin (EOA bundler check) + vm.prank(BUNDLER, BUNDLER); + entryPoint.handleOps(ops, BENEFICIARY); + } + + function _submitOps(PackedUserOperation[] memory ops) internal { + vm.prank(BUNDLER, BUNDLER); + entryPoint.handleOps(ops, BENEFICIARY); + } + + function _expectRevertOnOp(PackedUserOperation memory op) internal { + PackedUserOperation[] memory ops = new PackedUserOperation[](1); + ops[0] = op; + vm.prank(BUNDLER, BUNDLER); + vm.expectRevert(); + entryPoint.handleOps(ops, BENEFICIARY); + } + + /// @dev Helper: create a proposal via EntryPoint and return the nonce used + function _createProposal(bytes memory proposalCallData, uint256 proposalNonce) internal { + uint256 epNonce = entryPoint.getNonce(address(account), 0); + _submitOp(_buildCreationOp(proposalCallData, proposalNonce, epNonce)); + } + + /// @dev Helper: get a fresh nonce for a given key + function _getNonce(uint192 key) internal view returns (uint256) { + return entryPoint.getNonce(address(account), key); + } + + // ============ 1. Basic Lifecycle Tests ============ + + /// @notice Proposal creation via no-op UserOp goes through the EntryPoint and persists the proposal. + function testEntryPoint_ProposalCreationViaNoOp() public { + bytes memory proposalCallData = abi.encodeCall(MockTimelockAccount.setValue, (42)); + uint256 proposalNonce = 1; // execution will use nonce=1 (next seq for key=0) + + uint256 epNonce = _getNonce(0); + assertEq(epNonce, 0); + + _submitOp(_buildCreationOp(proposalCallData, proposalNonce, epNonce)); + + // Verify proposal was stored + (TimelockPolicy.ProposalStatus status, uint256 validAfter, uint256 graceEnd, 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); + + // EntryPoint nonce should have advanced + assertEq(_getNonce(0), 1); + } + + /// @notice Full lifecycle: create proposal -> wait -> execute -> verify state change. + function testEntryPoint_FullLifecycle() public { + bytes memory proposalCallData = abi.encodeCall(MockTimelockAccount.setValue, (42)); + uint256 proposalNonce = 1; + + // Step 1: Create proposal + _submitOp(_buildCreationOp(proposalCallData, proposalNonce, 0)); + + // Step 2: Warp past delay + grace period + vm.warp(block.timestamp + DELAY + GRACE_PERIOD + 1); + + // Step 3: Execute proposal through EntryPoint + _submitOp(_buildExecutionOp(proposalCallData, proposalNonce)); + + // Step 4: Verify the execution actually happened + assertEq(account.value(), 42); + + // Verify proposal status is Executed + (TimelockPolicy.ProposalStatus status,,,) = + policy.getProposal(address(account), proposalCallData, proposalNonce, POLICY_ID, address(account)); + assertEq(uint256(status), uint256(TimelockPolicy.ProposalStatus.Executed)); + } + + /// @notice Execution without a prior proposal fails at validation. + function testEntryPoint_NoProposalRevertsExecution() public { + bytes memory proposalCallData = abi.encodeCall(MockTimelockAccount.setValue, (42)); + _expectRevertOnOp(_buildExecutionOp(proposalCallData, 0)); + assertEq(account.value(), 0); + } + + // ============ 2. Time Window Enforcement ============ + + /// @notice EntryPoint rejects execution during the grace period (validAfter not yet reached). + function testEntryPoint_GracePeriodBlocksExecution() 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); + + _expectRevertOnOp(_buildExecutionOp(proposalCallData, proposalNonce)); + assertEq(account.value(), 0); + } + + /// @notice Execution at exactly graceEnd timestamp is still rejected (EntryPoint uses <=). + function testEntryPoint_ExecutionAtExactGraceEndIsRejected() 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); + + _expectRevertOnOp(_buildExecutionOp(proposalCallData, proposalNonce)); + assertEq(account.value(), 0); + } + + /// @notice Execution at graceEnd + 1 succeeds (first valid timestamp). + function testEntryPoint_ExecutionAtGraceEndPlusOneSucceeds() 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); + + _submitOp(_buildExecutionOp(proposalCallData, proposalNonce)); + assertEq(account.value(), 42); + } + + /// @notice Execution at exactly validUntil is still accepted (EntryPoint checks >). + function testEntryPoint_ExecutionAtExactValidUntilSucceeds() public { + bytes memory proposalCallData = abi.encodeCall(MockTimelockAccount.setValue, (42)); + uint256 proposalNonce = 1; + + uint256 creationTime = block.timestamp; + _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); + + _submitOp(_buildExecutionOp(proposalCallData, proposalNonce)); + assertEq(account.value(), 42); + } + + /// @notice EntryPoint rejects execution after the proposal has expired. + function testEntryPoint_ExpirationBlocksExecution() public { + bytes memory proposalCallData = abi.encodeCall(MockTimelockAccount.setValue, (42)); + uint256 proposalNonce = 1; + + uint256 creationTime = block.timestamp; + _submitOp(_buildCreationOp(proposalCallData, proposalNonce, 0)); + + // Warp 1 second past validUntil + vm.warp(creationTime + DELAY + GRACE_PERIOD + EXPIRATION + 1); + + _expectRevertOnOp(_buildExecutionOp(proposalCallData, proposalNonce)); + assertEq(account.value(), 0); + } + + /// @notice Execution before delay has passed is rejected (still in timelock period). + function testEntryPoint_ExecutionBeforeDelayRejected() public { + bytes memory proposalCallData = abi.encodeCall(MockTimelockAccount.setValue, (42)); + uint256 proposalNonce = 1; + + _submitOp(_buildCreationOp(proposalCallData, proposalNonce, 0)); + + // Don't warp at all — still at creation time + _expectRevertOnOp(_buildExecutionOp(proposalCallData, proposalNonce)); + assertEq(account.value(), 0); + } + + // ============ 3. Cancellation Tests ============ + + /// @notice Cancelled proposal cannot be executed even after the timelock passes. + function testEntryPoint_CancelPreventsExecution() public { + bytes memory proposalCallData = abi.encodeCall(MockTimelockAccount.setValue, (42)); + uint256 proposalNonce = 1; + + _submitOp(_buildCreationOp(proposalCallData, proposalNonce, 0)); + + vm.prank(address(account)); + policy.cancelProposal(POLICY_ID, address(account), proposalCallData, proposalNonce); + + vm.warp(block.timestamp + DELAY + GRACE_PERIOD + 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 { + 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); + + // Cancel should succeed + vm.prank(address(account)); + policy.cancelProposal(POLICY_ID, address(account), proposalCallData, proposalNonce); + + (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); + _expectRevertOnOp(_buildExecutionOp(proposalCallData, proposalNonce)); + } + + /// @notice Owner can cancel before the delay has even passed. + function testEntryPoint_CancelBeforeDelayPasses() public { + bytes memory proposalCallData = abi.encodeCall(MockTimelockAccount.setValue, (42)); + uint256 proposalNonce = 1; + + _submitOp(_buildCreationOp(proposalCallData, proposalNonce, 0)); + + // Cancel immediately (no warp) + vm.prank(address(account)); + policy.cancelProposal(POLICY_ID, address(account), proposalCallData, proposalNonce); + + (TimelockPolicy.ProposalStatus status,,,) = + policy.getProposal(address(account), proposalCallData, proposalNonce, POLICY_ID, address(account)); + assertEq(uint256(status), uint256(TimelockPolicy.ProposalStatus.Cancelled)); + } + + // ============ 4. Replay / Double-Use Prevention ============ + + /// @notice A proposal that was already executed cannot be executed again. + function testEntryPoint_DoubleExecutionFails() public { + bytes memory proposalCallData = abi.encodeCall(MockTimelockAccount.setValue, (42)); + // Use different nonce keys so execution nonces don't collide + uint256 proposalNonce = _getNonce(1); // key=1, seq=0 + + _createProposal(proposalCallData, proposalNonce); + + vm.warp(block.timestamp + DELAY + GRACE_PERIOD + 1); + + // First execution succeeds + _submitOp(_buildExecutionOp(proposalCallData, proposalNonce)); + assertEq(account.value(), 42); + + // Second execution attempt with the same callData+nonce via a different nonce key. + // The EntryPoint nonce for key=1 is now 1 (after first execution), so we'd need + // a new nonce. But the proposal is already Executed, so validation returns 1. + // 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,,,) = + policy.getProposal(address(account), proposalCallData, proposalNonce, POLICY_ID, address(account)); + assertEq(uint256(status), uint256(TimelockPolicy.ProposalStatus.Executed)); + } + + /// @notice Cannot create the same proposal twice (duplicate creation fails via EntryPoint). + function testEntryPoint_DuplicateCreationFails() public { + bytes memory proposalCallData = abi.encodeCall(MockTimelockAccount.setValue, (42)); + uint256 proposalNonce = 100; + + // First creation succeeds + _submitOp(_buildCreationOp(proposalCallData, proposalNonce, _getNonce(0))); + + // Second creation with same proposalCallData and proposalNonce fails at validation + // (status != None) → returns SIG_VALIDATION_FAILED → "AA24 signature error" + _expectRevertOnOp(_buildCreationOp(proposalCallData, proposalNonce, _getNonce(0))); + } + + // ============ 5. Epoch / Reinstall Tests ============ + + /// @notice Proposals from a previous installation cannot be executed after reinstall. + function testEntryPoint_StaleProposalAfterReinstall() public { + bytes memory proposalCallData = abi.encodeCall(MockTimelockAccount.setValue, (42)); + uint256 proposalNonce = _getNonce(1); // use key=1 for execution + + // Create proposal + _createProposal(proposalCallData, proposalNonce); + + // Uninstall + vm.prank(address(account)); + policy.onUninstall(abi.encode(POLICY_ID, "")); + + // Reinstall (increments epoch) + vm.prank(address(account)); + policy.onInstall(abi.encode(POLICY_ID, DELAY, EXPIRATION, GRACE_PERIOD)); + + // Warp past delay + grace + vm.warp(block.timestamp + DELAY + GRACE_PERIOD + 1); + + // Execution fails: proposal epoch doesn't match new epoch + _expectRevertOnOp(_buildExecutionOp(proposalCallData, proposalNonce)); + assertEq(account.value(), 0); + } + + /// @notice After reinstall, new proposals can be created and executed normally. + function testEntryPoint_NewProposalAfterReinstall() public { + bytes memory proposalCallData = abi.encodeCall(MockTimelockAccount.setValue, (99)); + uint256 proposalNonce = _getNonce(1); + + // Create a proposal in the first installation + _createProposal(proposalCallData, proposalNonce); + + // Uninstall + reinstall + vm.prank(address(account)); + policy.onUninstall(abi.encode(POLICY_ID, "")); + vm.prank(address(account)); + policy.onInstall(abi.encode(POLICY_ID, DELAY, EXPIRATION, GRACE_PERIOD)); + + // 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); + + // New proposal executes fine + _submitOp(_buildExecutionOp(abi.encodeCall(MockTimelockAccount.setValue, (77)), newProposalNonce)); + assertEq(account.value(), 77); + } + + // ============ 6. No-Op Calldata Variants ============ + + /// @notice Proposal creation with ERC-7579 execute(mode=0x00, "") no-op format. + 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), ""); + + _submitOp(_buildCreationOpWithCalldata(erc7579Noop, proposalCallData, proposalNonce, _getNonce(0))); + + (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); + _submitOp(_buildExecutionOp(proposalCallData, proposalNonce)); + assertEq(account.value(), 42); + } + + /// @notice Proposal creation with executeUserOp selector-only (4 bytes) no-op format. + function testEntryPoint_CreationViaExecuteUserOpEmpty() public { + bytes memory proposalCallData = abi.encodeCall(MockTimelockAccount.setValue, (42)); + uint256 proposalNonce = _getNonce(1); + + // executeUserOp selector only (4 bytes) + bytes memory executeUserOpNoop = abi.encodePacked(IAccountExecute.executeUserOp.selector); + + _submitOp(_buildCreationOpWithCalldata(executeUserOpNoop, proposalCallData, proposalNonce, _getNonce(0))); + + (TimelockPolicy.ProposalStatus status,,,) = + policy.getProposal(address(account), proposalCallData, proposalNonce, POLICY_ID, address(account)); + assertEq(uint256(status), uint256(TimelockPolicy.ProposalStatus.Pending)); + } + + /// @notice Proposal creation with executeUserOp + ERC-7579 execute no-op (wrapped format). + function testEntryPoint_CreationViaExecuteUserOpWrappedERC7579() public { + bytes memory proposalCallData = abi.encodeCall(MockTimelockAccount.setValue, (42)); + uint256 proposalNonce = _getNonce(1); + + bytes memory erc7579Noop = abi.encodeWithSelector(IERC7579Execution.execute.selector, bytes32(0), ""); + bytes memory wrappedNoop = abi.encodePacked(IAccountExecute.executeUserOp.selector, erc7579Noop); + + _submitOp(_buildCreationOpWithCalldata(wrappedNoop, proposalCallData, proposalNonce, _getNonce(0))); + + (TimelockPolicy.ProposalStatus status,,,) = + policy.getProposal(address(account), proposalCallData, proposalNonce, POLICY_ID, address(account)); + assertEq(uint256(status), uint256(TimelockPolicy.ProposalStatus.Pending)); + } + + // ============ 7. Multiple Proposals ============ + + /// @notice Two independent proposals (different callData) can coexist and execute separately. + function testEntryPoint_TwoIndependentProposals() public { + bytes memory callDataA = abi.encodeCall(MockTimelockAccount.setValue, (10)); + bytes memory callDataB = abi.encodeCall(MockTimelockAccount.setValue, (20)); + + // Use separate nonce keys so execution nonces don't collide + uint256 nonceA = _getNonce(1); // key=1, seq=0 + uint256 nonceB = _getNonce(2); // key=2, seq=0 + + // Create both proposals + _createProposal(callDataA, nonceA); + _createProposal(callDataB, nonceB); + + vm.warp(block.timestamp + DELAY + GRACE_PERIOD + 1); + + // Execute B first + _submitOp(_buildExecutionOp(callDataB, nonceB)); + assertEq(account.value(), 20); + + // Execute A second (overwrites value) + _submitOp(_buildExecutionOp(callDataA, nonceA)); + assertEq(account.value(), 10); + + // Both are Executed + (TimelockPolicy.ProposalStatus statusA,,,) = + policy.getProposal(address(account), callDataA, nonceA, POLICY_ID, address(account)); + (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)); + } + + /// @notice Cancel one proposal while leaving another intact, then execute the other. + function testEntryPoint_CancelOneExecuteAnother() public { + bytes memory callDataA = abi.encodeCall(MockTimelockAccount.setValue, (10)); + bytes memory callDataB = abi.encodeCall(MockTimelockAccount.setValue, (20)); + + uint256 nonceA = _getNonce(1); + uint256 nonceB = _getNonce(2); + + _createProposal(callDataA, nonceA); + _createProposal(callDataB, nonceB); + + // Cancel A + vm.prank(address(account)); + policy.cancelProposal(POLICY_ID, address(account), callDataA, nonceA); + + vm.warp(block.timestamp + DELAY + GRACE_PERIOD + 1); + + // A fails + _expectRevertOnOp(_buildExecutionOp(callDataA, nonceA)); + + // B succeeds + _submitOp(_buildExecutionOp(callDataB, nonceB)); + assertEq(account.value(), 20); + } + + /// @notice Proposals created at different times have different time windows. + /// @dev Uses explicit warp targets to avoid via_ir optimizer caching block.timestamp + /// across vm.warp boundaries (TIMESTAMP is constant within a real EVM transaction, + /// so the optimizer may legally forward the expression). + function testEntryPoint_SequentialProposalsDifferentWindows() public { + bytes memory callDataA = abi.encodeCall(MockTimelockAccount.setValue, (10)); + bytes memory callDataB = abi.encodeCall(MockTimelockAccount.setValue, (20)); + + uint256 nonceA = _getNonce(1); + uint256 nonceB = _getNonce(2); + + // Set a known start time to avoid depending on Foundry default block.timestamp + uint256 T0 = 10_000; + vm.warp(T0); + + // 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 + + // 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 + + // 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); + + // A works + _submitOp(_buildExecutionOp(callDataA, nonceA)); + assertEq(account.value(), 10); + + // B still blocked (B's graceEnd = 19000 > 15401) + _expectRevertOnOp(_buildExecutionOp(callDataB, nonceB)); + + // Warp to B's window: T1 + DELAY + GRACE_PERIOD + 1 = 19001 + vm.warp(T1 + uint256(DELAY) + uint256(GRACE_PERIOD) + 1); + _submitOp(_buildExecutionOp(callDataB, nonceB)); + assertEq(account.value(), 20); + } + + // ============ 8. Batch UserOps in Single handleOps ============ + + /// @notice Two creation UserOps can be batched in a single handleOps call. + function testEntryPoint_BatchCreation() public { + bytes memory callDataA = abi.encodeCall(MockTimelockAccount.setValue, (10)); + bytes memory callDataB = abi.encodeCall(MockTimelockAccount.setValue, (20)); + + uint256 nonceA = _getNonce(1); + uint256 nonceB = _getNonce(2); + + PackedUserOperation[] memory ops = new PackedUserOperation[](2); + ops[0] = _buildCreationOp(callDataA, nonceA, _getNonce(0)); + // The second op uses seq=1 for key=0 (after first op increments it) + ops[1] = _buildCreationOp(callDataB, nonceB, _getNonce(0) + 1); + + _submitOps(ops); + + // Both proposals should exist + (TimelockPolicy.ProposalStatus statusA,,,) = + policy.getProposal(address(account), callDataA, nonceA, POLICY_ID, address(account)); + (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)); + } + + /// @notice Two execution UserOps can be batched in a single handleOps call. + function testEntryPoint_BatchExecution() public { + bytes memory callDataA = abi.encodeCall(MockTimelockAccount.setValue, (10)); + bytes memory callDataB = abi.encodeCall(MockTimelockAccount.setValue, (20)); + + uint256 nonceA = _getNonce(1); + uint256 nonceB = _getNonce(2); + + _createProposal(callDataA, nonceA); + _createProposal(callDataB, nonceB); + + vm.warp(block.timestamp + DELAY + GRACE_PERIOD + 1); + + PackedUserOperation[] memory ops = new PackedUserOperation[](2); + ops[0] = _buildExecutionOp(callDataA, nonceA); + ops[1] = _buildExecutionOp(callDataB, nonceB); + + _submitOps(ops); + + // Last one wins for the value, both should be Executed + assertEq(account.value(), 20); + + (TimelockPolicy.ProposalStatus statusA,,,) = + policy.getProposal(address(account), callDataA, nonceA, POLICY_ID, address(account)); + (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)); + } + + // ============ 9. Nonce Key Separation ============ + + /// @notice Using different EntryPoint nonce keys for creation vs execution. + function testEntryPoint_SeparateNonceKeys() public { + bytes memory proposalCallData = abi.encodeCall(MockTimelockAccount.setValue, (42)); + + // Use key=5 for execution → proposalNonce = getNonce(account, 5) + uint256 proposalNonce = _getNonce(5); + + // Create using key=0 + _submitOp(_buildCreationOp(proposalCallData, proposalNonce, _getNonce(0))); + + vm.warp(block.timestamp + DELAY + GRACE_PERIOD + 1); + + // Execute using key=5 (nonce matches proposalNonce) + _submitOp(_buildExecutionOp(proposalCallData, proposalNonce)); + assertEq(account.value(), 42); + } + + // ============ 10. Multiple Accounts ============ + + /// @notice Two accounts with the same policy can have independent proposals. + function testEntryPoint_TwoAccountsIndependent() public { + // Deploy a second account + 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)); + + bytes memory proposalCallData = abi.encodeCall(MockTimelockAccount.setValue, (42)); + uint256 proposalNonce1 = entryPoint.getNonce(address(account), 1); + uint256 proposalNonce2 = entryPoint.getNonce(address(account2), 1); + + // Create proposal for account 1 + _createProposal(proposalCallData, proposalNonce1); + + // Create proposal for account 2 + PackedUserOperation memory op2 = PackedUserOperation({ + sender: address(account2), + nonce: entryPoint.getNonce(address(account2), 0), + initCode: "", + 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, proposalNonce2) + }); + _submitOp(op2); + + vm.warp(block.timestamp + DELAY + GRACE_PERIOD + 1); + + // Execute account 1 + _submitOp(_buildExecutionOp(proposalCallData, proposalNonce1)); + assertEq(account.value(), 42); + + // Execute account 2 + PackedUserOperation memory exec2 = PackedUserOperation({ + sender: address(account2), + nonce: proposalNonce2, + 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: "" + }); + _submitOp(exec2); + assertEq(account2.value(), 42); + } + + // ============ 11. Edge-Case Calldata ============ + + /// @notice Proposal with empty proposalCallData (legitimate: could be a "send ETH" tx). + function testEntryPoint_EmptyProposalCallData() public { + bytes memory proposalCallData = ""; + uint256 proposalNonce = _getNonce(1); + + // Proposal creation with empty calldata inside signature + // sig = [len=0 (32 bytes)] + [nonce (32 bytes)] + [0x00 (1 byte)] = 65 bytes total ✓ + _createProposal(proposalCallData, proposalNonce); + + (TimelockPolicy.ProposalStatus status,,,) = + policy.getProposal(address(account), proposalCallData, proposalNonce, POLICY_ID, address(account)); + assertEq(uint256(status), uint256(TimelockPolicy.ProposalStatus.Pending)); + } + + /// @notice Proposal with large calldata. + function testEntryPoint_LargeProposalCallData() public { + // Build a large calldata (256 bytes of arbitrary data after the selector) + bytes memory proposalCallData = abi.encodeCall(MockTimelockAccount.setValue, (12345)); + // Pad to make it larger + bytes memory largeCallData = abi.encodePacked(proposalCallData, new bytes(256)); + + uint256 proposalNonce = _getNonce(1); + + _createProposal(largeCallData, proposalNonce); + + (TimelockPolicy.ProposalStatus status,,,) = + policy.getProposal(address(account), largeCallData, proposalNonce, POLICY_ID, address(account)); + assertEq(uint256(status), uint256(TimelockPolicy.ProposalStatus.Pending)); + } + + // ============ 12. Events ============ + + /// @notice ProposalCreated event is emitted during creation through EntryPoint. + function testEntryPoint_ProposalCreatedEvent() public { + bytes memory proposalCallData = abi.encodeCall(MockTimelockAccount.setValue, (42)); + uint256 proposalNonce = _getNonce(1); + + bytes32 expectedKey = policy.computeUserOpKey(address(account), proposalCallData, proposalNonce); + uint256 expectedValidAfter = block.timestamp + DELAY; + uint256 expectedValidUntil = block.timestamp + DELAY + GRACE_PERIOD + EXPIRATION; + + vm.expectEmit(true, true, true, true); + emit TimelockPolicy.ProposalCreated( + address(account), POLICY_ID, expectedKey, expectedValidAfter, expectedValidUntil + ); + + _submitOp(_buildCreationOp(proposalCallData, proposalNonce, _getNonce(0))); + } + + /// @notice ProposalExecuted event is emitted during execution through EntryPoint. + function testEntryPoint_ProposalExecutedEvent() public { + bytes memory proposalCallData = abi.encodeCall(MockTimelockAccount.setValue, (42)); + uint256 proposalNonce = _getNonce(1); + + _createProposal(proposalCallData, proposalNonce); + + vm.warp(block.timestamp + DELAY + GRACE_PERIOD + 1); + + bytes32 expectedKey = policy.computeUserOpKey(address(account), proposalCallData, proposalNonce); + + vm.expectEmit(true, true, true, true); + emit TimelockPolicy.ProposalExecuted(address(account), POLICY_ID, expectedKey); + + _submitOp(_buildExecutionOp(proposalCallData, proposalNonce)); + } + + // ============ 13. EntryPoint Nonce Accounting ============ + + /// @notice EntryPoint nonce advances correctly across multiple operations. + function testEntryPoint_NonceAccounting() public { + assertEq(_getNonce(0), 0); + + bytes memory cd = abi.encodeCall(MockTimelockAccount.setValue, (1)); + uint256 pNonce = _getNonce(1); + + // Op 1: creation + _submitOp(_buildCreationOp(cd, pNonce, 0)); + assertEq(_getNonce(0), 1); + + // Op 2: another creation with different proposal nonce + bytes memory cd2 = abi.encodeCall(MockTimelockAccount.setValue, (2)); + uint256 pNonce2 = _getNonce(2); + _submitOp(_buildCreationOp(cd2, pNonce2, 1)); + assertEq(_getNonce(0), 2); + + vm.warp(block.timestamp + DELAY + GRACE_PERIOD + 1); + + // Op 3: execution of first proposal (key=1) + _submitOp(_buildExecutionOp(cd, pNonce)); + assertEq(_getNonce(1), pNonce + 1); + } + + /// @notice Reverted handleOps does NOT advance the EntryPoint nonce. + function testEntryPoint_RevertedOpDoesNotAdvanceNonce() public { + uint256 nonceBefore = _getNonce(0); + + // Try execution without proposal → revert + _expectRevertOnOp(_buildExecutionOp(abi.encodeCall(MockTimelockAccount.setValue, (1)), 0)); + + // Nonce unchanged + assertEq(_getNonce(0), nonceBefore); + } + + // ============ 14. Gas / Balance Tests ============ + + /// @notice Account pays gas to EntryPoint from its balance. + function testEntryPoint_AccountPaysGas() public { + uint256 balBefore = address(account).balance; + + bytes memory proposalCallData = abi.encodeCall(MockTimelockAccount.setValue, (42)); + _submitOp(_buildCreationOp(proposalCallData, _getNonce(1), _getNonce(0))); + + // Account balance should have decreased (gas was paid) + assertTrue(address(account).balance < balBefore); + } + + /// @notice Beneficiary receives collected gas fees. + function testEntryPoint_BeneficiaryReceivesFees() public { + uint256 balBefore = BENEFICIARY.balance; + + bytes memory proposalCallData = abi.encodeCall(MockTimelockAccount.setValue, (42)); + _submitOp(_buildCreationOp(proposalCallData, _getNonce(1), _getNonce(0))); + + // Beneficiary should have received fees + assertTrue(BENEFICIARY.balance > balBefore); + } + + // ============ 15. Full Grace Period Race-Condition Scenario ============ + + /// @notice Simulate the race condition the grace period is designed to prevent: + /// 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 { + 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 + 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); + _expectRevertOnOp(_buildExecutionOp(proposalCallData, proposalNonce)); + assertEq(account.value(), 0); + } + + // ============ 16. Policy Not Installed ============ + + /// @notice Operations on an account with uninstalled policy fail. + function testEntryPoint_UninstalledPolicyRevertsAll() public { + // Uninstall + vm.prank(address(account)); + policy.onUninstall(abi.encode(POLICY_ID, "")); + + // Creation fails + bytes memory proposalCallData = abi.encodeCall(MockTimelockAccount.setValue, (42)); + _expectRevertOnOp(_buildCreationOp(proposalCallData, 100, _getNonce(0))); + } + + // ============ 17. EntryPoint Deposit Tests ============ + + /// @notice Account can prefund via EntryPoint deposit, reducing per-op gas drain. + function testEntryPoint_DepositThenOperate() public { + // Deposit into EntryPoint on behalf of account + entryPoint.depositTo{value: 1 ether}(address(account)); + + uint256 balBefore = address(account).balance; + + bytes memory proposalCallData = abi.encodeCall(MockTimelockAccount.setValue, (42)); + _submitOp(_buildCreationOp(proposalCallData, _getNonce(1), _getNonce(0))); + + // Account's direct balance should not have decreased because deposit covers gas + assertEq(address(account).balance, balBefore); + } + + // ============ 18. Create-Cancel-Recreate Cycle ============ + + /// @notice After cancellation, a new proposal with different nonce for the same calldata works. + function testEntryPoint_CreateCancelRecreateWithDifferentNonce() public { + bytes memory proposalCallData = abi.encodeCall(MockTimelockAccount.setValue, (42)); + uint256 nonce1 = _getNonce(1); + + // Create + _createProposal(proposalCallData, nonce1); + + // Cancel + vm.prank(address(account)); + policy.cancelProposal(POLICY_ID, address(account), proposalCallData, nonce1); + + // Recreate with different nonce + uint256 nonce2 = _getNonce(2); + _createProposal(proposalCallData, nonce2); + + (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); + _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. + function testEntryPoint_AtExactDelayStillBlocked() public { + bytes memory proposalCallData = abi.encodeCall(MockTimelockAccount.setValue, (42)); + uint256 proposalNonce = _getNonce(1); + + 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 + vm.warp(t0 + DELAY); + _expectRevertOnOp(_buildExecutionOp(proposalCallData, proposalNonce)); + } + + // ============ 20. Same Calldata Different Nonces ============ + + /// @notice Same callData can be proposed with different nonces independently. + function testEntryPoint_SameCallDataDifferentNonces() public { + bytes memory proposalCallData = abi.encodeCall(MockTimelockAccount.setValue, (42)); + + uint256 nonceA = _getNonce(1); + uint256 nonceB = _getNonce(2); + + _createProposal(proposalCallData, nonceA); + _createProposal(proposalCallData, nonceB); + + // Both exist + (TimelockPolicy.ProposalStatus statusA,,,) = + policy.getProposal(address(account), proposalCallData, nonceA, POLICY_ID, address(account)); + (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); + + // Execute A, cancel B + _submitOp(_buildExecutionOp(proposalCallData, nonceA)); + assertEq(account.value(), 42); + + 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)); + assertEq(uint256(statusA), uint256(TimelockPolicy.ProposalStatus.Executed)); + assertEq(uint256(statusB), uint256(TimelockPolicy.ProposalStatus.Cancelled)); + } +} diff --git a/test/utils/MockTimelockAccount.sol b/test/utils/MockTimelockAccount.sol new file mode 100644 index 0000000..ba17609 --- /dev/null +++ b/test/utils/MockTimelockAccount.sol @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import {IAccount} from "account-abstraction/interfaces/IAccount.sol"; +import {PackedUserOperation} from "account-abstraction/interfaces/PackedUserOperation.sol"; +import {IEntryPoint} from "account-abstraction/interfaces/IEntryPoint.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 { + IEntryPoint public immutable entryPoint; + TimelockPolicy public immutable policy; + bytes32 public immutable policyId; + + uint256 public value; + + constructor(IEntryPoint _entryPoint, TimelockPolicy _policy, bytes32 _policyId) { + entryPoint = _entryPoint; + policy = _policy; + policyId = _policyId; + } + + function validateUserOp(PackedUserOperation calldata userOp, bytes32, uint256 missingAccountFunds) + external + returns (uint256 validationData) + { + require(msg.sender == address(entryPoint), "only entrypoint"); + + if (missingAccountFunds > 0) { + (bool ok,) = payable(msg.sender).call{value: missingAccountFunds}(""); + require(ok); + } + + return policy.checkUserOpPolicy(policyId, userOp); + } + + function setValue(uint256 _value) external { + value = _value; + } + + receive() external payable {} +} From 9f966f517fd1b0176cc35eaaa13f285b7ffe479c Mon Sep 17 00:00:00 2001 From: leekt Date: Fri, 13 Feb 2026 01:37:10 +0900 Subject: [PATCH 20/25] fix(TimelockPolicy): replace grace period with guardian cancellation --- src/policies/TimelockPolicy.sol | 54 ++++++++++++++++----------------- 1 file changed, 26 insertions(+), 28 deletions(-) diff --git a/src/policies/TimelockPolicy.sol b/src/policies/TimelockPolicy.sol index b88d1e1..f05c1fd 100644 --- a/src/policies/TimelockPolicy.sol +++ b/src/policies/TimelockPolicy.sol @@ -29,14 +29,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 +59,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 +81,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 +90,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 +117,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 +189,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 +201,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 +214,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,9 +237,7 @@ 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); } /** @@ -359,18 +358,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); } /** From d043f6090403f76c65f83c3a5b911bbe6b1352a4 Mon Sep 17 00:00:00 2001 From: leekt Date: Fri, 13 Feb 2026 01:37:52 +0900 Subject: [PATCH 21/25] fix(TimelockPolicy): use LibERC7579.decodeSingle for no-op detection --- src/policies/TimelockPolicy.sol | 48 +++++++++++++++++++++++---------- 1 file changed, 34 insertions(+), 14 deletions(-) diff --git a/src/policies/TimelockPolicy.sol b/src/policies/TimelockPolicy.sol index f05c1fd..0f831ed 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 { @@ -244,7 +245,7 @@ contract TimelockPolicy is PolicyBase, IStatelessValidator, IStatelessValidatorW * @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) */ @@ -254,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 @@ -269,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; } /** From c9f666b482fd06077c1149f90a20a532c4651f12 Mon Sep 17 00:00:00 2001 From: leekt Date: Fri, 13 Feb 2026 01:38:16 +0900 Subject: [PATCH 22/25] test(TimelockPolicy): update tests for guardian cancellation and no-op decoding --- test/TimelockPolicy.t.sol | 56 +++--- test/btt/Timelock.t.sol | 222 ++++++++++++++++++---- test/btt/TimelockCancellationRace.t.sol | 172 ++++++++--------- test/btt/TimelockEpochValidation.t.sol | 30 ++- test/btt/TimelockSignaturePolicy.t.sol | 4 +- test/integration/TimelockEntryPoint.t.sol | 208 ++++++++++---------- test/utils/MockTimelockAccount.sol | 51 ++++- 7 files changed, 456 insertions(+), 287 deletions(-) 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..2a2d482 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,136 @@ 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"); + } } 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..b138fa6 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,8 +963,8 @@ 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)); } 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; } From 8033d31f6e94163f403048a3e2d41f9bf8e94f0c Mon Sep 17 00:00:00 2001 From: leekt Date: Fri, 13 Feb 2026 01:46:32 +0900 Subject: [PATCH 23/25] test(TimelockPolicy): add comprehensive guardian cancellation test cases --- test/btt/Timelock.t.sol | 251 ++++++++++++++++++++++ test/integration/TimelockEntryPoint.t.sol | 90 ++++++++ 2 files changed, 341 insertions(+) diff --git a/test/btt/Timelock.t.sol b/test/btt/Timelock.t.sol index 2a2d482..b0f1dda 100644 --- a/test/btt/Timelock.t.sol +++ b/test/btt/Timelock.t.sol @@ -1025,4 +1025,255 @@ contract TimelockTest is Test { 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/integration/TimelockEntryPoint.t.sol b/test/integration/TimelockEntryPoint.t.sol index b138fa6..e72852c 100644 --- a/test/integration/TimelockEntryPoint.t.sol +++ b/test/integration/TimelockEntryPoint.t.sol @@ -968,4 +968,94 @@ contract TimelockEntryPointTest is Test { 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"); + } } From edbc28a01c4761e28e8fc4ae251c03f9ceca22fd Mon Sep 17 00:00:00 2001 From: leekt Date: Fri, 13 Feb 2026 01:58:54 +0900 Subject: [PATCH 24/25] refactor: remove redundant signature length check in TimelockPolicy --- src/policies/TimelockPolicy.sol | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/policies/TimelockPolicy.sol b/src/policies/TimelockPolicy.sol index 0f831ed..5bb6ed1 100644 --- a/src/policies/TimelockPolicy.sol +++ b/src/policies/TimelockPolicy.sol @@ -360,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); } From d307e6814d16673276185b9b46687b7433f4b430 Mon Sep 17 00:00:00 2001 From: taek Date: Mon, 23 Feb 2026 11:22:39 +0900 Subject: [PATCH 25/25] test: add TimelockNoOpDetection test cases --- test/TimelockNoOpDetection.t.sol | 230 +++++++++++++++++++++++++++++++ 1 file changed, 230 insertions(+) create mode 100644 test/TimelockNoOpDetection.t.sol diff --git a/test/TimelockNoOpDetection.t.sol b/test/TimelockNoOpDetection.t.sol new file mode 100644 index 0000000..0dabeed --- /dev/null +++ b/test/TimelockNoOpDetection.t.sol @@ -0,0 +1,230 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import {Test} from "forge-std/Test.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 {TimelockPolicy} from "src/policies/TimelockPolicy.sol"; + +/// @title TimelockPolicyHarness +/// @notice Exposes internal no-op detection functions for unit testing +contract TimelockPolicyHarness is TimelockPolicy { + function exposed_isNoOpCalldata(bytes calldata callData) external pure returns (bool) { + return _isNoOpCalldata(callData); + } + + function exposed_isNoOpERC7579Execute(bytes calldata callData) external pure returns (bool) { + return _isNoOpERC7579Execute(callData); + } +} + +/// @title TimelockNoOpDetectionTest +/// @notice Tests for the no-op calldata detection logic in TimelockPolicy. +/// Uses LibERC7579 to construct modes and execution data, confirming compatibility +/// with Solady's decodeSingle() which requires executionCalldata.length >= 52. +contract TimelockNoOpDetectionTest is Test { + TimelockPolicyHarness harness; + + function setUp() public { + harness = new TimelockPolicyHarness(); + } + + // ─── Helpers ──────────────────────────────────────────────────────── + + /// @dev Encode a single-call mode via LibERC7579 + function _singleMode() internal pure returns (bytes32) { + return LibERC7579.encodeMode(LibERC7579.CALLTYPE_SINGLE, LibERC7579.EXECTYPE_DEFAULT, bytes4(0), bytes22(0)); + } + + /// @dev Build a single-call no-op: execute(singleMode, abi.encodePacked(address(0), uint256(0))) + function _erc7579NoOp() internal pure returns (bytes memory) { + return abi.encodeWithSelector( + IERC7579Execution.execute.selector, _singleMode(), abi.encodePacked(address(0), uint256(0)) + ); + } + + /// @dev Build the old (broken) no-op: execute(singleMode, "") — 100 bytes, decodeSingle would revert + function _erc7579NoOpOldFormat() internal pure returns (bytes memory) { + return abi.encodeWithSelector(IERC7579Execution.execute.selector, _singleMode(), ""); + } + + /// @dev Build an execute call with a specific mode and execution data + function _erc7579Execute(bytes32 mode, bytes memory executionCalldata) internal pure returns (bytes memory) { + return abi.encodeWithSelector(IERC7579Execution.execute.selector, mode, executionCalldata); + } + + // ─── Case 1: Empty calldata ───────────────────────────────────────── + + function test_NoOp_EmptyCalldata() public view { + assertTrue(harness.exposed_isNoOpCalldata("")); + } + + // ─── Case 2: ERC-7579 execute with minimal execution data ─────────── + + function test_NoOp_ERC7579Execute_ZeroTarget() public view { + bytes memory callData = _erc7579NoOp(); + assertTrue(harness.exposed_isNoOpCalldata(callData)); + assertTrue(harness.exposed_isNoOpERC7579Execute(callData)); + } + + /// @notice Verify the calldata length is 164 (standard ABI padded encoding) + function test_NoOp_ERC7579Execute_CorrectLength() public pure { + bytes memory callData = _erc7579NoOp(); + // selector(4) + mode(32) + offset(32) + length(32) + data(52 padded to 64) = 164 + assertEq(callData.length, 164); + } + + // ─── Case 2 rejections ────────────────────────────────────────────── + + /// @notice Non-zero target could trigger receive()/fallback() — not a no-op + function test_Reject_ERC7579Execute_NonZeroTarget() public view { + bytes memory callData = _erc7579Execute(_singleMode(), abi.encodePacked(address(0xdead), uint256(0))); + assertFalse(harness.exposed_isNoOpCalldata(callData)); + assertFalse(harness.exposed_isNoOpERC7579Execute(callData)); + } + + function testFuzz_Reject_ERC7579Execute_NonZeroTarget(address target) public view { + vm.assume(target != address(0)); + bytes memory callData = _erc7579Execute(_singleMode(), abi.encodePacked(target, uint256(0))); + assertFalse(harness.exposed_isNoOpCalldata(callData)); + } + + /// @notice Old 100-byte format (empty executionCalldata) must be rejected. + /// decodeSingle("") would revert, so this can never be a valid no-op. + function test_Reject_ERC7579Execute_OldEmptyFormat() public view { + bytes memory callData = _erc7579NoOpOldFormat(); + assertEq(callData.length, 100); + assertFalse(harness.exposed_isNoOpCalldata(callData)); + assertFalse(harness.exposed_isNoOpERC7579Execute(callData)); + } + + /// @notice Non-zero value means ETH transfer — not a no-op + function test_Reject_ERC7579Execute_NonZeroValue() public view { + bytes memory callData = _erc7579Execute(_singleMode(), abi.encodePacked(address(0), uint256(1 ether))); + assertFalse(harness.exposed_isNoOpCalldata(callData)); + } + + function testFuzz_Reject_ERC7579Execute_NonZeroValue(uint256 value) public view { + vm.assume(value > 0); + bytes memory callData = _erc7579Execute(_singleMode(), abi.encodePacked(address(0), value)); + assertFalse(harness.exposed_isNoOpCalldata(callData)); + } + + /// @notice CALLTYPE_BATCH should be rejected + function test_Reject_ERC7579Execute_BatchMode() public view { + bytes32 mode = + LibERC7579.encodeMode(LibERC7579.CALLTYPE_BATCH, LibERC7579.EXECTYPE_DEFAULT, bytes4(0), bytes22(0)); + bytes memory callData = _erc7579Execute(mode, abi.encodePacked(address(0), uint256(0))); + assertFalse(harness.exposed_isNoOpCalldata(callData)); + } + + /// @notice CALLTYPE_DELEGATECALL should be rejected + function test_Reject_ERC7579Execute_DelegatecallMode() public view { + bytes32 mode = + LibERC7579.encodeMode(LibERC7579.CALLTYPE_DELEGATECALL, LibERC7579.EXECTYPE_DEFAULT, bytes4(0), bytes22(0)); + bytes memory callData = _erc7579Execute(mode, abi.encodePacked(address(0), uint256(0))); + assertFalse(harness.exposed_isNoOpCalldata(callData)); + } + + /// @notice CALLTYPE_STATICCALL should be rejected + function test_Reject_ERC7579Execute_StaticcallMode() public view { + bytes32 mode = + LibERC7579.encodeMode(LibERC7579.CALLTYPE_STATICCALL, LibERC7579.EXECTYPE_DEFAULT, bytes4(0), bytes22(0)); + bytes memory callData = _erc7579Execute(mode, abi.encodePacked(address(0), uint256(0))); + assertFalse(harness.exposed_isNoOpCalldata(callData)); + } + + /// @notice executionCalldata with inner calldata (length > 52) should be rejected + function test_Reject_ERC7579Execute_HasInnerCalldata() public view { + bytes memory callData = _erc7579Execute(_singleMode(), abi.encodePacked(address(0), uint256(0), hex"deadbeef")); + assertFalse(harness.exposed_isNoOpCalldata(callData)); + } + + /// @notice Wrong selector should be rejected + function test_Reject_ERC7579Execute_WrongSelector() public view { + bytes memory callData = + abi.encodeWithSelector(bytes4(0xdeadbeef), _singleMode(), abi.encodePacked(address(0), uint256(0))); + assertFalse(harness.exposed_isNoOpERC7579Execute(callData)); + } + + /// @notice Calldata too short to contain valid execution data + function test_Reject_ERC7579Execute_TooShort() public view { + // Only 20 bytes of exec data — decodeSingle needs > 0x33 + bytes memory callData = abi.encodeWithSelector(IERC7579Execution.execute.selector, _singleMode(), bytes20(0)); + assertFalse(harness.exposed_isNoOpERC7579Execute(callData)); + } + + // ─── Case 3: executeUserOp + empty ────────────────────────────────── + + function test_NoOp_ExecuteUserOpSelectorOnly() public view { + bytes memory callData = abi.encodePacked(IAccountExecute.executeUserOp.selector); + assertEq(callData.length, 4); + assertTrue(harness.exposed_isNoOpCalldata(callData)); + } + + // ─── Case 4: executeUserOp + ERC-7579 no-op ──────────────────────── + + function test_NoOp_ExecuteUserOpWrappedERC7579() public view { + bytes memory callData = abi.encodePacked(IAccountExecute.executeUserOp.selector, _erc7579NoOp()); + assertTrue(harness.exposed_isNoOpCalldata(callData)); + } + + /// @notice Wrapped old format should also be rejected + function test_Reject_ExecuteUserOpWrappedOldFormat() public view { + bytes memory callData = abi.encodePacked(IAccountExecute.executeUserOp.selector, _erc7579NoOpOldFormat()); + assertFalse(harness.exposed_isNoOpCalldata(callData)); + } + + /// @notice Wrapped ERC-7579 with non-zero target should be rejected + function test_Reject_ExecuteUserOpWrappedNonZeroTarget() public view { + bytes memory callData = abi.encodePacked( + IAccountExecute.executeUserOp.selector, + _erc7579Execute(_singleMode(), abi.encodePacked(address(0xdead), uint256(0))) + ); + assertFalse(harness.exposed_isNoOpCalldata(callData)); + } + + /// @notice Wrapped ERC-7579 with non-zero value should be rejected + function test_Reject_ExecuteUserOpWrappedNonZeroValue() public view { + bytes memory callData = abi.encodePacked( + IAccountExecute.executeUserOp.selector, + _erc7579Execute(_singleMode(), abi.encodePacked(address(0), uint256(1))) + ); + assertFalse(harness.exposed_isNoOpCalldata(callData)); + } + + /// @notice Wrapped ERC-7579 with batch mode should be rejected + function test_Reject_ExecuteUserOpWrappedBatchMode() public view { + bytes32 mode = + LibERC7579.encodeMode(LibERC7579.CALLTYPE_BATCH, LibERC7579.EXECTYPE_DEFAULT, bytes4(0), bytes22(0)); + bytes memory callData = abi.encodePacked( + IAccountExecute.executeUserOp.selector, _erc7579Execute(mode, abi.encodePacked(address(0), uint256(0))) + ); + assertFalse(harness.exposed_isNoOpCalldata(callData)); + } + + // ─── Non-no-op patterns ───────────────────────────────────────────── + + function test_Reject_RandomCalldata() public view { + assertFalse(harness.exposed_isNoOpCalldata(hex"deadbeef")); + } + + function test_Reject_ExecuteUserOpWithNonERC7579Data() public view { + bytes memory callData = abi.encodePacked( + IAccountExecute.executeUserOp.selector, hex"cafebabe0000000000000000000000000000000000000000" + ); + assertFalse(harness.exposed_isNoOpCalldata(callData)); + } + + /// @notice Real ERC-7579 execute with actual inner calldata (not a no-op) + function test_Reject_ERC7579Execute_RealExecution() public view { + bytes memory callData = _erc7579Execute( + _singleMode(), + abi.encodePacked( + address(0xdead), uint256(0), abi.encodeWithSignature("transfer(address,uint256)", address(1), 100) + ) + ); + assertFalse(harness.exposed_isNoOpCalldata(callData)); + } +}