ERC-8427: Portable Spend Grants

Hi all,

This is the discussion thread for a new ERC, Portable Spend Grants. I’ll open the ERCs PR as soon as this thread has a URL I can put in discussions-to.

Vectors, a compact Solidity reference, and an ERC-7730 display descriptor are in the same branch under assets/erc-draft_spend_grants/.

Why

If you’ve built with agents or session keys, you’ve probably needed this: “this key can spend up to X of these assets for a while, and I can kill it.” ERC-20 approve doesn’t fit, since it usually covers one token with no expiry and no time window. Calendar-day caps don’t fit either. They reset at midnight, so a delegate can spend the full cap at 11:59 and again at 12:01.
This draft defines that one object. The signed type is SpendGrant.

What gets signed

The principal signs:

  • who may act (the delegate)
  • which assets (native currency is the ERC-7528 0xEeee…EEeE address; everything else is ERC-20)
  • per-call, trailing-window, and lifetime caps for each asset
  • who may receive funds
  • a validity range

The digest is bound to one chain and one registry. The registry records remaining usage and revocation, and it never moves tokens. Whatever contract actually transfers value has to call consume in the same transaction, or the spend doesn’t count.

For example: “on this chain, the agent may spend up to 1000 USDC and 0.5 WETH per trailing 24 hours.” That’s a single signature and a single hash, and you revoke it once.

The window looks back windowSeconds from now. It isn’t “today.” A cap of zero means zero, never unlimited.

“Portable” refers to the terms: any wallet or tool can hash, render, and check them. It doesn’t mean the grant itself moves. Using it on another chain, registry, or executor needs a new signature.

These two roles are easy to mix up. The delegate is whoever the principal named in the grant. The executor is the one contract allowed to call consume, and it’s set immutably on the registry. So you choose the executor by choosing which registry to sign against.

Three design choices

  • Each asset has its own budget. assetCombine is in the signed type, but only 0 is valid for now. A shared budget across assets would be useful, but people budget in dollars, and that needs a price reference this draft doesn’t define. I kept the field so a later mode won’t change the type hash.
  • No rendering hash. There’s a canonical plain-text rendering, but it’s built entirely from signed fields, so hashing it into the grant wouldn’t commit to anything new.
  • Exact rolling window at flat cost. The reference keeps each asset’s debits in a 1024-slot ring with a running sum and drops expired debits from the front. Execution gas through the reference executor (excluding the 21k base and calldata) is about 161k for the first spend on a grant, about 89k after that regardless of how many debits are live, and about 70k once the ring has wrapped. The 1024 limit belongs to the reference, not to the signed grant.

Try it

It’s deployed on Arc testnet (chain 5042002) with verified source:

One Arc-specific catch: list USDC by its token address 0x3600…0000, not the native address. On Arc they’re the same balance, so listing both gives you two separate budgets over one pool of funds.

There’s an ERC-7730 descriptor for that registry, so a wallet can show the delegate, the recipient, the window, each asset’s caps in that asset’s units, and the validity dates. It rejects any assetCombine other than 0. It can’t show executor(), so the wallet still has to look that up on its own.

The rolling window is fuzzed against a naive model, including full-ring wraparound. Halmos proves the expiry rule, the cap checks at full 256-bit width, two-spend sequences, revocation, and executor-only consume.

The reference is unaudited, so please keep it on testnets.

How this relates to other ERCs

  • ERC-7710 / 7715. 7710 covers redeeming a delegation, and 7715 is the wallet RPC for requesting permissions. 7710 leaves the permission bytes opaque, and this draft is one typed meaning those bytes could carry. It doesn’t require 7710.
  • ERC-8226. This is a regulated single-asset grant with a compliance provider and freeze, which is a different problem. Where the failure conditions are the same, I reused its reason names (EXPIRED, REVOKED, OVER_TX_CAP, …).
  • ERC-8312. This tracks remaining usage for a capability it treats as opaque. This draft defines the capability itself, and the registry is its v1 store for remaining usage.
    A follow-up extension will add a swap price check inside the grant. The owner signs the price policy, and the agent can tighten it but not loosen it.

Where I’d like feedback

  1. EIP-7702 principals. The key’s ECDSA signature is checked before ERC-1271, so outstanding grants survive delegation changes. The tradeoff: delegating to smart-account code doesn’t retire the key, so grants the key signed, and any new ones it signs, stay valid until they’re revoked in the registry. The key can undo the delegation at any time anyway, so this gives it nothing it doesn’t already have. Does anyone object to that ordering?
  2. consume and the delegate. consume doesn’t bind msg.sender to delegate; the binding is “this registry’s executor.” Is that enough, or should consume also check delegate?
  3. Live-debit bound. A registry may cap live debits per asset and revert with WINDOW_FULL past that cap (the reference uses 1024). Should the spec set a minimum, so a delegate making lots of small payments knows what any conformant registry will allow?

