-
Notifications
You must be signed in to change notification settings - Fork 34
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
3 changed files
with
348 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,108 @@ | ||
// SPDX-License-Identifier: UNLICENSED | ||
pragma solidity ^0.8.26; | ||
|
||
import {_packValidationData} from "@eth-infinitism/account-abstraction/core/Helpers.sol"; | ||
import {PackedUserOperation} from "@eth-infinitism/account-abstraction/interfaces/PackedUserOperation.sol"; | ||
import {IERC165} from "@openzeppelin/contracts/interfaces/IERC165.sol"; | ||
|
||
import {IModule} from "@erc6900/reference-implementation/interfaces/IModule.sol"; | ||
import {IValidationHookModule} from "@erc6900/reference-implementation/interfaces/IValidationHookModule.sol"; | ||
|
||
import {BaseModule} from "../../modules/BaseModule.sol"; | ||
|
||
/// @title Time Range Module | ||
/// @author Alchemy | ||
/// @notice This module allows for the setting and enforcement of time ranges for a validation function. Enforcement | ||
/// relies on `block.timestamp`, either within this module for runtime validation, or by the EntryPoint for user op | ||
/// validation. Time ranges are inclusive of both the beginning and ending timestamps. | ||
contract TimeRangeModule is IValidationHookModule, BaseModule { | ||
struct TimeRange { | ||
uint48 validUntil; | ||
uint48 validAfter; | ||
} | ||
|
||
mapping(uint32 entityId => mapping(address account => TimeRange)) public timeRanges; | ||
|
||
error TimeRangeNotValid(); | ||
|
||
/// @inheritdoc IModule | ||
/// @notice Initializes the module with the given time range for `msg.sender` with a given entity id. | ||
/// @dev data is abi-encoded as (uint32 entityId, uint48 validUntil, uint48 validAfter) | ||
function onInstall(bytes calldata data) external override { | ||
(uint32 entityId, uint48 validUntil, uint48 validAfter) = abi.decode(data, (uint32, uint48, uint48)); | ||
|
||
setTimeRange(entityId, validUntil, validAfter); | ||
} | ||
|
||
/// @inheritdoc IModule | ||
/// @notice Resets module state for `msg.sender` with the given entity id. | ||
/// @dev data is abi-encoded as (uint32 entityId) | ||
function onUninstall(bytes calldata data) external override { | ||
uint32 entityId = abi.decode(data, (uint32)); | ||
|
||
delete timeRanges[entityId][msg.sender]; | ||
} | ||
|
||
/// @inheritdoc IValidationHookModule | ||
/// @notice Enforces the time range for a user op by returning the range in the ERC-4337 validation data. | ||
function preUserOpValidationHook(uint32 entityId, PackedUserOperation calldata, bytes32) | ||
external | ||
view | ||
override | ||
returns (uint256) | ||
{ | ||
// todo: optimize between memory / storage | ||
TimeRange memory timeRange = timeRanges[entityId][msg.sender]; | ||
return _packValidationData({ | ||
sigFailed: false, | ||
validUntil: timeRange.validUntil, | ||
validAfter: timeRange.validAfter | ||
}); | ||
} | ||
|
||
/// @inheritdoc IValidationHookModule | ||
/// @notice Enforces the time range for a runtime validation by reverting if `block.timestamp` is not within | ||
/// the range. | ||
function preRuntimeValidationHook(uint32 entityId, address, uint256, bytes calldata, bytes calldata) | ||
external | ||
view | ||
override | ||
{ | ||
TimeRange memory timeRange = timeRanges[entityId][msg.sender]; | ||
if (block.timestamp > timeRange.validUntil || block.timestamp < timeRange.validAfter) { | ||
revert TimeRangeNotValid(); | ||
} | ||
} | ||
|
||
/// @inheritdoc IValidationHookModule | ||
/// @dev No-op, signature checking is not enforced to be within a time range, due to uncertainty about whether | ||
/// the `timestamp` opcode is allowed during this operation. If the validation should not be allowed to | ||
/// generate 1271 signatures, the flag `isSignatureValidation` should be set to false when calling | ||
/// `installValidation`. | ||
// solhint-disable-next-line no-empty-blocks | ||
function preSignatureValidationHook(uint32, address, bytes32, bytes calldata) external pure override {} | ||
|
||
/// @inheritdoc IModule | ||
function moduleId() external pure returns (string memory) { | ||
return "alchemy.timerange-module.0.0.1"; | ||
} | ||
|
||
/// @notice Sets the time range for the sending account (`msg.sender`) and a given entity id. | ||
/// @param entityId The entity id to set the time range for. | ||
/// @param validUntil The timestamp until which the time range is valid, inclusive. | ||
/// @param validAfter The timestamp after which the time range is valid, inclusive. | ||
function setTimeRange(uint32 entityId, uint48 validUntil, uint48 validAfter) public { | ||
timeRanges[entityId][msg.sender] = TimeRange(validUntil, validAfter); | ||
} | ||
|
||
/// @inheritdoc IERC165 | ||
function supportsInterface(bytes4 interfaceId) | ||
public | ||
view | ||
virtual | ||
override(BaseModule, IERC165) | ||
returns (bool) | ||
{ | ||
return interfaceId == type(IValidationHookModule).interfaceId || super.supportsInterface(interfaceId); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,237 @@ | ||
// SPDX-License-Identifier: UNLICENSED | ||
pragma solidity ^0.8.26; | ||
|
||
import {_packValidationData} from "@eth-infinitism/account-abstraction/core/Helpers.sol"; | ||
import {PackedUserOperation} from "@eth-infinitism/account-abstraction/interfaces/PackedUserOperation.sol"; | ||
import {MessageHashUtils} from "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol"; | ||
|
||
import {ValidationDataView} from "@erc6900/reference-implementation/interfaces/IModularAccountView.sol"; | ||
|
||
import {ModularAccount} from "../../src/account/ModularAccount.sol"; | ||
import {HookConfigLib} from "../../src/libraries/HookConfigLib.sol"; | ||
import {ModuleEntity, ModuleEntityLib} from "../../src/libraries/ModuleEntityLib.sol"; | ||
import {TimeRangeModule} from "../../src/modules/permissions/TimeRangeModule.sol"; | ||
|
||
import {CustomValidationTestBase} from "../utils/CustomValidationTestBase.sol"; | ||
|
||
contract TimeRangeModuleTest is CustomValidationTestBase { | ||
TimeRangeModule public timeRangeModule; | ||
|
||
uint32 public constant HOOK_ENTITY_ID = 0; | ||
|
||
ModuleEntity internal _hookEntity; | ||
|
||
uint48 public validUntil; | ||
uint48 public validAfter; | ||
|
||
function setUp() public { | ||
_signerValidation = | ||
ModuleEntityLib.pack(address(singleSignerValidationModule), TEST_DEFAULT_VALIDATION_ENTITY_ID); | ||
|
||
timeRangeModule = new TimeRangeModule(); | ||
|
||
_hookEntity = ModuleEntityLib.pack(address(timeRangeModule), HOOK_ENTITY_ID); | ||
} | ||
|
||
function test_timeRangeModule_moduleId() public view { | ||
assertEq(timeRangeModule.moduleId(), "alchemy.timerange-module.0.0.1"); | ||
} | ||
|
||
function test_timeRangeModule_install() public { | ||
validUntil = 1000; | ||
validAfter = 100; | ||
|
||
_customValidationSetup(); | ||
|
||
// Verify that it is installed | ||
ValidationDataView memory validationData = account1.getValidationData(_signerValidation); | ||
|
||
assertTrue(validationData.isGlobal); | ||
assertTrue(validationData.isSignatureValidation); | ||
assertTrue(validationData.isUserOpValidation); | ||
|
||
assertEq(validationData.preValidationHooks.length, 1); | ||
assertEq(ModuleEntity.unwrap(validationData.preValidationHooks[0]), ModuleEntity.unwrap(_hookEntity)); | ||
|
||
assertEq(validationData.executionHooks.length, 0); | ||
assertEq(validationData.selectors.length, 0); | ||
|
||
// Verify that the time range is set | ||
(uint48 retrievedValidUntil, uint48 retrievedValidAfter) = | ||
timeRangeModule.timeRanges(HOOK_ENTITY_ID, address(account1)); | ||
assertEq(retrievedValidUntil, validUntil); | ||
assertEq(retrievedValidAfter, validAfter); | ||
} | ||
|
||
function test_timeRangeModule_uninstall() public { | ||
test_timeRangeModule_install(); | ||
|
||
// Uninstall the module | ||
bytes[] memory hookUninstallDatas = new bytes[](1); | ||
hookUninstallDatas[0] = abi.encode(HOOK_ENTITY_ID); | ||
|
||
vm.expectCall({ | ||
callee: address(timeRangeModule), | ||
data: abi.encodeCall(TimeRangeModule.onUninstall, (hookUninstallDatas[0])), | ||
count: 1 | ||
}); | ||
vm.prank(address(account1)); | ||
account1.uninstallValidation(_signerValidation, "", hookUninstallDatas); | ||
|
||
// Verify that the time range data is unset | ||
(uint48 retrievedValidUntil, uint48 retrievedValidAfter) = | ||
timeRangeModule.timeRanges(HOOK_ENTITY_ID, address(account1)); | ||
|
||
assertEq(retrievedValidUntil, 0); | ||
assertEq(retrievedValidAfter, 0); | ||
} | ||
|
||
function testFuzz_timeRangeModule_userOp(uint48 _validUntil, uint48 _validAfter) public { | ||
validUntil = _validUntil; | ||
validAfter = _validAfter; | ||
|
||
_customValidationSetup(); | ||
|
||
PackedUserOperation memory userOp = PackedUserOperation({ | ||
sender: address(account1), | ||
nonce: 0, | ||
initCode: hex"", | ||
callData: abi.encodeCall(ModularAccount.execute, (makeAddr("recipient"), 0 wei, "")), | ||
accountGasLimits: _encodeGas(VERIFICATION_GAS_LIMIT, CALL_GAS_LIMIT), | ||
preVerificationGas: 0, | ||
gasFees: _encodeGas(1, 1), | ||
paymasterAndData: hex"", | ||
signature: hex"" | ||
}); | ||
|
||
bytes32 userOpHash = entryPoint.getUserOpHash(userOp); | ||
(uint8 v, bytes32 r, bytes32 s) = vm.sign(owner1Key, MessageHashUtils.toEthSignedMessageHash(userOpHash)); | ||
|
||
userOp.signature = _encodeSignature(_signerValidation, GLOBAL_VALIDATION, abi.encodePacked(r, s, v)); | ||
|
||
vm.prank(address(entryPoint)); | ||
uint256 validationData = account1.validateUserOp(userOp, userOpHash, 0); | ||
|
||
uint48 expectedValidUntil = validUntil == 0 ? type(uint48).max : validUntil; | ||
|
||
assertEq( | ||
validationData, | ||
_packValidationData({sigFailed: false, validUntil: expectedValidUntil, validAfter: validAfter}) | ||
); | ||
} | ||
|
||
function testFuzz_timeRangeModule_userOp_fail(uint48 _validUntil, uint48 _validAfter) public { | ||
validUntil = _validUntil; | ||
validAfter = _validAfter; | ||
|
||
_customValidationSetup(); | ||
|
||
PackedUserOperation memory userOp = PackedUserOperation({ | ||
sender: address(account1), | ||
nonce: 0, | ||
initCode: hex"", | ||
callData: abi.encodeCall(ModularAccount.execute, (makeAddr("recipient"), 0 wei, "")), | ||
accountGasLimits: _encodeGas(VERIFICATION_GAS_LIMIT, CALL_GAS_LIMIT), | ||
preVerificationGas: 0, | ||
gasFees: _encodeGas(1, 1), | ||
paymasterAndData: hex"", | ||
signature: hex"" | ||
}); | ||
bytes32 userOpHash = entryPoint.getUserOpHash(userOp); | ||
|
||
// Generate a bad signature | ||
userOp.signature = _encodeSignature(_signerValidation, GLOBAL_VALIDATION, abi.encodePacked("abcd")); | ||
|
||
vm.prank(address(entryPoint)); | ||
uint256 validationData = account1.validateUserOp(userOp, userOpHash, 0); | ||
|
||
uint48 expectedValidUntil = validUntil == 0 ? type(uint48).max : validUntil; | ||
|
||
assertEq( | ||
validationData, | ||
_packValidationData({sigFailed: true, validUntil: expectedValidUntil, validAfter: validAfter}) | ||
); | ||
} | ||
|
||
function test_timeRangeModule_runtime_before() public { | ||
validUntil = 1000; | ||
validAfter = 100; | ||
|
||
_customValidationSetup(); | ||
|
||
// Attempt from before the valid time range, expect fail | ||
vm.warp(1); | ||
|
||
vm.expectRevert( | ||
abi.encodeWithSelector( | ||
ModularAccount.PreRuntimeValidationHookFailed.selector, | ||
timeRangeModule, | ||
HOOK_ENTITY_ID, | ||
abi.encodeWithSelector(TimeRangeModule.TimeRangeNotValid.selector) | ||
) | ||
); | ||
vm.prank(owner1); | ||
account1.executeWithAuthorization( | ||
abi.encodeCall(ModularAccount.execute, (makeAddr("recipient"), 0 wei, "")), | ||
_encodeSignature(_signerValidation, GLOBAL_VALIDATION, "") | ||
); | ||
} | ||
|
||
function test_timeRangeModule_runtime_during() public { | ||
validUntil = 1000; | ||
validAfter = 100; | ||
|
||
_customValidationSetup(); | ||
|
||
// Attempt during the valid time range, expect success | ||
vm.warp(101); | ||
|
||
vm.expectCall({callee: makeAddr("recipient"), msgValue: 0 wei, data: "", count: 1}); | ||
vm.prank(owner1); | ||
account1.executeWithAuthorization( | ||
abi.encodeCall(ModularAccount.execute, (makeAddr("recipient"), 0 wei, "")), | ||
_encodeSignature(_signerValidation, GLOBAL_VALIDATION, "") | ||
); | ||
} | ||
|
||
function test_timeRangeModule_runtime_after() public { | ||
validUntil = 1000; | ||
validAfter = 100; | ||
|
||
_customValidationSetup(); | ||
|
||
// Attempt after the valid time range, expect fail | ||
vm.warp(1001); | ||
|
||
vm.expectRevert( | ||
abi.encodeWithSelector( | ||
ModularAccount.PreRuntimeValidationHookFailed.selector, | ||
timeRangeModule, | ||
HOOK_ENTITY_ID, | ||
abi.encodeWithSelector(TimeRangeModule.TimeRangeNotValid.selector) | ||
) | ||
); | ||
vm.prank(owner1); | ||
account1.executeWithAuthorization( | ||
abi.encodeCall(ModularAccount.execute, (makeAddr("recipient"), 0 wei, "")), | ||
_encodeSignature(_signerValidation, GLOBAL_VALIDATION, "") | ||
); | ||
} | ||
|
||
function _initialValidationConfig() | ||
internal | ||
virtual | ||
override | ||
returns (ModuleEntity, bool, bool, bool, bytes4[] memory, bytes memory, bytes[] memory) | ||
{ | ||
bytes[] memory hooks = new bytes[](1); | ||
hooks[0] = abi.encodePacked( | ||
HookConfigLib.packValidationHook(address(timeRangeModule), HOOK_ENTITY_ID), | ||
abi.encode(HOOK_ENTITY_ID, validUntil, validAfter) | ||
); | ||
// patched to also work during SMA tests by differentiating the validation | ||
_signerValidation = ModuleEntityLib.pack(address(singleSignerValidationModule), type(uint32).max - 1); | ||
return | ||
(_signerValidation, true, true, true, new bytes4[](0), abi.encode(type(uint32).max - 1, owner1), hooks); | ||
} | ||
} |