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?
1 Like

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.

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.

1 Like

It covers it, with one wrinkle in the direction of the call.

In the MetaMask framework the delegation manager never calls the executor. redeemDelegations runs the beforeHooks, then calls executeFromExecutor on the last delegation’s delegator, and the array runs leaf to root (DelegationManager.sol:252-253 at v1.3.0). So the account is what calls the executor, and msg.sender at the executor is the delegator’s account rather than the manager. The manager keeps no redeemer anyone can read back, so the executor has nothing to ask. The only component told who redeemed is a caveat enforcer: beforeHook receives that address as its last argument (:239-246), and afterHook the same.

So on that path “reports the redeemer it authenticated” has to mean an enforcer that records the redeemer for the executor to read in the same transaction, not a call the executor makes. Worth naming, because the two shapes fail differently. An enforcer’s record is ambient: unless it is scoped to the exact spend, another call in the same transaction can read a redeemer that did not authorize it. The enforcer can scope it, since beforeHook also receives _executionCallData, so it can check that the call it is approving is the executor call for that grantHash, asset, amount and recipient, and key the record on that.

Your sub-delegate sentence holds here. beforeHook gets the msg.sender of redeemDelegations, and the manager requires that to be the leaf delegation’s delegate unless the delegation names ANY_DELEGATE (:156). A sub-delegate redemption therefore reports the sub-delegate and consume rejects it, and an ANY_DELEGATE leaf reports whoever called, which consume also rejects unless that address is the grant’s delegate.

The wording I would change is “or a caveat enforcer it calls”. For the enforcer case “it calls” points the wrong way. Something closer to “or a caveat enforcer that records the redeemer for it, scoped to this spend” matches what the framework actually does.

The security section acknowledges that token balance changes can exceed the recorded amount. How does this fit with the requirement that a conformant executor MUST NOT move more than the amount paired with consume?

Suppose consume records 100 but the token debits 105 because it charges an additional sender-side fee. Must the executor detect this and revert? Or is that execution still conformant?

Should the standard require balance-delta checks or explicitly restrict supported token behavior? Otherwise two conformant executors could give the same signed cap different practical guarantees.

Thanks for the review, and for pushing on both the direction and the wording. You were right, and the fix needed more than the phrase alone. It landed in 15fae3d, with follow-ups in 17806f4 and 7bbd3aa; the text is in #2037.

The Delegation framework section now describes the two shapes a 7710 redemption actually takes. If the delegator’s account is the grant’s delegate, the executor sees a direct call, and that account’s own delegations are its access control. The spec notes that such a delegation reaches every grant the account holds as delegate, so the account restricts the grant with calldata caveats of its own. Otherwise the executing account must be the principal, and the executor learns the redeemer from a caveat enforcer that records the redeemer for it, scoped to this spend, your phrase.

The record now has to meet these rules: only the trusted manager may write it; it is written only when the delegator the manager reports to the hook is the grant’s principal, so a caveat any other delegator attaches is inert; it is keyed on the grant hash (computed under the executor’s registry from the execution calldata), asset, amount, recipient, and the principal; it must not be usable outside the transaction, with EIP-1153 named as the direct way; the executor clears it before consume or movement and treats a missing record as no authorization; and the enforcer clears anything untaken after the execution. The mode word has to be all zero, not just the first two bytes.

Why principal-only: an audit pass over the reference found that a middle delegator in principal -> R -> delegate could put the caveat on its own hop, add an enforcer of its own that drives its account into the executor during the hooks, and ride the delegate’s one redemption for a second spend per hop. That reproduced against your framework’s v1.3.0 manager on Arc. Confining records to the principal’s own delegation removes the surface rather than patching it. Your ANY_DELEGATE observation is in the Rationale as the reason the registry’s authorizer comparison does real work.

The reference has the enforcer and an executor that takes the record when its caller is the principal. Tests run through a mock manager with your call shape, and a fork suite runs the same cases against the live DelegationManager on Arc testnet, including the hostile intermediary.

Does keeping the record path to the principal’s own account match how you would compose it, with the caveat on the principal’s delegation and nothing on the hops below? Grateful for another look if you have time.

