Putting this up for early feedback before opening a PR.
Agents are starting to move money on their own. The safe primitive for that is the asset itself bounding what a delegate may spend, per-transaction cap, expiry, allowed token, plus an instant revoke that cuts the agent off, all enforced by the token rather than by the agent’s good behavior. Even if the agent’s key or session is fully owned, every transfer stays within the per-transaction cap and the principal can revoke to stop it, and the constraint travels with the token instead of living in a particular wallet or session key. (Bounding total spend over a period is a companion extension, noted below.) Treasury sub-accounts with a department budget, allowance wallets, and custodial accounts under a withdrawal limit are the same shape.
Tokens can already refuse a transfer: ERC-7943 and ERC-3643 put a pre-transfer check in the token and enforce it. What they answer with is a bare boolean or a custom error. So the real gap isn’t enforcement, it’s that the refusal carries no machine-readable cause, and that the policy lives inside each token instead of in one reusable place. When a transfer is blocked, the integrator needs to know why (no mandate on file, over the per-transaction cap, expired, revoked, or wrong asset) because each needs a different reaction, and today everyone infers that off-chain against a specific token’s internals. A reusable gate that many assets can share, answering with a shared machine-readable reason vocabulary, is what’s missing.
One way to place it in the existing stack:
ERC-20 what you hold
ERC-3643 / 7943 who may hold it
ERC-4337 / 7702 who may sign for it
ERC-8004 which agent is which
this proposal what a holder may spend, enforced at the asset
ERC-8226 is the regulated, identity-bound take on that last row; this is the minimal, identity-agnostic gate (more below).
The interface is deliberately small. A transfer-eligibility gate is a separately deployable contract with two views:
interface ISpendGate {
// true whenever the gate holds any record for `subject` (active, lapsed,
// revoked, or no-mandate). Sticky and constant-gas.
function isGated(address subject) external view returns (bool);
// side-effect-free verdict. ok == (reason == OK).
function checkTransfer(address from, address token, uint256 amount)
external view returns (bool ok, bytes32 reason);
}
Two functions, but the signatures aren’t the proposal. What the EIP standardizes is the reserved, byte-pinned reason vocabulary (below), the asset’s enforcement obligations (when to consult the gate, when to revert with TransferBlocked, which movements are exempt, STATICCALL and fail-closed handling), ERC-165 discovery, and the IGatedAsset surface. The interoperability lives there, not in the two views.
A compliant ERC-20 exposes spendGate(), and inside its transfer path: if isGated(from), it reads checkTransfer and reverts TransferBlocked(reason) unless reason == OK. If isGated(from) is false, the transfer is plain ERC-20. Mints, address(0) burns, zero-value and self-transfers are exempt.
reason is a bytes32 short string from a reserved, byte-pinned set, evaluated in this precedence:
OK
NO_MANDATE
REVOKED
EXPIRED
TOKEN_NOT_ALLOWED
OVER_TX_CAP
Because the gate is a separate contract, one policy engine can back many assets, and because the codes are byte-pinned, independent assets and gates compare them for equality. Gates may add their own codes; every non-OK code is a denial.
What it deliberately leaves out: how a mandate is granted and who grants it (out of scope), recipient eligibility (the ERC-7943 side, layered alongside), and cumulative per-period caps (a companion extension, since they need shared accounting state).
Where it sits. ERC-7943 already mandates enforcement with a boolean canTransfer plus typed errors; this keeps that enforcement but externalizes the verdict and makes the rejection self-describing. ERC-8226 (Regulated Agent Mandate) is the closest neighbor: it bundles identity/compliance, and as I read its integration section the mandate registry enforces through the token’s existing pre-transfer hook, with the spend path surfaced as a boolean (isActiveForAmount). That hook is the seam this fills, so the two compose: an 8226-style registry could enforce through a gate like this and get a spend-reason vocabulary instead of a bare bool. Curious whether that matches how the 8226 authors see it.
I’ll post the full draft (full interface incl. IGatedAsset, reserved codes with exact byte values, asset obligations, STATICCALL/fail-closed rules, security considerations) alongside the PR. Wanted to surface the shape here first.
Keen on feedback on: the reason vocabulary and its precedence, and the gate-as-separate-contract seam.