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.
253 lines
11 KiB
Solidity
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 {}
|
|
}
|