On the consume and delegate question, I don’t think the registry is the place for the check, but the spec does need it somewhere. grant plus grantSignature stop being secret the first time the delegate uses them. They’re calldata on the first spend, and sitting in the mempool before that. After that, anyone holding them can call the executor.

So what the delegate field actually protects depends on the executor. Your reference does the right thing in SpendGrantExecutor.sol#L29

if (msg.sender != grant.delegate) revert NotDelegate();

but the spec only says consume does not require msg.sender to equal delegate, and leaves how the delegate authorizes the executor undefined. An executor that follows the text exactly and skips that line leaves the grant open to anyone who has seen it once.

recipientMode what a third party can do with a grant they saw once
0 push the remaining budget to the signed recipient at times the delegate didn’t pick, or fill the ring with 1-unit debits until WINDOW_FULL
1 send the remaining budget to themselves

The Security Considerations line on WINDOW_FULL already assumes the check (“the grief requires the executor or the delegate it serves”), so today it’s only true for executors built like yours.

I’d add a MUST to the Executor section, something like “an executor MUST establish that grant.delegate authorized this call before calling consume”. I’d word it that way rather than as msg.sender == grant.delegate, so it still fits a 7710 path where the caller is a delegation manager redeeming for the delegate. Checking msg.sender inside the registry can’t do this job, because there it’s always the executor. You could pass the authorizing address into consume and have the registry compare it to grant.delegate. The executor supplies that value though, so it catches an executor that forgot, not one that lies. Maybe still worth it so conformance tests can see it.

The difference between “per day” and “over the last 24 hours” could be easy to miss. I’d find it helpful to see how much can still be spent and when more becomes available, rather than just the original limit. That would make these permissions easier to understand after approving them.

(post deleted by author)

Thanks, this is right, and it’s a spec bug rather than a wording problem. Fixed in 8aa860f; the updated text is in ERCs#2037, now numbered ERC-8427:

  1. The Executor section now requires the executor to authenticate who authorized each spend and pass that address to consume as authorizer. Possession of the grant and signature is explicitly not authorization. It lists the mechanisms in the shape you suggested, so a 7710 redemption fits: a direct call, a delegate-signed authorization, or a delegation framework the executor trusts by address. For signed authorizations, the executor must compute the grant hash itself, record a nonce rather than signature bytes, and validate the delegate’s signature under the same rules as the principal’s. Otherwise the same bearer problem comes back one level down.

  2. consume takes authorizer, and the registry reverts with UNAUTHORIZED_DELEGATE unless it equals grant.delegate. As you said, that catches an executor that authenticates someone but forgets the comparison, not one that passes grant.delegate without authenticating anyone. The spec now forbids the latter outright. The reference passes msg.sender.

  3. Security Considerations has your table, and the WINDOW_FULL sentence now rests on the MUST instead of assuming it.

  4. The same reading turned up a second gap: the spec said what the executor moves and to whom, but not from whom. Movement now has to come from grant.principal’s own balance, so an executor shared by several principals can’t spend one principal’s funds under another’s grant. The reference executor no longer pays native from the delegate’s msg.value. In mode 0 it now passes the delegate’s recipient through instead of substituting the signed one, so a wrong recipient reverts.

  5. Redeployed on Arc testnet: registry 0xA4B1Cf19bE43f8c4879e779e82b6Eee026e925f1, executor 0x2e5386E8b9c43b2c90Cd59b257e89085bA28AcD5.

Does the delegation-framework wording cover the 7710 path you had in mind?

This is good feedback, thanks! With a trailing window, spent amounts come back as each payment expires, not all at once at a reset, so “how much now” and “when more” are the useful numbers after approval. Added in 3bd700c:

  1. liveDebits(grantHash, asset, maxCount) returns, oldest first, each unexpired payment’s expiry time and amount, so a wallet can show “0.30 USDC available now, 0.10 more at 14:05, 0.25 at 16:40.”

  2. A SHOULD for wallets to describe the window as trailing rather than “per day”, and, after approval, to show what’s spendable now and when more frees up. The calculation is spelled out, including the principal’s balance and allowance, which several grants can share.

  3. The ERC-7730 descriptor now labels the cap “Max in any rolling window” instead of “Max per window”.

Redeployed on Arc testnet with the view: registry 0xf22647d93a960db3629cAEd6D75AdC688A6947B4, executor 0x45708e14e1B19B43cBaB9a90D4374C6bEd4a70Dd.