Skip to content
176 changes: 44 additions & 132 deletions src/policies/TimelockPolicy.sol
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import {
contract TimelockPolicy is PolicyBase, IStatelessValidator, IStatelessValidatorWithSender {
enum ProposalStatus {
None, // Proposal doesn't exist
Pending, // Proposal created, waiting for timelock
Pending, // Clock started, waiting for timelock
Executed, // Proposal executed
Cancelled // Proposal cancelled
}
Expand Down Expand Up @@ -66,13 +66,8 @@ contract TimelockPolicy is PolicyBase, IStatelessValidator, IStatelessValidatorW
error InvalidDelay();
error InvalidExpirationPeriod();
error InvalidGracePeriod();
error ProposalNotFound();
error ProposalAlreadyExists();
error TimelockNotExpired(uint256 validAfter, uint256 currentTime);
error ProposalExpired(uint256 validUntil, uint256 currentTime);
error ProposalNotPending();
error OnlyAccount();
error ProposalFromPreviousEpoch();
error ParametersTooLarge();

/**
Expand All @@ -89,7 +84,7 @@ contract TimelockPolicy is PolicyBase, IStatelessValidator, IStatelessValidatorW
if (delay == 0) revert InvalidDelay();
if (expirationPeriod == 0) revert InvalidExpirationPeriod();
if (gracePeriod == 0) revert InvalidGracePeriod();
// Prevent uint48 overflow in createProposal: uint48(block.timestamp) + delay + gracePeriod + expirationPeriod
// Prevent uint48 overflow: uint48(block.timestamp) + delay + gracePeriod + expirationPeriod
if (uint256(delay) + uint256(gracePeriod) + uint256(expirationPeriod) > type(uint48).max - block.timestamp) {
revert ParametersTooLarge();
}
Expand Down Expand Up @@ -123,41 +118,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 timelock delay provides the security
* @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);

// 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 graceEnd = validAfter + config.gracePeriod;
uint48 validUntil = graceEnd + config.expirationPeriod;

// 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 proposal (stored by userOpKey) with current epoch
proposals[userOpKey][id][account] =
Proposal({status: ProposalStatus.Pending, validAfter: validAfter, graceEnd: graceEnd, validUntil: validUntil, epoch: currentEpoch[id][account]});

emit ProposalCreated(account, id, userOpKey, validAfter, validUntil);
}

/**
* @notice Cancel a pending proposal
* @dev Only the account itself can cancel proposals to prevent griefing
Expand Down Expand Up @@ -207,8 +167,10 @@ 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 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 _handleProposalCreationInternal(
bytes32 id,
Expand All @@ -221,8 +183,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]));
Expand All @@ -235,19 +197,17 @@ contract TimelockPolicy is PolicyBase, IStatelessValidator, IStatelessValidatorW
// 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
Proposal storage proposal = proposals[userOpKey][id][account];

if (proposal.status != ProposalStatus.None) {
return SIG_VALIDATION_FAILED_UINT;
}

// Create proposal with current epoch
proposals[userOpKey][id][account] =
Proposal({status: ProposalStatus.Pending, validAfter: validAfter, graceEnd: graceEnd, validUntil: validUntil, epoch: currentEpoch[id][account]});

emit ProposalCreated(account, id, userOpKey, validAfter, 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);
}

Expand Down Expand Up @@ -283,91 +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;

// Need at least 4 bytes for selector
if (callData.length < 4) return false;
function _isNoOpCalldata(bytes calldata callData) internal pure returns (bool) {
uint256 len = callData.length;

bytes4 selector = bytes4(callData[0:4]);
// Case 1: Empty calldata
if (len == 0) return true;

// 2. Check for ERC-7579 execute(bytes32 mode, bytes calldata executionCalldata)
if (selector == IERC7579Execution.execute.selector) {
return _isNoOpERC7579Execute(callData);
}
// Case 2: ERC-7579 execute with empty execution data
if (_isNoOpERC7579Execute(callData)) return true;

// 3. Check for executeUserOp(PackedUserOperation calldata userOp, bytes32 userOpHash)
if (selector == IAccountExecute.executeUserOp.selector) {
return _isNoOpExecuteUserOp(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;
}

// 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, "")
*/
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;

// 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)
* @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 _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;

// Decode offset to userOp data (should be 32)
uint256 offset = uint256(bytes32(callData[4:36]));
if (offset != 32) 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;
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 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;
}

/**
Expand All @@ -388,12 +306,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");
}

Expand Down Expand Up @@ -431,7 +344,6 @@ contract TimelockPolicy is PolicyBase, IStatelessValidator, IStatelessValidatorW
// 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) {
// This is a proposal creation request
return _handleProposalCreationInternal(id, userOp, config, sig, account);
}

Expand Down
Loading