aere-contracts/contracts/passkey/AerePasskeyAccountV2.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

253 lines
11 KiB
Solidity

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