// SPDX-License-Identifier: MIT pragma solidity ^0.8.20; import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; /// @title AereSessionKeyValidator - scoped session keys for AereModularAccount /// @notice Singleton validator module (ERC-7579 module type 1). Per-account state /// is keyed by msg.sender: ALL mutating functions take effect for the /// calling account, so registration is owner-only by construction (the /// account's execute()/installModule() paths are gated to the EntryPoint, /// self-calls and the root owner). /// /// A session is (key, validAfter, validUntil, maxValuePerOp) plus an /// allowlist of (target, selector) pairs. Selector 0x00000000 is the /// sentinel for plain native transfers (empty calldata). /// /// validateUserOp returns the CANONICAL ERC-4337 validationData packing: /// authorizer (160 bits) | validUntil (48) << 160 | validAfter (48) << 208 /// with authorizer = 0 on signature+scope success, 1 on failure. /// AereModularAccount unpacks it and enforces the time window itself /// because AereEntryPointV2 reverts on any non-zero account return. contract AereSessionKeyValidator { struct Session { bool enabled; uint48 validAfter; uint48 validUntil; uint256 maxValuePerOp; } uint256 internal constant SIG_VALIDATION_FAILED = 1; bytes4 internal constant EIP1271_MAGIC = 0x1626ba7e; bytes4 internal constant EIP1271_FAIL = 0xffffffff; /// @notice Sentinel selector matching an empty-calldata (native transfer) call. bytes4 public constant SELECTOR_NATIVE_TRANSFER = 0x00000000; /// @notice Selectors of AereModularAccount execution functions a session key /// is allowed to enter through. bytes4 public constant EXECUTE_SELECTOR = bytes4(keccak256("execute(address,uint256,bytes)")); bytes4 public constant EXECUTE_BATCH_SELECTOR = bytes4(keccak256("executeBatch((address,uint256,bytes)[])")); /// @dev Mirror of AereModularAccount.Call for callData decoding. struct Call { address target; uint256 value; bytes data; } /// @notice account => sessionKey => session parameters. mapping(address => mapping(address => Session)) public sessions; /// @notice account => sessionKey => target => selector => allowed. mapping(address => mapping(address => mapping(address => mapping(bytes4 => bool)))) public allowedCall; event SessionEnabled(address indexed account, address indexed key, uint48 validAfter, uint48 validUntil, uint256 maxValuePerOp); event SessionRevoked(address indexed account, address indexed key); event PermissionAdded(address indexed account, address indexed key, address target, bytes4 selector); event PermissionRemoved(address indexed account, address indexed key, address target, bytes4 selector); error InvalidSessionKey(); error InvalidWindow(); error LengthMismatch(); error SessionNotEnabled(); // ---- ERC-7579-style module base ------------------------------------------- function isModuleType(uint256 moduleTypeId) external pure returns (bool) { return moduleTypeId == 1; } /// @notice Optional initial session at install time. initData layout: /// abi.encode(address key, uint48 validAfter, uint48 validUntil, /// uint256 maxValuePerOp, address[] targets, bytes4[] selectors) /// Pass empty bytes to install with no session. function onInstall(bytes calldata data) external { if (data.length == 0) return; ( address key, uint48 validAfter, uint48 validUntil, uint256 maxValuePerOp, address[] memory targets, bytes4[] memory selectors ) = abi.decode(data, (address, uint48, uint48, uint256, address[], bytes4[])); _enableSession(msg.sender, key, validAfter, validUntil, maxValuePerOp, targets, selectors); } /// @notice Per-account session state is NOT bulk-cleared on uninstall (nested /// mappings are not enumerable). The account removes routing on /// uninstall, which makes stale state unreachable; revoke sessions /// explicitly before a planned reinstall. function onUninstall(bytes calldata) external {} // ---- Owner-only registration (msg.sender = the smart account) ------------ function enableSession( address key, uint48 validAfter, uint48 validUntil, uint256 maxValuePerOp, address[] calldata targets, bytes4[] calldata selectors ) external { address[] memory t = targets; bytes4[] memory s = selectors; _enableSession(msg.sender, key, validAfter, validUntil, maxValuePerOp, t, s); } function revokeSession(address key) external { if (!sessions[msg.sender][key].enabled) revert SessionNotEnabled(); sessions[msg.sender][key].enabled = false; emit SessionRevoked(msg.sender, key); } function addPermission(address key, address target, bytes4 selector) external { if (!sessions[msg.sender][key].enabled) revert SessionNotEnabled(); allowedCall[msg.sender][key][target][selector] = true; emit PermissionAdded(msg.sender, key, target, selector); } function removePermission(address key, address target, bytes4 selector) external { allowedCall[msg.sender][key][target][selector] = false; emit PermissionRemoved(msg.sender, key, target, selector); } function _enableSession( address account, address key, uint48 validAfter, uint48 validUntil, uint256 maxValuePerOp, address[] memory targets, bytes4[] memory selectors ) internal { if (key == address(0)) revert InvalidSessionKey(); if (validUntil != 0 && validUntil <= validAfter) revert InvalidWindow(); if (targets.length != selectors.length) revert LengthMismatch(); sessions[account][key] = Session({ enabled: true, validAfter: validAfter, validUntil: validUntil, maxValuePerOp: maxValuePerOp }); for (uint256 i = 0; i < targets.length; i++) { allowedCall[account][key][targets[i]][selectors[i]] = true; emit PermissionAdded(account, key, targets[i], selectors[i]); } emit SessionEnabled(account, key, validAfter, validUntil, maxValuePerOp); } // ---- Validation (called by the account, msg.sender = account) ------------- /// @param userOpHash hash the session key signed (EIP-191 envelope). /// @param accountCallData the userOp.callData; must be execute()/executeBatch() /// on the account, fully inside the session allowlist. /// @param signature 65-byte ECDSA signature by the session key. function validateUserOp( bytes32 userOpHash, bytes calldata accountCallData, bytes calldata signature ) external view returns (uint256 validationData) { address account = msg.sender; (address key, ECDSA.RecoverError err) = ECDSA.tryRecover(ECDSA.toEthSignedMessageHash(userOpHash), signature); if (err != ECDSA.RecoverError.NoError || key == address(0)) return SIG_VALIDATION_FAILED; Session memory s = sessions[account][key]; if (!s.enabled) return SIG_VALIDATION_FAILED; if (!_checkCallData(account, key, s.maxValuePerOp, accountCallData)) { return SIG_VALIDATION_FAILED; } // Canonical 4337 packing; the account enforces the time window. return (uint256(s.validUntil) << 160) | (uint256(s.validAfter) << 208); } function _checkCallData( address account, address key, uint256 maxValuePerOp, bytes calldata accountCallData ) internal view returns (bool) { if (accountCallData.length < 4) return false; bytes4 outer = bytes4(accountCallData[0:4]); if (outer == EXECUTE_SELECTOR) { (address target, uint256 value, bytes memory data) = abi.decode(accountCallData[4:], (address, uint256, bytes)); return _checkSingle(account, key, maxValuePerOp, target, value, data); } if (outer == EXECUTE_BATCH_SELECTOR) { Call[] memory calls = abi.decode(accountCallData[4:], (Call[])); if (calls.length == 0) return false; for (uint256 i = 0; i < calls.length; i++) { if (!_checkSingle(account, key, maxValuePerOp, calls[i].target, calls[i].value, calls[i].data)) { return false; } } return true; } // Session keys may only enter through execute/executeBatch. return false; } function _checkSingle( address account, address key, uint256 maxValuePerOp, address target, uint256 value, bytes memory data ) internal view returns (bool) { if (value > maxValuePerOp) return false; bytes4 sel; if (data.length == 0) { sel = SELECTOR_NATIVE_TRANSFER; } else if (data.length >= 4) { assembly { sel := mload(add(data, 32)) } } else { return false; // 1-3 byte calldata is never in scope } return allowedCall[account][key][target][sel]; } // ---- EIP-1271 pass-through (msg.sender = account) -------------------------- /// @notice Validates an off-chain signature by a currently-active session key. /// Scoping does not apply (no call is being made); only key validity /// and the time window are enforced. function isValidSignatureWithSender(address, bytes32 hash, bytes calldata signature) external view returns (bytes4) { address account = msg.sender; (address key, ECDSA.RecoverError err) = ECDSA.tryRecover(ECDSA.toEthSignedMessageHash(hash), signature); if (err != ECDSA.RecoverError.NoError || key == address(0)) return EIP1271_FAIL; Session memory s = sessions[account][key]; if (!s.enabled) return EIP1271_FAIL; if (s.validAfter != 0 && block.timestamp < s.validAfter) return EIP1271_FAIL; if (s.validUntil != 0 && block.timestamp > s.validUntil) return EIP1271_FAIL; return EIP1271_MAGIC; } }