Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 20 additions & 62 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 @@ -321,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;
Expand Down Expand Up @@ -354,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)

Expand Down Expand Up @@ -388,12 +352,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 +390,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
106 changes: 93 additions & 13 deletions test/TimelockPolicy.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -174,9 +174,22 @@ contract TimelockPolicyTest is PolicyTestBase, StatelessValidatorTestBase, State

PackedUserOperation memory userOp = validUserOp();

// First create a proposal
// 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: "",
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, userOp.callData, userOp.nonce);
policyModule.checkUserOpPolicy(policyId(), noopOp);
vm.stopPrank();

// Fast forward past the delay AND grace period
Expand Down Expand Up @@ -248,10 +261,26 @@ 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();

assertEq(result, 0);

// Verify proposal was created
(TimelockPolicy.ProposalStatus status, uint256 validAfter, uint256 graceEnd, uint256 validUntil) =
policyModule.getProposal(WALLET, callData, nonce, policyId(), WALLET);
Expand All @@ -271,9 +300,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
Expand Down Expand Up @@ -320,8 +362,7 @@ 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
Expand All @@ -340,9 +381,22 @@ contract TimelockPolicyTest is PolicyTestBase, StatelessValidatorTestBase, State

PackedUserOperation memory userOp = validUserOp();

// Create a proposal
// 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: "",
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, userOp.callData, userOp.nonce);
policyModule.checkUserOpPolicy(policyId(), noopOp);
vm.stopPrank();

// Fast forward past delay
Expand Down Expand Up @@ -376,9 +430,22 @@ contract TimelockPolicyTest is PolicyTestBase, StatelessValidatorTestBase, State

PackedUserOperation memory userOp = validUserOp();

// Create a proposal
// Create a proposal via no-op UserOp
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: "",
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, userOp.callData, userOp.nonce);
policyModule.checkUserOpPolicy(policyId(), noopOp);
vm.stopPrank();

// Fast forward past delay but NOT past grace period
Expand Down Expand Up @@ -411,9 +478,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();

// Fast forward past delay but still in grace period
Expand Down
Loading