--- name: aegis-smart-accounts description: Give AI agents on-chain spending guardrails. Deploy ERC-4337 smart accounts with policy-enforced limits — agents cannot move funds beyond what you authorize, enforced at the contract level, not just in software. version: 4.0.0 metadata: openclaw: requires: env: - AEGIS_CHAIN_ID - AEGIS_RPC_URL - AEGIS_ROUTE_MODE - AEGIS_SMART_ACCOUNT_ADDRESS optionalSecrets: - name: AEGIS_API_URL when: Using dashboard API for policy/permission management sensitive: false description: Aegis API base URL. Defaults to https://api.projectaegis.ai - name: AEGIS_SIGNING_MODE when: The signing key is not referenced by AEGIS_SIGNER_SECRET_FILE sensitive: false description: "One of: external_signer, wallet_connector, session_key" - name: AEGIS_SIGNER_SECRET_FILE when: Using the local-key quickstart with a separate mode-0600 signer backup sensitive: true description: Explicit signer-backup path; never infer or scan for secret files - name: AEGIS_SIGNER_ENDPOINT when: AEGIS_SIGNING_MODE is external_signer sensitive: true description: External signer service URL - name: AEGIS_SIGNER_AUTH_TOKEN when: AEGIS_SIGNING_MODE is external_signer sensitive: true description: Scoped, revocable auth token for the external signer - name: AEGIS_BUNDLER_URL when: AEGIS_ROUTE_MODE is bundler sensitive: true description: Explicit bundler JSON-RPC endpoint; intentionally absent when AEGIS_ROUTE_MODE is direct - name: AEGIS_RUNTIME_API_KEY when: Using Aegis-managed submission, optional agent-scoped reads, or denial reporting sensitive: true description: Expiring, revocable credential bound to the agent, account, and chain; never substitute a wallet-scoped management key - name: AEGIS_RUNTIME_CREDENTIAL_EXPIRES_AT when: AEGIS_RUNTIME_API_KEY is present sensitive: false description: Exact credential expiry used to fail closed and request rotation primaryEnv: AEGIS_RPC_URL emoji: "\U0001F6E1" homepage: https://app.projectaegis.ai tags: - ai-agents - smart-accounts - erc-4337 - defi - guardrails - policy-enforcement - on-chain - spending-limits - permissions - audit --- # Aegis Smart Accounts — On-Chain Spending Guardrails for AI Agents > **Give your AI agents a wallet they can't abuse.** Aegis deploys ERC-4337 smart accounts with policy-enforced spending limits. Agents cannot move funds beyond what you authorize — enforcement happens at the contract level, not just in software. **Homepage:** https://app.projectaegis.ai **Execution boundary:** Aegis supplies the smart account, policy, permission, and on-chain enforcement. The MCP server is a long-running diagnostic/tool server; it does not hold the bot key or sign and submit general UserOperations. Use the Python SDK with secure signing and the explicit route in the handoff. New v16 accounts with a generated signer may select policy-enforced direct execution; the signer pays ordinary gas and no bundler URL is present. ERC-4337 mode instead requires an explicit compatible bundler route. A blank value never enables legacy `EntryPoint.handleOps` self-submit. Running `npx -y @project-aegis/mcp-server` alone normally prints nothing because it waits for an MCP client. **Intent status:** `IntentRegistry`, intent modes, and `/api/v1/intents` are experimental and not end-to-end tested. New accounts use `MODE_POLICY_ONLY`. Do not use intent envelopes for production execution until Aegis explicitly marks them supported. --- ## Agent Golden Path Use this short path first; the rest of this skill is reference material. The onboarding surfaces are complementary: - Human setup: https://app.projectaegis.ai/docs/quickstart — use the guided account, guardrails, funding, route, and handoff-package flow. - Agent execution guide: https://app.projectaegis.ai/docs/agent-golden-path — consume the prepared runtime and verify/submit safely. - Agent runbook: https://app.projectaegis.ai/claude-start.md — the detailed handoff to give an agent with the ZIP path or installed handoff directory. Dashboard setup builds one `aegis.handoff.v1` ZIP locally in the browser after the permission and live account context exist. Its manifest binds one exact primary permission and every included file hash. The archive keeps the public descriptor, runtime secrets, and optional signer backup separate internally; no owner key or wallet-scoped management key belongs in the handoff. The SDK/API-first `aegis init` flow creates resumable public setup state but does not assemble those runtime artifacts; prepare them through secure storage before execution. 0. Install Python SDK 0.5.0 or newer (`python -m pip install aegis-smart-accounts`) in a Python 3.10+ virtual environment and register MCP server 0.5.0 or newer with an MCP client (`npx -y @project-aegis/mcp-server`). Verify both installed versions and `aegis --help`. Keep API credentials, RPC URLs, and signing configuration out of prompts and source control. 1. Prefer the dashboard package: run `aegis handoff install PACKAGE --destination ~/.aegis/handoffs/AGENT`, then `aegis handoff check ~/.aegis/handoffs/AGENT`. Read `START-HERE.md` and the public manifest; never print files under `secrets/`. If the operator explicitly asks for API-first provisioning instead, `aegis init` records only resumable public state and does not generate signer material. 2. Use the package's exact primary permission. In trusted provisioning, `aegis doctor AGENT_UUID --permission-id PERMISSION_UUID` remains available; in an agent runtime call the MCP tool `aegis_doctor` without a wallet-scoped management key. Use `aegis_get_holdings` separately because a held asset is not automatically covered by the permission. Never select a permission by “newest.” 3. Resolve existing accounts through the runtime `account.enforcer()`, `account.spendResolver()`, and `account.entryPoint()` bindings; derive registries from that enforcer. The MCP `aegis_resolve_account_context` tool does this locally from chain. 4. Read the exact permission and quota with `aegis_get_permission_state`, create a calls-form `aegis.action-request.v1` with unprefixed target calldata, and run `aegis_preflight_transaction`. 5. Prefer `aegis action run` and follow `AEGIS_ROUTE_MODE`. In `direct` mode it verifies the v16 capability, uses the pending signer nonce, simulates and estimates the ordinary policy-enforced transaction, checks signer gas, signs locally, and submits only with explicit `--execute`. In `bundler` mode it builds nonce, fees, policy prefix, and account calldata, then signs → estimates the UserOperation → re-signs every changed gas field; submission is also explicit. Never translate one route into the other implicitly. Use `aegis_decode_error` before changing gas, signer, or permission state. Standalone ERC-20 `approve` calls are blocked. Pair approval and its consuming action in the same `executeBatch`, with the exact decoded spend. --- ## Why Aegis? AI agents need to move money. The problem is trust: how do you let an agent trade, bridge, or pay for compute without risking runaway spending, compromised keys, or unauthorized actions? Aegis solves this with: - **On-chain enforcement** — the EntryPoint agent path runs `AgentSmartAccount.validateUserOp()` and calls `PermissionEnforcer` before any transaction executes. Violating agent operations revert. The owner has a deliberate privileged recovery path; never hand the owner key to the agent. - **Policy-bound accounts** — every smart account is deployed against a policy that defines allowed actions, tokens, protocols, chains, and spend limits. - **x402 payment enforcement** — x402 / EIP-3009 stablecoin payments are authorized through `authorizeX402Payment`, which policy-checks and records them before they can settle via EIP-1271. x402 spend counts toward the same limits and shows in the audit trail — it cannot bypass enforcement. - **Non-custodial** — Aegis never holds your funds. Enforcement is in the contracts you deploy. - **Full audit trail** — successful on-chain validations, usage, and executions are indexed with tx hash and block number. Denied attempts that fail before a durable chain event is emitted should be reported to the audit API. --- ## Overview Every agent gets an ERC-4337 smart account deployed via `AgentAccountFactory`. The account's `validateUserOp()` enforces your policy through `PermissionEnforcer` before any transaction reaches the blockchain. If the action violates the policy — wrong token, wrong protocol, spend limit exceeded — the UserOperation reverts. **Execution path (policy-only; the deployed account's generation is authoritative):** ``` Agent builds UserOperation (callData = execute(target, value, AEGIS_MAGIC||permissionId||data)) → AgentSmartAccount.validateUserOp() [read-only fail-fast: PermissionEnforcer.checkAction] → EntryPoint → AgentSmartAccount.execute() → PermissionEnforcer.consumeAllowance(agentId, permissionId, ...) [atomic check + record — the real guard] → target.call(actualData) [a revert rolls back the reservation] ``` Limits are PER-ASSET in raw base units (no price oracle). `consumeAllowance` runs before the external call, so even ops bundled in one block can't exceed a cap. **Token-input swaps and DeFi calls (v14):** standalone ERC-20 `approve` calls are still blocked. Aegis permits token-input DeFi paths only when the spender call is decoded into enforceable token spends. Built-in and adapter-backed support covers Uniswap V3 `exactInputSingle`, Aerodrome swaps/LP mint/increase/collect, multicall, Universal Router, Permit2, Curve, Balancer, Aave, Compound, Beefy, and V2-style router paths. LP unwind support includes both Uniswap/Pancake-style tuple `decreaseLiquidity` (`0x0c49ccbe`) and Aerodrome/Slipstream's non-tuple overload (`0x03a3f2ab`), both `collect` ABI shapes (`0xfc6f7865` and `0x260e12b0`), and `burn(uint256)` (`0x42966c68`). Calls that require tracking more than the current two-asset spend model fail closed. > **Target ABI warning — PancakeSwap V3 NFPM:** On Base Sepolia, PancakeSwap's NonfungiblePositionManager uses the tuple-encoded function `decreaseLiquidity((uint256,uint128,uint256,uint256,uint256))`, selector `0x0c49ccbe`. Do **not** encode the flat Uniswap/Aerodrome-style signature `decreaseLiquidity(uint256,uint128,uint256,uint256,uint256)`, selector `0x03a3f2ab`, against that target; PancakeSwap may revert with empty data because it does not recognize the selector. An empty target revert is an ABI/target-calldata problem, not evidence that Aegis blocked the operation. The same principle applies to `collect`: use the exact overload exposed by the target ABI (the tested tuple form is `0xfc6f7865`). > Resolver recognition and target execution are separate checks. A spend resolver can recognize a selector and return the position's tokens while the target contract still rejects that selector. Derive calldata from the exact target ABI, confirm the target address and chain, then run target gas estimation plus `aegis_preflight_transaction`; do not infer target ABI support from `aegis_get_defi_support` alone. The selector table describes the current resolver implementation, not a guarantee for an older account or a target contract's ABI. A smart account's immutable `spendResolver()`/registry is authoritative; resolve it at runtime and run `aegis_preflight_transaction` against the actual position-manager target before submitting an LP unwind. If the account is bound to an older resolver that lacks these selectors, migrate the account rather than bypassing policy with the owner key. Registries deployed before the LP lifecycle overlay may need the owner-authorized `DeployNpmLifecycleAdapter.s.sol` registration for the non-tuple/standard/burn variants. Treat a failed preflight as unsupported until the overlay is confirmed on that registry. The Aegis API at **https://api.projectaegis.ai** provides: - A management interface for creating policies and granting permissions - Pre-flight validation for simulation and dashboards - Aggregated audit logs with on-chain event indexing **MCP server:** Route-aware direct/bundler preflight requires v0.5.0 or newer. Verify that `npm view @project-aegis/mcp-server version` reports a compatible version before using it from npm; until then, run the repository copy with `node mcp/aegis/server.mjs`. Agents should use these tools before relying on hardcoded addresses or free-form API calls: - `aegis_get_contracts` for packaged provisioning references (never authoritative for an existing account) - `aegis_get_defi_support` for supported selectors and fail-closed limits - `aegis_amount_to_base_units` for raw policy amount conversion - `aegis_resolve_account_context` for authoritative per-account contract and `spendResolver()` discovery - `aegis_doctor` for the authenticated readiness report - `aegis_get_holdings` for native and known-token balances (holdings are not permission grants) - `aegis_get_permission_state` for permission existence, validity, constraints, and quota - `aegis_get_userop_receipt` for the nested mined transaction receipt - `aegis_decode_error` for conservative ERC-4337/Aegis failure classification - `aegis_preflight_transaction` to call the live account-bound `checkAction` path and diagnose EntryPoint prefund readiness before submitting a UserOperation - `aegis_api_request` for explicitly authenticated `/api/v1/...` calls when a separately scoped runtime credential is supported and configured; keep wallet-scoped management credentials in the trusted control plane Exact preflight emits `allowed` or `denied` only when the generated capability registry has promoted the matched chain/generation evaluator. While support is unproven it returns `unknown`/`POLICY_NOT_CHECKED`; stop there and never convert that result into permission to submit or use the owner bypass. ### Resolve an Existing Account Before Using It Deployment tables describe the current generation and are appropriate for provisioning new accounts. They are **not authoritative for an existing account**, which may be bound immutably to an older generation. For every existing account, resolve the execution context on-chain: 1. Read `account.enforcer()`, `account.spendResolver()`, and `account.entryPoint()`. 2. Read `policyRegistry()` and `identityRegistry()` from that enforcer. 3. Use those resolved addresses and the account's runtime resolver/EntryPoint for permission, policy, usage, identity, and UserOperation reads. Do not combine a discovered enforcer with configured registries, and do not silently substitute the current deployment. The management API deliberately refuses mutations when the resolved account context differs from the current deployment, returning `DEPLOYMENT_GENERATION_MISMATCH`. Migrate the account instead of minting or revoking against a registry it does not consult. --- ## x402 Quickstart (Base) — Read This First If You're Paying an x402 Resource The fastest path for an agent making an x402 / EIP-3009 USDC payment on Base via the gas-sponsored relay. Full detail in [§3c](#3c-x402--eip-3009-payments--read-this-before-running-x402); this is the checklist. **You need these 5 inputs before starting:** | Input | What it is | Where to get it | |-------|-----------|-----------------| | Bot **signer** private key | The `signer` EOA the account was deployed with — signs everything | Your secure secret store / `AEGIS_SIGNING_MODE`. **Never the owner key.** | | `AEGIS_RPC_URL` | Base JSON-RPC endpoint (chain `8453`) | Env (treat as sensitive) | | `X-API-Key` | Auth for the Aegis relay | Trusted control-plane service using a wallet-scoped management key; not part of the default agent runtime handoff | | Smart-account address + `agentId` | Your deployed account (the x402 `from`) and its ERC-8004 id | `factory.getAddress(...)` or dashboard; agentId from agent creation | | `permissionId` | The minted permission carrying the per-asset USDC limit | `GET /api/v1/permissions?agent_id=` or dashboard → Permissions | **Account mode must be `MODE_POLICY_ONLY` (0)** — the only mode this skill provisions. **The 3-step order (do not reorder — the digest must match):** 1. **Sign the EIP-3009 authorization** with the bot signer over `from = `. This yields the tuple `{ to, value, validAfter, validBefore, nonce }` + token (`asset`). Pass these values *verbatim* to step 2 — any change alters the digest and breaks settlement. 2. **Pre-authorize the same tuple on-chain** via the relay: build a bot-signed UserOp calling `authorizeX402Payment(permissionId, token, to, value, validAfter, validBefore, nonce)` and `POST` it to `/api/v1/agents/{id}/x402/relay`. This runs the policy check (counts toward the same daily USDC limit), records usage, and flips `authorizedDigests[digest] = true`. Use the `relayX402Authorization` helper in [§3c](#recommended-relay-the-bot-signed-userop-via-the-aegis-backend-gas-sponsored). 3. **Submit the payment to the facilitator** by retrying the request with the x402 payment header — **v2 uses `PAYMENT-SIGNATURE`, NOT `X-PAYMENT`** (v1 only); value is base64 of `JSON({x402Version, scheme, network, payload:{signature, authorization}})`. The facilitator verifies (EIP-1271 for smart accounts), then `transferWithAuthorization` → `isValidSignature` → magic value → settles. > **Empty-body `402`? Two causes:** (1) you skipped step 2 (on-chain authorize) → EIP-1271 check fails; (2) wrong header — **`X-PAYMENT` instead of v2's `PAYMENT-SIGNATURE`** → the facilitator never sees the payment and re-challenges. --- ## Management and runtime paths — do not mix them Aegis has two security planes. **A human-controlled owner provisions and recovers the account; the autonomous agent receives only the policy-bounded runtime signer and an expiring account-bound credential when managed submission or optional backend access is enabled.** ### Management plane (human or trusted provisioning service) - Uses the connected owner wallet, JWT, or wallet-wide management key to create agents, deploy accounts, activate policies, mint/revoke permissions, and issue runtime credentials. - Keeps the owner key and `aegissa_`/legacy `erc8004_` management credentials out of the agent runtime. - Preserves the owner as a deliberate privileged recovery bypass. Do not make the autonomous agent signer the owner merely to simplify setup. ### Runtime plane (autonomous agent) - Reads the existing account's immutable `signer()`, `entryPoint()`, `enforcer()`, `spendResolver()`, enforcement mode, and code hashes from chain. - Follows `AEGIS_ROUTE_MODE`: for a capability-verified v16 account it signs policy-prefixed ordinary transactions to the signer-only direct methods; otherwise it signs policy-prefixed UserOperations for the configured bundler. It never performs management writes or direct owner calls. - Uses `POST /api/v1/validate/explain` or local exact preflight for real calldata. The legacy `/validate` endpoint is abstract compatibility evidence, not an exact verdict. - Receives an expiring `aegisrt_` credential with `bundler:use` when the handoff uses Aegis-managed submission. Optional `agent:read`, `explain:read`, and `audit:write` scopes may be added. Every scope remains bound to this agent, account, and chain. Without a runtime credential, use local chain reads and a custom bundler; omit backend enrichment. - Stops on unknown, unsupported generation, unresolved bindings, stale signatures, or unverified routes. Never substitute the owner path to work around a denial. --- ## Security & Credential Model (Required) This skill performs on-chain operations that require JSON-RPC access and transaction signing. **Private keys must never be provided in chat and must never be stored in unconstrained agent memory.** ### Signing Modes #### 1. External Signer (Recommended) The agent prepares a transaction. The runtime forwards it to a secure signer service (HSM, MPC, hosted signer). The signer enforces scope, rate limits, and allowlists. The agent never sees raw private keys. #### 2. Wallet Connector / User-Approved Signing Transactions are prepared by the agent. A user wallet (browser, hardware wallet) prompts for approval. Keys remain in the wallet. #### 3. Scoped Session Keys (Advanced) Session keys must be policy-restricted, short-lived, and rotated frequently. Never use a long-lived owner EOA private key as a session key. ### The Skill Must NOT - Ask users to paste private keys or seed phrases - Store private keys in memory, logs, or prompts - Access unrelated environment variables or local files - Request cloud credentials or system-level secrets - Persist secrets beyond runtime execution If secure signing is not configured, operate in **read-only mode** until proper signing is established. --- ## Runtime Configuration The dashboard builds a verified handoff ZIP only after the permission exists and the account's immutable live context resolves. `aegis handoff install` verifies its schema, account bindings, safe relative paths, and SHA-256 file integrity, then installs owner-only directories and mode-0600 files. The public descriptor explicitly references `secrets/runtime-secrets.env` and, for a generated signer, its separate signer file. Names match [claude-start.md](https://app.projectaegis.ai/claude-start.md) exactly. Required (via secure secret storage, not chat): | Variable | Description | |----------|-------------| | `AEGIS_CHAIN_ID` | Target chain ID (e.g., `8453` for Base, `84532` for Base Sepolia, `11155111` for Sepolia) | | `AEGIS_ROUTE_MODE` | `direct` for a capability-verified v16 signer transaction, or `bundler` for ERC-4337. Never switch implicitly | | `AEGIS_RUNTIME_SECRET_FILE` | Explicit relative path to the package's allowlisted RPC, bundler, and optional `aegisrt_` runtime secrets | | `AEGIS_RPC_URL` | JSON-RPC endpoint for reads. Guided setup supplies a credential-free public default; it may be rate-limited, so replace only this value with an owner-approved agent-specific route when needed | | `AEGIS_SMART_ACCOUNT_ADDRESS` | The ERC-4337 account you transact from | | `AEGIS_BOT_ADDRESS` | The `signer` EOA the account was deployed with | | `AEGIS_SIGNER_SECRET_FILE` | Explicit path to the separate mode-0600 signer backup for the local-key quickstart. **Never the owner key.** Do not scan for or infer secret files | | `AEGIS_ENTRYPOINT_ADDRESS` | EntryPoint v0.6 — `0x5FF137D4b0FDCD49DcA30c7CF57E578a026d2789` | Required policy/execution values for the standard supported path: | Variable | Description | |----------|-------------| | `AEGIS_PERMISSION_ID` | On-chain permission ID (bytes32) used in every call prefix. Not the agent UUID and not derivable from it — resolve during provisioning, then hand off the identifier; do not require a management key at runtime | | `AEGIS_BUNDLER_URL` | Required only in `bundler` mode. Explicit ERC-4337 endpoint for `eth_sendUserOperation`; intentionally absent in direct handoffs | Optional runtime metadata and backend access: | Variable | Description | |----------|-------------| | `AEGIS_AGENT_UUID` | Aegis agent identifier for optional backend lookups; it is not a substitute for `AEGIS_PERMISSION_ID` | | `AEGIS_API_URL` | Aegis API base for optional reads/reporting. Defaults to `https://api.projectaegis.ai` | | `AEGIS_RUNTIME_API_KEY` | Expiring, revocable `aegisrt_` credential bound to the agent/account/chain. Aegis-managed submission requires `bundler:use`; reads and denial reporting use separate scopes. Never rename or substitute an `aegissa_`/`erc8004_` management key here | | `AEGIS_RUNTIME_CREDENTIAL_EXPIRES_AT` | Exact runtime-credential expiry; rotate before it and fail closed after it | SDK/MCP 0.5.0 send the runtime key to a bundler only when its URL exactly matches the configured Aegis API origin and `/api/v1/runtime/bundler/` with no query or fragment. They never send it to `AEGIS_RPC_URL`, a custom bundler, a lookalike route, or a redirect. `AEGIS_API_KEY` and `AEGIS_DASHBOARD_API_KEY` are wallet-scoped management credentials. A trusted provisioning or control-plane process may send them as `X-API-Key`, but they must not be copied into the runtime file or default MCP configuration. If no scoped runtime credential is available, omit API authentication and use the chain/RPC tools; do not substitute the management key. Alternative signing setups. `AEGIS_SIGNER_SECRET_FILE` above is the local-key case; set these instead when the key lives elsewhere: | Variable | Description | |----------|-------------| | `AEGIS_SIGNING_MODE` | One of: `external_signer`, `wallet_connector`, `session_key`. Omit for a local signer backup referenced by `AEGIS_SIGNER_SECRET_FILE` | | `AEGIS_SIGNER_ENDPOINT` | External signer URL — required when `AEGIS_SIGNING_MODE=external_signer` | | `AEGIS_SIGNER_AUTH_TOKEN` | Auth token for the external signer — sensitive, store securely | The API URL default in all code examples below is `https://api.projectaegis.ai`. Override with `AEGIS_API_URL` if self-hosting. ### MCP Configuration For MCP-capable clients, configure the runtime-only set below. The `${...}` values are references to the MCP client's environment; do not paste credential values into a checked-in JSON file: ```json { "mcpServers": { "aegis": { "command": "npx", "args": ["-y", "@project-aegis/mcp-server"], "env": { "AEGIS_API_URL": "${AEGIS_API_URL}", "AEGIS_RPC_URL": "${AEGIS_RPC_URL}", "AEGIS_BUNDLER_URL": "${AEGIS_BUNDLER_URL}" } } } } ``` This default configuration deliberately contains no wallet-scoped management key. The MCP server still provides read-only chain metadata, DeFi support, endpoint discovery, amount conversion, and RPC-backed transaction preflight when `AEGIS_RPC_URL` is configured. If the installed API/MCP release supports agent-scoped reads or denial reporting, add only `"AEGIS_RUNTIME_API_KEY": "${AEGIS_RUNTIME_API_KEY}"` from secure storage. API-key requests use `X-API-Key: `; `Authorization: Bearer ` is reserved for JWT authentication and must not be used for an Aegis API key. For local development against a checkout, use `node /absolute/path/to/aegis/mcp/aegis/server.mjs` instead of the `npx` command. --- ## Smart Contract Addresses The tables below are a **versioned provisioning reference**, not an account lookup. Smart accounts bind immutably to an enforcer, spend resolver, and EntryPoint at deployment time, and older accounts can legitimately resolve to different bindings. For an existing account, always read `account.enforcer()`, `account.spendResolver()`, and `account.entryPoint()` at runtime, then derive `enforcer.policyRegistry()` / `enforcer.identityRegistry()`; use those resolved values for every permission, policy, identity, asset-limit, usage, and UserOperation read. Use `aegis_doctor` to determine whether that account is on the current writable generation. Never infer generation from a matching or mismatching table entry alone. ### Base Mainnet (Chain ID 8453) | Contract | Address | |-----------------------|--------------------------------------------| | IdentityRegistry | `0xE49Eeb26346c0af54c98D82BA33DeA43174Ef7C1` | | PolicyRegistry | `0x1C3aA1eb29Bbe4C18Cc5C483524E18B40CF64f94` | | PermissionEnforcer | `0x1BD9deD0e5C80C7c163D5eFD86ff552706B538a4` | | PriceOracle | `0x9883A48206F0e84bbaB8d8Cb93700e51A03078f7` | | GuardrailFeeManager | `0x5178098F549a3cb4eC06B036EAf4fA0c2160cC6A` | | IntentRegistry | `0xa4B0Fe24e77Aef406cC1dda2B2AF9cC4F20Dde87` | | SpendResolverRegistry | `0x2ef16690b9298Fb99eBc8903CC1c4d14A95793A2` | | CommonDeFiSpendResolver | `0x79098A54eef98364e9058d8975C1CFAf570ce7d1` | | AgentAccountFactory | `0xe94B60040f169D063b6E1684d683097907fC7F41` | | EntryPoint (v0.6) | `0x5FF137D4b0FDCD49DcA30c7CF57E578a026d2789` | ### Base Sepolia (Chain ID 84532) | Contract | Address | |-----------------------|--------------------------------------------| | IdentityRegistry | `0x03cf1EbD5A7727ee1d3e9F07d96188407D4035F9` | | PolicyRegistry | `0x687D218AA8b7088e2376c9d420b8E1f3253f2d9A` | | PermissionEnforcer | `0x83301bd61640c9AfA6eD6eb51521D0F1299c8B03` | | PriceOracle | `0xDE3B9DAEC6BacbA69A2470058DD6B9DefC629A50` | | GuardrailFeeManager | `0x1A8a26C457f520ef61A84c8AA459ffE700BBd1f4` | | IntentRegistry | `0xe7A4C477a0a6aeD4159042f38E0B3CfDf74D02a4` | | SpendResolverRegistry | `0x7A31231aC0f5263172493a01A87C40b5a61ee5F6` | | CommonDeFiSpendResolver | `0xF3f81D9b3E2aC251441C2E508f51eB9C2B7D8db8` | | AgentAccountFactory | `0x421558444D2d23c5e04b20F50765ae5EC4DD0EbF` | | EntryPoint (v0.6) | `0x5FF137D4b0FDCD49DcA30c7CF57E578a026d2789` | ### Sepolia Testnet (Chain ID 11155111) | Contract | Address | |-----------------------|--------------------------------------------| | IdentityRegistry | `0x9F388Dcf2B37c2384EbF8A79f58861ca8c8Dcd14` | | PolicyRegistry | `0x96d00C3b3a686a30044BAD93dbbF52540b649b18` | | PermissionEnforcer | `0x989b844317f0a7f731cB2e1dB367BEd72a142aA6` | | PriceOracle | `0xf8d5Ed5520593187AFC950738946C6f1bf0d349d` | | GuardrailFeeManager | `0x37c03a1d96D70dc60F2dde48Ebc19AC5cAD1fb88` | | IntentRegistry | `0x9c40b1A82115caBdD70EDD7f20b77ebD8F0ec4Ef` | | SpendResolverRegistry | `0xb24F255b776179363132e870743105732b195De9` | | CommonDeFiSpendResolver | `0xDD990DbFAAefEa727e6B28ADA45091fbAd59FBfe` | | AgentAccountFactory | `0x74298E689C46955696c26Ceba5BA71DA7058A231` | | EntryPoint (v0.6) | `0x5FF137D4b0FDCD49DcA30c7CF57E578a026d2789` | ### Arc Testnet (Chain ID 5042002) | Contract | Address | |-----------------------|--------------------------------------------| | IdentityRegistry | `0x162fEd23de17D7e4998933475a04acF2c3cb10bD` | | PolicyRegistry | `0xa89c36172D01BF33746B401f0e8C4D316B06D6ec` | | PermissionEnforcer | `0x4B8C8Ef37fd7a07fAd7a8bc2Cb1ccFDc6bC68019` | | PriceOracle | `0xEAF9179C6FEBDb41f1a317D1f6287B6773312278` | | StaticUsdFeed | `0xcDe6900a4D975Ae3585D78DeE0e1087B3Fceb6E6` | | GuardrailFeeManager | `0x1ee21DB922Cf6D781497f565b33cb39822892165` | | IntentRegistry | `0x8bE2b4A9678c9302ff3B2163aBd841Cd863A85f1` | | SpendResolverRegistry | `0x195768687C95E6A09fC8D30e4909D1Fda3d76254` | | CommonDeFiSpendResolver | `0xb30692735F001088ad096ba3C49e55EBA2F6ed3E` | | AgentAccountFactory | `0xF9e9BB23a0B76Ae6F85652Da7c85A16a0B6B21e8` | | EntryPoint (v0.6) | `0x5FF137D4b0FDCD49DcA30c7CF57E578a026d2789` | ### Robinhood Chain Testnet (Chain ID 46630) — contracts deployed, API not enabled yet > **Do not target this chain yet.** The contracts below are deployed and verified on chain, but > the Aegis backend is not yet configured for 46630, so API calls specifying this chain will not > work. Addresses are published here for provisioning provenance only. Use Sepolia, Arc Testnet, > or Base until this notice is removed. Generation **v15** (v14 plus a newer `SpendResolverRegistry` and a `GuardrailFeeManager` that does not read the price oracle while fees are zero). Native ETH only — there is no canonical testnet USDC, WETH, or USDG here, and mainnet addresses must never be substituted. | Contract | Address | |-----------------------|--------------------------------------------| | IdentityRegistry | `0x92cd41e6a4aA13072CeBCda8830d48f269F058c4` | | PolicyRegistry | `0xbF63Fa97cfBba99647B410f205730d63d831061c` | | PermissionEnforcer | `0xf3c8c6BDc54C60EDaE6AE84Ef05B123597C355B3` | | PriceOracle | `0x28bd44158F7A824eB20330D761035cCb7D1D2AD5` | | StaticUsdFeed | `0xc1fa477f991C74Cc665E605fC74f0e2B795b5104` | | GuardrailFeeManager | `0x3Aa04083aDA29a77b97e52646C6f0b2B7D24F46d` | | IntentRegistry | `0xD1B7Bd65F2aB60ff84CdDF48f306a599b01d293A` | | SpendResolverRegistry | `0xCE621A324A8cb40FD424EB0D41286A97f6a6c91C` | | CommonDeFiSpendResolver | `0x6D7d5D9A772c11fb7bd9998A8E3B3c3Df00709e9` | | AgentAccountFactory | `0x05E1701516B086CEf743cb5BC2c2A821aE848FdC` | | EntryPoint (v0.6) | `0x5FF137D4b0FDCD49DcA30c7CF57E578a026d2789` | `StaticUsdFeed` is a deliberately labeled test-only ETH/USD feed. Never treat a price read from this chain as real. > Start on Sepolia or Arc Testnet. Move to Base Mainnet when policies are validated. ### Assets & Per-Asset Limits (v14) Limits are **per asset, in that asset's own base units — no price oracle, no cross-token normalization.** A policy lists the assets it governs; each asset carries its own `maxPerTx` / `maxDaily` / `maxTxCount`. The native asset is the key `address(0)` / `"native"` (limits in its 18-decimal base units); ERC-20s use the token address (limits in the token's base units, e.g. USDC = 6 decimals). On most chains the native asset is ETH, but **on Arc it is USDC** — see the Arc native-USDC note below before setting limits there. | Asset | Key | Limit units example | |-------|-----|---------------------| | Native ETH | `address(0)` | `1000000000000000000` = 1 ETH | | USDC (Base) | `0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913` | `1000000000` = 1,000 USDC (6 dp) | | Any ERC-20 | token address | that token's base units | An action moving an asset with **no configured limit is denied** unless the policy sets `allowUnlistedAssets`. Usage is tracked per on-chain `agentId + asset` in a fixed UTC calendar-day window. The first recorded spend anchors that window to that UTC day; permission grant time does not. Multiple permissions for the same agent and asset share the same usage counter, while a different agent or enforcer has separate usage. A configured `maxDaily=0` or `maxTxCount=0` is unbounded even though `getRemainingQuota` returns zero for that corresponding field; read `assetLimits` before interpreting zero quota. Value resolution decodes native ETH, ERC-20 `transfer`/`transferFrom`, common swaps, LP flows (including decrease/collect/burn lifecycle calls), Permit2 transfer paths, and the registered CommonDeFi adapter selectors. Unknown selectors and flows that require more than two spend assets stay default-denied. **Quota measures gross outflow, not net position change.** Reversing a transaction does not return its allowance. Wrapping 0.01 ETH and then unwrapping it leaves balances exactly where they started while permanently consuming 0.01 of the native quota, 0.01 of the WETH quota, and two transaction counts. Any position that churns — rebalancing, round-tripping, retrying — exhausts caps faster than the balance sheet suggests, and `maxTxCount` is the brake that catches a loop the volume caps miss. Size daily limits for gross flow. **Caps are inclusive, and a balance may legitimately exceed its per-transaction cap.** Under a 10 USDC `maxPerTx`, exactly `10.000000` passes and `10.000001` is denied; an account holding 11 USDC cannot sweep itself in one operation. Split it, or explain the cap to the operator — do not assume the policy is misconfigured. ### Supported Tokens — Sepolia Testnet | Token | Contract Address | Notes | |-------|-----------------|-------| | ETH (native) | — | Always supported | | USDC | `0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238` | Sepolia test USDC | | LINK | `0x779877A7B0D9E8603169DdbD7836e478b4624789` | Sepolia test LINK | ### Supported Tokens — Arc Testnet | Token | Contract Address | Notes | |-------|-----------------|-------| | USDC (native) | — | Native gas token on Arc (18 dec as native value / `msg.value`) | | USDC | `0x3600000000000000000000000000000000000000` | Arc USDC ERC-20 (6 dec) | | EURC | `0x89B50855Aa3bE2F677cD6303Cec089B5F319D72a` | Circle EURC (6 dec) | | cirBTC | `0xf0C4a4CE82A5746AbAAd9425360Ab04fbBA432BF` | Circle cirBTC (8 dec) | | ETH | `0x4ccccd3220ac80c07a8B575A4cb494c0E77606Ed` | Arc ETH ERC-20 (18 dec) | > **⚠️ Arc native-USDC accounting — read before writing an Arc policy or building an Arc DeFi tx.** > On Arc the **native gas token is USDC** (18 decimals, via `eth_getBalance` / `msg.value`), and the *same* balance is also exposed as a canonical 6-decimal ERC-20 at `0x3600…0000`. These are two views of one balance, not two assets. Enforcement keys off **how the funds move in the call**, so the per-asset limit must be set on the matching representation: > > | The call moves USDC as… | Metered asset key | Decimals | Example: 25 USDC cap | > |---|---|---|---| > | **native value** (`msg.value` — payable routers, `swapExactETHForTokens`, plain sends, native withdrawals) | `"native"` / `address(0)` | **18** | `maxPerTx: "25000000000000000000"` | > | **ERC-20** (`transfer` / `transferFrom` on `0x3600…0000`) | `0x3600000000000000000000000000000000000000` | **6** | `maxPerTx: "25000000"` | > > If you cap the wrong key the limit **does not bind** — the spend is metered against a different bucket (or denied as an unlisted asset). To cap *all* USDC movement on Arc regardless of path, set a limit on **both** keys. Decimals differ (18 native vs 6 ERC-20), so the same human amount is a different base-unit number on each. This only applies on chains whose native token isn't ETH; on Base/Sepolia native is ETH and USDC is an ordinary 6-dp ERC-20, so the dual-representation issue does not arise. --- ## Core Capabilities ### 1. Deploy a Smart Account Deploying a Secure Account is a **two-step on-chain sequence**: register the agent's identity in `IdentityRegistry`, then deploy the ERC-4337 account via `AgentAccountFactory`. The factory's `createAccount` reverts with `NotAgentOwner` unless the caller-supplied `owner` matches the registry owner of `agentId` — so registration must happen first, signed by whoever will own the account. **Creation fee:** currently 0 (free during early access). The fee mechanism is still wired through `GuardrailFeeManager` — always read `factory.getCreationFee()` before deploying so this code keeps working if fees are reintroduced. **Direct contract call (recommended for autonomous agents):** ```solidity // Step 1: register the agent's ERC-8004 identity. msg.sender becomes the // registry owner — sign this with the wallet that will own the account. identityRegistry.registerAgent( agentId, // bytes32 identifier for this agent metadata // string — JSON or arbitrary metadata ); // Step 2: deploy the ERC-4337 smart account. Pay the creation fee as msg.value. uint256 fee = factory.getCreationFee(); address account = factory.createAccount{value: fee}( ownerAddress, // Controls the account; must equal identityRegistry.getAgentOwner(agentId) signerAddress, // Key verified in validateUserOp (== owner for self-signed; bot EOA for delegated signing) agentId, // bytes32 — same as step 1 uint8(0), // enforcementMode: MODE_POLICY_ONLY — every action is policy-checked on-chain. (Part of the CREATE2 salt, so the address depends on it.) salt, // bytes32 for CREATE2 determinism — NOTE: enforcementMode is part of the CREATE2 salt, so the address depends on it bytes32(0) // parentId — bytes32(0) for root agents (no delegation hierarchy) ); ``` The deployed address is deterministic — `factory.getAddress(owner, signer, agentId, mode, salt)` returns the same address whether or not it's been deployed yet, so you can pre-fund or reference it before step 2. **Via API (dashboard / human-driven control-plane flow):** > This is a provisioning example, not an agent-runtime handoff. The wallet-scoped > management credential is loaded from secure process environment and stays in > the trusted control plane. ```javascript const apiUrl = process.env.AEGIS_API_URL ?? "https://api.projectaegis.ai"; const response = await fetch(`${apiUrl}/api/v1/agents/${agentId}/deploy-smart-account`, { method: "POST", headers: { "X-API-Key": process.env.AEGIS_DASHBOARD_API_KEY }, }); const { smart_account_address } = await response.json(); ``` The API path handles registration, ownership transfer to the user's wallet, and account deployment server-side. Useful for the dashboard's "Generate Bot Signer" flow where a keypair is created in the browser and the user never touches a private key directly. Slower than the direct path because the backend's deployer key has to register first and then transfer ownership to the user. --- ### 2. Fund a Smart Account Send ETH directly to the smart account address. Inbound transfers are free — no fee, no contract call needed. ```javascript await walletClient.sendTransaction({ to: smartAccountAddress, value: parseEther("1.0"), }); ``` > **The human funds the account. Provisioning gas from there is YOUR job.** > Money and gas live in different places. Use only the bucket required by the > handoff's explicit route and do not move funds between buckets without owner approval. > > | Bucket | What it pays for | Who fills it | > |---|---|---| > | Account native balance | policy-protected spendable value (and possible first-op bundler prefund) | **human** | > | Bot EOA balance | ordinary network fees on the direct route; outside account policy | **human**, in small refills | > | Account **EntryPoint deposit** | ERC-4337 prefund on the bundler route — gas, and nothing else | **human** or a later approved account operation | > > **Native ETH in the account is spendable value first, but this account can also > provide just-in-time prefund.** During a correctly signed UserOperation, > `validateUserOp` sends `missingAccountFunds` from the account to EntryPoint when > needed. That can make a first operation work with a zero `balanceOf(account)` if > the account has enough native ETH. It does not create a submission route, and it > is less predictable than keeping a standing deposit. `AA21 didn't pay prefund` > means the route, estimate, or available prefund still needs diagnosis. > > **Top up the deposit from the account itself** — a normal policy-checked > UserOperation, no operator involvement: > > ``` > execute(ENTRY_POINT, amountWei, AEGIS_MAGIC‖permissionId‖depositTo(account)) > // depositTo(address) = 0xb760faf9 > ``` > > `resolveSpend` reports this as `recognized=true, token0=address(0)`, so it bills > the **native** per-tx cap and daily quota like any other ETH spend. Budget for > that: gas provisioning consumes policy allowance. > > **Choose and verify the submission route before funding:** > > - **Direct signer (recommended when selected by guided setup):** require > `supportsDirectSignerExecution() == true`. Send an ordinary transaction from > the bound signer to `executeAsAgent`/`executeBatchAsAgent`. This usually > removes the extra bundler hop. Keep signer gas small, coordinate pending EOA > nonces, and inspect the deterministic transaction hash before retrying an > ambiguous submission. > > - **Aegis-managed bundler (recommended on Base, Base Sepolia, and Sepolia):** > use the route in the dashboard handoff. Its `bundler:use` credential is > revocable and bound to the exact agent, account, and chain. The bundler > broadcasts and the account's prefund reimburses it; the > bot EOA can stay at 0 ETH. A standing `EntryPoint.depositTo(account)` reserve > is recommended, but account-native `missingAccountFunds` may cover a first op > when the signed estimate fits. > - **Custom bundler:** an owner may replace `AEGIS_BUNDLER_URL` with a verified > compatible provider route. Credential-bearing URLs are visible to the > agent, so use a dedicated restricted provider key. > - **Legacy self-submit:** disabled. It is not direct signer execution and a > blank `AEGIS_BUNDLER_URL` never enables it. > > Tell the operator which route and which destination you need. Keep the smart > account balance, EntryPoint deposit, and bot EOA balance as separate buckets. --- ### 3. Execute from a Smart Account Agent executes a transaction from its smart account. Bundler mode enforces in `validateUserOp()` and again during account execution. Direct mode enforces atomically inside `executeAsAgent`/`executeBatchAsAgent`. The owner has a deliberate privileged recovery bypass; the agent has no bypass on either route. **Outbound transfer fee:** currently 0 (free during early access). The on-chain hook still calls `feeManager.calculateTransferFee(value)` per transfer — if fees are reintroduced (immutable safety cap: 10% / 1000 bps), they apply automatically without redeploying anything. There are two execution paths, and they behave differently in v10: **A) UserOperation via EntryPoint (the agent path — policy-enforced).** The bot signer signs a `UserOperation` whose `callData` invokes `execute(target, value, prefixedData)`. The bundler submits it to EntryPoint; `validateUserOp` runs a read-only fail-fast check and `execute` calls `PermissionEnforcer.consumeAllowance` (atomic check + record) **before** the external call. > **The inner `data` MUST carry the v9 calldata prefix** so the account can extract the permission: > ``` > MODE_POLICY_ONLY: AEGIS_MAGIC (0xae915909) || permissionId (32) || actualCalldata > ``` ```javascript const AEGIS_MAGIC = "0xae915909"; // actualCalldata = what the target ultimately runs, e.g. an ERC-20 transfer: const actualCalldata = encodeFunctionData({ abi: erc20Abi, functionName: "transfer", args: [to, amount] }); // Prepend the prefix (MODE_POLICY_ONLY: AEGIS_MAGIC + permissionId + calldata): const prefixed = concat([AEGIS_MAGIC, permissionId, actualCalldata]); // execute(target, value, prefixed) is the UserOperation.callData target call: const callData = encodeFunctionData({ abi: agentSmartAccountABI, functionName: "execute", args: [tokenAddress, 0n, prefixed], }); // Build the final UserOperation, sign it, estimate that signed operation, // re-sign after the estimate returns changed gas fields, then submit the // final signed operation with your bundler lib against // EntryPoint v0.6 (0x5FF137D4b0FDCD49DcA30c7CF57E578a026d2789). The signature // is the *signer* key's ECDSA over the EIP-191 ("\x19Ethereum Signed Message") // digest of userOpHash — NOT the raw hash. _validateSignature applies the // prefix before ecrecover, so you must sign the prefixed digest: const signature = await signer.signMessage({ message: { raw: userOpHash } }); // viem // Python: Account.sign_message(encode_defunct(primitive=userOpHash), key) // Signing the raw userOpHash recovers to the wrong address → SIG_VALIDATION_FAILED. ``` > **Getting the action on-chain — where the gas lives (READ THIS before funding anything).** > Follow `AEGIS_ROUTE_MODE`; the two routes use different transactions and gas buckets: > > | Path | Who broadcasts | Where gas must live | Use it for | > |---|---|---|---| > | **Direct signer (recommended when capability-verified)** | the bound signer sends an ordinary transaction to the smart account | a small native balance on the signer EOA | lowest-latency `executeAsAgent`/`executeBatchAsAgent` | > | **Aegis-managed or custom bundler** | the bundler sends `EntryPoint.handleOps` | the **smart account** prefund (`depositTo(account)` or account native funds) | ERC-4337 `execute`/`executeBatch` while the signer stays empty | > | **Aegis backend relay (gas-sponsored)** | Aegis deployer EOA | nothing — Aegis sponsors it | **only** `authorizeX402Payment` ops (see [§3c](#3c-x402--eip-3009-payments--read-this-before-running-x402)); it rejects every other selector | > > In `bundler` mode, send the final signed UserOp to `AEGIS_BUNDLER_URL` with > `eth_sendUserOperation`. The dashboard normally supplies Aegis-managed > submission on Base, Base Sepolia, and Sepolia; a custom compatible endpoint > remains an advanced option. The bundler broadcasts and fronts gas, then is > reimbursed from your account's prefund during `handleOps`; **your bot EOA can stay at 0 ETH.** > The standard bundler path does not require bot-EOA gas. In `direct` mode, do > not build a UserOperation: use the SDK's direct lifecycle and the signer gas > wallet. A UserOp that reverts with `AA21 didn't pay prefund` > still requires preflight and raw error decoding; do not infer the cause from a > bundler message alone. > **Multi-step DeFi (approve → spend) MUST use `executeBatch`, never separate ops.** > A standalone `execute(approve(...))` is **rejected** — Aegis does not permit > unbounded standing allowances. Any flow that needs an ERC-20 approval (LP mint, > a router swap that pulls tokens, a deposit) must put the approve(s) **and** the > consuming call in a **single `executeBatch`** UserOperation. Each inner `data` > still carries its own `AEGIS_MAGIC‖permissionId‖calldata` prefix. > > ```javascript > // LP mint = approve WETH + approve USDC + mint, in ONE batch: > const callData = encodeFunctionData({ > abi: agentSmartAccountABI, > functionName: "executeBatch", > args: [ > [WETH, USDC, NFPM], // targets > [0n, 0n, 0n], // values > [ concat([AEGIS_MAGIC, permId, approveWethCd]), // approve(NFPM, wethAmt) > concat([AEGIS_MAGIC, permId, approveUsdcCd]), // approve(NFPM, usdcAmt) > concat([AEGIS_MAGIC, permId, mintCd]) ], // mint(...) > ], > }); > ``` > > **Approve-pairing rules (all four are enforced — miss one and validation fails closed):** > 1. The approve and its consumer must be in the **same `executeBatch`**. > 2. The approve must come **before** its consumer in the batch. > 3. The approve's **spender must equal the consumer's target** (approve `NFPM`, then call `NFPM`). > 4. The approved amount must **exactly equal** the consumer's decoded spend — **not** `>=`, > not rounded up, not `type(uint256).max`. Approve precisely what the consuming call pulls. > **Decoding bundler/EVM errors — a policy denial often masquerades as a signature error.** > Before chasing a signing/hash bug, rule out policy first: > > | Surface | Usually means | First check | > |---|---|---| > | `AA24` / "Invalid account signature" / `sigFailed=1` | Frequently a **policy** denial, not a signature problem — `validateUserOp` returned `false` | Is this a standalone `approve`? A limit exceeded? Verify the signature **only after** ruling out policy. | > | revert `X402PolicyViolation()` (`0x17bb5f11`) | **Generic** policy violation in the `execute`/`executeBatch` path — the name is legacy and **not** x402-specific. Standalone approve, over-cap, or an approve amount that doesn't exactly match the paired spend | Re-check the approve-pairing rules above. | > | `0x2a4c6ff6` / `ConstraintViolated()` | Shared boolean policy guard: the selector collapses one or more constraint failures and does not encode a fixed cause count | Read `assetLimits`, `getRemainingQuota`, the permission binding, and the action details; do not infer one specific cause from the selector alone. | > | `0xacfdb444` / `ExecutionFailed` | The smart account reached `target.call` and the target reverted; inner revert data may be absent | Verify exact target ABI/selector, calldata, balance/allowance, and target state. An empty target revert often means the target does not recognize the selector. | > | `A30` / “paymaster not deployed” from `simulateValidation` | Simulation wrapper diagnostic, not proof that a paymaster is configured or that prefund is the cause | Use `checkAction` and signed `eth_estimateUserOperationGas` for the real operation. | > | `AA21 didn't pay prefund` | Prefund masking a validation failure (see below) | — | > > **Fast disambiguation:** if *any* UserOp succeeded earlier in the same run/session, the signer > key, nonce derivation, and chainId are all proven good — so a later "Invalid account signature" > is a **policy** rejection of *that specific op* (a standalone or mis-paired approve, an > over-cap spend, or an unsupported router/selector), **not** a signing bug. The `signer` is > immutable and cannot be rotated, so "stale/rotated signer key" is never the cause. This applies > per-op: an LP mint via `executeBatch` can succeed while a sibling swap fails, because the swap > needs its **own** approve batched with it. Confirm the target selector/router is covered via > `aegis_get_defi_support`, and preflight with `aegis_preflight_transaction`. > **Troubleshooting `AA21 didn't pay prefund`:** first ask the account-bound enforcer > directly whether the operation is policy-allowed. For a promoted capability, > the MCP `aegis_preflight_transaction` tool reproduces the exact > `PermissionEnforcer.checkAction(agentId, permissionId, actionHash, actionData)` > call used by `validateUserOp`, then checks EntryPoint deposit/native ETH against > the UserOperation gas envelope. If capability support or policy evidence is > unknown, stop. If policy is denied, AA21/AA23 is the bundler's > ambiguous surface for a policy/signature validation failure. If policy is > allowed, treat AA21 as a real ERC-4337 prefund issue: fund the smart account > with native ETH, top up `EntryPoint.depositTo(account)`, or use a paymaster/relay > path. Always also verify the bot signer is using EIP-191 `signMessage` over > `userOpHash`; signing the raw hash recovers to the wrong key. > > **Why AA21 masks other failures:** `validateUserOp` runs signature → enforcer > `checkAction` → prefund transfer **last**, and the EntryPoint checks the > account's deposit against `requiredPrefund` *before* interpreting the returned > `validationData`. So any early validation return (bad EIP-191 sig, or > `checkAction` returning `false`) skips the prefund and the EntryPoint reports > `AA21` instead of the true `AA24`/`AA23`. **Mitigation + observability > insurance:** keep a small *standing* `EntryPoint.depositTo(account)` balance. It > decouples prefund from validation success, so future signature/enforcer bugs > surface with their real error code instead of being disguised as AA21. > **Estimation versus simulation:** provider behavior varies. In current testing, > `eth_estimateUserOperationGas` often preserves the underlying revert selector, > while `simulateValidation` may flatten validation failures into `sigFailed=1`. > Treat estimation output as diagnostic evidence, not a portable guarantee: run > direct signer, permission/policy, quota, and `checkAction` checks first, then use > both RPC paths when available. Decode raw revert data rather than branching on > the bundler's human-readable message. **Do not use an empty or unsigned > `signature` as a simulation of the real operation.** An invalid signature can > make `validateUserOp` return before the prefund transfer, causing EntryPoint to > report `AA21 didn't pay prefund` even though the correctly signed operation can > succeed. Use a fully constructed, correctly signed UserOperation for simulation; > use direct policy preflight when you intentionally want a signature-free check. > **Quota reads:** call `getRemainingQuota(permissionId, agentId, asset)`, which > returns `(remainingVolume, remainingTxCount)`. It is a read-only quota lookup, > not proof that the permission is bound to that agent, so verify the permission's > on-chain `agentId` and the account's resolved enforcer first. Read > `assetLimits(permissionId, asset)` as well: `maxDaily=0` and `maxTxCount=0` > mean those limits are unbounded even though the corresponding quota outputs are > zero. Wrong IDs, assets, registries, and exhausted bounded quotas can also > produce zero-looking values. > **UserOperation receipts:** `eth_getUserOperationReceipt` returns the mined > transaction receipt under `result.receipt`. Read the transaction hash from > `result.receipt.transactionHash`; the top-level `userOpHash` is a different > identifier. Treat a missing nested transaction hash as “not mined yet,” not as > a successful inclusion. **B) Direct `execute()` from the owner wallet — PRIVILEGED BYPASS.** The owner (the wallet passed as `owner` to `createAccount`) can call `execute(target, value, rawData)` with **raw** calldata (no prefix) and it is **NOT policy-enforced** — this is the documented emergency/recovery path for the fully-trusted principal (also how `Recovery` drains via EIP-7702). Policy enforcement applies to the agent/EntryPoint path, not to direct owner calls. Fee enforcement occurs inside `GuardrailFeeManager`. The fee is read on every call and deducted from the transaction value automatically. Current rate is 0, so the full `value` reaches `target`. | Transfer Amount | Fee (current) | |----------------|---------------| | $1,000 | $0 | | $10,000 | $0 | | $100,000+ | $0 | --- ### 3c. x402 / EIP-3009 Payments — READ THIS BEFORE RUNNING x402 x402 payments (gasless USDC `transferWithAuthorization`) settle off-account, so they cannot be policed by `validateUserOp`. Aegis enforces them with a **gated EIP-1271** instead: > `isValidSignature(digest, sig)` returns the magic value **only after** the exact EIP-3009 digest has been authorized on-chain by `authorizeX402Payment(...)`, **and** `sig` recovers to the account's `signer`. A valid signature alone is NOT enough. **Two failures both return `402` with an empty body — check both:** (1) a vanilla x402 client skips the on-chain authorize step (below), so the facilitator's EIP-1271 check fails; (2) the payment is sent in the wrong header — **x402 v2 uses `PAYMENT-SIGNATURE`, not `X-PAYMENT`** (v1 only), so the facilitator never sees it and just re-challenges. #### Two keys — and why the owner key is NEVER needed at runtime | Key | Address source | Role in x402 | | --- | --- | --- | | **owner** | the connected wallet that deployed the account (`account.owner()`) | the human principal. **Not used by the agent at runtime.** | | **signer** | the bot EOA (`account.signer()`) | signs everything: the EIP-3009 authorization AND the authorize UserOp | `owner() != signer()` is **by design** for bot agents. `authorizeX402Payment` is `onlyOwnerOrEntryPoint`, but you do **not** need the owner key: the bot authorizes itself through the **EntryPoint** (an ERC-4337 UserOperation). `validateUserOp` only runs policy checks for `execute`/`executeBatch` selectors, so for an `authorizeX402Payment` UserOp it just verifies the bot signature; the EntryPoint then becomes `msg.sender`, satisfying `onlyOwnerOrEntryPoint`. **Never put the owner key in the agent's environment.** #### Required order of operations 1. Get the x402 `402` challenge and build the payment payload — the bot **signer** signs the EIP-3009 authorization over `from = `. This yields the tuple `{ to, value, validAfter, validBefore, nonce }` and the token (`asset`). 2. **Pre-authorize the SAME tuple on-chain** via a bot-signed UserOp calling `authorizeX402Payment(permissionId, token, to, value, validAfter, validBefore, nonce)`, relayed through the EntryPoint. This re-derives the digest, runs the policy check (raw per-asset limit), records usage (same daily budget as direct transfers — x402 cannot bypass it), and flips `authorizedDigests[digest] = true`. Reverts `X402PolicyViolation` if over cap. 3. **Submit the signed payment to the facilitator** by retrying the original request with the x402 payment header. **The header name is version-specific: x402 v2 uses `PAYMENT-SIGNATURE`; only v1 used `X-PAYMENT`.** Read `x402Version` from the `402` challenge and pick accordingly. The value is standard base64 of `JSON({ x402Version, scheme, network, payload: { signature, authorization: { from, to, value, validAfter, validBefore, nonce } } })`. The facilitator verifies the signature (via EIP-1271 for a smart-account `from` — CDP-backed facilitators support this), then USDC's `transferWithAuthorization` calls your account's `isValidSignature` → magic value (the digest was pre-authorized in step 2) → settles. The bot signs the EIP-3009 typed data over the **raw** EIP-712 digest (no Ethereum message prefix) — that's what `isValidSignature` recovers against. **A `402` with an empty body has TWO common causes — check both:** (a) you skipped the step-2 on-chain authorize (the facilitator's EIP-1271 check fails), OR (b) you sent the payment in the **wrong header** (`X-PAYMENT` instead of v2's `PAYMENT-SIGNATURE`), so the facilitator never saw it and just re-challenged. Also: any change to the tuple between steps 1 and 2 changes the digest and the authorization won't match — pass the x402-generated values verbatim. #### Recommended: relay the bot-signed UserOp via the Aegis backend (gas-sponsored) The Aegis backend acts as a bundler + gas sponsor: it relays the bot-signed `authorizeX402Payment` UserOp through the EntryPoint and tops up the account's deposit so the agent needs no ETH. The backend is a **dumb relay with no authority over funds** — it can't move funds and can't alter the op (tampering breaks the bot signature). ``` POST /api/v1/agents/{id}/x402/relay (auth: X-API-Key) body: the bot-signed v0.6 UserOperation (sender, nonce, init_code, call_data, call_gas_limit, verification_gas_limit, pre_verification_gas, max_fee_per_gas, max_priority_fee_per_gas, paymaster_and_data, signature) → { tx_hash, token, to, value } ``` Strict scoping: the relay only accepts ops where `sender` is the caller's own deployed account AND `call_data`'s selector is `authorizeX402Payment`. Anything else is rejected. Client helper — build the UserOp, sign with the **bot key**, post to the relay: ```ts import { createPublicClient, encodeFunctionData, getAddress, http } from "viem"; import { privateKeyToAccount } from "viem/accounts"; import { getUserOperationHash } from "viem/account-abstraction"; import { base } from "viem/chains"; const ENTRY_POINT = "0x5FF137D4b0FDCD49DcA30c7CF57E578a026d2789"; // v0.6 const AUTHORIZE_ABI = [{ type: "function", name: "authorizeX402Payment", stateMutability: "nonpayable", inputs: [{ name: "permissionId", type: "bytes32" }, { name: "token", type: "address" }, { name: "to", type: "address" }, { name: "value", type: "uint256" }, { name: "validAfter", type: "uint256" }, { name: "validBefore", type: "uint256" }, { name: "nonce", type: "bytes32" }], outputs: [{ name: "digest", type: "bytes32" }] }] as const; const EP_ABI = [{ type: "function", name: "getNonce", stateMutability: "view", inputs: [{ name: "sender", type: "address" }, { name: "key", type: "uint192" }], outputs: [{ name: "nonce", type: "uint256" }] }] as const; // `authorization` is x402's payload.authorization; `token` is requirement.asset. export async function relayX402Authorization(o: { botKey: `0x${string}`; rpcUrl: string; smartAccount: `0x${string}`; permissionId: `0x${string}`; token: `0x${string}`; authorization: { to: `0x${string}`; value: string; validAfter: string; validBefore: string; nonce: `0x${string}` }; aegisApiBaseUrl: string; aegisApiKey: string; agentId: string; }) { const bot = privateKeyToAccount(o.botKey); const pub = createPublicClient({ chain: base, transport: http(o.rpcUrl) }); const callData = encodeFunctionData({ abi: AUTHORIZE_ABI, functionName: "authorizeX402Payment", args: [o.permissionId, getAddress(o.token), getAddress(o.authorization.to), BigInt(o.authorization.value), BigInt(o.authorization.validAfter), BigInt(o.authorization.validBefore), o.authorization.nonce] }); const [nonce, fees] = await Promise.all([ pub.readContract({ address: ENTRY_POINT, abi: EP_ABI, functionName: "getNonce", args: [getAddress(o.smartAccount), 0n] }), pub.estimateFeesPerGas(), ]); const userOperation = { sender: getAddress(o.smartAccount), nonce: nonce as bigint, initCode: "0x" as const, callData, callGasLimit: 300_000n, verificationGasLimit: 250_000n, preVerificationGas: 80_000n, maxFeePerGas: fees.maxFeePerGas, maxPriorityFeePerGas: fees.maxPriorityFeePerGas, paymasterAndData: "0x" as const, signature: "0x" as const }; const userOpHash = getUserOperationHash({ chainId: base.id, entryPointAddress: ENTRY_POINT, entryPointVersion: "0.6", userOperation }); const signature = await bot.signMessage({ message: { raw: userOpHash } }); // EIP-191; matches _validateSignature const res = await fetch(`${o.aegisApiBaseUrl}/api/v1/agents/${o.agentId}/x402/relay`, { method: "POST", headers: { "Content-Type": "application/json", "X-API-Key": o.aegisApiKey }, body: JSON.stringify({ sender: userOperation.sender, nonce: nonce.toString(), init_code: "0x", call_data: callData, call_gas_limit: "300000", verification_gas_limit: "250000", pre_verification_gas: "80000", max_fee_per_gas: fees.maxFeePerGas.toString(), max_priority_fee_per_gas: fees.maxPriorityFeePerGas.toString(), paymaster_and_data: "0x", signature }), }); if (!res.ok) throw new Error(`relay failed ${res.status}: ${await res.text()}`); return res.json(); // { tx_hash, token, to, value } } ``` Drop this call between building the x402 payment payload and retrying the paid request. `permissionId` is the minted permission for the agent (dashboard → Permissions, or `GET /api/v1/permissions?agent_id=`). #### Legacy handleOps self-submit is not supported Do not have the signer call `EntryPoint.handleOps` itself. That design adds an outer transaction, nonce and gas surface and can let an untrusted signer choose gas fields or a beneficiary against account prefund. Aegis direct mode instead uses the account's dedicated policy-enforced signer methods, pays gas only from the small signer EOA balance, and never reimburses a signer-selected beneficiary. > Cost note: enforcing policy on x402 requires one on-chain `authorize` per payment (a state-changing call — `isValidSignature` is `view` and cannot record spend). The backend relay sponsors that gas so the agent needs none. --- ### 4. Read Contract State (No Signing Required) > **Two free probes answer "will this be allowed?" before you spend anything. > Use both on every new call shape.** > > **1. `SpendResolverRegistry.resolveSpend(account, target, ethValue, data)`** — a > `view` call that returns the exact `ResolvedSpend` the enforcer will see: > > ``` > (amount0, token0, amount1, token1, recognized, requireRecipientSelf, recipient, recipientResolved) > ``` > > Read it before building anything unfamiliar. It tells you (a) whether the call > is decoded at all — `recognized=false` is **denied**, never waved through — and > (b) which asset the spend is billed to, which is frequently not the one you > expect. Wrapping ETH bills the *native* cap, not the WETH cap. A protocol call > may bill an underlying token rather than the vault share. > > `recipientResolved=false` is only safe while `getAllowedRecipients` is empty. > Once a destination allowlist exists, v14 fails **closed** and every > calldata-bearing value call — `WETH.deposit()` included — starts getting denied. > > **2. `eth_estimateGas` on `EntryPoint.handleOps([op], beneficiary)`** — runs real > validation against the deployed enforcer. A policy denial surfaces as a revert > here, costing nothing and broadcasting nothing. Preflight every operation this > way and show the operator the result before sending. > > Selectors must match the exact deployed variant. Aegis recognizes Uniswap > `exactInputSingle` as **`0x04e45aaf`** (SwapRouter**02**, no `deadline` field), > not `0x414bf389` (SwapRouter v1). Right protocol, wrong contract, silently > denied. Also confirm every protocol address has code on *your* chain with > `eth_getCode` — addresses are not portable between networks. > > **Selectors collide across protocols, so probe rather than reason.** Worked > example: `WETH.withdraw(uint256)` is `0x2e1a7d4d`, which is also > `CommonDeFiSpendResolver`'s `BEEFY_WITHDRAW_SELECTOR` — and that adapter is > registered for it, so an unwrap is decoded by the Beefy branch, not the > built-in. It happens to be **correct** (`_resolveBeefy` sets > `amount0 = amount, token0 = target`, and both functions burn `amount` of > `target`), but you cannot know that by reading either contract alone. Note the > near-miss: `WETH.deposit()` is `0xd0e30db0` while Beefy's is > `deposit(uint256)` = `0xb6b55f25`. Had those collided, the Beefy branch would > have called `want()` on WETH, got `address(0)`, and denied the wrap. One > `resolveSpend` call settles all of this in a second. ```javascript // Get account owner const owner = await publicClient.readContract({ address: smartAccountAddress, abi: agentSmartAccountABI, functionName: "owner", }); // Get smart account creation fee const fee = await publicClient.readContract({ address: factoryAddress, abi: agentAccountFactoryABI, functionName: "getCreationFee", }); // Calculate transfer fee before executing const transferFee = await publicClient.readContract({ address: feeManagerAddress, abi: guardrailFeeManagerABI, functionName: "calculateTransferFee", args: [parseEther("10.0")], }); // Check whether a permission is currently valid on-chain. This includes the // permission window, policy activation/expiry, and the permission's revoked flag. const isValid = await publicClient.readContract({ address: policyRegistryAddress, abi: policyRegistryABI, functionName: "isPermissionValid", args: [permissionId], }); ``` --- ### 5. Policy Management Policies define what an agent is allowed to do: which actions, tokens, protocols, chains, and spend limits. Policies are stored on-chain in `PolicyRegistry` and enforced by `PermissionEnforcer`. **Direct contract call (recommended for autonomous agents):** The on-chain policy is split across two contracts: `PolicyRegistry` stores the policy *identity* (an opaque content hash), and `PermissionEnforcer` stores the *constraints* keyed by permissionId after a permission is granted. So creating a policy is a one-liner; the constraints land on-chain at permission-granting time (next section). ```solidity // Step 1: create the policy entry. contentHash is your chosen identifier — // commonly keccak256 of the off-chain policy JSON or an IPFS CID. bytes32 policyId = policyRegistry.createPolicy(contentHash); // → emits PolicyCreated(policyId, msg.sender, contentHash) // → policyId = keccak256(msg.sender, contentHash, block.timestamp) // → msg.sender is recorded as the policy owner; only they can later update, // deactivate/reactivate, or grant permissions on this policy. ``` To deactivate or reactivate later (only the policy owner can call): ```solidity policyRegistry.deactivatePolicy(policyId); // freezes — active permissions stop validating policyRegistry.reactivatePolicy(policyId); policyRegistry.updatePolicy(policyId, newContentHash); // bumps version, owner-only ``` **Via API (dashboard / human-driven flow):** ```javascript const apiUrl = process.env.AEGIS_API_URL ?? "https://api.projectaegis.ai"; const headers = { "Content-Type": "application/json", "X-API-Key": process.env.AEGIS_DASHBOARD_API_KEY, }; // Create and activate a policy const policy = await fetch(`${apiUrl}/api/v1/policies`, { method: "POST", headers, body: JSON.stringify({ name: "DeFi Trading Policy", description: "Allow swaps and transfers with daily limits", definition: { actions: ["swap", "transfer"], assets: { protocols: ["*"], chains: [8453], }, constraints: { // v10: PER-ASSET limits in each asset's OWN base units (no oracle). // Use "native" (or "") for ETH. Blank/0 cap = unlimited for that field. assetLimits: [ { asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", maxPerTx: "100000000", maxDaily: "1000000000", maxTxCount: 50 }, // 100 / 1,000 USDC (6 dp) { asset: "native", maxPerTx: "1000000000000000000", maxDaily: "5000000000000000000" }, // 1 / 5 ETH (wei) ], allowUnlistedAssets: false, // assets not listed above are denied maxTxCount: 100, }, duration: { validFrom: "2026-01-01T00:00:00Z", validUntil: "2026-12-31T23:59:59Z", // Optional: restrict each day to a UTC time-of-day window. // Both must be set together; omit both for 24/7 access. allowedTimeStart: "09:00", // inclusive allowedTimeEnd: "17:00", // exclusive — start must be before end }, }, }), }).then((r) => r.json()); // Activate it (registers on-chain via PolicyRegistry) await fetch(`${apiUrl}/api/v1/policies/${policy.id}/activate`, { method: "POST", headers, }); ``` **PolicyDefinition fields:** | Field | Type | Description | |-------|------|-------------| | `actions` | `string[]` | `swap`, `transfer`, `approve`, `stake`, `unstake`, `deposit`, `withdraw`, `mint`, `burn`, `bridge`, `claim`, `vote`, `delegate`, `lp_add`, `lp_remove`, `borrow`, `repay`, `liquidate`, `*` | | `assets.protocols` | `string[]` | Protocol addresses, or `["*"]` for all | | `assets.chains` | `number[]` | Allowed chain IDs | | `constraints.assetLimits` | `AssetLimit[]` | **v10 per-asset limits.** Each: `{ asset, maxPerTx?, maxDaily?, maxTxCount? }` in the asset's OWN base units. `asset` = `"native"`/`""` for ETH, else token address. The listed assets ARE the token allowlist. | | `constraints.allowUnlistedAssets` | `boolean` | If true, assets without an `assetLimits` entry are allowed (no value cap). Default false (deny). | | `constraints.maxTxCount` | `number` | Overall tx-count hint (off-chain pre-flight) | | `duration.validFrom` / `validUntil` | `string` | ISO 8601 — overall validity period | | `duration.allowedTimeStart` / `allowedTimeEnd` | `string` | Optional UTC `HH:MM` — narrows each day to an intraday window (e.g. business hours). Both must be set or both omitted; start must be strictly before end (non-wrapping; can't cross midnight in one permission). Enforced on-chain in `PolicyRegistry.isPermissionValid` via `block.timestamp % 86400`. | | `conditions` | `array` | Advanced rules: `{ field, operator, value }`. Operators: `eq`, `ne`, `gt`, `gte`, `lt`, `lte`, `in`, `not_in`, `contains`, `regex` | **Other policy operations:** ```javascript // Update (draft policies update in place; active policies create a new version) await fetch(`${apiUrl}/api/v1/policies/${policyId}`, { method: "PUT", headers, body: JSON.stringify({...}) }); // Revoke (disables on-chain — active permissions using this policy stop validating) await fetch(`${apiUrl}/api/v1/policies/${policyId}/revoke`, { method: "POST", headers }); // Reactivate await fetch(`${apiUrl}/api/v1/policies/${policyId}/reactivate`, { method: "POST", headers }); // List / Get / Delete (draft only) await fetch(`${apiUrl}/api/v1/policies`, { headers }); await fetch(`${apiUrl}/api/v1/policies/${policyId}`, { headers }); await fetch(`${apiUrl}/api/v1/policies/${policyId}`, { method: "DELETE", headers }); ``` --- ### 6. Permission Management Permissions link an agent to a policy for a defined period. On-chain, `PolicyRegistry.grantPermission()` registers the link. `PermissionEnforcer` reads active permissions during `validateUserOp`. **Permission state semantics:** `PolicyRegistry.getPermission(permissionId)` returns the nine static fields `(id, policyId, agentId, grantor, validFrom, validUntil, revoked, allowedStartSecondOfDay, allowedEndSecondOfDay)`. The seventh field (ABI word/index `6`) is `revoked`: `0` means **not revoked**, and `1` means revoked. It is not an “active” status field. Use `isPermissionValid(permissionId)` for the complete validity result; it is false for a revoked, expired, not-yet-started, inactive-policy, or nonexistent permission. A nonzero grantor distinguishes an existing permission from an all-zero mapping entry. **Direct contract call (recommended for autonomous agents):** ```solidity // Step 1: grant the permission. msg.sender must be the policy owner // (the wallet that created the policy via createPolicy). The agent must // already be registered and active in IdentityRegistry. // 6-arg form (preferred — supports the intraday allowed-time window): bytes32 permissionId = policyRegistry.grantPermission( policyId, // bytes32 — from createPolicy agentId, // bytes32 — the agent's ERC-8004 identifier (NOT the smart account address) validFrom, // uint256 unix timestamp validUntil, // uint256 unix timestamp (0 = no expiry) allowedStartSecondOfDay, // uint32 — UTC seconds-of-day, inclusive (0..86399). 0 with end=0 = no window. allowedEndSecondOfDay // uint32 — UTC seconds-of-day, exclusive (1..86400). Must be > start. ); // → reverts if policy.owner != msg.sender (only the policy owner can grant) // → reverts if !identityRegistry.isAgentActive(agentId) // → reverts with InvalidTimeWindow if (start,end) is malformed (only one set, or start >= end, or out of range) // → emits PermissionGranted, plus PermissionTimeWindowSet if either bound is non-zero // 4-arg form (backward compatible — sets no intraday window, equivalent to start=0,end=0): // bytes32 permissionId = policyRegistry.grantPermission(policyId, agentId, validFrom, validUntil); // Step 2: push the PER-ASSET constraints into PermissionEnforcer (v9). Limits // are parallel arrays, one entry per asset, in that asset's OWN base units — // no oracle. msg.sender must equal the permission's grantor. permissionEnforcer.setConstraints( permissionId, // bytes32 — from step 1 (NOT policyId) assets, // address[] — asset keys; address(0) = native ETH. This IS the token allowlist. maxPerTx, // uint256[] — per-asset per-tx cap, raw base units (parallel to assets; 0 = unlimited) maxDaily, // uint256[] — per-asset daily cap, raw base units maxTxCounts, // uint256[] — per-asset daily tx count allowedActions, // bytes32[] — keccak256(actionName) hashes; empty = any allowedProtocols, // address[] — empty = any allowedChains, // uint256[] — empty = any allowUnlistedAssets // bool — if false, assets not in `assets` are denied ); ``` **Revoke on-chain:** ```solidity policyRegistry.revokePermission(permissionId); // grantor-only ``` The same wallet that called `grantPermission` must call `setConstraints` and `revokePermission`. With `allowUnlistedAssets = false`, an unconfigured permission denies everything — set per-asset limits for each asset the agent may move. **Via API (dashboard / human-driven control-plane flow — handles `setConstraints` sync automatically):** > Use the wallet-scoped management key only in this trusted provisioning > process. It is sent as `X-API-Key` from `process.env` and is never part of the > agent runtime configuration. ```javascript const apiUrl = process.env.AEGIS_API_URL ?? "https://api.projectaegis.ai"; const headers = { "Content-Type": "application/json", "X-API-Key": process.env.AEGIS_DASHBOARD_API_KEY, }; // Grant a permission const permission = await fetch(`${apiUrl}/api/v1/permissions`, { method: "POST", headers, body: JSON.stringify({ agent_id: "agent-uuid", policy_id: "policy-uuid", valid_from: "2026-01-01T00:00:00Z", valid_until: "2026-12-31T23:59:59Z", }), }).then((r) => r.json()); // Mint it on-chain (registers in PolicyRegistry, syncs constraints to PermissionEnforcer) const minted = await fetch(`${apiUrl}/api/v1/permissions/${permission.id}/mint`, { method: "POST", headers, }).then((r) => r.json()); // minted.onchain_token_id — the on-chain token ID // Revoke await fetch(`${apiUrl}/api/v1/permissions/${permission.id}`, { method: "DELETE", headers }); // List (filter by agent or policy) await fetch(`${apiUrl}/api/v1/permissions?agent_id=${agentId}`, { headers }); ``` > The API path is convenient when you want `PermissionEnforcer` constraints synced for you, but autonomous agents should generally drive `policyRegistry.grantPermission` + `permissionEnforcer.setConstraints` directly to avoid the latency and credential dependencies of going through the dashboard backend. --- ### 7. Action Validation **On-chain enforcement (primary):** Validation happens automatically inside `AgentSmartAccount.validateUserOp()` via `PermissionEnforcer`. You do not call this directly — it runs as part of every UserOperation. Transactions that violate policy constraints revert before execution. **Off-chain pre-flight (via API — useful for dashboards, simulation, agent planning):** > This API example is control-plane/API-mediated. Prefer the live chain-bound > MCP preflight for a runtime that has no management credential. If backend > reporting is needed, use only an agent-scoped expiring credential when the > deployed API release supports it. ```javascript const apiUrl = process.env.AEGIS_API_URL ?? "https://api.projectaegis.ai"; // Pre-flight check before building a UserOperation const result = await fetch(`${apiUrl}/api/v1/validate`, { method: "POST", headers: { "Content-Type": "application/json", "X-API-Key": process.env.AEGIS_DASHBOARD_API_KEY, }, body: JSON.stringify({ agent_id: "agent-uuid", action: { type: "swap", token: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", protocol: "0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D", amount: "500000000000000000", chain: 8453, to: "0xDef1C0ded9bec7F1a1670819833240f027b25EfF", }, }), }).then((r) => r.json()); // result.allowed — boolean // result.reason — explanation if denied // result.permission_id — matching permission // result.policy_id — matching policy // result.constraints — active constraints // result.request_id — audit trail reference ``` **Simulate (dry-run, no audit record):** ```javascript const sim = await fetch(`${apiUrl}/api/v1/validate/simulate`, { method: "POST", headers: { "Content-Type": "application/json", "X-API-Key": process.env.AEGIS_DASHBOARD_API_KEY }, body: JSON.stringify({ agent_id: "agent-uuid", action: { type: "transfer", amount: "1000000000000000000", chain: 1 }, }), }).then((r) => r.json()); // sim.would_allow — boolean // sim.current_usage — current period usage // sim.remaining_quota — remaining allowance // sim.recommendations — suggested policy adjustments ``` **Batch validate:** ```javascript // POST /api/v1/validate/batch // Body: { actions: [...] } — same structure as single validate ``` --- ### 8. Audit & Monitoring **On-chain events (source of truth):** The Aegis indexer polls contracts every 12 seconds and writes events to the audit log with `tx_hash`, `block_number`, and `details.chain_id`. Events emitted by the contracts: | Event | Contract | Description | |-------|----------|-------------| | `EnforcementResult` | AgentSmartAccount | Successful account validation/execution paths that persist | | `UsageRecorded` | PermissionEnforcer | Spend/volume tracked | | `Executed` | AgentSmartAccount | Transaction executed | | `AccountCreated` | AgentAccountFactory | New smart account deployed | **Query via API (trusted control-plane read):** ```javascript const apiUrl = process.env.AEGIS_API_URL ?? "https://api.projectaegis.ai"; // List audit logs const logs = await fetch( `${apiUrl}/api/v1/audit?agent_id=${agentId}&source=onchain&start_date=2026-01-01T00:00:00Z`, { headers: { "X-API-Key": process.env.AEGIS_DASHBOARD_API_KEY } } ).then((r) => r.json()); // Each log entry includes: source ('onchain'|'offchain'), tx_hash, block_number, event_type // Filter by source // source=onchain — events from contract indexer (tx_hash + block_number included) // source=offchain — API validation requests and reported denied attempts // Filter by chain // chain_id=8453 — Base // chain_id=11155111 — Sepolia // chain_id=5042002 — Arc Testnet // Export (JSON or CSV) const csvUrl = `${apiUrl}/api/v1/audit/export?format=csv&start_date=2026-01-01T00:00:00Z`; ``` Event types include: `policy.created`, `policy.activated`, `policy.revoked`, `permission.created`, `permission.minted`, `permission.revoked`, `validation.request`, `policy.enforcement.denied`, `onchain.account_created`, `onchain.enforcement.allowed`, `onchain.enforcement.blocked`, `onchain.usage_recorded`, `onchain.executed`, `onchain.x402_authorized`. **Report denied attempts (API-mediated audit):** > Use a separately issued agent-scoped, expiring credential when the deployed > API supports runtime reporting. Otherwise perform this call in a trusted > control-plane service with its wallet-scoped management key; never copy that > management key into the agent runtime. A failed UserOperation or reverted validation does not reliably leave an indexable event because reverted logs roll back and some denials stop at the bundler/simulation layer. When an agent, relay, or skill receives a policy-enforcement denial, record it explicitly: ```javascript await fetch(`${apiUrl}/api/v1/audit/denied`, { method: "POST", headers: { "Content-Type": "application/json", "X-API-Key": process.env.AEGIS_DASHBOARD_API_KEY, }, body: JSON.stringify({ agent_id: agentId, permission_id: permissionId, reason: "exceeds per-tx cap: 11 USDC > 10 USDC", chain_id: 8453, surface: "agent-runtime", action: { type: "transfer", asset: "USDC", amount: "11000000", to: "0x...", }, }), }); ``` Use this endpoint only after the Aegis on-chain validation path, relay, or bundler reports a real policy block. Do not use it as a replacement for contract enforcement. --- ## Enforcement Model All agents are **ERC-4337 smart accounts with enforced on-chain policy enforcement**. There is no advisory or EOA mode. `AgentSmartAccount.validateUserOp()` calls `PermissionEnforcer` on every agent UserOperation. Transactions that violate policy constraints revert before execution. The owner can deliberately call the account's privileged recovery path, but the agent's EntryPoint path cannot bypass enforcement. Off-chain validation (`/api/v1/validate`) runs as a pre-flight simulation for dashboards, SDK use, and agent planning. The on-chain `PermissionEnforcer` performs the final, authoritative enforcement. --- ## Setup Flow — Each Step Maps to a Smart Contract Call The order below is mandatory: each step's preconditions are checked on-chain by the next step's contract. The primary column shows the **direct-contract path** (recommended for autonomous agents); the dashboard API path is shown for reference. | # | Action | Direct contract call (signed by agent's wallet) | API equivalent | |---|--------|--------------------------------------------------|----------------| | 1 | Register the agent's identity | `IdentityRegistry.registerAgent(agentId, metadata)` | `POST /api/v1/agents` then `POST /api/v1/agents/{id}/register-onchain` | | 2 | Deploy the agent's wallet | `AgentAccountFactory.createAccount{value: fee}(owner, signer, agentId, mode, salt, parentId=bytes32(0))` — `mode=0` (MODE_POLICY_ONLY); mode is part of the CREATE2 salt | `POST /api/v1/agents/{id}/deploy-smart-account` (MODE_POLICY_ONLY) | | 3 | Define a policy | `PolicyRegistry.createPolicy(contentHash)` | `POST /api/v1/policies` then `POST /api/v1/policies/{id}/activate` | | 4 | Grant the agent a permission | `PolicyRegistry.grantPermission(policyId, agentId, validFrom, validUntil, allowedStartSecondOfDay, allowedEndSecondOfDay)` — last two args are `0,0` for no intraday window | `POST /api/v1/permissions` then `POST /api/v1/permissions/{id}/mint` | | 5 | Sync PER-ASSET constraints | `PermissionEnforcer.setConstraints(permissionId, assets[], maxPerTx[], maxDaily[], maxTxCounts[], allowedActions[], allowedProtocols[], allowedChains[], allowUnlistedAssets)` | (handled automatically by the mint endpoint) | | 6 | Agent transacts | `AgentSmartAccount.execute(target, value, AEGIS_MAGIC‖permissionId‖data)` via the ERC-4337 `EntryPoint` | (always direct — no API equivalent for execution) | **Why this order matters:** - **Identity (step 1) must exist before deployment (step 2).** `factory.createAccount` reverts with `NotAgentOwner` unless `IdentityRegistry.getAgentOwner(agentId) == owner`. Sign step 1 with the wallet that will own the account so this check passes naturally — no ownership transfer needed. - **Deployment (step 2) must exist before granting permissions (step 4).** The factory registers the account with `PermissionEnforcer` so it can record usage. - **Per-asset constraints (step 5) must be synced or the permission denies everything** (default-deny on unlisted assets). Each asset the agent may move needs a limit entry, in that asset's own base units. --- ## Autonomy & Safety Guidance 1. **Start on Sepolia or Arc Testnet.** All contract addresses are provided above. 2. Fund accounts with small amounts while validating policies. 3. Use strict policies — prefer explicit token/protocol allowlists over wildcards. 4. Enable autonomous execution only with secure signing configured. 5. Apply rate limits and allowlists at the signer layer as a second line of defense. --- ## Privacy and Data Handling - This skill does not store, log, or transmit private keys, seed phrases, or signer tokens. - `AEGIS_RPC_URL` may contain an embedded API key. Treat it as sensitive. - `AEGIS_SIGNER_AUTH_TOKEN` grants signing capability. Store in secure secret storage — never expose in logs, prompts, or chat. - On-chain transactions are public by nature. The skill adds no off-chain data collection beyond what the blockchain records. - The skill does not access local files, browser storage, or environment variables beyond those declared in the manifest. --- ## Design Principles 1. **Contract-first** — All enforcement is on-chain. The API is a convenience layer, not the authority. 2. **Policy-bound by default** — Every account is bound to a policy at creation. No account is unconstrained. 3. **Non-custodial** — Aegis never holds funds. Enforcement lives in contracts you can verify. 4. **Least privilege** — Signing must use scoped, secure integrations. Never expose long-lived owner keys. 5. **Agent and human neutral** — Authority derives from ownership and policy, not caller identity. 6. **Infrastructure-grade fees** — Fees are enforced at the contract layer. No software bypass exists. --- ## Dashboard All operations are available via the web dashboard at **https://app.projectaegis.ai** — manage agents, policies, permissions, and audit logs visually. API keys and bot signer keypairs generated by the dashboard should be stored in secure secret storage and never pasted into chat.