aere-contracts/contracts/delegation/AereDelegate7702.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

312 lines
13 KiB
Solidity

// 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":"<expected>"` 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":"<base64url(expected)>"`. 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":"<enc>"` — 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 {}
}