Skip to content
Merged
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
19 changes: 15 additions & 4 deletions src/policies/TimelockPolicy.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand All @@ -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});

Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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);

Expand All @@ -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;

Expand Down
36 changes: 36 additions & 0 deletions test/TimelockPolicy.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Loading