MANDATELaunch app →

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

MandateRegistrar.sol
  • issueMandate / attenuate
  • amendMandate / revokeMandate
  • org's own UserRegistry
PermissionedResolver
  • one instance per mandate
  • mandate.* — principal-only
  • agent.* — per-key agent grant

off-chain

Enforcer
  • watches Sepolia events
  • writes Privy policy
  • signs EIP-712 SyncPayload
↓ narrows only, never widens ↓

Arc — money plane

MandateAnchor.sol
  • syncMandate (EIP-712)
  • heartbeat (liveness)
  • assertSpend — fails closed
AgentTreasury.sol
  • 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:

AreaOriginal designWhat shipped, and why
Treasury spend pathA 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 budgetA 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 batchEvery 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.