Skip to content

Commit

Permalink
feat: add time range module
Browse files Browse the repository at this point in the history
  • Loading branch information
adamegyed committed Sep 26, 2024
1 parent 4afc628 commit 108d147
Show file tree
Hide file tree
Showing 3 changed files with 348 additions and 1 deletion.
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,18 @@
},
"scripts": {
"clean": "forge clean && FOUNDRY_PROFILE=optimized-build forge clean",
"prep": "pnpm fmt && forge b && pnpm lint && pnpm test && pnpm gas",
"coverage": "forge coverage --no-match-coverage '(test)'",
"fmt": "forge fmt && FOUNDRY_PROFILE=gas forge fmt",
"fmt:check": "forge fmt --check && FOUNDRY_PROFILE=gas forge fmt --check",
"gas": "FOUNDRY_PROFILE=gas forge test -vv",
"gas:check": "FOUNDRY_PROFILE=gas FORGE_SNAPSHOT_CHECK=true forge test -vv",
"lcov": "forge coverage --no-match-coverage '(test)' --report lcov",
"lint": "pnpm lint:src && pnpm lint:test && pnpm lint:gas && pnpm lint:script",
"lint:src": "solhint --max-warnings 0 -c ./config/solhint-src.json './src/**/*.sol'",
"lint:test": "solhint --max-warnings 0 -c ./config/solhint-test.json './test/**/*.sol'",
"lint:gas": "solhint --max-warnings 0 -c ./config/solhint-gas.json './gas/**/*.sol'",
"lint:script": "solhint --max-warnings 0 -c ./config/solhint-script.json './script/**/*.sol'",
"prep": "pnpm fmt && forge b && pnpm lint && pnpm test && pnpm gas",
"test": "forge test && SMA_TEST=true forge test"
}
}
108 changes: 108 additions & 0 deletions src/modules/permissions/TimeRangeModule.sol
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);
}
}
237 changes: 237 additions & 0 deletions test/modules/TimeRangeModule.t.sol
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);
}
}

0 comments on commit 108d147

Please sign in to comment.