Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/`:
Expand Down
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
119 changes: 119 additions & 0 deletions scripts/deploy-agent-registry.ts
Original file line number Diff line number Diff line change
@@ -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<typeof createPublicClient>,
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<typeof privateKeyToAccount>) {
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 });
Comment on lines +90 to +91
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep RPC fallback path reachable on balance check

The script claims multi-RPC resiliency, but it performs an unconditional getBalance against RPC_URLS[0] before entering the retry loop. If the first endpoint is down or rate-limited, deployment aborts before trying SEPOLIA_RPC_URL2/3, so the fallback mechanism never runs in exactly the failure mode it is meant to handle.

Useful? React with 👍 / 👎.

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);
});
71 changes: 51 additions & 20 deletions src/core/AAStarAirAccountBase.sol
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,8 @@ abstract contract AAStarAirAccountBase is Initializable {
error WeightChangeNotApproved();
error NoWeightChangeProposal();
error WeightChangeAlreadyApproved();
// M8.1 AgentRegistry
error AgentRegistrationFailed();

// ─── Events ───────────────────────────────────────────────────────

Expand Down Expand Up @@ -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();
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -1527,24 +1554,28 @@ 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)
/// @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).
function setAgentWallet(
uint256 agentId,
address agentWallet,
address erc8004Registry
address agentRegistry
) 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)", agentWallet)
);
(ok); // silence unused variable warning
if (!ok) revert AgentRegistrationFailed();
Comment on lines +1578 to +1581
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Verify registry effect after low-level call

setAgentWallet treats any non-reverting low-level call as success, but that does not guarantee registerAgent(address) actually executed (a contract with a permissive fallback can return success without updating state). In that case the function still emits AgentWalletSet, leaving the account in a false-success state where downstream sponsorship checks fail because the wallet was never truly registered.

Useful? React with 👍 / 👎.

emit AgentWalletSet(agentId, agentWallet);
}

Expand Down
100 changes: 100 additions & 0 deletions src/registries/AgentRegistry.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
// SPDX-License-Identifier: GPL-3.0
pragma solidity ^0.8.33;

/// @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 {
/// @dev agentWallet → humanOwner
mapping(address => address) public agentWalletOwner;
/// @dev humanOwner → agentWallet[] (for enumeration)
mapping(address => address[]) public ownerAgents;

event AgentRegistered(address indexed humanOwner, address indexed agentWallet);
event AgentDeregistered(address indexed humanOwner, address indexed agentWallet);

error NotAgentOwner();
error AgentAlreadyRegistered();
error InvalidAddress();

/// @notice Register msg.sender as the owner of agentWallet.
/// Called by AirAccount.setAgentWallet() on behalf of the account owner.
function registerAgent(address agentWallet) external {
if (agentWallet == address(0)) revert InvalidAddress();
if (agentWalletOwner[agentWallet] != address(0)) revert AgentAlreadyRegistered();
agentWalletOwner[agentWallet] = msg.sender;
ownerAgents[msg.sender].push(agentWallet);
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);
// Remove from ownerAgents array (swap-and-pop)
address[] storage agents = ownerAgents[msg.sender];
uint256 len = agents.length;
for (uint256 i = 0; i < len; i++) {
if (agents[i] == agentWallet) {
agents[i] = agents[len - 1];
agents.pop();
break;
}
}
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 ERC-721-compatible stub required by IAgentIdentityRegistry.
/// Always returns address(0) — AgentRegistry does not use token IDs.
function ownerOf(uint256 /* agentId */) external pure returns (address) {
return address(0);
}

/// @notice Alias for deregisterAgent — matches IAgentIdentityRegistry.revokeAgent(address).
function revokeAgent(address agentWallet) external {
if (agentWalletOwner[agentWallet] != msg.sender) revert NotAgentOwner();
agentWalletOwner[agentWallet] = address(0);
address[] storage agents = ownerAgents[msg.sender];
uint256 len = agents.length;
for (uint256 i = 0; i < len; i++) {
if (agents[i] == agentWallet) {
agents[i] = agents[len - 1];
agents.pop();
break;
}
}
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 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;
}
}
Loading
Loading