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.
312 lines
13 KiB
Solidity
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 {}
|
|
}
|