Architecture
Three planes, one source of truth. Identity and permissions belong on Ethereum, where they’re portable and legible to any counterparty. High-frequency machine payments belong on a chain where gas is USDC and settlement is deterministic. The split is the design, not a limitation to work around.
Sepolia — authority plane
- issueMandate / attenuate
- amendMandate / revokeMandate
- org's own UserRegistry
- one instance per mandate
- mandate.* — principal-only
- agent.* — per-key agent grant
off-chain
- watches Sepolia events
- writes Privy policy
- signs EIP-712 SyncPayload
Arc — money plane
- syncMandate (EIP-712)
- heartbeat (liveness)
- assertSpend — fails closed
- payTo / fundJob
- leaky-bucket budget
- revolving credit facility
Authority plane — Sepolia
MandateRegistrar deploys and owns its org’s ENSv2 UserRegistry at construction. Issuing a mandate deploys a dedicated PermissionedResolver instance, writes every mandate.* record, registers the name with a zero registry-level role bitmap (soulbound, non-renewable by omission — no feature to disable, nothing to withhold later), then grants the agent authorizeTextRoles on exactly three keys: agent.status, agent.heartbeat, agent.output.last. Nothing under mandate.* is ever agent-writable.
A sub-agent’s mandate is created by attenuate, self-service by the parent’s own agent wallet, and checked against the parent’s current headroom — depth, expiry, per-tx cap, and remaining budget all narrow monotonically. The allowlist root is not re-supplied; it is inherited verbatim from the parent, which makes widening it structurally impossible rather than merely checked.
Enforcement plane — off-chain
The Enforcer watches Sepolia and propagates state into two independent enforcement points: a Privy conditional policy (so a wallet physically cannot sign outside its mandate) and the Arc anchor (so the treasury physically cannot pay outside it). It is a propagator, not an authority — every write it makes is EIP-712 signed and independently reproducible from the Sepolia state it's mirroring.
Money plane — Arc
MandateAnchor holds the Arc-side shadow of each agent’s mandate. assertSpend reverts on revocation, expiry, an over-cap amount, an unlisted recipient, or staleness — checked in that order. AgentTreasury is the org’s USDC pool, structured as a revolving credit facility: agents draw against the pool to pay a counterparty directly (never through their own wallet), and a leaky-bucket accumulator — not a fixed window — tracks the mandate’s rolling budget.
What changed from the original design
Three corrections, made while implementing rather than deferred to a known-limitations list:
| Area | Original design | What shipped, and why |
|---|---|---|
| Treasury spend path | A generic executor: (target, calldata) allowlisted by (target, selector). | Typed payTo / fundJob. The selector-only leaf never bound the recipient — an allowlisted transfer selector could send pooled USDC anywhere. |
| Rolling budget | A fixed window, spentInWindow[agent][timestamp / period]. | A leaky-bucket accumulator. The fixed window let a boundary be straddled for double the budget, and divided by zero at period = 0 (the spec's own lifetime-budget case). |
| Resolver init batch | Every mandate.* record and agent.* grant written in one atomic multicall. | Records still batch atomically; the agent.* grants run as separate calls right after — the resolver's permission-check bypass during initialize() covers direct setters, not the authorize* grant path. |