Search projects, hackathons, sponsors and tech

    ← All projects

    Attenuate

    AI agents get ENS names, not keys. Ask for more than your parent holds and the name never mints.

    Demo

    About

    An AI agent that spends money needs a key, and a key is all or nothing. There is no way to say "this agent may spend 200 USDC repaying my Aave loan and nothing else." The moment that agent delegates to a sub-agent, the sub-agent inherits the same key, so whatever limits you had in mind are gone. Attenuate makes a permission a name. Every agent is an ENSv2 subname carrying a grant: a capability bitmask, a spend cap, a query budget, an expiry, and how many further hops it may delegate. A child's grant must be a strict subset of its parent's, and that check runs inside the registry contract. The key design decision: we inherit ENSv2's PermissionedRegistry and override register() to revert. The standard way of minting a name under ours does not work at all. The only entry point runs the subset check first, so an over-privileged subname is not rejected after the fact. It is never minted. Ask for one capability your parent lacks, or one wei more than your parent has left, and the name does not exist. Three properties follow. Attenuation is structural, not a policy we remember to apply. Budgets are conserved: a parent's remaining balance drops as children are minted, so it cannot hand the same money to two children. Revocation is O(1): bumping a parent's epoch kills the entire subtree in the same block, measured flat at 4,327 gas whether the subtree has 1 node or 15. Sub-agents are real OS processes holding their own keys, not function calls with names attached. Executor.execute requires msg.sender == agentOf(node), so a process that knows a name but not its key is refused NOT_AGENT on chain. A parent that delegates mints the child's name to a key derived for it, funds it, and spawns it as a separate process. LEDGER: one EIP-712 mandate signed on device establishes the root. initRoot recovers the signature and compares it to the device key. There is no owner function, no admin path, and no seed method that bypasses it, so without that signature the tree cannot exist. Escalations surface as a device prompt rather than a silent revert, and a rejection is a decision the code understands. Development runs against Speculos with no hardware. ENS: attenuate.eth is live on the ENSv2 Sepolia deployment, with every grant published as text records on its own PermissionedResolver. Reading them shows the capability mask narrowing at each hop: 0xff at the root, 0xd4 at risk, 0x54 at exec, 0x40 at probe, with each parent's remaining budget dropping as children are minted. No child ever receives ROLE_SET_RESOLVER, because an agent that can repoint its own resolver could rewrite the permissions its name publishes. THE GRAPH: a subgraph indexes the permission tree itself, including registries that did not exist when it was deployed, since a delegation tree grows at runtime. An MCP server exposes six tools so any agent in any framework can read its own scope and dry-run a proposed grant before spending gas, getting back the exact reason and which field to narrow. x402 payments debit the same on-chain query budget through the same Executor path that governs moving money. An LLM proposes what each sub-agent should be allowed to do. Its output is never filtered in TypeScript; whatever it asks for goes to the registry, and the registry is what refuses. In our run the model stayed in scope every time, which is the point: the guarantee does not depend on the model behaving.

    How it's made

    STACK: Solidity with Foundry for the contracts, TypeScript everywhere else (viem, Next.js, Node), AssemblyScript for the subgraph mappings, and the Model Context Protocol SDK for the agent-facing server. ENSv2. The core is AttenuatedSubregistry, which inherits ENSv2's PermissionedRegistry and overrides register() to revert with UseRegisterWithGrant. That single override is what makes attenuation structural: the standard mint path does not exist, so the only way in runs the subset check first. GrantStore holds the state and the invariant, six requires comparing a child's capabilities, spendCap, queryBudget, expiry, maxDepth and readOnly against the parent's remaining values, not its totals. Checking against spendCap instead of spendRemaining is the bug that would let one parent hand its whole budget to two children, and we have a conservation invariant test for it. A registry cannot embed its own initcode, so delegating names get their child registry from a SubregistryFactory that the store authorises at grant time. Revocation bumps an epoch on the parent and every descendant compares its stored parentEpochAtGrant on read, so killing a subtree is one storage write: measured flat at 4,327 gas for subtrees of 1, 3, 7 and 15 nodes. isLive walks up to MAX_WALK ancestors and fails closed past it, bounded at 11,310 gas. Enhanced Access Control gave us a role split we actually use: the granting role and the revoking role are held by different keys, and no child ever receives ROLE_SET_RESOLVER or ROLE_SET_SUBREGISTRY, because an agent that can repoint its own resolver could rewrite the permissions its name publishes. Withholding ROLE_CAN_TRANSFER_ADMIN makes a permission non-sellable. Executor meters spending from calldata. Each capability is registered with a target, a selector, and the argument index holding the amount, so the contract reads the amount out of the call it is about to make rather than trusting a number passed alongside it. Capabilities can also pin an argument, which is how "approve, but only the pool" is expressed. LEDGER. One EIP-712 RootMandate signed on device, recovered in initRoot and compared to deviceKey. Signing goes through the Device Management Kit over the Speculos transport. Two things cost us a day each and are worth passing on. A DMK session signs exactly once: the second signTypedData in a process returns 0x6980, which reads like a device fault, and rebuilding the signer does not help because the old session still holds the transport. It only works if you explicitly call dmk.disconnect({sessionId}) first. Separately, a user rejecting on device arrives as EthAppCommandError 0x6985 "Condition not satisfied", so the obvious catch treats a deliberate human decision as a crash. THE GRAPH. The subgraph indexes the permission tree itself. The interesting part is that a delegation tree grows at runtime: child registries are deployed when a name is granted CAP_DELEGATE, so a static address list misses everything below depth one. We spawn a data source template from the store's own RegistryAuthorized event, which makes registries that did not exist at deploy time indexable from the block they were authorised. The MCP server exposes six tools over stdio; simulate_grant dry-runs a proposed grant and returns the exact revert reason plus which field to narrow. HACKY BITS WORTH MENTIONING. Registration on the hackathon Sepolia deployment kept failing with ERC1155InvalidReceiver for an account that was plainly an EOA. The cause was an EIP-7702 delegation designator: anvil's default account has 0xef0100... code on real Sepolia, so it has code, so the ERC-1155 receiver check fires. A fresh address worked first try. The PermissionedResolver proxy would not initialize and reverted with empty data. We extracted every PUSH4 in the implementation's runtime bytecode and matched candidate signatures against it, which showed the selector we had been sending was not in the contract at all. The real signature was initialize(address,uint256,bytes[]). Reading bytecode beat reading docs more than once on this project. The Ledger Key Ring CLI ships SPECULOS_API_PORT and SPECULOS_DEVICE in its binary but its only transport is UsbTransport, so ring init cannot be emulated. We confirmed that by grepping the compiled binary. Since we had no hardware, the broker instead leaves the ring on the operator's machine and seals short-lived secrets to a remote host's ephemeral X25519 key after a device-signed approval over an eight-character fingerprint the operator reads off the screen. Agent identities are keccak256 of a seed plus the label, so the demo is reproducible and anyone re-running it lands on the same addresses. Executor requires msg.sender == agentOf(node), which means a process that knows a name but not its key is refused NOT_AGENT on chain rather than by our client.