// SPDX-License-Identifier: MIT pragma solidity 0.8.23; /** * @title AereDelegate7702 — EIP-7702 delegation target for native AERE EOAs * @notice This is the contract code that an AERE EOA points its 7702 * delegation tuple at. When a user signs the EIP-7702 authorisation * tuple and includes it in a transaction, their EOA's code becomes * the *deployed* bytecode of this contract for the lifetime of * that authorisation. Their EOA can then expose all the smart- * account functionality below — batched calls, session keys, * passkey signature verification — while remaining controllable * by the original secp256k1 EOA key. * * @dev PHASE 1 surface (Q3 2026 ship): * * 1. executeBatch(Call[]) — atomic multi-call from the EOA. The * EOA's own ECDSA signature on the surrounding transaction is * the authorisation; no additional signature is needed inside. * * 2. executeWithPasskey(Call, P256Signature, bytes challenge) * — for users who have ALSO registered a WebAuthn passkey * (registerPasskey() below). The transaction itself is paid * by a relayer (Foundation paymaster), the passkey signature * authorises the inner call. This is the bridge between * "I have MetaMask" and "I have Touch ID / Face ID". * * 3. addSessionKey(address key, uint48 expiry, bytes4[] allowedSelectors) * — register a secondary key with a narrow scope (e.g. swap * router only) and a short expiry (e.g. 24h). Used for * dApp UX flows where you want to skip the wallet popup * for a defined set of low-risk operations. * * 4. revokeSessionKey(address key) — kill switch. * * 5. setPasskey(bytes32 pubkeyX, bytes32 pubkeyY) — add a single * WebAuthn passkey. Phase 1 supports one key; Phase 2 (Q4 * 2026) extends to a MultiOwnable-style set via the same * pattern as AerePasskeyAccountFactoryV2. * * STORAGE LAYOUT (7702-safe): * 7702 transmits ONLY code, not storage. Every read of state * below happens at the EOA's account storage. That means * different EOAs delegating to this same code get independent * storage. No upgrade migration needed; the EOA can re-point * its delegation tuple at a new code address at any time. * * REENTRANCY: * Calls are external; the contract sets a transient _entered * flag for the executeBatch path. Session-key invocation goes * through the same path. * * GAS: * Phase 1 verifies P-256 via the RIP-7951 precompile at 0x100 * (~6,900 gas per signature). The chain has supported this * since the Tier 1.15 deploy (block 2,157,502). */ contract AereDelegate7702 { /* --------------------------------- types --------------------------------- */ struct Call { address target; uint256 value; bytes data; } struct SessionKey { uint48 expiry; // unix seconds; 0 = revoked bool active; mapping(bytes4 => bool) allowedSelectors; } struct P256Signature { bytes32 r; bytes32 s; bytes authenticatorData; bytes clientDataJSON; } /* ------------------------------- storage ------------------------------- */ /// @notice One WebAuthn (P-256) public key per delegating EOA. Phase 1 /// supports a single key; Phase 2 extends to a set. bytes32 public passkeyPubX; bytes32 public passkeyPubY; /// @notice Anti-replay nonce for passkey-authorised calls. uint256 public passkeyNonce; mapping(address => SessionKey) internal _sessionKeys; /// @dev Transient reentrancy guard. bool private _entered; /// @notice RIP-7951 P256Verify precompile address on AERE. address internal constant P256_VERIFY = 0x0000000000000000000000000000000000000100; /* ---------------------------------- events --------------------------------- */ event BatchExecuted(uint256 callCount); event SessionKeyAdded(address indexed key, uint48 expiry); event SessionKeyRevoked(address indexed key); event PasskeySet(bytes32 indexed pubX, bytes32 indexed pubY); event PasskeyExecuted(uint256 nonce, address indexed target, uint256 value); /* ---------------------------------- errors -------------------------------- */ error Reentrant(); error NotEoaOwner(); error SessionKeyExpired(); error SelectorNotAllowed(); error CallFailed(uint256 idx, bytes reason); error InvalidPasskeySignature(); error PasskeyNotConfigured(); error InvalidChallenge(); /* -------------------------------- modifiers ------------------------------- */ modifier nonReentrant() { if (_entered) revert Reentrant(); _entered = true; _; _entered = false; } /** * @dev The EOA itself is identified by address(this) under EIP-7702. So * msg.sender == address(this) (i.e. the EOA owner sending a * directly-signed tx to themselves) is the canonical owner check. */ modifier onlyOwner() { if (msg.sender != address(this)) revert NotEoaOwner(); _; } /* --------------------------------- core: batch ---------------------------- */ /// @notice Atomic batched-call entrypoint. Authorised by the EOA's own /// ECDSA signature (the tx is signed by the EOA). function executeBatch(Call[] calldata calls) external payable nonReentrant onlyOwner { 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(i, ret); } emit BatchExecuted(calls.length); } /* ------------------------------- session keys ----------------------------- */ function addSessionKey( address key, uint48 expiry, bytes4[] calldata allowedSelectors ) external onlyOwner { SessionKey storage sk = _sessionKeys[key]; sk.expiry = expiry; sk.active = true; for (uint256 i = 0; i < allowedSelectors.length; i++) { sk.allowedSelectors[allowedSelectors[i]] = true; } emit SessionKeyAdded(key, expiry); } function revokeSessionKey(address key) external onlyOwner { SessionKey storage sk = _sessionKeys[key]; sk.active = false; emit SessionKeyRevoked(key); } /// @notice Public read for dashboards. function sessionKeyInfo(address key, bytes4 selector) external view returns (bool active, uint48 expiry, bool selectorAllowed) { SessionKey storage sk = _sessionKeys[key]; return (sk.active, sk.expiry, sk.allowedSelectors[selector]); } /// @notice A session key invokes itself as msg.sender. Verifies the key /// is active and the call's selector is allowed. function executeWithSessionKey(Call calldata callItem) external payable nonReentrant { SessionKey storage sk = _sessionKeys[msg.sender]; if (!sk.active || sk.expiry < block.timestamp) revert SessionKeyExpired(); if (callItem.data.length < 4) revert SelectorNotAllowed(); bytes4 selector; bytes calldata d = callItem.data; assembly { selector := calldataload(d.offset) } if (!sk.allowedSelectors[selector]) revert SelectorNotAllowed(); (bool ok, bytes memory ret) = callItem.target.call{value: callItem.value}(callItem.data); if (!ok) revert CallFailed(0, ret); } /* -------------------------------- passkeys ------------------------------- */ function setPasskey(bytes32 pubX, bytes32 pubY) external onlyOwner { passkeyPubX = pubX; passkeyPubY = pubY; emit PasskeySet(pubX, pubY); } /** * @notice Execute a single call authorised by a WebAuthn (P-256) signature. * @param callItem The call to perform. * @param sig The P-256 signature components. * @dev The signed challenge is computed from the current passkeyNonce * and the call payload. After verification, nonce is incremented * (replay-protection). */ function executeWithPasskey(Call calldata callItem, P256Signature calldata sig) external payable nonReentrant { if (passkeyPubX == bytes32(0) && passkeyPubY == bytes32(0)) revert PasskeyNotConfigured(); // Build the WebAuthn signed digest: SHA256(authenticatorData || SHA256(clientDataJSON)) bytes32 clientDataHash = sha256(sig.clientDataJSON); bytes32 digest = sha256(abi.encodePacked(sig.authenticatorData, clientDataHash)); // ROUND-3 FIX: previously the contract verified the P-256 signature // over the WebAuthn digest but NEVER bound the signed clientDataJSON // to the call payload. That allowed any valid WebAuthn signature from // this passkey (e.g. an unrelated dApp login) to be replayed here as // authorisation for arbitrary calls. We now compute the expected // challenge (keccak256(this, nonce, call)) base64url-encoded and // require clientDataJSON to contain the canonical // `"challenge":""` substring. bytes32 expectedChallenge = keccak256(abi.encode(address(this), passkeyNonce, callItem)); if (!_clientDataBindsChallenge(sig.clientDataJSON, expectedChallenge)) { revert InvalidChallenge(); } // Verify via RIP-7951 P256Verify at 0x100. // Calldata layout: 160 bytes — msgHash || r || s || qx || qy. bytes memory input = abi.encodePacked(digest, sig.r, sig.s, passkeyPubX, passkeyPubY); (bool ok, bytes memory ret) = P256_VERIFY.staticcall(input); if (!ok || ret.length == 0 || ret[31] != 0x01) revert InvalidPasskeySignature(); // Replay-protect. uint256 nonce = passkeyNonce++; // Perform the call. (bool ok2, bytes memory r2) = callItem.target.call{value: callItem.value}(callItem.data); if (!ok2) revert CallFailed(0, r2); emit PasskeyExecuted(nonce, callItem.target, callItem.value); } /// @notice Convenience for relayers / dApps: recover the expected /// challenge bytes32 for the NEXT passkey-authorised call. function nextPasskeyChallenge(Call calldata callItem) external view returns (bytes32) { return keccak256(abi.encode(address(this), passkeyNonce, callItem)); } /* ----------------------- challenge-binding helpers ---------------------- */ /// @dev base64url alphabet (RFC 4648 §5, no padding). bytes internal constant B64URL = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; /// @dev Encode a 32-byte value as 43 chars of base64url (no padding). function _b64url32(bytes32 v) internal pure returns (bytes memory out) { out = new bytes(43); bytes memory tbl = B64URL; uint256 j = 0; for (uint256 i = 0; i + 3 <= 30; i += 3) { uint256 a = uint256(uint8(v[i])); uint256 b = uint256(uint8(v[i + 1])); uint256 c = uint256(uint8(v[i + 2])); out[j++] = tbl[a >> 2]; out[j++] = tbl[((a & 0x03) << 4) | (b >> 4)]; out[j++] = tbl[((b & 0x0f) << 2) | (c >> 6)]; out[j++] = tbl[c & 0x3f]; } // bytes 30, 31 → 3 b64url chars uint256 a2 = uint256(uint8(v[30])); uint256 b2 = uint256(uint8(v[31])); out[j++] = tbl[a2 >> 2]; out[j++] = tbl[((a2 & 0x03) << 4) | (b2 >> 4)]; out[j++] = tbl[(b2 & 0x0f) << 2]; } /// @dev Check that `clientDataJSON` contains the canonical substring /// `"challenge":""`. Webauthn's CollectedClientData /// always encodes the challenge as base64url without padding (W3C spec). function _clientDataBindsChallenge(bytes calldata clientDataJSON, bytes32 expected) internal pure returns (bool) { bytes memory enc = _b64url32(expected); // Build the literal pattern `"challenge":""` — 14 fixed chars + 43. bytes memory needle = new bytes(14 + 43); bytes memory prefix = bytes('"challenge":"'); for (uint256 i = 0; i < 13; i++) needle[i] = prefix[i]; for (uint256 i = 0; i < 43; i++) needle[13 + i] = enc[i]; needle[56] = bytes1('"'); return _contains(clientDataJSON, needle); } /// @dev Naive substring search; clientDataJSON is small (<512 bytes typical) /// and needle is fixed 57 bytes → bounded gas. function _contains(bytes calldata hay, bytes memory needle) internal pure returns (bool) { uint256 hLen = hay.length; uint256 nLen = needle.length; if (nLen == 0 || hLen < nLen) return false; uint256 last = hLen - nLen; for (uint256 i = 0; i <= last; i++) { bool match_ = true; for (uint256 j = 0; j < nLen; j++) { if (hay[i + j] != needle[j]) { match_ = false; break; } } if (match_) return true; } return false; } /* ---------------------------------- receive ------------------------------- */ receive() external payable {} }