aere-contracts/contracts/modular/AereSessionKeyValidator.sol
Aere Network a13a649b77
Some checks are pending
contracts-ci / Install (lockfile) → compile → full test suite (push) Waiting to run
contracts-ci / PQC known-answer tests (NIST vectors) (push) Waiting to run
contracts-ci / Coverage (scoped, with artifacts) (push) Waiting to run
Initial public release
Aere Network public source. Everything here can be checked against the live
chain (chain id 2800, https://rpc.aere.network).

Scope note, stated up front rather than buried: consensus on chain 2800 is
classical secp256k1 ECDSA QBFT. The post-quantum work in this repository is at
the signature, precompile, account and transport layers. Nothing here makes the
consensus post-quantum, and no document in it should be read as claiming so.
2026-07-20 01:02:37 +03:00

250 lines
10 KiB
Solidity

// 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;
}
}