diff --git a/CLAUDE.md b/CLAUDE.md index 72f7458..cd57b68 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,6 +2,12 @@ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. +## Mycelium Protocol 生态上下文 + +@/Users/jason/Dev/Brood/protocol/MISSION.md +@/Users/jason/Dev/Brood/orgs/aastar/PROFILE.md +@/Users/jason/Dev/Brood/orgs/aastar/INTERFACES.md + ## Project Overview This repository is the smart contract layer for **AirAccount** - a privacy-first, non-upgradable, multi-signature ERC-4337 smart wallet. Rather than building from scratch, it integrates three reference implementations as git submodules in `lib/`: diff --git a/README.md b/README.md index 6be32e0..a1a760c 100644 --- a/README.md +++ b/README.md @@ -207,6 +207,10 @@ forge test --summary # per-suite breakdown - **EIP-7212 P256** — hardware-bound passkey authentication, available on OP Mainnet (Fjord) - **Audit reports** — see `docs/2026-03-*-audit-report.md` +--- + ## License -Licensed under the [Apache License, Version 2.0](https://opensource.org/licenses/Apache-2.0). See [LICENSE](./LICENSE) for details. +This project is licensed under the [Apache License, Version 2.0](LICENSE). +Copyright 2024-present MushroomDAO Contributors. +See [NOTICE](./NOTICE) · [TRADEMARK.md](./TRADEMARK.md) · [LICENSE-zh.md](./LICENSE-zh.md) · [TRADEMARK-zh.md](./TRADEMARK-zh.md) for details. diff --git a/scripts/deploy-agent-registry.ts b/scripts/deploy-agent-registry.ts new file mode 100644 index 0000000..494f6eb --- /dev/null +++ b/scripts/deploy-agent-registry.ts @@ -0,0 +1,119 @@ +/** + * deploy-agent-registry.ts — Deploy AgentRegistry (M8.1) + * + * Deploys the AgentRegistry contract that maps agent execution wallets to their + * human AirAccount owners. Used by AirAccount.setAgentWallet() and SuperPaymaster + * for sponsorship eligibility checks. + * + * Usage: + * pnpm tsx scripts/deploy-agent-registry.ts + * + * Prerequisites: + * - .env.sepolia with PRIVATE_KEY and SEPOLIA_RPC_URL + * - Run `forge build` first to generate out/AgentRegistry.sol/AgentRegistry.json + */ + +import { config } from "dotenv"; +import { resolve } from "path"; +import { readFileSync } from "fs"; +import { + createPublicClient, + createWalletClient, + http, + encodeDeployData, + formatEther, + type Address, + type Hex, +} from "viem"; +import { privateKeyToAccount } from "viem/accounts"; +import { sepolia } from "viem/chains"; + +config({ path: resolve(import.meta.dirname, "../.env.sepolia") }); + +const PRIVATE_KEY = process.env.PRIVATE_KEY as Hex; + +const RPC_URLS = [ + process.env.SEPOLIA_RPC_URL, + process.env.SEPOLIA_RPC_URL2, + process.env.SEPOLIA_RPC_URL3, +].filter(Boolean) as string[]; + +function loadArtifact(name: string) { + const artifact = JSON.parse( + readFileSync(resolve(import.meta.dirname, `../out/${name}.sol/${name}.json`), "utf-8") + ); + return { abi: artifact.abi as unknown[], bytecode: artifact.bytecode.object as Hex }; +} + +async function waitTx( + pub: ReturnType, + hash: Hex, + label: string +) { + console.log(` TX(${label}): https://sepolia.etherscan.io/tx/${hash}`); + const receipt = await pub.waitForTransactionReceipt({ hash, timeout: 300_000 }); + if (receipt.status !== "success") throw new Error(`${label} reverted`); + console.log(` Gas used: ${receipt.gasUsed} Block: ${receipt.blockNumber}`); + return receipt; +} + +async function tryDeploy(rpcUrl: string, owner: ReturnType) { + const transport = http(rpcUrl, { timeout: 300_000 }); + const pub = createPublicClient({ chain: sepolia, transport }); + const wal = createWalletClient({ account: owner, chain: sepolia, transport }); + + const art = loadArtifact("AgentRegistry"); + const hash = await wal.sendTransaction({ + data: encodeDeployData({ abi: art.abi, bytecode: art.bytecode, args: [] }), + gas: 800_000n, + }); + const receipt = await waitTx(pub, hash, "AgentRegistry"); + return receipt.contractAddress as Address; +} + +async function main() { + if (!PRIVATE_KEY) { + console.error("Missing PRIVATE_KEY in .env.sepolia"); + process.exit(1); + } + if (RPC_URLS.length === 0) { + console.error("Missing SEPOLIA_RPC_URL in .env.sepolia"); + process.exit(1); + } + + const owner = privateKeyToAccount(PRIVATE_KEY); + + console.log("=== Deploy AgentRegistry (M8.1) ==="); + console.log(`Deployer: ${owner.address}`); + + // Check balance on first RPC + const pub0 = createPublicClient({ chain: sepolia, transport: http(RPC_URLS[0]) }); + const bal = await pub0.getBalance({ address: owner.address }); + console.log(`Balance: ${formatEther(bal)} ETH\n`); + + let agentRegistryAddr: Address | undefined; + + for (const rpcUrl of RPC_URLS) { + try { + console.log(`Trying RPC: ${rpcUrl.slice(0, 60)}...`); + agentRegistryAddr = await tryDeploy(rpcUrl, owner); + break; + } catch (err: any) { + console.warn(` Failed: ${err.message?.slice(0, 100)}`); + } + } + + if (!agentRegistryAddr) { + console.error("All RPCs failed."); + process.exit(1); + } + + console.log(`\n✓ AgentRegistry deployed at: ${agentRegistryAddr}`); + console.log(` Verify: https://sepolia.etherscan.io/address/${agentRegistryAddr}`); + console.log(`\nNext step: pass this address as agentRegistry to setAgentWallet()`); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/src/core/AAStarAirAccountBase.sol b/src/core/AAStarAirAccountBase.sol index 3868591..e8aad0f 100644 --- a/src/core/AAStarAirAccountBase.sol +++ b/src/core/AAStarAirAccountBase.sol @@ -256,6 +256,8 @@ abstract contract AAStarAirAccountBase is Initializable { error WeightChangeNotApproved(); error NoWeightChangeProposal(); error WeightChangeAlreadyApproved(); + // M8.1 AgentRegistry + error AgentRegistrationFailed(); // ─── Events ─────────────────────────────────────────────────────── @@ -280,7 +282,7 @@ abstract contract AAStarAirAccountBase is Initializable { event WeightChangeCancelled(); event ModuleInstalled(uint256 indexed moduleTypeId, address indexed module); event ModuleUninstalled(uint256 indexed moduleTypeId, address indexed module); - event AgentWalletSet(uint256 indexed agentId, address indexed agentWallet); + event AgentWalletSet(uint256 indexed agentId, address indexed agentWallet, address agentRegistry); // ─── Modifiers ──────────────────────────────────────────────────── @@ -1026,20 +1028,33 @@ abstract contract AAStarAirAccountBase is Initializable { bytes4 private constant _PRECHECK_SEL = bytes4(keccak256("preCheck(address,uint256,bytes)")); /// @dev Dispatch ERC-7579 preCheck to the active hook module (if any). - /// Uses compact assembly encoding to avoid abi.encodeWithSignature bytecode cost. + /// Forwards the full execute() calldata (msg.data) as the `bytes msgData` parameter so + /// hook modules can inspect call target and inner selector for scope enforcement. + /// msg.data layout for execute(address,uint256,bytes): + /// [0:4] execute() selector + /// [4:36] dest (address padded) + /// [36:68] value (uint256) + /// [68:100] offset for func bytes param (= 0x60 relative to args start) + /// [100:132] func length + /// [132:] func data /// Reverts HookReverted() if hook call fails. function _dispatchHook(uint256 ethValue) private { address hook = _activeHook; bytes4 sel = _PRECHECK_SEL; bool ok; assembly { + let cdSize := calldatasize() let m := mload(0x40) - mstore(m, sel) // selector at [0:4] - mstore(add(m, 4), caller()) // address at [4:36] - mstore(add(m, 36), ethValue) // value at [36:68] - mstore(add(m, 68), 0x60) // bytes offset = 96 - mstore(add(m,100), 0) // bytes length = 0 - ok := call(gas(), hook, 0, m, 132, 0, 0) + mstore(m, sel) // [0:4] preCheck selector + mstore(add(m, 4), caller()) // [4:36] msgSender + mstore(add(m, 36), ethValue) // [36:68] msgValue + mstore(add(m, 68), 0x60) // [68:100] bytes offset = 96 (relative to args start) + mstore(add(m,100), cdSize) // [100:132] bytes length = calldatasize (= full execute calldata) + calldatacopy(add(m, 132), 0, cdSize) // [132:] = full execute calldata (selector+args) + // Total size: 4 + 32 + 32 + 32 + 32 + cdSize = 132 + cdSize + // Round up to 32-byte boundary for safety (required by ABI) + let totalSize := add(132, cdSize) + ok := call(gas(), hook, 0, m, totalSize, 0, 0) } if (!ok) revert HookReverted(); } @@ -1147,6 +1162,18 @@ abstract contract AAStarAirAccountBase is Initializable { } } + /// @notice Peek at the next session key in the transient queue without consuming it. + /// Called by TierGuardHook.preCheck() for session scope enforcement. + /// Returns bytes32(0) if the queue is empty (no session key stored in this batch). + /// Top byte of returned value: 0x01 = ECDSA session (lower 20 bytes = address), + /// 0x02 = P256 session (lower 31 bytes = key hash). + function getCurrentSessionKey() external view returns (bytes32 taggedId) { + assembly { + let readIdx := tload(add(SESSION_KEY_SLOT_BASE, 1)) + taggedId := tload(add(add(SESSION_KEY_SLOT_BASE, 2), readIdx)) + } + } + /// @dev Push validated algId to transient storage queue. /// Called during validateUserOp (validation phase). function _storeValidatedAlgId(uint8 algId) internal { @@ -1527,25 +1554,32 @@ abstract contract AAStarAirAccountBase is Initializable { // ─── ERC-8004 Agent Identity Binding (M7.16) ───────────────────────── - /// @notice Link an ERC-8004 agent NFT to a session key address on this account. - /// @param agentId ERC-8004 agent NFT token ID - /// @param agentWallet Session key address that this agent uses for transactions - /// @param erc8004Registry ERC-8004 Identity Registry contract address - /// @dev Only owner can set agent wallet bindings. This is a metadata operation — - /// the actual spending limits are still enforced by session key scopes. + /// @notice Link an agent wallet to this AirAccount by registering it in AgentRegistry. + /// @param agentId Logical agent identifier (used for event indexing only) + /// @param agentWallet Execution wallet address that the agent uses for transactions + /// @param agentRegistry AgentRegistry contract address (M8.1) + /// @param agentWalletSig ECDSA signature from agentWallet proving it consents to registration + /// (prevents front-run griefing — see AgentRegistry.registerAgent for hash construction) + /// @dev Only owner can set agent wallet bindings. Calls AgentRegistry.registerAgent() + /// which records msg.sender (this account) as the human owner of agentWallet. + /// Reverts if the registry call fails (e.g. already registered or invalid signature). function setAgentWallet( uint256 agentId, address agentWallet, - address erc8004Registry + address agentRegistry, + bytes calldata agentWalletSig ) external onlyOwner { - if (agentWallet == address(0) || erc8004Registry == address(0)) revert InvalidGuardian(); - // Register the agent wallet with the ERC-8004 registry - // setAgentWallet(agentId, wallet) — best-effort, non-blocking - (bool ok,) = erc8004Registry.call( - abi.encodeWithSignature("setAgentWallet(uint256,address)", agentId, agentWallet) + if (agentWallet == address(0) || agentRegistry == address(0)) revert InvalidGuardian(); + // Require agentRegistry to be a deployed contract (extcodesize > 0). + // Low-level calls to EOAs succeed silently — we must reject them explicitly. + uint256 codeSize; + assembly { codeSize := extcodesize(agentRegistry) } + if (codeSize == 0) revert AgentRegistrationFailed(); + (bool ok,) = agentRegistry.call( + abi.encodeWithSignature("registerAgent(address,bytes)", agentWallet, agentWalletSig) ); - (ok); // silence unused variable warning - emit AgentWalletSet(agentId, agentWallet); + if (!ok) revert AgentRegistrationFailed(); + emit AgentWalletSet(agentId, agentWallet, agentRegistry); } receive() external payable {} diff --git a/src/registries/AgentRegistry.sol b/src/registries/AgentRegistry.sol new file mode 100644 index 0000000..d7c6ed4 --- /dev/null +++ b/src/registries/AgentRegistry.sol @@ -0,0 +1,177 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity ^0.8.33; + +import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; +import {MessageHashUtils} from "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol"; + +/// @title AgentRegistry — maps agent execution wallets to their human AirAccount owners +/// @notice Any AirAccount owner can register their agent's wallet address. +/// Provides the reverse lookup needed by SuperPaymaster to verify sponsorship eligibility. +contract AgentRegistry { + using ECDSA for bytes32; + using MessageHashUtils for bytes32; + + /// @dev agentWallet → humanOwner + mapping(address => address) public agentWalletOwner; + /// @dev humanOwner → agentWallet[] (for enumeration) + mapping(address => address[]) public ownerAgents; + /// @dev owner → agentWallet → index+1 in ownerAgents[owner] (0 = not in array) + mapping(address => mapping(address => uint256)) private _agentIndexPlusOne; + + event AgentRegistered(address indexed humanOwner, address indexed agentWallet); + event AgentDeregistered(address indexed humanOwner, address indexed agentWallet); + + error NotAgentOwner(); + error AgentAlreadyRegistered(); + error InvalidAddress(); + error InvalidAgentSignature(); + error SelfRegistrationForbidden(); + error NotSupported(); + /// @dev HIGH-1: msg.sender is not a known AirAccount (no accountId() or wrong prefix). + error CallerNotAirAccount(); + + /// @notice Register msg.sender (AirAccount) as the human owner of agentWallet. + /// agentWalletSig proves the caller controls agentWallet, preventing front-run griefing. + /// Supports both EOA (ECDSA) and smart-contract (ERC-1271) agent wallets. + /// @param agentWallet The agent's wallet address (EOA or smart contract) + /// @param agentWalletSig Signature from agentWallet over: + /// keccak256(abi.encodePacked("REGISTER_AGENT", chainId, address(this), msg.sender, agentWallet)).toEthSignedMessageHash() + function registerAgent(address agentWallet, bytes calldata agentWalletSig) external { + // HIGH-1: Only AirAccount contracts may register agents. + // This prevents plain EOAs and unrelated contracts from obtaining SuperPaymaster sponsorship. + (bool ok, bytes memory data) = msg.sender.staticcall( + abi.encodeWithSignature("accountId()") + ); + if (!ok || data.length < 32) revert CallerNotAirAccount(); + if (!_startsWith(abi.decode(data, (string)), "airaccount.")) revert CallerNotAirAccount(); + + if (agentWallet == address(0)) revert InvalidAddress(); + if (agentWallet == msg.sender) revert SelfRegistrationForbidden(); + if (agentWalletOwner[agentWallet] != address(0)) revert AgentAlreadyRegistered(); + + // HIGH-2: Verify agentWallet signed acceptance — supports EOA (ECDSA) and smart contract (ERC-1271). + // Prevents front-run griefing by requiring the agent's explicit consent. + bytes32 hash = keccak256( + abi.encodePacked("REGISTER_AGENT", block.chainid, address(this), msg.sender, agentWallet) + ).toEthSignedMessageHash(); + + bool sigValid; + uint256 codeSize; + assembly { codeSize := extcodesize(agentWallet) } + if (codeSize == 0) { + // EOA: ECDSA recovery + sigValid = (ECDSA.recover(hash, agentWalletSig) == agentWallet); + } else { + // Smart contract: ERC-1271 + (bool callOk, bytes memory result) = agentWallet.staticcall( + abi.encodeWithSignature("isValidSignature(bytes32,bytes)", hash, agentWalletSig) + ); + sigValid = callOk && result.length >= 32 && bytes4(result) == bytes4(0x1626ba7e); + } + if (!sigValid) revert InvalidAgentSignature(); + + agentWalletOwner[agentWallet] = msg.sender; + ownerAgents[msg.sender].push(agentWallet); + _agentIndexPlusOne[msg.sender][agentWallet] = ownerAgents[msg.sender].length; + emit AgentRegistered(msg.sender, agentWallet); + } + + /// @notice Deregister an agent wallet. Only the original registrant can deregister. + function deregisterAgent(address agentWallet) external { + if (agentWalletOwner[agentWallet] != msg.sender) revert NotAgentOwner(); + agentWalletOwner[agentWallet] = address(0); + _removeFromOwnerArray(msg.sender, agentWallet); + emit AgentDeregistered(msg.sender, agentWallet); + } + + /// @notice Returns true if agentWallet is registered (has any owner). + function isRegisteredAgent(address agentWallet) external view returns (bool) { + return agentWalletOwner[agentWallet] != address(0); + } + + /// @notice Returns count of agent wallets registered by this owner. + /// Implements IAgentIdentityRegistry.balanceOf(address) — returns actual count. + function balanceOf(address humanOwner) external view returns (uint256) { + return ownerAgents[humanOwner].length; + } + + /// @notice Not supported — AgentRegistry does not use token IDs. + /// Reverts unconditionally. Exists only for IAgentIdentityRegistry interface compatibility. + function ownerOf(uint256) external pure returns (address) { + revert NotSupported(); + } + + /// @notice Alias for deregisterAgent — matches IAgentIdentityRegistry.revokeAgent(address). + function revokeAgent(address agentWallet) external { + if (agentWalletOwner[agentWallet] != msg.sender) revert NotAgentOwner(); + agentWalletOwner[agentWallet] = address(0); + _removeFromOwnerArray(msg.sender, agentWallet); + emit AgentDeregistered(msg.sender, agentWallet); + } + + /// @notice Convenience lookup: returns the human AirAccount that registered agentWallet. + /// Returns address(0) if agentWallet is not registered. + function getHumanOwner(address agentWallet) external view returns (address) { + return agentWalletOwner[agentWallet]; + } + + /// @notice Returns all agent wallets registered by a human owner. + function getAgents(address humanOwner) external view returns (address[] memory) { + return ownerAgents[humanOwner]; + } + + /// @notice Paginated enumeration of agent wallets for a human owner. + /// @param start Index to start from (0-based) + /// @param count Maximum number of entries to return + function getAgentsPage(address owner, uint256 start, uint256 count) + external view returns (address[] memory page) + { + address[] storage all = ownerAgents[owner]; + uint256 total = all.length; + // LOW-1: overflow-safe bounds check — avoids start+count wraparound. + if (start >= total || count == 0) return new address[](0); + uint256 remaining = total - start; // safe: start < total + uint256 size = count > remaining ? remaining : count; + page = new address[](size); + for (uint256 i = 0; i < size; i++) { + page[i] = all[start + i]; + } + } + + /// @notice Returns agentWallets[index] for a given owner (for enumeration). + function getAgentByIndex(address owner, uint256 index) external view returns (address) { + return ownerAgents[owner][index]; + } + + /// @notice Returns count of agent wallets registered by this owner. + function getAgentCount(address owner) external view returns (uint256) { + return ownerAgents[owner].length; + } + + /// @dev Returns true if `str` starts with `prefix`. + function _startsWith(string memory str, string memory prefix) private pure returns (bool) { + bytes memory s = bytes(str); + bytes memory p = bytes(prefix); + if (s.length < p.length) return false; + for (uint256 i = 0; i < p.length; i++) { + if (s[i] != p[i]) return false; + } + return true; + } + + /// @dev O(1) swap-and-pop removal using the index mapping. + function _removeFromOwnerArray(address owner, address agentWallet) private { + uint256 idxPlusOne = _agentIndexPlusOne[owner][agentWallet]; + if (idxPlusOne == 0) return; + uint256 idx = idxPlusOne - 1; + address[] storage agents = ownerAgents[owner]; + uint256 last = agents.length - 1; + if (idx != last) { + address lastAgent = agents[last]; + agents[idx] = lastAgent; + _agentIndexPlusOne[owner][lastAgent] = idx + 1; + } + agents.pop(); + delete _agentIndexPlusOne[owner][agentWallet]; + } +} diff --git a/test/AAStarAirAccountV7_M7.t.sol b/test/AAStarAirAccountV7_M7.t.sol index 758eb7d..dc8372a 100644 --- a/test/AAStarAirAccountV7_M7.t.sol +++ b/test/AAStarAirAccountV7_M7.t.sol @@ -72,12 +72,13 @@ contract MockTarget { receive() external payable {} } -// ─── Mock ERC-8004 registry ─────────────────────────────────────────────────── +// ─── Mock AgentRegistry (M8.1) ─────────────────────────────────────────────── contract MockRegistry { - mapping(uint256 => address) public agentWallets; - function setAgentWallet(uint256 agentId, address wallet) external { - agentWallets[agentId] = wallet; + mapping(address => address) public agentWalletOwner; + // Accept the new (address, bytes) signature — MockRegistry skips signature verification + function registerAgent(address agentWallet, bytes calldata /* agentWalletSig */) external { + agentWalletOwner[agentWallet] = msg.sender; } } @@ -768,49 +769,48 @@ contract AAStarAirAccountV7_M7Test is Test { vm.prank(ownerWallet.addr); vm.expectEmit(true, true, false, false); - emit AAStarAirAccountBase.AgentWalletSet(42, agentWallet); - account.setAgentWallet(42, agentWallet, address(mockRegistry)); + emit AAStarAirAccountBase.AgentWalletSet(42, agentWallet, address(mockRegistry)); + // MockRegistry skips sig verification — pass empty bytes + account.setAgentWallet(42, agentWallet, address(mockRegistry), ""); } function test_setAgentWallet_registersWithRegistry() public { address agentWallet = makeAddr("agentWallet"); vm.prank(ownerWallet.addr); - account.setAgentWallet(7, agentWallet, address(mockRegistry)); + // MockRegistry skips sig verification — pass empty bytes + account.setAgentWallet(7, agentWallet, address(mockRegistry), ""); - // Registry should have recorded the agent wallet - assertEq(mockRegistry.agentWallets(7), agentWallet); + // Registry should have recorded agentWallet → account as owner + assertEq(mockRegistry.agentWalletOwner(agentWallet), address(account)); } function test_setAgentWallet_notOwner_reverts() public { vm.prank(randomWallet.addr); vm.expectRevert(AAStarAirAccountBase.NotOwner.selector); - account.setAgentWallet(1, makeAddr("agent"), address(mockRegistry)); + account.setAgentWallet(1, makeAddr("agent"), address(mockRegistry), ""); } function test_setAgentWallet_zeroWallet_reverts() public { vm.prank(ownerWallet.addr); vm.expectRevert(); // require("Invalid agent wallet") - account.setAgentWallet(1, address(0), address(mockRegistry)); + account.setAgentWallet(1, address(0), address(mockRegistry), ""); } function test_setAgentWallet_zeroRegistry_reverts() public { vm.prank(ownerWallet.addr); vm.expectRevert(); // require("Invalid registry") - account.setAgentWallet(1, makeAddr("agent"), address(0)); + account.setAgentWallet(1, makeAddr("agent"), address(0), ""); } - function test_setAgentWallet_failingRegistry_doesNotRevert() public { - // setAgentWallet uses best-effort (ok is silenced) — a failing registry should not revert + function test_setAgentWallet_failingRegistry_reverts() public { + // setAgentWallet now hard-fails if the registry call fails (M8.1: AgentRegistrationFailed) address agentWallet = makeAddr("agentWallet"); - address brokenRegistry = makeAddr("brokenRegistry"); // no code → call fails silently + address brokenRegistry = makeAddr("brokenRegistry"); // no code → call returns false - // Give it some bytecode-like status — actually makeAddr returns EOA with no code - // The (bool ok,) call will fail silently. The emit should still happen. vm.prank(ownerWallet.addr); - vm.expectEmit(true, true, false, false); - emit AAStarAirAccountBase.AgentWalletSet(99, agentWallet); - account.setAgentWallet(99, agentWallet, brokenRegistry); + vm.expectRevert(AAStarAirAccountBase.AgentRegistrationFailed.selector); + account.setAgentWallet(99, agentWallet, brokenRegistry, ""); } // ─── Round-trip: install + reinstall after uninstall ───────────────────── diff --git a/test/AgentRegistry.t.sol b/test/AgentRegistry.t.sol new file mode 100644 index 0000000..2150042 --- /dev/null +++ b/test/AgentRegistry.t.sol @@ -0,0 +1,802 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity ^0.8.33; + +import {Test, Vm} from "forge-std/Test.sol"; +import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; +import {MessageHashUtils} from "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol"; +import {AgentRegistry} from "../src/registries/AgentRegistry.sol"; +import {AAStarAirAccountV7} from "../src/core/AAStarAirAccountV7.sol"; +import {AAStarAirAccountBase} from "../src/core/AAStarAirAccountBase.sol"; +import {AAStarGlobalGuard} from "../src/core/AAStarGlobalGuard.sol"; + +// ─── Minimal mock EntryPoint ────────────────────────────────────────────────── + +contract MockEntryPoint { + function depositTo(address) external payable {} + function balanceOf(address) external pure returns (uint256) { return 0; } + function withdrawTo(address payable, uint256) external {} + receive() external payable {} +} + +// ─── MockAirAccount ──────────────────────────────────────────────────────────── +// Simulates an AirAccount contract that implements accountId() returning an +// "airaccount." prefixed string. Wraps registry.registerAgent calls as msg.sender. + +contract MockAirAccount { + address public owner; + + constructor(address _owner) { + owner = _owner; + } + + modifier onlyOwner() { + require(msg.sender == owner, "NotOwner"); + _; + } + + /// @dev Implements the ERC-7579 accountId() selector required by AgentRegistry.HIGH-1 check. + function accountId() external pure returns (string memory) { + return "airaccount.v7@0.16.0"; + } + + /// @notice Calls registry.registerAgent on behalf of the owner. + /// msg.sender for the registry call will be address(this), which passes the + /// HIGH-1 AirAccount interface check. + function registerAgent(AgentRegistry registry, address agentWallet, bytes calldata sig) external onlyOwner { + registry.registerAgent(agentWallet, sig); + } + + /// @notice Deregisters an agent wallet through the registry. + function deregisterAgent(AgentRegistry registry, address agentWallet) external onlyOwner { + registry.deregisterAgent(agentWallet); + } + + /// @notice Revokes an agent wallet through the registry. + function revokeAgent(AgentRegistry registry, address agentWallet) external onlyOwner { + registry.revokeAgent(agentWallet); + } +} + +// ─── MockERC1271Wallet ───────────────────────────────────────────────────────── +// Smart-contract wallet that implements ERC-1271 isValidSignature. +// Controlled by a single EOA owner; validates signatures from that owner. + +contract MockERC1271Wallet { + address public signer; + bool public returnValid; // if false, returns wrong magic value + + constructor(address _signer, bool _returnValid) { + signer = _signer; + returnValid = _returnValid; + } + + /// @notice ERC-1271: return 0x1626ba7e if sig is valid for signer; otherwise return 0xffffffff. + function isValidSignature(bytes32 hash, bytes calldata sig) external view returns (bytes4) { + if (returnValid) { + address recovered = ECDSA.recover(hash, sig); + if (recovered == signer) return bytes4(0x1626ba7e); + } + return bytes4(0xffffffff); + } +} + +/// @title AgentRegistryTest — Unit + integration tests for AgentRegistry (M8.1) +contract AgentRegistryTest is Test { + using MessageHashUtils for bytes32; + + AgentRegistry public registry; + + // Named wallets with known private keys for signature tests + Vm.Wallet public aliceWallet; + Vm.Wallet public bobWallet; + Vm.Wallet public agentAWallet; + Vm.Wallet public agentBWallet; + + // MockAirAccount wrappers owned by alice / bob + MockAirAccount public aliceAccount; + MockAirAccount public bobAccount; + + address public alice; + address public bob; + address public agentA; + address public agentB; + + function setUp() public { + registry = new AgentRegistry(); + + aliceWallet = vm.createWallet("alice"); + bobWallet = vm.createWallet("bob"); + agentAWallet = vm.createWallet("agentA"); + agentBWallet = vm.createWallet("agentB"); + + alice = aliceWallet.addr; + bob = bobWallet.addr; + agentA = agentAWallet.addr; + agentB = agentBWallet.addr; + + // Deploy MockAirAccount instances for alice and bob. + // These pass the HIGH-1 AirAccount interface check in registerAgent(). + aliceAccount = new MockAirAccount(alice); + bobAccount = new MockAirAccount(bob); + } + + // ─── Signature helper ───────────────────────────────────────────────────── + + /// @dev Build the canonical REGISTER_AGENT signature for (humanOwner=account, agentWallet). + /// humanOwner must be address(account) since that is msg.sender when the registry call happens. + function _buildRegSig( + uint256 agentPrivKey, + address humanOwner, + address agentWallet + ) internal view returns (bytes memory) { + bytes32 regHash = keccak256( + abi.encodePacked("REGISTER_AGENT", block.chainid, address(registry), humanOwner, agentWallet) + ).toEthSignedMessageHash(); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(agentPrivKey, regHash); + return abi.encodePacked(r, s, v); + } + + // ─── HIGH-1: EOA caller reverts ─────────────────────────────────────────── + + /// @notice Calling registerAgent directly from an EOA must revert with CallerNotAirAccount. + /// EOAs have no code so accountId() staticcall returns ok=false. + function test_RegisterAgent_eoaCaller_reverts() public { + bytes memory sig = _buildRegSig(agentAWallet.privateKey, alice, agentA); + + vm.prank(alice); + vm.expectRevert(AgentRegistry.CallerNotAirAccount.selector); + registry.registerAgent(agentA, sig); + } + + /// @notice Calling registerAgent from a contract that has no accountId() must revert. + function test_RegisterAgent_contractWithoutAccountId_reverts() public { + // MockEntryPoint has no accountId() function + MockEntryPoint ep = new MockEntryPoint(); + bytes memory sig = _buildRegSig(agentAWallet.privateKey, address(ep), agentA); + + vm.prank(address(ep)); + vm.expectRevert(AgentRegistry.CallerNotAirAccount.selector); + registry.registerAgent(agentA, sig); + } + + // ─── registerAgent ──────────────────────────────────────────────────────── + + function test_RegisterAgent_success() public { + bytes memory sig = _buildRegSig(agentAWallet.privateKey, address(aliceAccount), agentA); + + vm.prank(alice); + vm.expectEmit(true, true, false, false); + emit AgentRegistry.AgentRegistered(address(aliceAccount), agentA); + aliceAccount.registerAgent(registry, agentA, sig); + + assertEq(registry.agentWalletOwner(agentA), address(aliceAccount)); + assertEq(registry.getAgentCount(address(aliceAccount)), 1); + assertEq(registry.getAgentByIndex(address(aliceAccount), 0), agentA); + } + + function test_RegisterAgent_validSig_succeeds() public { + bytes memory sig = _buildRegSig(agentAWallet.privateKey, address(aliceAccount), agentA); + + vm.prank(alice); + aliceAccount.registerAgent(registry, agentA, sig); + + assertEq(registry.agentWalletOwner(agentA), address(aliceAccount)); + assertTrue(registry.isRegisteredAgent(agentA)); + } + + function test_RegisterAgent_invalidSig_reverts() public { + // Bob's private key signs but agentA address is claimed — wrong signer + bytes memory wrongSig = _buildRegSig(bobWallet.privateKey, address(aliceAccount), agentA); + + vm.prank(alice); + vm.expectRevert(AgentRegistry.InvalidAgentSignature.selector); + aliceAccount.registerAgent(registry, agentA, wrongSig); + } + + function test_RegisterAgent_frontRunPrevented() public { + // Attacker Eve tries to register agentA before Alice does. + // Eve does not control agentA's key, so she cannot produce a valid signature. + MockAirAccount eveAccount = new MockAirAccount(makeAddr("eve")); + Vm.Wallet memory eveWallet = vm.createWallet("eve"); + bytes memory attackSig = _buildRegSig(eveWallet.privateKey, address(eveAccount), agentA); + + vm.prank(eveWallet.addr); + vm.expectRevert(AgentRegistry.InvalidAgentSignature.selector); + eveAccount.registerAgent(registry, agentA, attackSig); + + // Alice can still register legitimately + bytes memory aliceSig = _buildRegSig(agentAWallet.privateKey, address(aliceAccount), agentA); + vm.prank(alice); + aliceAccount.registerAgent(registry, agentA, aliceSig); + assertEq(registry.agentWalletOwner(agentA), address(aliceAccount)); + } + + function test_RegisterAgent_zeroAddress_reverts() public { + vm.prank(alice); + vm.expectRevert(AgentRegistry.InvalidAddress.selector); + aliceAccount.registerAgent(registry, address(0), ""); + } + + function test_RegisterAgent_alreadyRegistered_reverts() public { + bytes memory sig = _buildRegSig(agentAWallet.privateKey, address(aliceAccount), agentA); + + vm.prank(alice); + aliceAccount.registerAgent(registry, agentA, sig); + + // Same caller, same agent — should revert + vm.prank(alice); + vm.expectRevert(AgentRegistry.AgentAlreadyRegistered.selector); + aliceAccount.registerAgent(registry, agentA, sig); + } + + function test_RegisterAgent_alreadyRegistered_differentCaller_reverts() public { + bytes memory sig = _buildRegSig(agentAWallet.privateKey, address(aliceAccount), agentA); + vm.prank(alice); + aliceAccount.registerAgent(registry, agentA, sig); + + // Different account but same agentWallet — still reverts (agent already has an owner) + bytes memory bobSig = _buildRegSig(agentAWallet.privateKey, address(bobAccount), agentA); + vm.prank(bob); + vm.expectRevert(AgentRegistry.AgentAlreadyRegistered.selector); + bobAccount.registerAgent(registry, agentA, bobSig); + } + + function test_RegisterAgent_multipleAgents_success() public { + bytes memory sigA = _buildRegSig(agentAWallet.privateKey, address(aliceAccount), agentA); + bytes memory sigB = _buildRegSig(agentBWallet.privateKey, address(aliceAccount), agentB); + + vm.prank(alice); + aliceAccount.registerAgent(registry, agentA, sigA); + + vm.prank(alice); + aliceAccount.registerAgent(registry, agentB, sigB); + + assertEq(registry.getAgentCount(address(aliceAccount)), 2); + assertEq(registry.agentWalletOwner(agentA), address(aliceAccount)); + assertEq(registry.agentWalletOwner(agentB), address(aliceAccount)); + } + + // ─── HIGH-2: ERC-1271 smart contract agent wallets ──────────────────────── + + /// @notice Register with a smart-contract agentWallet that returns valid ERC-1271 magic. + function test_RegisterAgent_erc1271AgentWallet_succeeds() public { + // Deploy an ERC-1271 wallet controlled by agentAWallet.addr + MockERC1271Wallet smartAgent = new MockERC1271Wallet(agentA, true); + + // Build the sig as the EOA signer of the smart wallet + bytes32 hash = keccak256( + abi.encodePacked("REGISTER_AGENT", block.chainid, address(registry), address(aliceAccount), address(smartAgent)) + ).toEthSignedMessageHash(); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(agentAWallet.privateKey, hash); + bytes memory sig = abi.encodePacked(r, s, v); + + vm.prank(alice); + aliceAccount.registerAgent(registry, address(smartAgent), sig); + + assertEq(registry.agentWalletOwner(address(smartAgent)), address(aliceAccount)); + assertTrue(registry.isRegisteredAgent(address(smartAgent))); + } + + /// @notice Register with a smart-contract agentWallet returning wrong magic → InvalidAgentSignature. + function test_RegisterAgent_erc1271AgentWallet_wrongMagic_reverts() public { + // Deploy an ERC-1271 wallet that always returns the wrong magic value + MockERC1271Wallet badAgent = new MockERC1271Wallet(agentA, false); + + bytes32 hash = keccak256( + abi.encodePacked("REGISTER_AGENT", block.chainid, address(registry), address(aliceAccount), address(badAgent)) + ).toEthSignedMessageHash(); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(agentAWallet.privateKey, hash); + bytes memory sig = abi.encodePacked(r, s, v); + + vm.prank(alice); + vm.expectRevert(AgentRegistry.InvalidAgentSignature.selector); + aliceAccount.registerAgent(registry, address(badAgent), sig); + } + + // ─── deregisterAgent ───────────────────────────────────────────────────── + + function test_DeregisterAgent_success() public { + bytes memory sig = _buildRegSig(agentAWallet.privateKey, address(aliceAccount), agentA); + vm.prank(alice); + aliceAccount.registerAgent(registry, agentA, sig); + + vm.prank(alice); + vm.expectEmit(true, true, false, false); + emit AgentRegistry.AgentDeregistered(address(aliceAccount), agentA); + aliceAccount.deregisterAgent(registry, agentA); + + assertEq(registry.agentWalletOwner(agentA), address(0)); + assertEq(registry.getAgentCount(address(aliceAccount)), 0); + } + + function test_DeregisterAgent_notOwner_reverts() public { + bytes memory sig = _buildRegSig(agentAWallet.privateKey, address(aliceAccount), agentA); + vm.prank(alice); + aliceAccount.registerAgent(registry, agentA, sig); + + // Bob tries to deregister Alice's agent — should revert + vm.prank(bob); + vm.expectRevert(AgentRegistry.NotAgentOwner.selector); + bobAccount.deregisterAgent(registry, agentA); + } + + function test_DeregisterAgent_unregistered_reverts() public { + vm.prank(alice); + vm.expectRevert(AgentRegistry.NotAgentOwner.selector); + aliceAccount.deregisterAgent(registry, agentA); + } + + function test_DeregisterAgent_swapAndPop_preservesOtherAgents() public { + Vm.Wallet memory agentCWallet = vm.createWallet("agentC"); + address agentC = agentCWallet.addr; + + bytes memory sigA = _buildRegSig(agentAWallet.privateKey, address(aliceAccount), agentA); + bytes memory sigB = _buildRegSig(agentBWallet.privateKey, address(aliceAccount), agentB); + bytes memory sigC = _buildRegSig(agentCWallet.privateKey, address(aliceAccount), agentC); + + vm.prank(alice); + aliceAccount.registerAgent(registry, agentA, sigA); + vm.prank(alice); + aliceAccount.registerAgent(registry, agentB, sigB); + vm.prank(alice); + aliceAccount.registerAgent(registry, agentC, sigC); + + assertEq(registry.getAgentCount(address(aliceAccount)), 3); + + vm.prank(alice); + aliceAccount.deregisterAgent(registry, agentB); + + assertEq(registry.getAgentCount(address(aliceAccount)), 2); + assertEq(registry.agentWalletOwner(agentB), address(0)); + assertEq(registry.agentWalletOwner(agentA), address(aliceAccount)); + assertEq(registry.agentWalletOwner(agentC), address(aliceAccount)); + } + + function test_DeregisterAgent_O1_multipleAgents() public { + // Verify O(1) removal correctness with 3+ agents + Vm.Wallet memory agentCWallet = vm.createWallet("agentC2"); + address agentC = agentCWallet.addr; + + bytes memory sigA = _buildRegSig(agentAWallet.privateKey, address(aliceAccount), agentA); + bytes memory sigB = _buildRegSig(agentBWallet.privateKey, address(aliceAccount), agentB); + bytes memory sigC = _buildRegSig(agentCWallet.privateKey, address(aliceAccount), agentC); + + vm.prank(alice); + aliceAccount.registerAgent(registry, agentA, sigA); + vm.prank(alice); + aliceAccount.registerAgent(registry, agentB, sigB); + vm.prank(alice); + aliceAccount.registerAgent(registry, agentC, sigC); + + vm.prank(alice); + aliceAccount.deregisterAgent(registry, agentB); + + assertEq(registry.getAgentCount(address(aliceAccount)), 2); + assertEq(registry.agentWalletOwner(agentB), address(0)); + assertEq(registry.agentWalletOwner(agentA), address(aliceAccount)); + assertEq(registry.agentWalletOwner(agentC), address(aliceAccount)); + + vm.prank(alice); + aliceAccount.deregisterAgent(registry, agentA); + assertEq(registry.getAgentCount(address(aliceAccount)), 1); + assertEq(registry.agentWalletOwner(agentA), address(0)); + assertEq(registry.agentWalletOwner(agentC), address(aliceAccount)); + + vm.prank(alice); + aliceAccount.deregisterAgent(registry, agentC); + assertEq(registry.getAgentCount(address(aliceAccount)), 0); + assertEq(registry.agentWalletOwner(agentC), address(0)); + } + + // ─── isRegisteredAgent ──────────────────────────────────────────────────── + + function test_IsRegisteredAgent() public { + assertFalse(registry.isRegisteredAgent(agentA)); + + bytes memory sig = _buildRegSig(agentAWallet.privateKey, address(aliceAccount), agentA); + vm.prank(alice); + aliceAccount.registerAgent(registry, agentA, sig); + + assertTrue(registry.isRegisteredAgent(agentA)); + + vm.prank(alice); + aliceAccount.deregisterAgent(registry, agentA); + + assertFalse(registry.isRegisteredAgent(agentA)); + } + + // ─── balanceOf ──────────────────────────────────────────────────────────── + + function test_BalanceOf() public { + // Before registration: 0 + assertEq(registry.balanceOf(address(aliceAccount)), 0); + + bytes memory sigA = _buildRegSig(agentAWallet.privateKey, address(aliceAccount), agentA); + vm.prank(alice); + aliceAccount.registerAgent(registry, agentA, sigA); + + // After first registration: 1 + assertEq(registry.balanceOf(address(aliceAccount)), 1); + + bytes memory sigB = _buildRegSig(agentBWallet.privateKey, address(aliceAccount), agentB); + vm.prank(alice); + aliceAccount.registerAgent(registry, agentB, sigB); + + // After second registration: 2 (actual count, not capped at 1) + assertEq(registry.balanceOf(address(aliceAccount)), 2); + + vm.prank(alice); + aliceAccount.deregisterAgent(registry, agentA); + vm.prank(alice); + aliceAccount.deregisterAgent(registry, agentB); + + // After all deregistered: 0 + assertEq(registry.balanceOf(address(aliceAccount)), 0); + } + + function test_BalanceOf_returnsActualCount() public { + assertEq(registry.balanceOf(address(aliceAccount)), 0); + + bytes memory sigA = _buildRegSig(agentAWallet.privateKey, address(aliceAccount), agentA); + vm.prank(alice); + aliceAccount.registerAgent(registry, agentA, sigA); + assertEq(registry.balanceOf(address(aliceAccount)), 1); + + bytes memory sigB = _buildRegSig(agentBWallet.privateKey, address(aliceAccount), agentB); + vm.prank(alice); + aliceAccount.registerAgent(registry, agentB, sigB); + assertEq(registry.balanceOf(address(aliceAccount)), 2); + + // Bob registering does not affect alice's count + assertEq(registry.balanceOf(address(bobAccount)), 0); + } + + // ─── ownerOf ────────────────────────────────────────────────────────────── + + function test_OwnerOf_alwaysReverts_existing() public { + // ownerOf is not supported — always reverts with NotSupported + vm.expectRevert(AgentRegistry.NotSupported.selector); + registry.ownerOf(0); + } + + // ─── revokeAgent ───────────────────────────────────────────────────────── + + function test_RevokeAgent_success() public { + bytes memory sig = _buildRegSig(agentAWallet.privateKey, address(aliceAccount), agentA); + vm.prank(alice); + aliceAccount.registerAgent(registry, agentA, sig); + assertEq(registry.agentWalletOwner(agentA), address(aliceAccount)); + + vm.prank(alice); + vm.expectEmit(true, true, false, false); + emit AgentRegistry.AgentDeregistered(address(aliceAccount), agentA); + aliceAccount.revokeAgent(registry, agentA); + + assertEq(registry.agentWalletOwner(agentA), address(0)); + assertEq(registry.getAgentCount(address(aliceAccount)), 0); + assertFalse(registry.isRegisteredAgent(agentA)); + } + + function test_RevokeAgent_notOwner_reverts() public { + bytes memory sig = _buildRegSig(agentAWallet.privateKey, address(aliceAccount), agentA); + vm.prank(alice); + aliceAccount.registerAgent(registry, agentA, sig); + + vm.prank(bob); + vm.expectRevert(AgentRegistry.NotAgentOwner.selector); + bobAccount.revokeAgent(registry, agentA); + } + + function test_RevokeAgent_unregistered_reverts() public { + vm.prank(alice); + vm.expectRevert(AgentRegistry.NotAgentOwner.selector); + aliceAccount.revokeAgent(registry, agentA); + } + + function test_RevokeAgent_twoAgents_preservesOther() public { + bytes memory sigA = _buildRegSig(agentAWallet.privateKey, address(aliceAccount), agentA); + bytes memory sigB = _buildRegSig(agentBWallet.privateKey, address(aliceAccount), agentB); + + vm.prank(alice); + aliceAccount.registerAgent(registry, agentA, sigA); + vm.prank(alice); + aliceAccount.registerAgent(registry, agentB, sigB); + + assertEq(registry.getAgentCount(address(aliceAccount)), 2); + + vm.prank(alice); + aliceAccount.revokeAgent(registry, agentA); + + assertEq(registry.getAgentCount(address(aliceAccount)), 1); + assertEq(registry.agentWalletOwner(agentA), address(0)); + assertEq(registry.agentWalletOwner(agentB), address(aliceAccount)); + } + + // ─── getHumanOwner ──────────────────────────────────────────────────────── + + function test_GetHumanOwner() public { + // Unregistered returns address(0) + assertEq(registry.getHumanOwner(agentA), address(0)); + + bytes memory sigA = _buildRegSig(agentAWallet.privateKey, address(aliceAccount), agentA); + vm.prank(alice); + aliceAccount.registerAgent(registry, agentA, sigA); + assertEq(registry.getHumanOwner(agentA), address(aliceAccount)); + + bytes memory sigB = _buildRegSig(agentBWallet.privateKey, address(bobAccount), agentB); + vm.prank(bob); + bobAccount.registerAgent(registry, agentB, sigB); + assertEq(registry.getHumanOwner(agentB), address(bobAccount)); + + // After deregistration returns address(0) + vm.prank(alice); + aliceAccount.deregisterAgent(registry, agentA); + assertEq(registry.getHumanOwner(agentA), address(0)); + } + + // ─── getAgents ──────────────────────────────────────────────────────────── + + function test_GetAgents() public { + // Empty before registration + address[] memory empty = registry.getAgents(address(aliceAccount)); + assertEq(empty.length, 0); + + bytes memory sigA = _buildRegSig(agentAWallet.privateKey, address(aliceAccount), agentA); + bytes memory sigB = _buildRegSig(agentBWallet.privateKey, address(aliceAccount), agentB); + + vm.prank(alice); + aliceAccount.registerAgent(registry, agentA, sigA); + vm.prank(alice); + aliceAccount.registerAgent(registry, agentB, sigB); + + address[] memory agents = registry.getAgents(address(aliceAccount)); + assertEq(agents.length, 2); + // Both agentA and agentB are present (order may vary after swap-and-pop) + bool foundA = (agents[0] == agentA || agents[1] == agentA); + bool foundB = (agents[0] == agentB || agents[1] == agentB); + assertTrue(foundA); + assertTrue(foundB); + + // Bob's list is unaffected + assertEq(registry.getAgents(address(bobAccount)).length, 0); + } + + function test_GetAgents_afterRevoke() public { + bytes memory sigA = _buildRegSig(agentAWallet.privateKey, address(aliceAccount), agentA); + bytes memory sigB = _buildRegSig(agentBWallet.privateKey, address(aliceAccount), agentB); + + vm.prank(alice); + aliceAccount.registerAgent(registry, agentA, sigA); + vm.prank(alice); + aliceAccount.registerAgent(registry, agentB, sigB); + + vm.prank(alice); + aliceAccount.revokeAgent(registry, agentA); + + address[] memory agents = registry.getAgents(address(aliceAccount)); + assertEq(agents.length, 1); + assertEq(agents[0], agentB); + } + + // ─── setAgentWallet integration (account → registry) ───────────────────── + + /// @dev Build sig for setAgentWallet integration tests where humanOwner = address(account). + function _buildRegSigForAccount( + uint256 agentPrivKey, + address humanOwner, + address agentWallet + ) internal view returns (bytes memory) { + bytes32 regHash = keccak256( + abi.encodePacked("REGISTER_AGENT", block.chainid, address(registry), humanOwner, agentWallet) + ).toEthSignedMessageHash(); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(agentPrivKey, regHash); + return abi.encodePacked(r, s, v); + } + + function test_SetAgentWalletCallsRegistry() public { + MockEntryPoint ep = new MockEntryPoint(); + + // Deploy a fresh AirAccount + AAStarAirAccountV7 account = new AAStarAirAccountV7(); + address ownerAddr = makeAddr("accountOwner"); + + uint8[] memory algs = new uint8[](0); + account.initialize( + address(ep), + ownerAddr, + AAStarAirAccountBase.InitConfig({ + guardians: [makeAddr("g0"), makeAddr("g1"), makeAddr("g2")], + dailyLimit: 0, + approvedAlgIds: algs, + minDailyLimit: 0, + initialTokens: new address[](0), + initialTokenConfigs: new AAStarGlobalGuard.TokenConfig[](0) + }) + ); + + // Build signature: humanOwner = address(account), agentWallet = agentAWallet.addr + bytes memory sig = _buildRegSigForAccount(agentAWallet.privateKey, address(account), agentA); + + vm.prank(ownerAddr); + vm.expectEmit(true, true, false, false); + emit AAStarAirAccountBase.AgentWalletSet(1, agentA, address(registry)); + account.setAgentWallet(1, agentA, address(registry), sig); + + // Verify registry recorded account as owner of agentA + assertEq(registry.agentWalletOwner(agentA), address(account)); + assertTrue(registry.isRegisteredAgent(agentA)); + } + + function test_SetAgentWalletCallsRegistry_notOwner_reverts() public { + MockEntryPoint ep = new MockEntryPoint(); + + AAStarAirAccountV7 account = new AAStarAirAccountV7(); + address ownerAddr = makeAddr("accountOwner"); + + uint8[] memory algs = new uint8[](0); + account.initialize( + address(ep), + ownerAddr, + AAStarAirAccountBase.InitConfig({ + guardians: [makeAddr("g0"), makeAddr("g1"), makeAddr("g2")], + dailyLimit: 0, + approvedAlgIds: algs, + minDailyLimit: 0, + initialTokens: new address[](0), + initialTokenConfigs: new AAStarGlobalGuard.TokenConfig[](0) + }) + ); + + vm.prank(makeAddr("notOwner")); + vm.expectRevert(AAStarAirAccountBase.NotOwner.selector); + account.setAgentWallet(1, agentA, address(registry), ""); + } + + function test_SetAgentWalletCallsRegistry_failingRegistry_reverts() public { + MockEntryPoint ep = new MockEntryPoint(); + + AAStarAirAccountV7 account = new AAStarAirAccountV7(); + address ownerAddr = makeAddr("accountOwner"); + + uint8[] memory algs = new uint8[](0); + account.initialize( + address(ep), + ownerAddr, + AAStarAirAccountBase.InitConfig({ + guardians: [makeAddr("g0"), makeAddr("g1"), makeAddr("g2")], + dailyLimit: 0, + approvedAlgIds: algs, + minDailyLimit: 0, + initialTokens: new address[](0), + initialTokenConfigs: new AAStarGlobalGuard.TokenConfig[](0) + }) + ); + + address noCodeAddr = makeAddr("noCodeAddr"); // EOA with no code + + vm.prank(ownerAddr); + vm.expectRevert(AAStarAirAccountBase.AgentRegistrationFailed.selector); + account.setAgentWallet(1, agentA, noCodeAddr, ""); + } + + // ─── MEDIUM: self-registration forbidden ───────────────────────────────── + + function test_RegisterAgent_selfRegistration_reverts() public { + // aliceAccount tries to register its own address as the agentWallet + bytes memory sig = _buildRegSig(aliceWallet.privateKey, address(aliceAccount), address(aliceAccount)); + vm.prank(alice); + vm.expectRevert(AgentRegistry.SelfRegistrationForbidden.selector); + aliceAccount.registerAgent(registry, address(aliceAccount), sig); + } + + // ─── MEDIUM: ownerOf always reverts ────────────────────────────────────── + + function test_OwnerOf_alwaysReverts() public { + vm.expectRevert(AgentRegistry.NotSupported.selector); + registry.ownerOf(0); + vm.expectRevert(AgentRegistry.NotSupported.selector); + registry.ownerOf(type(uint256).max); + } + + // ─── LOW-1: getAgentsPage pagination ───────────────────────────────────── + + function test_GetAgentsPage_emptyOwner() public view { + address[] memory page = registry.getAgentsPage(address(aliceAccount), 0, 10); + assertEq(page.length, 0); + } + + function test_GetAgentsPage_startBeyondEnd_returnsEmpty() public { + bytes memory sig = _buildRegSig(agentAWallet.privateKey, address(aliceAccount), agentA); + vm.prank(alice); + aliceAccount.registerAgent(registry, agentA, sig); + address[] memory page = registry.getAgentsPage(address(aliceAccount), 5, 10); + assertEq(page.length, 0); + } + + function test_GetAgentsPage_countZero_returnsEmpty() public { + bytes memory sig = _buildRegSig(agentAWallet.privateKey, address(aliceAccount), agentA); + vm.prank(alice); + aliceAccount.registerAgent(registry, agentA, sig); + address[] memory page = registry.getAgentsPage(address(aliceAccount), 0, 0); + assertEq(page.length, 0); + } + + function test_GetAgentsPage_overflowSafe() public { + // count = type(uint256).max — must not overflow, just return the available elements. + bytes memory sig = _buildRegSig(agentAWallet.privateKey, address(aliceAccount), agentA); + vm.prank(alice); + aliceAccount.registerAgent(registry, agentA, sig); + address[] memory page = registry.getAgentsPage(address(aliceAccount), 0, type(uint256).max); + assertEq(page.length, 1); + assertEq(page[0], agentA); + } + + function test_GetAgentsPage_slicedCorrectly() public { + // Register 3 agents for aliceAccount + Vm.Wallet memory w1 = vm.createWallet("w1"); + Vm.Wallet memory w2 = vm.createWallet("w2"); + Vm.Wallet memory w3 = vm.createWallet("w3"); + _registerAgentViaAccount(w1, aliceAccount, alice); + _registerAgentViaAccount(w2, aliceAccount, alice); + _registerAgentViaAccount(w3, aliceAccount, alice); + + address[] memory all = registry.getAgents(address(aliceAccount)); + assertEq(all.length, 3); + + // Page [1, 2) — one element at index 1 + address[] memory page = registry.getAgentsPage(address(aliceAccount), 1, 1); + assertEq(page.length, 1); + assertEq(page[0], all[1]); + + // Page [0, 2) — first two + address[] memory page2 = registry.getAgentsPage(address(aliceAccount), 0, 2); + assertEq(page2.length, 2); + assertEq(page2[0], all[0]); + assertEq(page2[1], all[1]); + + // Page [2, 100) — only one element remains + address[] memory page3 = registry.getAgentsPage(address(aliceAccount), 2, 100); + assertEq(page3.length, 1); + assertEq(page3[0], all[2]); + } + + function _registerAgentViaAccount(Vm.Wallet memory w, MockAirAccount account, address accountOwner) internal { + bytes32 regHash = keccak256( + abi.encodePacked("REGISTER_AGENT", block.chainid, address(registry), address(account), w.addr) + ).toEthSignedMessageHash(); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(w.privateKey, regHash); + vm.prank(accountOwner); + account.registerAgent(registry, w.addr, abi.encodePacked(r, s, v)); + } + + function test_SetAgentWalletCallsRegistry_duplicateRegistration_reverts() public { + MockEntryPoint ep = new MockEntryPoint(); + + AAStarAirAccountV7 account = new AAStarAirAccountV7(); + address ownerAddr = makeAddr("accountOwner"); + + uint8[] memory algs = new uint8[](0); + account.initialize( + address(ep), + ownerAddr, + AAStarAirAccountBase.InitConfig({ + guardians: [makeAddr("g0"), makeAddr("g1"), makeAddr("g2")], + dailyLimit: 0, + approvedAlgIds: algs, + minDailyLimit: 0, + initialTokens: new address[](0), + initialTokenConfigs: new AAStarGlobalGuard.TokenConfig[](0) + }) + ); + + bytes memory sig = _buildRegSigForAccount(agentAWallet.privateKey, address(account), agentA); + + vm.prank(ownerAddr); + account.setAgentWallet(1, agentA, address(registry), sig); + + // Second call with same agentWallet should revert (AgentAlreadyRegistered propagated as AgentRegistrationFailed) + vm.prank(ownerAddr); + vm.expectRevert(AAStarAirAccountBase.AgentRegistrationFailed.selector); + account.setAgentWallet(2, agentA, address(registry), sig); + } +}