
Leash
The AI agrees to pay, and the chain says no. On-chain spending rules for agent wallets.
Demo
About
Leash is a permission layer for AI agent wallets. What an agent may spend and who it may pay lives in a smart contract, and the wallet checks it on every payment the agent makes — so you don't have to trust the agent to follow its own rules.** **The problem.** An AI agent that can spend money needs a spending limit, and every existing answer puts that limit somewhere the agent can reach: a config file it reads, an API key it holds, a session key whose cap the agent itself enforces. Compromise the agent and the limit goes with it. There's a second problem that gets less attention and happens more often. **Limits given to agents don't add up.** Teams don't deploy one agent, they deploy several. At 9am the subscriptions agent renews a licence; at 11 the invoices agent pays a contractor; at noon the account is empty and the payment that mattered fails. Neither agent did anything wrong — each stayed inside the limit it was given. There were two limits and one bank account. No per-agent setting fixes that, because it's arithmetic, not a security failure. **What Leash does.** The limit isn't attached to an agent. It's attached to an **ENS name**. The wallet resolves that name on every single payment to find the policy contract that governs it, and the on-chain ledger of what's been spent is keyed by the name, the token and the day — with no agent in the key. So every agent bound to that name draws down one budget, enforced by the chain, with no shared database, no coordinator, and no message passing between them. The agents don't know each other exist. **What you see in the demo.** A four-person studio with two AI agents — one pays invoices, one handles renewals — sharing fifty dollars a day. 1. You type an instruction in English. Claude turns it into a payment. It never writes an address: it picks a vendor by name, and those names are ENS records — our vendor directory contains no addresses at all, so a hallucinated payee has nowhere to appear. The retainer is paid with no approval and no human click. 2. A new contractor's first invoice is refused. Nobody caught it — no filter, no review queue. The rule isn't in the agent's prompt or its config; it's on chain, and the agent can disagree with it, ignore it, or be compromised, and the wallet still says no. To widen what the agent may do, a live human scans their face with **World ID Selfie Check**, and the wallet checks that proof against the one World ID it has registered. No key can do that — not the agent's, not the wallet's own. Then the agent tries again on its own and the answer has changed; nobody told it. 3. The *other* agent, on a different key in a different process, is refused a payment its vendor and per-transaction limit both allow — because the first agent already spent the budget. It works that out by reading the chain. **And notice what the face scan bought: it added a payee. It did not add a dollar.** 4. Rules are swappable. A second policy a human already approved says *small payments to anyone, OR the full original rules* — two policy contracts composed with an OR. Point the ENS name at it, and a payment that was refused a minute ago goes through, with nothing retyped and nothing said to the agent. **Refusals are first-class.** A blocked payment doesn't revert — it emits an event and returns. That's deliberate: it means every refusal is indexed and visible on **The Graph**, so you can audit not just what the agents did but what they *tried* to do. It's also the only reason a refusal exists anywhere at all. **The asymmetry is the design.** Expanding what an agent may do costs a live human face. Reducing it — tightening a rule, removing a payee, revoking an agent, pausing the wallet — is free and instant, because when something has gone wrong nobody should have to find their phone before pulling the brake. **What it does not claim.** EIP-7702 constrains calls *to* the delegated account, so the wallet's own private key can always sign a direct transfer. That's both the boundary and the escape hatch: the owner can always retrieve their funds and can never be locked out by a rule they installed. Leash is the agent's only spending path, not the wallet's. Everything runs on Sepolia against deployed contracts. Every claim in the README has a transaction hash behind it, including the refusals.
How it's made
**The wallet is an EOA with delegated code.** `LeashAccount` is an EIP-7702 delegate implementation (2,856 lines of Solidity, 260 Foundry tests). Delegation is 23 bytes — `0xef0100 || impl` — and the code runs against the EOA's own storage, so state lives in an ERC-7201 namespace at `keccak256("leash.account.v1")`. Re-delegating to a newer impl reads back identically because every field keeps its slot; `ownerNullifier` was appended at SLOT+6, which was unused. **`spend` is the only path an agent has, and it cannot go around the check** because the check happens inside its own execution path. Authorise the caller from per-EOA storage → walk ENS three hops to resolve the policy → verify that address against a human-approved list → `staticcall` the policy under a 200k gas cap → honour the reason code. **ENS is the lookup path, not a label.** `LeashRegistry` implements the ENSv2 `IRegistry` (`getSubregistry` / `getResolver` / `getParent` + `IERC1155Singleton`) and is mounted under `leash.eth`, so resolution is forced through it. `LeashResolver` implements only ENSIP-10 `resolve(bytes,bytes)` — measured fact: minimal ENSv2 resolvers have no `addr()` at all, so a compatibility layer is wasted work. ENS's own UniversalResolverV2 resolves our names. Three things hang off the name and breaking any one stops the system: the policy address, every payee address (`agent/vendors.json` contains names and zero addresses, so a hallucinated payee has nowhere to appear), and — the part we didn't design and then discovered — **the budget**. The ledger is `spent[node][token][bucket]`: three keys, no agent. So two agents bound to one subname share one budget, enforced by the account, with no coordination between the processes. That needed no contract change; it fell out of the storage layout. **The Graph is the agent's only sense organ.** A refusal doesn't revert — it emits `SpendBlocked` and returns — so a blocked payment leaves a status-1 receipt and exists nowhere a getter can reach. That was a deliberate design decision to make refusals indexable, and it means the subgraph is the only place four things exist: the refusal log, the payee allow-list (no contract can enumerate it), the list of approved policies (events, not an array), and the per-agent split of the shared budget. That last one is reconstructed client-side by walking `spentAfter` backwards from the budget's current total until it reaches zero — which finds the period boundary by arithmetic rather than by assuming 86400 seconds. EIP-7702 delegation emits no log, so the subgraph has no factory event to trigger a template from; `LeashAccount` emits `Leashed` on the first `bindAgent` to be that trigger. **World ID Selfie Check is the asymmetry.** Widening costs a live human; reducing is free, because when something has gone wrong nobody should have to find their phone before pulling the brake. `WorldAttester` verifies an EIP-712 attestation from our RP signer against a digest bound to the exact widening, and `LeashAccount` additionally checks the nullifier equals the one `ownerNullifier` it has registered. **`allowPayeeByFace` deliberately has no `onlySelf`** — the wallet's own key gets `NotAttested()` while a valid face scan relayed by anyone succeeds. The face outranks every key in the system, including the wallet's. **Claude drives the agent.** `claude -p` with a vendor directory of ENS names; the model picks an id and never writes an address. **Three hacky/hard-won things worth naming.** (1) `@worldcoin/idkit-standalone` cannot request a face check at all — we proved it from the published bundle (zero occurrences of "selfie", four verification levels) and migrated to `idkit-core`'s `selfieCheckLegacy()`. On the wire it's `verification_level: "face"` — the same vocabulary with a fifth value the widget's switch is missing. (2) Once you *store* a nullifier, the action stops being a free choice: a nullifier is `hash(person, action)`, so every future scan must reuse the same action or the stored one never matches. Nothing documents that, and the verification *succeeds* either way — it fails only in your own code. Both are written up in `docs/world-feedback.md`. (3) `PolicySet` reaches its members by `staticcall`, not `call`, because the account doesn't revert when it blocks — a member that wrote state on the way to a refusal could never be rolled back. **Honestly stated:** `PolicyApprovals` is wired to `MockAttester` in this deployment, so getting onto the approved list is currently ungated. The account's own attester is the real `WorldAttester`; the approvals contract's is `immutable`, so fixing it is a redeploy of two contracts. It's flagged in the README and `docs/deployments.md` rather than buried. Foundry, viem, node:test. 260 Solidity tests, 212 JavaScript. Every claim in the README has a Sepolia transaction hash behind it.