Thanks for catching that. The two sentences did not compose, and you were right to press on it. Three direct answers, then what changed in 15fae3d (#2037).

  • No, the executor is not required to detect it.
  • Yes, that execution is conformant: the executor requested 100 and consume recorded 100.
  • The cap bounds the amount the executor requests from the token, the same quantity the ERC-20 allowance bounds. It does not bound the principal’s balance change.

The Executor section now says so. The executor must not take a fee or charge itself, the amount passed to consume must equal the amount requested in the transfer, and what the token’s own code does with that request is the asset’s behavior, outside the executor’s conformance. An executor MAY read the principal’s balance before and after and revert if the decrease exceeds the request; it MUST NOT pass an observed balance change to consume in place of the requested amount. So every conformant executor gives the same guarantee, a bound on requested amounts, and one that adds the check only rejects more.

Security Considerations separates three cases. A sender-side fee on top of the request lets the principal’s balance fall by more than the caps, by the fee. A fee deducted from the delivered amount, the common pattern, debits the principal exactly the request and short-pays the recipient. A rebasing balance changes with no transfer at all. Wallets SHOULD warn on known fee or rebasing tokens and say the caps are requested raw units. The Rationale records why a mandatory balance check was not taken: two balance reads per spend, false rejections on share-based tokens that can round a unit, and no stable meaning of a balance change for an elastic supply.

Two related points fell out of the later review. GrantConsumed.amount is the requested amount, and the spec now says a payee that needs the delivered amount reads the token’s own Transfer event or its balance change. And the code check on the spent asset excludes only addresses with no code and EIP-7702 accounts; code that accepts any call passes it, so wallets SHOULD confirm a listed asset answers balanceOf and decimals before the principal signs.

The reference’s tests pin the semantics with sender-fee and recipient-fee mocks, plus a delta-checked executor extension that reverts on the sender-fee token and records nothing. Grateful for the push on this one.

1 Like

A round-up of what changed since the last posts, all in ERCs#2037.

Review of the whole draft (17806f4, 5f2936f). consume rejects a payee of zero, the principal, the registry, the executor, or the asset being spent, in both modes. Code is checked only on the asset being spent, and an EIP-7702 account is not a token. GrantConsumed indexes grantHash, principal, and recipient, so a principal’s wallet can find its spends, and the hashes of grants it has lost, by its own address. Grants never supersede one another; the text says so and wallets SHOULD revoke the old hash when tightening a grant. A contract principal’s ERC-1271 policy is its grant-signing policy, session keys included. The spendable-now formula is bounded by free debit slots. consume and the movement happen within one call to the executor. The ERC-1271 call is a STATICCALL. The signed authorization is EIP-712 over its own message, with the nonce checked before the signature and a cancel that fails for a nonce that already ran.

Audit pass (7bbd3aa). I ran the evm-audit checklists with Slither, fuzzing, and Halmos over the reference. One High, now fixed with tests on a mock manager and on MetaMask’s live DelegationManager on Arc: the redemption record path is confined to the principal’s own account (details in my reply to zexoverz). Lows: return-data bombs can no longer swallow the reason, a new evict(grantHash, asset, maxCount) lets anyone drop expired debits in bounded pieces so a burst expiring at once does not land its whole cost on the next spend, and the EVM version is pinned to Cancun.

Reference. Three executors now: the minimal one, SpendGrantAuthorizationExecutor for delegate-signed authorizations submitted by anyone, and SpendGrantRedemptionExecutor with its caveat enforcer for ERC-7710. A shared SpendGrantSignature library carries the Signatures rules. There is a viem-generated vector for the authorization digest next to the grant vectors.

Redeployed on Arc testnet (5042002), both verified: registry 0x37ceecB34B97B39D5B07e8D7Dd974A5a9aD14425, executor 0x08493Be1675070c2069a719A850f2F9d4624177E. The earlier pairs are superseded; the ERC-7730 descriptor points at the new registry.

Where I’d like feedback:

  • evict is in the normative interface. Should it stay there, or be an optional extension?
  • GrantConsumed indexes principal and recipient and leaves asset as data. Is that the right pair for indexers?
  • Rejecting the spent asset as payee, and treating a 7702 account as a non-token for the code check: do these belong in the standard or in the reference?
1 Like

If I lower a spending limit, I’d expect the old, higher limit to stop applying. If removing the old permission fails or is still pending, the wallet should make that clear instead of showing the lower limit as though the change is complete. Otherwise, I could think I’ve reduced access when I haven’t.

On this point: because grants never supersede one another, lowering a limit leaves two live grants until the old hash is revoked, and the spendable-now view is the more permissive of the two. The wallet cannot report the lower limit as done until the revoke is mined.

Two small additions would make that explicit. First, an optional replaces field (the old grantHash) inside the signed grant, so a wallet and an indexer can show old and new as one pending change, and the executor could revoke the old grant in the same transaction as the first consume of the new one. Second, a registry view that returns, per (principal, delegate, asset), the live hashes and whether each is replaced but not yet revoked.

We use the same distinction in an ERC-8004 outcome profile, where a superseded outcome stays visible until its replacement is final: GitHub - predgeAI/erc8004-outcome-validator: Predge as an ERC-8004 Validation Registry validator — resolved-outcome-vs-claim: signed outcome attestation mapped to a real validationResponse (responseHash bound to the signed evidence). · GitHub

2 Likes

I was just sitting down to think about hashGrants (or grantHash). Timely! Thanks!

1 Like

Yes, that is how I would compose it, and the hook ordering gives you a stronger claim than the one you made, with one caveat about where the strength comes from.

DelegationManager.sol:205-274 at v1.3.0. beforeAllHook is its own pass over the whole batch, and after that each entry runs alone: every beforeHook for that entry’s chain, then executeFromExecutor, then the afterHooks in reverse. The beforeHook loop walks delegationsIndex_ from 0 to length - 1, and a permission context is ordered leaf to root, so the root delegation’s caveats run last. The account the manager then calls is batchDelegations_[batchIndex_][length - 1].delegator, the root delegator.

A record is written only at the hop whose delegator is the grant’s principal. On any path that can succeed that hop is the root, so the record is written by one of the last beforeHooks of the entry and the execution follows it. If the principal sits mid-chain instead, hops above it do run while the record is live, but then the root delegator is not the principal, the executor rejects with UNAUTHORIZED_DELEGATE, and the entry reverts. In the shape you removed, a record keyed on an intermediate delegator sat open across every hop above it, which is exactly where a hostile intermediary’s enforcer runs.

The caveat is that this is a property of v1.3.0’s loop and not of ERC-7710. A manager that ran every entry’s beforeHooks and then every execution would put arbitrary entries’ hook code inside the window. Your enforcer pins one immutable MANAGER, so a given deployment is safe, but the Rationale sentence should say the confinement relies on the manager interleaving each entry’s hooks with its own execution, or it records a guarantee the standard does not confer.

Two residuals are worth stating rather than leaving implicit.

The root delegation’s own caveat array is walked in order, so an enforcer sitting after yours on that same delegation runs with a live record. I would not call that the principal attacking itself. EncoderLib._getCaveatPacketHash commits only enforcer and keccak256(terms), so args is not signed and the redeemer supplies it at redemption time. The principal signs which enforcers run, and whoever redeems chooses what they are told. It holds anyway, for two concrete reasons: anything re-entering spend directly arrives with its own address as msg.sender, so _authorizer rejects it, and a nested redeemDelegations that did drive the principal’s account would take and clear the record, so the outer take returns zero and the entry reverts. Worth a line saying a principal should not place an enforcer that can be driven into its own account after this caveat on the same delegation.

Two entries in one batch with identical parameters do not collide either, because the entries interleave: entry 0 writes, executes, take clears, afterHook clears, then entry 1 writes. I went looking for a collision because _key has no counter. It is keccak256(abi.encode(executingAccount, grantHash, asset, amount, recipient)), five fields and no nonce, so reusing a key safely rests entirely on that interleave and nothing in your own code enforces it. Worth a test.

spend’s tuple also matches what _record decodes, (SpendGrant, bytes, address, uint256, address), so a malformed execution reverts there rather than writing a record against the wrong spend.

On your three questions.

evict should stay in the normative interface, though not for the reason the ring suggests. The 1024 bound is an implementation limit, and the spec already lets a registry that keeps no expired debits make evict a no-op. It stays because a caller cannot discover it otherwise. A relayer sizing a spend reads liveDebits, sees debits whose expiresAt falls before expected inclusion, and needs to drop them without first working out which registry it is talking to. A no-op costs one function on the registries that do not need it, and saves every caller a feature test.

GrantConsumed is already the right triple, and your own Security Considerations answers it better than the interface section does: a principal has to find spends, and the hashes of grants it has lost, by its own address, a payee has to watch for payments to itself, and filtering by asset can be done off chain. What I would change is where that sentence lives. An indexer author reads the interface and the Events section, not Security Considerations, so cross-reference it from there. The question you are being asked is the one that sentence already answers.

Keep both of the last two in the standard. The payee restriction is a conformance difference the principal cannot see from the grant it signed: on one registry that grant can pay tokens to the token contract and on another it cannot, which is Dmitry’s point in #8 wearing different clothes. The 7702 clause in check 1 earns its place for a sharper reason than the general code check does. A delegation designator is the single most likely address to pass a bare EXTCODESIZE > 0, because delegated code accepts calls, so it is precisely the false positive the check would otherwise wave through. The general check stays a heuristic and a proxy with an empty fallback still gets past it, but that carve-out removes the one case that is both common and nameable.

1 Like

I’m exploring budget changes for agent service orders. One clarification on the new Replacing a grant section at fb57be3: does “requested value” mean a new cumulative ceiling, or an additional budget?

For one old grant, after its revocation is final, take old maxTotal = 1000, spent = 700, and a requested cumulative ceiling of 800. The section’s bound, min(requested value, old remaining), gives min(800, 300) = 300. Spending that replacement permits 700 + 300 = 1000, rather than the intended 800.

For a tightening of the cumulative ceiling, I think the replacement needs:

replacement maxTotal <= max(0, min(old maxTotal, target cumulative total) - old spent)

That gives 100 here. Targets of 700 or 600 leave no additional budget; since zero caps are invalid, the wallet should omit that asset, or issue no replacement if none remain. It should also explain when spending has already exceeded the requested target.

I saw the reset/stacking cases in SpendGrantReplacement.t.sol. Could the wallet guidance also distinguish these two meanings and include the 1000 / 700 / 800 -> 100 case? If “requested value” already means additional budget, naming it that way would resolve the ambiguity. This is a specification-reading question; I haven’t run the Solidity suite.

Thanks for reading it this closely. You’re right, and it is an error, not a wording problem. “Requested value” was meant as the new lifetime total, and the rule as written doesn’t produce it: with 1000 / 700 / 800, the lesser of 800 and the 300 left is 300, so the delegate could still reach 1000.

The section now uses your bound. To hold an asset’s lifetime spend under the earlier grant and the replacement to T, a wallet should sign the replacement’s maxTotal no higher than min(T, M) - S, where M is the earlier maxTotal and S is what the earlier grant spent, read once the revocation is final. In the 1000 / 700 / 800 example the replacement’s maxTotal is 100, and spending stops at 800. When that comes to zero or less, as with a new total of 700 or 600, the asset is left out of the replacement, since a zero cap is invalid; a grant needs at least one asset, so if none is left there is nothing to sign. Since maxPerCall <= maxPerWindow <= maxTotal is a structural rule, the replacement’s other two caps can be no higher than 100 either.

On your two meanings: an additional budget is the section’s other case, a further grant with no revocation, whose caps add to the earlier grant’s. When spending has already passed the new total, the draft only has the wallet leave the asset out; I haven’t added a separate display rule, since the wallet has S from usage at that point.

The test had the same mistake: it sized the replacement from what was left, 300, so it passed without checking the new total. It now sizes the replacement at 100, checks that spending under the two grants comes to 800, checks that the replacement can spend no more in the window or after it, and checks that the revoked grant cannot spend either. Fixed in b02ad813, in ERCs#2037.

@Anzus_GemWallet @predge-ai, following up on #12 and #13 now that the changes are in ERCs#2037.

Anzus: the old limit stops applying when the revocation is included, and until then the draft has the wallet behave as you described. In Replacing a grant (4e3b594d), the wallet holds the new grant back from the delegate until the revocation of the old one is final, shows the old caps as still in force, says whether the revocation is unsent, pending, or failed, and does not show the lower limit as current. When the wallet sizes the new grant’s lifetime cap from what the old one already spent (the rule corrected in b02ad813), lowering a limit cannot raise the lifetime total the delegate may spend under the two grants. The new grant’s window starts empty, which the wallet either discloses or avoids by delaying the new grant’s start.

predge: one correction to #13 first. If the replacement reaches the delegate before the revocation, the delegate can spend under both grants, so the exposure is their sum, not the more permissive of the two; that is why the draft holds the replacement back. hashGrant is in the draft and the reference (fb57be3f): the registry returns the hash it computes, and the wallet revokes that value and then reads revoked for it. I didn’t add replaces. Revoking the old grant on the first spend under the new one would leave the delegate deciding when the old grant ends, since it could keep spending under the old grant and never use the new one. A registry view of live hashes can only list grants the registry has seen, and a signed grant appears nowhere on chain until its first use. Holding the replacement back leaves the wallet one pending change to show, the revocation.

1 Like

Thank you for walking the v1.3.0 loop line by line. Taking your points in order.

Confinement. I agree that the narrow window comes from v1.3.0’s loop and not from ERC-7710, and the draft doesn’t claim it: the one Rationale sentence on the record says it is “confined to one transaction”, which is the transient-storage rule. Checking your premise turned up a case it misses, though. “On any path that can succeed that hop is the root” fails when the root delegator is the grant’s delegate. The principal’s delegation is then an intermediate one, so its hook writes a record, but the execution is a direct call from the delegate’s account, which succeeds and never takes it. The record stayed live until the principal’s afterHook, through the hooks above it and the execution, token callbacks included, and anything in that span that could drive the principal’s account through another delegation could pay the same spend again on one redemption. It reproduced on v1.3.0 on Arc.

An enforcer after ours on the same delegation. Your second reason fails when the caveat appears again after that enforcer: the second copy’s hook writes the record again after the nested redemption took the first, so the execution still finds a record and the spend is paid twice. That also reproduced on v1.3.0.

The draft now has the enforcer write only at the root delegation, whose delegator performs the execution, when that delegator is the principal and that delegation carries the caveat exactly once (728482a4). Since args are not signed, as you pointed out, the reference has the redeemer pass that delegation in the caveat’s args and checks it against the delegation hash the manager passes to the hook, which commits to the authority and to each caveat’s enforcer. A principal’s delegation with no args, or with args that are not that delegation, reverts, and so does a root that carries the caveat twice. The only hooks left between the write and the execution are those of enforcers after ours on that root delegation, and there your two reasons now hold: a nested redemption that drives the principal’s account takes the record, the execution finds none, and the whole redemption reverts. So I’ve left your suggested line out of the text. The unit tests’ mock manager now follows v1.3.0’s loop, and a matrix of chain shapes with a thief at every hook and in the token callback finds no redemption that pays twice. A fork test checks the mock’s hook order against the live manager; it and a Halmos proof of the write rule are in tankcdr/erc-spend-grants 986b23c.

Identical entries. Agreed, nothing in our code enforces the interleave. A fork test now redeems two identical entries in one batch through v1.3.0 on Arc and checks that both spend (tankcdr/erc-spend-grants a084502; it needs a network, so it is not in the ERC bundle). Under a manager that ran every entry’s hooks before any execution, the two entries would share one record, and the second would find none and revert.

evict stays in the normative interface, for your reason: a caller should not have to work out which registry it is talking to.

GrantConsumed. The reason for the indexed topics is already in the Registry section, in the paragraph on what consume records: a principal’s wallet finds every spend against it, and the hashes of grants it has lost, by filtering on its own address, and a payee watches for payments to itself. An indexer author reading that section finds it without going to the Rationale.

The payee restriction and the 7702 clause stay in the standard, for the reasons you gave.

I also trimmed the draft in this round, mostly Security Considerations (6d3dd728); the changelog in tankcdr/erc-spend-grants lists what came out. The record paragraph now points to the Executor requirements instead of restating them, and apart from the record rule above, those requirements are unchanged.

Thanks for the thoughtful follow-up and the fix! The updated formula and test case address my original concern. Really appreciate you taking the time to work through the example.

Revision: grants are now registered on chain (035f8eb1).

Several points here and on the map thread came back to one root cause (and an X dm): a signed grant lived off chain until its first spend, so a principal could not list the grants it had made, tightening a cap meant revoking and re-signing with usage starting over, and a wallet had no standard way to ask why a spend would fail. In this revision the principal registers the grant with the registry and hands the delegate only its id.

What changed:

  • register(terms) stores the terms and returns grantId, the same EIP-712 digest as before; the typed data is unchanged. Only the principal can register its own grants.
  • update(grantId, expectedVersion, terms) changes a grant in place, everything except the principal, the delegate and the window length, and keeps its usage. The version check stops an older queued update from undoing a newer one.
  • revoke(grantId) is open only to the principal.
  • grantsOf and grantsTo list every grant for each side, and getGrant and grantState return the terms and current state, including what can be spent now.
  • check(...) returns the reason consume would fail with, from the same evaluation, and the revert encoding SpendGrantError(uint8) and the reason numbers are now normative. NONEXISTENT is 14; BAD_SIGNATURE stays reserved.
  • The executors and the ERC-7710 enforcer key everything on grantId.
  • The ERC-7710 enforcer now accepts only the principal’s own root delegation to the grant’s delegate, redeemed by that delegate, and, for a registered grant, reverts on every other delegation that carries it, such as one open to any delegate, and on a sub-delegate’s redemption. A chain that leaves the delegate and returns to it still pays once: the enforcer checks the root and the redeemer, not the hops between. Before, the caveat was inert on any other delegator’s delegation, and any other redeemer was recorded for consume to reject. That failed for an account that is one grant’s principal and another grant’s delegate: an open delegation it signed with the caveat for its own grants let anyone make it spend, as delegate, under the grants others gave it, because the executor consults no record when the delegate’s account calls. The matrix, the Halmos properties of the write rule and the fork tests on DelegationManager v1.3.0 are updated, and the matrix still finds no redemption that pays twice.

Deployed on Arc testnet (5042002), both verified (5b226852):

  • registry 0xe8987A22a1e3fd4a63cB517A9476B9d214388287
  • executor 0xB99785329AbEc9936b6EF6350015be39D3392646.

The previous pair (registry 0x37ceecB34B97B39D5B07e8D7Dd974A5a9aD14425, executor 0x08493Be1675070c2069a719A850f2F9d4624177E) stays on chain and runs the signed-grant design; the ERC-7730 descriptor points at the new registry.

Superseded, so earlier posts are not read as current:

  • #1: the principal signing the grant digest. You still choose the executor by choosing the registry. The question about checking an EIP-7702 principal’s key before ERC-1271 is moot for principals; the same order now applies to delegates signing authorizations.
  • #5: the executor computing the grant hash itself. It now reads the grant’s delegate from its registry.
  • #9: the record keyed on the grant hash. It is now keyed on grantId.
  • #9: a caveat that any other delegator attaches being inert, and zexoverz’s ANY_DELEGATE observation in #7 as the reason the registry’s authorizer comparison does real work. The stricter enforcer above replaces both, and it also refuses #9’s example chain, principal → R → delegate.
  • #11: revoking the old hash when tightening a grant (use update), and a contract principal’s ERC-1271 policy as its grant policy (its call policy is).
  • #11 and #19: finding lost grants through GrantConsumed. grantsOf lists them.
  • #17: sizing a replacement as min(T, M) - S. With update, lowering maxTotal from 1000 to 800 after 700 is spent leaves exactly 100.
  • #18: holding a replacement back until the revocation is final, and “a signed grant appears nowhere on chain until its first use”.
  • #19: the list of what reverts, and “apart from the record rule above, those requirements are unchanged”. Under the stricter enforcer, the shape in #19’s Confinement paragraph (the delegate’s account as root, the principal’s delegation below it) reverts instead of paying as a direct call.

@ted0x, thanks for #20. update retires the min(T, M) - S sizing, and your replacement case is now a test: the update to 800 stops spending at 800. @Anzus_GemWallet, grantState now shows what can still be spent, and liveDebits when more frees up (your #3), update lowers a limit in place in one transaction, so no older, higher grant is left to remove (your #12), and check with the normative reason codes gives the clear failure reasons you asked for on the map thread. @zexoverz, the enforcer bullet above reverses what #9 said about your #7.