// SPDX-License-Identifier: MIT pragma solidity ^0.8.20; import {WebAuthn} from "./WebAuthn.sol"; import {MultiOwnable} from "./MultiOwnable.sol"; /// @title AerePasskeyAccountV2 — production-grade passkey-controlled smart account /// @notice Adds to V1: /// - MultiOwnable: passkey + EOA + multiple keys, full recovery /// - ERC-4337 validateUserOp shim → Pimlico/Stackup/ZeroDev bundlers compatible /// - EIP-1271 isValidSignature → DeFi integrations (Permit2, OpenSea, Snapshot) /// - Direct executeWithPasskey path for our own Foundation relayer /// - Owner-selecting signature wrapper (which key signed?) /// /// @dev Architecture follows Coinbase Smart Wallet (audited by ChainSecurity, OpenZeppelin) /// with WebAuthn library = base-org/webauthn-sol (precompile-only port). contract AerePasskeyAccountV2 is MultiOwnable { /// @notice ERC-4337 EntryPoint that's allowed to call validateUserOp + executeFromEntryPoint. /// Set once at construction by the factory. address public entryPoint; /// @notice Replay counter for the direct passkey path (executeWithPasskey). uint256 public passkeyNonce; /// @notice Factory that deployed this account (for initialize gate). address public immutable factory; /// @notice EIP-1271 magic value returned by isValidSignature on success. bytes4 internal constant EIP1271_MAGIC = 0x1626ba7e; /// @notice Signature wrapper — selects which owner signed. /// For passkey owners: signatureData is abi.encode(WebAuthn.WebAuthnAuth). /// For EOA owners: signatureData is the raw 65-byte ECDSA signature. struct SignatureWrapper { uint256 ownerIndex; bytes signatureData; } /// @notice Generic call struct (used by executeBatch). struct Call { address target; uint256 value; bytes data; } /// @notice ERC-4337 v0.7 PackedUserOperation shape (minimal subset we use). struct PackedUserOperation { address sender; uint256 nonce; bytes initCode; bytes callData; bytes32 accountGasLimits; uint256 preVerificationGas; bytes32 gasFees; bytes paymasterAndData; bytes signature; } event Executed(uint256 indexed nonce, address indexed target, uint256 value, bytes data); event ExecutedBatch(uint256 indexed nonce, uint256 count); event EntryPointSet(address indexed entryPoint); error InvalidSignature(); error CallFailed(bytes returnData); error NotInitialized(); error AlreadyInitialized(); error InvalidEntryPoint(); error InvalidOwnerForSignature(); error NoOwners(); constructor() { factory = msg.sender; } /// @notice One-shot initializer called by the factory at deploy time. function initialize(address _entryPoint, bytes[] calldata initialOwners) external { if (msg.sender != factory) revert AlreadyInitialized(); if (entryPoint != address(0)) revert AlreadyInitialized(); if (_entryPoint == address(0)) revert InvalidEntryPoint(); // Defense in depth: an empty owner set bricks the account permanently // (funds at its address can never be moved). The factory blocks this too. if (initialOwners.length == 0) revert NoOwners(); entryPoint = _entryPoint; emit EntryPointSet(_entryPoint); bytes[] memory owners = new bytes[](initialOwners.length); for (uint256 i = 0; i < initialOwners.length; i++) owners[i] = initialOwners[i]; _initializeOwners(owners); } // ─── ERC-4337 path ──────────────────────────────────────────────────────── /// @notice Called by the EntryPoint to validate a user operation. /// @dev Returns 0 on success per ERC-4337. Does NOT revert on bad signature /// (per spec — caller handles the failure code). function validateUserOp( PackedUserOperation calldata userOp, bytes32 userOpHash, uint256 missingAccountFunds ) external returns (uint256 validationData) { if (msg.sender != entryPoint) revert Unauthorized(); // Verify the signature over userOpHash. bool ok = _verifySignature(userOpHash, userOp.signature); if (!ok) return 1; // SIG_VALIDATION_FAILED per ERC-4337 // Pay the EntryPoint any prefund it needs (uncapped — EntryPoint enforces gas budget). if (missingAccountFunds > 0) { (bool sent, ) = payable(entryPoint).call{value: missingAccountFunds}(""); (sent); // ignore — EntryPoint will revert if underfunded } return 0; } /// @notice Called by the EntryPoint after validation. Dispatches the user's intended call. /// @dev The userOp.callData should encode a call to executeFromEntryPoint(target, value, data) /// or executeBatchFromEntryPoint(calls). function executeFromEntryPoint(address target, uint256 value, bytes calldata data) external { if (msg.sender != entryPoint) revert Unauthorized(); (bool ok, bytes memory ret) = target.call{value: value}(data); if (!ok) revert CallFailed(ret); } function executeBatchFromEntryPoint(Call[] calldata calls) external { if (msg.sender != entryPoint) revert Unauthorized(); for (uint256 i = 0; i < calls.length; i++) { (bool ok, bytes memory ret) = calls[i].target.call{value: calls[i].value}(calls[i].data); if (!ok) revert CallFailed(ret); } } // ─── Direct passkey path (Foundation relayer / self-relay) ──────────────── /// @notice Hash that the user's signing key must sign for an executeWithPasskey() call. function getExecuteChallenge( address target, uint256 value, bytes calldata data, uint256 expectedNonce ) public view returns (bytes32) { return keccak256(abi.encode( address(this), block.chainid, expectedNonce, target, value, data )); } /// @notice Execute a single call, authorized by any registered owner. function executeWithPasskey( SignatureWrapper calldata sw, address target, uint256 value, bytes calldata data ) external returns (bytes memory result) { if (ownerCount == 0) revert NotInitialized(); bytes32 challenge = getExecuteChallenge(target, value, data, passkeyNonce); if (!_verifyWrappedSignature(challenge, sw)) revert InvalidSignature(); uint256 n = passkeyNonce; passkeyNonce = n + 1; (bool ok, bytes memory ret) = target.call{value: value}(data); if (!ok) revert CallFailed(ret); emit Executed(n, target, value, data); return ret; } /// @notice Hash for an executeBatchWithPasskey call. function getExecuteBatchChallenge(Call[] calldata calls, uint256 expectedNonce) public view returns (bytes32) { return keccak256(abi.encode( address(this), block.chainid, expectedNonce, keccak256(abi.encode(calls)) )); } function executeBatchWithPasskey(SignatureWrapper calldata sw, Call[] calldata calls) external returns (bytes[] memory results) { if (ownerCount == 0) revert NotInitialized(); bytes32 challenge = getExecuteBatchChallenge(calls, passkeyNonce); if (!_verifyWrappedSignature(challenge, sw)) revert InvalidSignature(); uint256 n = passkeyNonce; passkeyNonce = n + 1; results = new bytes[](calls.length); for (uint256 i = 0; i < calls.length; i++) { (bool ok, bytes memory ret) = calls[i].target.call{value: calls[i].value}(calls[i].data); if (!ok) revert CallFailed(ret); results[i] = ret; } emit ExecutedBatch(n, calls.length); } // ─── EIP-1271 ───────────────────────────────────────────────────────────── /// @notice EIP-1271: validate an off-chain signature for the given hash. /// Used by Permit2, OpenSea, Snapshot, dapp login flows. function isValidSignature(bytes32 hash, bytes calldata signature) external view returns (bytes4) { // Wrap the hash in a domain-separated digest to prevent cross-account replay. bytes32 wrappedHash = keccak256(abi.encode( keccak256("AereAccount1271(address account,uint256 chainid,bytes32 hash)"), address(this), block.chainid, hash )); return _verifyWrappedSignature(wrappedHash, abi.decode(signature, (SignatureWrapper))) ? EIP1271_MAGIC : bytes4(0); } // ─── Signature verification (internal) ──────────────────────────────────── function _verifySignature(bytes32 hash, bytes calldata signature) internal view returns (bool) { SignatureWrapper memory sw = abi.decode(signature, (SignatureWrapper)); return _verifyWrappedSignature(hash, sw); } function _verifyWrappedSignature(bytes32 hash, SignatureWrapper memory sw) internal view returns (bool) { bytes memory owner = _ownerAtIndex[sw.ownerIndex]; if (owner.length == 0) return false; if (owner.length == 32) { // EOA owner — recover from 65-byte ECDSA sig. address signer = abi.decode(owner, (address)); address recovered = _recoverEOA(hash, sw.signatureData); // Reject address(0): a malformed signature or an ecrecover failure returns // address(0), which must never authorize (belt-and-suspenders with the // address(0)-owner ban in MultiOwnable). return recovered != address(0) && recovered == signer; } else if (owner.length == 64) { // Passkey owner — decode WebAuthnAuth from signatureData. (bytes32 x, bytes32 y) = abi.decode(owner, (bytes32, bytes32)); WebAuthn.WebAuthnAuth memory auth = abi.decode(sw.signatureData, (WebAuthn.WebAuthnAuth)); return WebAuthn.verify(abi.encodePacked(hash), true, auth, uint256(x), uint256(y)); } return false; } function _recoverEOA(bytes32 hash, bytes memory sig) internal pure returns (address) { if (sig.length != 65) return address(0); bytes32 r; bytes32 s; uint8 v; // solhint-disable-next-line no-inline-assembly assembly { r := mload(add(sig, 32)) s := mload(add(sig, 64)) v := byte(0, mload(add(sig, 96))) } if (v < 27) v += 27; // Use EIP-191 personal_sign envelope for EOA signers (compatible with MetaMask). bytes32 ethHash = keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", hash)); return ecrecover(ethHash, v, r, s); } receive() external payable {} }