EIP.tools

EIP.tools

⚠️ DraftStandards Track: ERC

ERC-8427: Portable Spend Grants

Signed multi-asset spend grants with rolling and lifetime caps

Authors
Created2026-09-17
Discussion Linkhttps://ethereum-magicians.org/t/erc-8427-portable-spend-grants/29776
Requires

Markdown

https://raw.githubusercontent.com/tankcdr/ERCs/ref...

EIP-GPT summary

Contents
AbstractMotivationSpecificationTermsGrant objectEncode typeDomain and digestStructural validationCapsRolling windowSignaturesCanonical renderingJSON interchangeRegistryReason namesExecutorRationaleBackwards CompatibilityTest CasesReference ImplementationSecurity ConsiderationsCopyright

Abstract

This specification defines a portable spend grant: a typed, signed grant from a principal to a delegate that authorizes repeated spending of one or more assets under per-call, trailing-window, and lifetime caps. Native currency of the execution chain is identified by the ERC-7528 address; every other asset is an ERC-20 contract. The principal signs an EIP-712 digest bound to the execution chain and to an immutable revocation registry. The registry stores remaining usage and does not move funds. A separate executor, selected by signing that registry as the domain verifying contract, checks that the delegate authorized each spend and calls consume within the same call as the value movement, so that neither happens without the other. Contract principals validate signatures with ERC-1271; EIP-7702 delegated accounts also accept their own key's signature. Caps never treat zero as unlimited. The terms are portable: any wallet or tool can hash, render, and check them. A grant is bound to one chain and one registry, and through the registry to one executor; using a different one requires a new signature.

Motivation

Approvals and one-shot signatures do not give wallets, applications, and relying parties a shared object for bounded spend. An ERC-20 allowance is usually a single-asset, uncapped, non-expiring debit right. Recurring allowances that reset at a UTC day or calendar period allow two full spends on either side of midnight. Multi-asset grants need each asset to carry its own remaining, so that one signature can cover several assets without one asset's spending drawing down another's.

Delegation and permission RPCs exist, but they leave the meaning of the permission opaque. Two implementations can show the same hash and still disagree on window arithmetic, native-currency encoding, or whether a second asset has its own remaining. Principals also need a revocation path that does not depend on the delegate continuing to cooperate.

Existing tokens and native currency should not have to opt into a hook in order to be spent under a grant. The grant should be a portable typed-data object with a canonical rendering, a JSON interchange, and an on-chain remaining store that any conformant executor can debit atomically with movement.

Specification

The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "NOT RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in RFC 2119 and RFC 8174.

Bytes are hashed with Ethereum Keccak-256. grantHash denotes the complete EIP-712 digest defined below. Amounts are raw asset units: for native currency, the unit of msg.value and BALANCE on the execution chain (wei on Ethereum); for ERC-20, the token's decimals units. This specification does not define compilation into other permission systems.

Terms

  • Principal: the address that signs the grant and whose assets are spent.
  • Delegate: the address named in the signed terms as the grantee. Only the delegate may authorize a spend under the grant. The executor authenticates who authorized each spend and passes that address to consume, which rejects any address other than delegate (see Executor).
  • Executor: the immutable address returned by the registry executor() function. Only this address may call consume.
  • Recipient: the payee of a single consume; never zero, the principal, the registry, the executor, or the asset being spent.
  • Registry: the immutable contract that verifies grants, records remaining, and records revocations. It NEVER transfers assets.
  • Window: a trailing lookback of windowSeconds seconds, not a UTC calendar day.
  • NATIVE: the ERC-7528 address 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE, which denotes the execution chain's native currency.

Grant object

The exact types, field order, and integer widths are:

struct AssetLimit {
    address asset;         // ERC-20, or NATIVE (ERC-7528) for this chain's native currency
    uint256 maxPerCall;    // one payment, raw units
    uint256 maxPerWindow;  // trailing window, raw units
    uint256 maxTotal;      // lifetime, never resets
}

struct SpendGrant {
    address principal;
    address delegate;
    uint8   recipientMode; // 0 = one address, 1 = any
    address recipient;     // required nonzero if mode is 0; MUST be address(0) if mode is 1
    uint8   assetCombine;  // MUST be 0 (independent per-asset caps); other values reserved
    uint64  windowSeconds; // trailing lookback; 86400 = 24 hours, not a UTC day
    AssetLimit[] assets;   // 1–16, unique, strictly ascending by uint160(asset)
    uint64  validAfter;    // inclusive unix seconds
    uint64  validUntil;    // exclusive unix seconds
    uint256 salt;
}

All fields are required. salt distinguishes otherwise identical grants; it is not an execution nonce. Reusing a grant permits additional executions only within remaining limits. Grants never supersede one another: every unrevoked grant stays spendable within its own caps, so a principal who signs a second grant to the same delegate, for example to lower a cap, has added the new caps to the old until the old grantHash is revoked.

Encode type

The exact encodeType for SpendGrant is the following single string, including the concatenated AssetLimit definition and with no spaces:

SpendGrant(address principal,address delegate,uint8 recipientMode,address recipient,uint8 assetCombine,uint64 windowSeconds,AssetLimit[] assets,uint64 validAfter,uint64 validUntil,uint256 salt)AssetLimit(address asset,uint256 maxPerCall,uint256 maxPerWindow,uint256 maxTotal)

The AssetLimit encodeType is:

AssetLimit(address asset,uint256 maxPerCall,uint256 maxPerWindow,uint256 maxTotal)

SPENDGRANT_TYPEHASH is keccak256 of the SpendGrant encodeType bytes. ASSETLIMIT_TYPEHASH is keccak256 of the AssetLimit encodeType bytes.

Domain and digest

The domain uses exactly four fields: name = "SpendGrant", version = "1", chainId equal to the execution chain ID, and verifyingContract equal to the registry address. The domain MUST NOT include salt or additional fields. The registry MUST be deployed on the execution chain. A change of chain, registry, or executor requires a new grant and a new signature.

EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)
domainSeparator = keccak256(
    abi.encode(
        keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"),
        keccak256("SpendGrant"),
        keccak256("1"),
        chainId,
        registry
    )
)

String values are hashed as UTF-8 bytes. abi.encode above is the 32-byte ABI word encoding of each field.

Each AssetLimit element is hashed as:

hashStruct(AssetLimit) = keccak256(
    abi.encode(
        ASSETLIMIT_TYPEHASH,
        asset,
        maxPerCall,
        maxPerWindow,
        maxTotal
    )
)

The assets array hash is keccak256 of the concatenation of those 32-byte element hashes in signed order. There MUST NOT be an ABI offset, a length prefix, or any other padding between element hashes. That 32-byte array hash occupies the assets word in the primary struct hash.

hashStruct(SpendGrant) = keccak256(
    abi.encode(
        SPENDGRANT_TYPEHASH,
        principal,
        delegate,
        recipientMode,
        recipient,
        assetCombine,
        windowSeconds,
        assetsArrayHash,
        validAfter,
        validUntil,
        salt
    )
)

Atomic types are encoded as 32-byte ABI words (uint8 and uint64 are zero-extended). grantHash is the digest the principal signs:

grantHash = keccak256(0x1901 || domainSeparator || hashStruct(SpendGrant))

0x1901 is a two-byte prefix. Implementations MUST NOT substitute hashStruct(SpendGrant) or the domain separator for grantHash.

Structural validation

A grant is structurally valid only if every condition below holds. Unknown future values of recipientMode and assetCombine MUST fail closed. Zero MUST NOT be interpreted as unlimited.

  • principal and delegate are nonzero, and delegate != principal.
  • recipientMode is 0 or 1.
  • If recipientMode == 0, recipient is nonzero and recipient != principal.
  • If recipientMode == 1, recipient == address(0).
  • assetCombine == 0. Every other value is reserved for a future version of this specification and MUST be rejected.
  • windowSeconds > 0.
  • validAfter < validUntil.
  • assets.length is in 1 ..= 16.
  • assets are strictly ascending by uint160(asset) and contain no duplicate addresses.
  • For every asset: maxPerCall > 0, maxPerWindow > 0, maxTotal > 0, and maxPerCall <= maxPerWindow <= maxTotal.
  • No asset is address(0).
  • NATIVE MUST NOT be rewritten to a wrapped-token address and MUST NOT be required to have code. Whether a listed asset has code is checked at consume, for the asset being spent only (see Registry).

Caps

A single consume spends exactly one asset. amount MUST be greater than zero and MUST be less than or equal to that asset's maxPerCall.

Each listed asset has independent remaining window and lifetime. Spending one asset MUST NOT reduce another asset's remaining. For the selected asset, let windowSpent be the sum of unexpired debit amounts and lifetimeSpent be the sum of all debit amounts. consume MUST revert if windowSpent + amount > maxPerWindow or lifetimeSpent + amount > maxTotal. Equality is permitted and exhausts that cap. Implementations MUST evaluate these comparisons without overflow.

Rolling window

A debit recorded at timestamp s (the block.timestamp of the successful consume) counts in the window if and only if block.timestamp < s + windowSeconds in 256-bit arithmetic, with windowSeconds zero-extended. Equivalently, the debit counts while block.timestamp - s < windowSeconds when block.timestamp >= s, and it expires at age == windowSeconds. Midnight and UTC day boundaries have no effect. Lifetime remaining never resets.

Exact rolling requires timestamped debits. Views MUST recompute unexpired totals at the queried block and MUST NOT return a stale stored window counter.

An implementation MAY bound the number of live (unexpired) stored debits per (grantHash, asset). The reference bound is 1024. If a bound is in force and a further debit would exceed it, consume MUST revert. That bound is an implementation limit, not a signed call cap. Expired debits MAY be dropped from storage; they MUST remain excluded from window checks and rolling views. evict(grantHash, asset, maxCount) drops up to maxCount expired debits from storage for that asset and nothing else: anyone MAY call it, it MUST NOT change what any check or view returns, and an implementation that keeps no expired debits MAY make it a no-op. It lets a wallet or relayer pay for dropping a burst of expired debits in bounded pieces instead of leaving the whole cost on the next consume. This specification assumes block.timestamp does not decrease from one block to the next.

An implementation MAY also bound the stored width of a single debit amount. The reference stores amounts in 192 bits. If such a bound is in force, a consume whose amount exceeds it MUST revert with OVER_TX_CAP.

Signatures

The principal signs grantHash.

If the principal has no code at the evaluated block, the signature MUST be exactly 65 bytes r || s || v of secp256k1 over grantHash, with v equal to 27 or 28, s in the lower half of the curve order (s <= 0x7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF5D576E7357A4501DDFE92F46681B20A0), and recovered signer equal to principal.

If the principal has code at the evaluated block and that code is not an EIP-7702 delegation designator, the signature MUST be validated with ERC-1271 isValidSignature(grantHash, grantSignature) against execution-time state. The call MUST be made with STATICCALL: isValidSignature is declared view, and a static call rules out reentry into consume during validation. The call MUST return exactly 32 bytes equal to the 32-byte word 0x1626ba7e left-aligned (28 trailing zero bytes). A raw four-byte return, a revert, a malformed return, or any other value is invalid. Implementations MUST NOT fall back to ECDSA recovery for a code-bearing principal.

If the principal's code at the evaluated block is exactly 23 bytes beginning with 0xef0100 (an EIP-7702 delegation designator), the signature is valid if either (a) it satisfies the 65-byte secp256k1 rule above and recovers principal, or (b) the ERC-1271 call above succeeds against the delegated code. Implementations MUST try (a) first and MUST NOT require (b) when (a) holds.

A cached ERC-1271 success MUST NOT replace execution-time validation.

Canonical rendering

The canonical rendering is a plain-text form of a grant, derived entirely from the signed fields and the domain. It is not signed separately and carries no information the typed data does not. Any display, log, or record that presents a grant's terms as plain text MUST use exactly these bytes, so that two implementations show the same text for the same grant. Formatted displays, such as a wallet using an ERC-7730 descriptor, are not constrained by this section.

The rendering is ASCII (hence UTF-8), uses LF (0x0a) line endings, and contains exactly one trailing LF. It MUST NOT use CR, CRLF, a BOM, trailing spaces, or blank lines. Addresses are 0x followed by 40 lowercase hexadecimal digits (no mixed-case checksum). Unsigned integers are canonical decimal: 0, or a digit 1–9 followed by zero or more digits, with no sign, fraction, exponent, or leading zeros.

i in budget lines is the 0-based index in signed assets order. Budget lines are repeated for each asset in that order. Labels and spacing are exact:

Spend grant v1
Chain: {chainId}
Revocation registry: {revocationRegistry}
Principal: {principal}
Delegate: {delegate}
Recipient mode: {recipientMode}
Recipient: {recipient}
Asset combine: {assetCombine}
Window seconds: {windowSeconds}
Budget count: {n}
Budget {i} asset: {asset}
Budget {i} maximum per call (raw units): {maxPerCall}
Budget {i} maximum per window (raw units): {maxPerWindow}
Budget {i} maximum total (raw units): {maxTotal}
Valid after (inclusive Unix seconds): {validAfter}
Valid until (exclusive Unix seconds): {validUntil}
Salt: {salt}

The canonical rendering does not include the executor address. A wallet SHOULD also display executor() of {revocationRegistry} before signing, because only that address can call consume, and it authenticates the delegate and moves the principal's funds.

JSON interchange

The interchange object contains exactly chainId, revocationRegistry, and grant. grant contains exactly the SpendGrant fields: principal, delegate, recipientMode, recipient, assetCombine, windowSeconds, assets, validAfter, validUntil, salt. Each element of assets contains exactly asset, maxPerCall, maxPerWindow, maxTotal.

When hashing a grant from interchange JSON, implementations MUST use chainId as the domain chainId and revocationRegistry as verifyingContract.

Unsigned integers MUST be JSON strings in canonical decimal form as defined for rendering, and MUST fit the field width (uint8, uint64, or uint256). Addresses MUST be lowercase 0x plus 40 hex digits. Hex MUST use [0-9a-f] only.

Implementations MUST reject extra fields, missing fields, duplicate JSON member names (rejected before object conversion), JSON numbers in place of integer strings, uppercase hex, leading zeros other than the value 0, out-of-range values, wrong types, null, and trailing commas. Object property order and insignificant white space are not signed. Implementations MUST NOT use a JSON byte hash in place of grantHash.

Illustrative shape (values are examples, not vectors):

{
  "chainId": "1",
  "revocationRegistry": "0x1111111111111111111111111111111111111111",
  "grant": {
    "principal": "0x2222222222222222222222222222222222222222",
    "delegate": "0x3333333333333333333333333333333333333333",
    "recipientMode": "0",
    "recipient": "0x4444444444444444444444444444444444444444",
    "assetCombine": "0",
    "windowSeconds": "86400",
    "assets": [
      {
        "asset": "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee",
        "maxPerCall": "1000000000000000000",
        "maxPerWindow": "5000000000000000000",
        "maxTotal": "10000000000000000000"
      }
    ],
    "validAfter": "0",
    "validUntil": "1893456000",
    "salt": "1"
  }
}

Registry

The registry is immutable: no admin, no pause, no upgrade, no unrevoke, and no proxy. The executor address is set in the constructor to a nonzero value and NEVER changes. The principal binds that executor by signing this registry as verifyingContract.

interface ISpendGrantRegistry {
    event GrantRevoked(address indexed principal, bytes32 indexed grantHash);
    event GrantConsumed(
        bytes32 indexed grantHash,
        address indexed principal,
        address asset,
        uint256 amount,
        address indexed recipient
    );

    function executor() external view returns (address);
    function revoke(bytes32 grantHash) external;
    function revoked(address principal, bytes32 grantHash) external view returns (bool);
    function usage(bytes32 grantHash, address asset) external view returns (uint256 spent, uint256 calls);
    function rollingUsage(bytes32 grantHash, address asset) external view returns (uint256 spent, uint256 calls);
    function evict(bytes32 grantHash, address asset, uint256 maxCount) external;
    function liveDebits(bytes32 grantHash, address asset, uint256 maxCount)
        external
        view
        returns (uint256[] memory expiresAt, uint256[] memory amounts);

    function consume(
        SpendGrant calldata grant,
        bytes calldata grantSignature,
        address authorizer,
        address asset,
        uint256 amount,
        address recipient
    ) external;
}

revoke sets revoked[msg.sender][grantHash] = true. It is idempotent and MUST emit GrantRevoked only on the first change. Anyone MAY revoke in their own namespace. A revocation MUST NOT affect another principal. consume consults revoked[grant.principal][grantHash]. There is no unrevoke. GrantRevoked names the caller's namespace, not the grant's principal: grantHash is public once a grant is used, so anyone can emit GrantRevoked for any grant's hash from their own address. A grant is revoked only when revoked(grant.principal, grantHash) is true; a wallet, indexer, or relying party that reads GrantRevoked logs MUST take them only from the registry address and MUST treat one as revoking a grant only if its principal topic equals grant.principal.

executor() returns the immutable executor. consume MUST revert unless msg.sender == executor(). authorizer is the address the executor authenticated as authorizing this spend (see Executor); consume MUST revert unless authorizer == grant.delegate.

consume MUST validate, then record the debit, then emit GrantConsumed, then return. It MUST NOT move tokens, send native currency, or call the selected asset for transfer. Recording before return applies to storage of the debit; signature validation MAY call the principal under ERC-1271 before recording.

consume MUST revert unless all of the following hold, checked in this order, using the reason names in Reason names:

  1. msg.sender == executor (UNAUTHORIZED_EXECUTOR).
  2. authorizer == grant.delegate (UNAUTHORIZED_DELEGATE).
  3. The grant is structurally valid (INVALID_GRANT).
  4. The signature is valid for grant.principal over grantHash (BAD_SIGNATURE).
  5. block.timestamp >= grant.validAfter (NOT_YET_VALID).
  6. block.timestamp < grant.validUntil (EXPIRED).
  7. revoked[grant.principal][grantHash] is false (REVOKED).
  8. The recipient argument is nonzero and is not grant.principal, the registry, executor(), or asset, in either mode and even when a recipientMode == 0 grant signed one of those addresses, which makes such a grant fail closed; and if recipientMode == 0, the argument equals grant.recipient (WRONG_RECIPIENT).
  9. asset equals grant.assets[j].asset for exactly one j (WRONG_ASSET).
  10. If asset is not NATIVE, it has code at the evaluated block (EXTCODESIZE > 0), and that code is not an EIP-7702 delegation designator, which marks an account rather than a token (INVALID_GRANT). A listed asset that is not being spent is not checked, so a listing without code does not block the grant's other assets.
  11. amount > 0, amount <= grant.assets[j].maxPerCall, and amount is within any implementation amount bound (OVER_TX_CAP).
  12. The spend does not exceed the window cap as defined in Caps (OVER_WINDOW_CAP).
  13. The spend does not exceed the lifetime cap (OVER_CUMULATIVE_CAP).
  14. Recording the debit does not exceed the live-debit bound, if any (WINDOW_FULL).

On success the registry appends a timestamped debit for (grantHash, asset) with the consumed amount. calls counts successful consume executions for that (grantHash, asset) and is observation only; it is not a signed cap. GrantConsumed indexes grantHash, grant.principal, and recipient: a principal's wallet can find every spend against it, and the hashes of grants it has lost, by filtering on its own address, and a payee can watch for payments to itself; asset and amount are data. amount is what the executor requested from the asset, not what the payee received: a token that deducts a fee delivers less, and a payee that needs the delivered amount reads the asset's own Transfer event or its balance change.

View behavior:

  • revoked(principal, grantHash) is true if that principal has revoked that hash in this registry.
  • usage returns lifetime raw spent (sum of amount over every successful consume of that asset, including those that have left the window) and lifetime calls.
  • rollingUsage returns the same pair recomputed over unexpired debits only.
  • liveDebits(grantHash, asset, maxCount) returns the oldest min(maxCount, n) of the n unexpired debits for that asset at the queried block, in the order they were recorded. expiresAt[i] is that debit's recorded timestamp plus windowSeconds in 256-bit arithmetic, amounts[i] is its amount, and the two arrays have equal length; the debit counts against the window while block.timestamp < expiresAt[i]. When maxCount >= n, amounts sums to rollingUsage's spent and n equals its calls at the same block. Like rollingUsage, it MUST be recomputed at the queried block.

A wallet that displays a grant, before or after signing, SHOULD describe the window as a trailing window of windowSeconds seconds, not a calendar period; a plain-text display says so outside the canonical rendering rather than changing it. After signing, a wallet SHOULD also show, for each asset, how much can be spent now and when more becomes available, reading usage, rollingUsage, and liveDebits at the same block. Nothing can be spent before validAfter, at or after validUntil, or after the principal revokes the grant. Otherwise the total that can be spent now is the least of maxPerWindow minus rollingUsage's spent, maxTotal minus usage's spent, and, where the registry bounds live debits, the free slots (the bound minus rollingUsage's calls; the bound is 1024 in the reference) times the lesser of maxPerCall and any implementation amount bound, which is zero while the bound is reached. A single payment is further limited to maxPerCall, to any implementation amount bound (2^192 - 1 in the reference), and to what the executor can move from the principal: for NATIVE, the principal's native balance; for an ERC-20, the principal's balance and its allowance to the executor, both shared with every other grant that draws on them, and to any restriction the asset itself applies, such as a pause or a blocklist entry for the principal, the recipient, or the executor. Window spend frees up as each debit returned by liveDebits reaches its expiresAt, but only up to what maxTotal still allows and only before validUntil; lifetime spend never frees up. A wallet that helps a principal replace or tighten a grant SHOULD revoke the earlier grantHash in the same flow, and SHOULD show the total of all unrevoked grants to a delegate rather than the newest alone, because grants stack (see Grant object).

Reason names

These names are the normative vocabulary. Encoding of revert data is implementation-defined. consume returns with no reason on success. On failure it MUST revert and MUST correspond to exactly one name other than OK. OK denotes success for simulators; it is not a consume return value. A view helper that returns (bool, reason) is not part of ISpendGrantRegistry.

NameCondition
OKSuccess
INVALID_GRANTStructural validation failed, or the asset being spent is not NATIVE and has no code or is an EIP-7702 delegated account
BAD_SIGNATUREEOA recovery, EIP-7702 recovery, or ERC-1271 validation failed
NOT_YET_VALIDblock.timestamp < validAfter
EXPIREDblock.timestamp >= validUntil
REVOKEDPrincipal has revoked grantHash
WRONG_ASSETasset is not in grant.assets
WRONG_RECIPIENTrecipient is zero, the principal, the registry, the executor, or the asset being spent, or recipientMode == 0 and it is not the signed recipient
OVER_TX_CAPamount == 0, amount > maxPerCall, or amount exceeds an implementation amount bound
OVER_WINDOW_CAPTrailing-window cap would be exceeded
OVER_CUMULATIVE_CAPLifetime cap would be exceeded
WINDOW_FULLLive unexpired debit bound would be exceeded
UNAUTHORIZED_EXECUTORmsg.sender is not the immutable executor
UNAUTHORIZED_DELEGATEauthorizer is not grant.delegate

Executor

A conformant executor MUST call consume and perform the movement of amount of asset from grant.principal to recipient within one call to the executor, and that call MUST revert if consume fails or the movement fails, so that neither happens without the other. An executor cannot revert its caller's frame, and two separate calls to the executor that a caller joins in one transaction do not satisfy this. The movement MUST come from grant.principal's own balance; the executor MUST NOT fund it from its caller, its own balance, or any other account. Order of consume and movement is unspecified. A conformant executor MUST call consume only on a registry that it fixes at deployment or otherwise trusts by address, and MUST NOT take the registry from its caller, a grant, or any other call input; checking that a registry's executor() returns the executor's own address is not enough, because anyone can deploy a contract that does so and accepts every call. A conformant executor MUST NOT initiate any movement of any asset out of grant.principal, in any function, other than the one movement paired with a successful consume, and MUST NOT take a fee or other charge from the principal; such a charge would fall outside every cap the principal signed. The amount passed to consume MUST equal the amount the executor requests in that movement. The caps bound what the executor asks the asset to move, which for an ERC-20 is also what the principal's allowance to the executor bounds; what the asset's own code does with that request, such as charging a transfer fee or rounding a share-based balance, is the asset's behavior, not the executor's, and does not affect the executor's conformance (see Security Considerations).

Before calling consume, a conformant executor MUST authenticate who authorized this spend of amount of asset to recipient, and MUST pass the address that authentication produced as authorizer. Possession of grant and grantSignature is not authorization: both become public when the grant is first used. Whatever the mechanism, each successful consume MUST correspond to one authorization by the delegate that names that grant, asset, amount, and recipient, and an authorization MUST NOT produce more than one successful consume. Mechanisms include:

  • Direct call. The delegate calls the executor. authorizer is msg.sender.
  • Signed authorization. The delegate signs this spend and anyone may submit it. The signed message MUST include the grantHash of the grant passed to consume, which the executor computes itself, with the registry it trusts as verifyingContract, and never takes from the submitter, together with asset, amount, recipient, and a nonce. It SHOULD include a deadline and MUST be EIP-712 typed data whose verifyingContract is the executor, so that its digest is defined. The executor MUST validate the signature by the rules in Signatures, applied to the digest of that message in place of grantHash and to grant.delegate in place of the principal, MUST reject it at or after any deadline, and MUST record the nonce, not the signature bytes, in a namespace scoped to the authorizer or to grantHash, so that the authorization succeeds at most once. It MUST record the nonce before calling consume or moving funds, because either step can call out (ERC-1271, token hooks) and reenter the executor. It SHOULD check the nonce before validating the signature, so that a submitter who lost the race pays for a storage read rather than for the delegate's ERC-1271 call. It SHOULD let the delegate invalidate an unused nonce, and that invalidation MUST fail for a nonce that already succeeded or was already invalidated, so that a successful invalidation proves the authorization never ran and never will. authorizer is the signer the executor recovered, or, for a delegate with code, the address whose ERC-1271 validation succeeded.
  • Delegation framework. In an ERC-7710 redemption the delegate calls the delegation manager, the manager validates the delegation chain and runs the caveat hooks, and the delegator's account performs the execution. The executor is therefore called by that account, not by the manager and not by the redeemer, and the manager keeps no record of the redeemer that the executor could ask for; only the caveat enforcers on the chain are told who redeemed. Two shapes follow.
    • If the delegator's account is grant.delegate, this is a direct call: authorizer is msg.sender. Whatever drove that account to call, such as a sub-delegation it granted, is that account's own access control (see Security Considerations).
    • Otherwise the executing account MUST be grant.principal, and the executor can learn the redeemer only from a caveat enforcer that it trusts by address and that records the redeemer for it, scoped to this spend. The executor MUST reject any other caller on this path before consulting any record. The enforcer MUST accept hook calls only from the delegation manager it trusts; a record anyone could write would name any redeemer. It MUST write a record only for an execution that is a call to the executor's spend function in single-call default mode with no other mode bits set, and only when the delegator the manager reports to the hook is the grant's principal; a caveat that any other delegator attaches MUST be inert. The manager runs each caveat's hook once per delegation in the chain, with that delegation's own delegator, so without that rule a record would be left for an intermediate delegator, and that delegator, or anyone who can drive its account later in the same transaction, could spend again on the delegate's one redemption. It MUST key the record on the grantHash of the grant in that call's data, computed with the executor's trusted registry as verifyingContract, together with asset, amount, recipient, and the principal. The record MUST NOT be usable outside the transaction that wrote it; transient storage (EIP-1153) gives this directly. The enforcer MUST clear its record after the execution if the executor did not take it. The executor MUST look the record up under the grantHash it computes itself and under its caller, MUST clear it before calling consume or moving funds, so that one record produces at most one consume, and MUST treat a missing record as no authorization. authorizer is the recorded redeemer. The enforcer reports the redeemer as it is: a redemption by a sub-delegate records the sub-delegate, and a redemption of a delegation open to any delegate records whoever called; consume rejects both unless that address is grant.delegate.

An executor MUST NOT pass as authorizer an address it has not authenticated for this spend. When authentication produces nothing, the executor MUST revert rather than pass address(0) or any other placeholder. Passing grant.delegate is correct only when it is the address the executor just authenticated, for example the address whose ERC-1271 validation succeeded. This specification does not define account adapters, permission compilation, or which mechanism an executor uses.

For asset == NATIVE, movement is a native transfer of amount wei from grant.principal. For any other asset, movement is one call to the asset's ERC-20 transferFrom (or an equivalent transfer function) requesting exactly amount raw units from grant.principal to recipient; a revert, a false return, or a malformed return is a failed movement. The executor MUST NOT substitute a wrapped native token for NATIVE. An executor MUST NOT call consume with asset == NATIVE unless the same call moves amount wei out of grant.principal's balance, and MUST NOT pay a NATIVE spend from value supplied by its caller or from its own balance. An executor MAY read grant.principal's balance of the asset before and after the movement and revert if the decrease exceeds amount; it MUST NOT pass an observed balance change to consume in place of the requested amount.

Rationale

This proposal is the signed terms of a spend grant plus an on-chain remaining store. The typed object, rendering, and JSON are the terms. The registry is the v1 remaining store. Movement stays with an executor so existing ERC-20 contracts and native currency need not opt in.

ERC-7710 standardizes redeemDelegations and leaves _permissionContexts opaque, determined by the specific implementation. Granting a delegation is out of scope for that interface. This proposal is the terms those contexts may compile to. It is not listed in requires, because a remaining store and a typed grant are useful without that redemption bus.

ERC-7715 is wallet JSON-RPC wallet_requestExecutionPermissions. Its permission types are not an exhaustive list. A wallet may request a spend grant of this shape without this proposal claiming to extend 7715.

ERC-8226 is a regulated agent mandate: one asset per mandate, per-transaction and cumulative caps, a required compliance provider, venue-agnostic canExecute, and MandateReason codes. Freeze is not revoke. Covering this proposal's case would change that model rather than extend it. Its cumulative counter never resets, and changing a cap means revoking and re-issuing, so a trailing window has no place in its state. Only one mandate may be active per agent and principal, while a principal here may hold several grants to one delegate, each with its own hash and revocation. type(uint256).max means no limit there, while this proposal has no value that means unlimited; a cap set to the largest integer is only a large cap. grantMandate rejects a zero compliance provider, and general-purpose spend has none. Reason names in this proposal match ERC-8226 where the conditions coincide (OK, NOT_YET_VALID, EXPIRED, REVOKED, WRONG_ASSET, OVER_TX_CAP, OVER_CUMULATIVE_CAP).

A separate draft on bounded agent actions, still an open ERCs pull request at the time of writing, meters remaining of an opaque capabilityRoot. It does not enforce the capability. This proposal defines the capability. A future profile may store remaining in that draft's cursor; this registry is the v1 remaining store.

Discussion of asset-enforced spend on Magicians argued that general spend belongs at the account layer so existing ERC-20 contracts and native currency need not opt in, and that token hooks are for issuer-controlled assets. This proposal follows that split: the registry never moves funds, and tokens need not implement a spend hook.

A widely deployed smart-wallet spend permission (not an ERC) is one token with a recurring period allowance; its batch form signs several such single-token permissions at once. Each permission in a batch keeps its own period, which resets at a boundary. This proposal puts up to sixteen assets in one grant with one hash and one revocation, and uses a trailing lookback rather than a period reset.

Native currency uses the ERC-7528 address rather than address(0). ERC-7528 is Final and is the native identifier in some deployed spend-permission systems; other deployed wallet-permission systems use address(0) for native currency. This proposal uses the Final standard, and an adapter for a system that uses address(0) translates at its boundary. It also keeps address(0) available as an unambiguous "unset" value: a zero asset is always invalid, a zero recipient field only ever means "any" in recipientMode == 1, and a zero recipient argument to consume is always rejected.

EIP-7702 accounts are controlled by their key for as long as the key exists: the key can replace or clear the delegation at any time. Accepting that key's ECDSA signature therefore grants nothing the key does not already hold. That is true of rule (a); rule (b) accepts whatever the delegated code's ERC-1271 accepts, as for any contract principal (see Security Considerations). Without rule (a), a grant signed by an EOA would change validity when the account adopts, changes, or clears a delegation, and delegated code that lacks ERC-1271 (or requires a nested format such as ERC-7739) would silently invalidate every outstanding grant. Ordinary contract principals keep ERC-1271 only, because a contract's owner key is not the contract.

Trailing lookback: a UTC-day reset allows two full maxPerWindow spends across midnight. Expiry at age == windowSeconds is exact, independent of clock hour. 86400 is twenty-four hours, not "today". With a trailing window, spend frees up as each debit expires rather than at one reset time, so the caps and rollingUsage do not say when more becomes available; liveDebits returns each unexpired debit's expiry so a wallet can show it. The same list can be rebuilt from GrantConsumed logs and block timestamps, but a view answers from state at one block without log-range limits or reorg handling. maxCount bounds how many debits are returned; oldest-first truncation keeps the debits that free up soonest, so no offset is defined. It does not bound the cost of passing over expired debits the registry has not yet dropped: in the reference, after a full ring goes idle for longer than the window, every liveDebits and rollingUsage call reads up to 1024 slots (about 2.6 million gas) until a consume drops them.

assetCombine is reserved rather than removed. A shared budget across assets ("spend this much across A or B") is useful, but the only oracle-free form, dividing each spend by that asset's own cap, is not how principals budget; they budget in a unit of account, which needs a price reference this specification does not define. Keeping the field fixed at 0 means a later version can define another mode without changing encodeType, the type hash, the rendering, or the JSON shape, and fail-closed validation means registries built to this version reject such grants rather than misread them.

consume is restricted to an immutable executor because a public debit function would let any caller fill the window, exhaust caps, or grief WINDOW_FULL. The principal selects that executor by choosing the registry. The executor, not the registry, authenticates the delegate: the registry's caller is always the executor, and delegates authorize in different ways (a direct call, a signed authorization that someone else relays, a redemption through a delegation framework such as ERC-7710). The registry still requires the executor to name the address its authentication produced and rejects anything but grant.delegate. That catches an executor that authenticates a caller or signer but omits the comparison with the grant's delegate; it does not catch one that passes grant.delegate without authenticating anyone. It also gives conformance tests a value to check, and it is what does the filtering on the delegation-framework path: a caveat enforcer reports whoever redeemed, a sub-delegate or the caller of a delegation open to any delegate included, and the registry's comparison with grant.delegate rejects them. The record such an enforcer writes is bound to the exact spend and to the executing account, cleared on use, and confined to one transaction, because the executor's caller on that path is the delegator's account, and a record that any call in the transaction could read, or that outlived a failed try-mode execution, would be a bearer authorization again. Verifying a per-spend delegate signature in the registry was not chosen: it would impose one mechanism on every executor and add replay-protection storage to the core, and it fits better as an executor profile.

The caps bound the amount the executor requests, not the change in the principal's balance, because the request is the only quantity every executor controls and the same quantity the ERC-20 allowance bounds. A mandatory balance check was considered and not taken: it costs two balance reads on every spend, it rejects legitimate spends on tokens whose share-based accounting can debit a unit more than requested, and an elastic-supply balance changes with no transfer at all, so a balance change has no stable meaning for such a token. Every conformant executor therefore gives the same guarantee, a bound on requested amounts, and one that adds the optional check only rejects more.

consume rejects a payee of zero, the principal, the registry, or the executor in both modes. A zero payee burns or misattributes funds on tokens that allow it, a payment to the principal spends cap and allowance on a no-op, and the registry and the executor have no way to release what they receive. Code is checked only for the asset being spent: the check exists so that no debit is recorded against an address that plainly cannot be a token, which only the spent asset needs, while checking every listing on every spend would cost a cold account access per asset and let one mistaken address block the whole grant. The check excludes addresses with no code and EIP-7702 delegated accounts; code that accepts any call, such as a proxy whose implementation has no code or a contract with an empty fallback, passes it and records a debit that moved nothing, so a wallet SHOULD confirm that a listed asset answers balanceOf and decimals before the principal signs. GrantConsumed indexes the principal and the recipient as well as the hash, because a principal's wallet has to find spends, and the hashes of grants it has lost, by its own address, and a payee has to watch for payments to itself; filtering by asset is an indexer convenience that can be done off chain, so asset is data.

The grant carries no hash of its rendering. The rendering is a function of the signed fields and the domain, so a hash of it would commit to nothing the signature does not already cover, and a wallet displays what it derives from the fields in any case. The canonical text is kept so that every text display of a grant is byte-identical. An application that needs to bind a grant to something outside it, such as a parent grant or an off-chain terms document, can derive salt from a commitment to that data, for example salt = uint256(keccak256(abi.encode(parentGrantHash, index))), without a change to this specification. A salt derived this way is public once the grant is used: anyone who knows parentGrantHash can recompute it for each small index and link the child grant to its parent, whatever addresses the two use, and a commitment to a document that can be guessed can be confirmed the same way. Where that link must not be public, the commitment also includes a random value kept by the parties who need to check the binding, for example keccak256(abi.encode(parentGrantHash, index, blinding)). Sorted unique assets make the typed-data encoding canonical and prevent two disagreeing limits for one address. Fail-closed enumerations mean a future recipientMode == 2 is invalid to old registries rather than silently treated as "any". No unrevoke: a principal who wants to spend again signs a new salt.

A non-normative ERC-7730 descriptor for the reference deployment is in spend-grant.json. It lets a wallet show the delegate, recipient, window, each asset's caps in that asset's units, and the validity dates, and it rejects a grant whose assetCombine is not 0. A descriptor is bound to specific registry deployments and cannot call executor(), so a wallet still has to look up and show the executor itself.

The live-debit bound exists because exact rolling cannot be a single counter. Because block.timestamp does not decrease, debits are appended in timestamp order and expired debits are always the oldest ones, so an implementation can keep a running window sum and drop expired debits from the front before each check. The reference does this with a fixed ring of 1024 one-word debits (64-bit timestamp, 192-bit amount), so the cost of a consume does not grow with the number of live debits and slots are reused once the ring wraps. The cost does grow with the number of expired debits dropped in that call: after a full ring goes idle for longer than the window, the next consume drops up to 1024 entries at once. The next spend that succeeds pays that cost, about 2.7 million gas in the reference, and later spends return to the flat cost. A spend with too little gas, or whose movement fails, reverts without dropping anything, and the trigger is not only an idle ring: any burst of debits recorded close together expires together, so a gas estimate made just before that moment falls short by the cost of each expired debit. evict exists for this: anyone can drop expired debits in bounded pieces beforehand, and a relayer estimating a spend should allow for the debits whose expiresAt, from liveDebits, falls before the expected inclusion time. 1024 is a reference storage bound, not a signed maxCalls. usage.calls is observational for the same reason.

Backwards Compatibility

This proposal adds a new typed-data domain, JSON shape, and registry interface. It does not change consensus, EIP-712, ERC-1271, or ERC-20 token behavior. Existing tokens need not implement hooks. Native currency is identified by the ERC-7528 address, as in other ERC-7528 systems; an executor MUST NOT treat address(0) as native.

Test Cases

Golden vectors are in v1.json. They include domain separator, struct hash, digest, canonical rendering bytes, and an EOA signature. An implementation of the Specification reproduces those values. No additional requirements are defined here.

Reference Implementation

A non-normative Solidity reference lives under the assets directory. It is an aid to implementers. It includes a minimal executor that the delegate calls, an executor that also accepts delegate-signed authorizations submitted by anyone, an executor that also accepts ERC-7710 redemptions, and the caveat enforcer whose records that executor reads, with tests that drive them through a manager of that shape. The Specification is authoritative. Hashing, rendering, JSON interchange, validation, caps, and rolling expiry are implementable from this document without those sources.

Security Considerations

For ERC-20 assets, the principal's allowance to the executor is the authority that actually lets funds move; the grant bounds how the executor uses it. A principal SHOULD keep that allowance no higher than the sum of remaining budgets of outstanding grants on that executor, and wallets SHOULD show the allowance next to the grants it backs. An executor with a larger allowance can move more than any grant permits if the executor is faulty.

Two asset entries can denote the same underlying balance, for example a chain whose native currency is also exposed through an ERC-20 interface at a different decimal scale. A grant that lists both has two independent budgets over one balance. Issuers SHOULD list only one identifier for such an asset; wallets SHOULD warn when a grant lists a known alias pair.

A chain's JSON-RPC can report native amounts at a different scale from its EVM: Hedera's EVM counts tinybars (8 decimals) while its JSON-RPC relay reports 18. A NATIVE cap is in the EVM unit, so a cap computed at the RPC scale there authorizes 10^10 times what was meant, and a wallet that formats it with the RPC's decimals shows the smaller figure.

The registry never moves funds. Safety of the principal's assets depends on the executor calling consume and moving funds within one call that reverts if either step fails. A dishonest executor that the principal bound by signing that registry can move value without a matching debit, or debit without moving. Choosing a registry is choosing an executor. Wallets that omit executor() from the pre-sign display hide that binding.

The registry fixes the executor's address, not its code. An executor that is an upgradeable proxy, has an owner or admin function, is an externally owned account, or is an EIP-7702 delegated account can change what it does after the principal signs, and whoever controls it holds every principal's approval to it; the same applies to any contract the executor trusts by address, such as a delegation manager or the caveat enforcer whose records it reads. For NATIVE, the account adapter's authority to the executor plays the role of the ERC-20 allowance, and nothing caps it unless the adapter does. An executor SHOULD have no admin and no upgrade path, and a wallet SHOULD show, with executor(), whether that address has code, is an EIP-7702 delegation designator, or is a proxy it recognizes. The executor in turn relies on its registry: the registry's signature, cap, and revocation checks are all that stand between a principal's approval and a grant the principal never signed, so an executor that trusts several registries exposes every principal's approval to the weakest of them.

Grants are not private. A grant's terms (principal, delegate, recipient, assets, caps, window, and validity) become public in calldata when it is first used, and every later spend is linked to it by grantHash through GrantConsumed and registry state: asset, amount, recipient, and time. Anyone can therefore follow a delegate's spending under a grant, see whom it pays, and compute what remains and when more frees up; usage, rollingUsage, and liveDebits only read what is already public. salt distinguishes grants but hides nothing, and a salt derived from public data, such as a parent grant's hash (see Rationale), links the grant to that data. A principal who needs payments not to be linkable to one another uses separate grants and, where the principal or delegate address itself links them, separate addresses. Revocations are public as well.

A grant and its signature are not secret. They are calldata on the grant's first use, visible in the mempool before that, and usually handed to the delegate off-chain. The registry sees only the executor as its caller, so the delegate binding is exactly as strong as the executor's authorization check; authorizer catches an executor that authenticates a caller or signer but omits the comparison with grant.delegate; it does not catch one that passes grant.delegate without authenticating anyone. An executor that treats possession of a grant as authority lets anyone who has seen the grant spend under it:

recipientModeWhat a third party can do with a copied grant
0Pay the signed recipient at times the delegate did not choose (if the third party is the signed recipient, it is paying itself), exhaust the window and lifetime caps, or fill the live-debit bound with minimal debits
1Send the remaining budget to itself

An executor that lets one delegate-signed authorization succeed more than once lets anyone who has seen it repeat that payment, to the recipient the delegate named, until the caps are exhausted: the recipientMode 0 exposure above, whatever the grant's mode. An unused delegate authorization without a deadline stays usable until the grant expires or is revoked; the delegate cannot withdraw it except through the executor.

A submitter of a signed authorization forwards gas to three contracts it does not control, the delegate's ERC-1271, the principal's ERC-1271, and the asset, all evaluated at inclusion rather than at simulation. A relayer SHOULD cap the gas it forwards, simulate against the block it expects inclusion in, or relay only for parties it trusts. Invalidating a nonce is a call from the delegate's own address, so a delegate that can sign but cannot call relies on the deadline.

A signed authorization is public once broadcast, and one whose transaction reverted or was dropped is still unused: its calldata may be on chain and its nonce was not recorded, so anyone can submit it later, once it would succeed. A delegate that replaces a failed authorization with a new one for the same payment can pay the recipient twice unless it first invalidates the old nonce and confirms the old authorization did not execute; a short deadline bounds how long a failed authorization stays usable. Because anyone may submit it, anyone who sees it pending can submit it alone first, so a contract that submits an authorization inside a larger transaction and reverts when its nonce is already used fails after the payment has been made.

When the delegate is a contract, its own access control is the delegate binding. The executor authenticates the contract and the registry compares only its address, so a delegate contract that lets its caller choose the grant, amount, or recipient (for example one contract acting for many principals, a forwarder, or a contract with a general call function), or whose ERC-1271 accepts signers the principal did not intend, gives whoever can make it call the exposure in the table above. A principal who names a contract delegate relies on that contract's access control as it relies on the executor's. A delegate account that performs an ERC-7710 execution for a sub-delegate it appointed is this case: the executor sees the delegate account, consume accepts it, and the sub-delegation is that account's own policy. Such a delegation lets its redeemer make the account call the executor for any grant the account can exercise as delegate, not only the grants the account had in mind; a caveat that records the redeemer does not narrow this, because the record is not consulted when the caller is the delegate, so the account restricts the grant, asset, amount, or recipient with calldata caveats of its own.

On the delegation-framework path the record a caveat enforcer writes is the delegate binding, and the executing account is the principal. A record that any caller could write names any redeemer. A record not scoped to the exact spend, or not cleared on use, lets another call in the same transaction, such as a token hook in the recipient or a neighbouring execution in the same batch, spend as the recorded redeemer. A record for any account other than the principal is an opening for that account: the manager runs each caveat's hook with its own delegation's delegator, so a middle delegator that places the caveat on its own hop, and attaches an enforcer of its own that drives its account into the executor during the hooks, would turn the delegate's one redemption into as many spends as the caps allow, and a record left behind would serve anyone who can drive that account later in the transaction, which under account-abstraction bundles and multicalls includes other parties' calls. A record in ordinary storage can survive a try-mode execution that failed. The requirements in Executor exist for those cases. The executor trusts the enforcer by address, and through it the manager that calls the enforcer's hooks, as it trusts its registry.

One executor serves every principal that signs its registry, and those principals approve the executor itself. An executor that moved funds from any account other than grant.principal could therefore spend one principal's balance under another principal's grant; the movement source is fixed to grant.principal in Executor for that reason. The executor is also the ERC-20 spender for all of them, so a token that restricts the spender can stop every grant for that token on that registry at once; USDC's FiatToken transferFrom, for example, rejects a blocklisted msg.sender. The registry and executor cannot be changed, so resuming means new grants on a new registry.

consume does not inspect balances: it records the amount the executor requested, and the caps bound requested amounts, as the principal's allowance does. Three kinds of ERC-20 make the principal's balance change differ from recorded spend. A token that charges the sender a fee on top of the requested amount lets the principal's balance fall by more than the caps over the grant's life, by the fee; a conformant executor is not required to detect this, because the spend it requested was within the caps. A token that deducts a fee from the delivered amount, the common fee-on-transfer pattern, debits the principal exactly the requested amount and short-pays the recipient, which does not touch the caps. An elastic-supply token changes the principal's balance with no transfer at all. A wallet SHOULD warn when a listed asset is known to charge transfer fees or to rebase, and SHOULD say that the caps are raw units requested from the token, not balance changes. An executor that adds the optional balance check in Executor protects the principal from the first kind, at the cost of two balance reads per spend and of rejecting spends on tokens whose rounding can debit a unit more than requested. This concerns ERC-20 assets: a native transfer of amount wei is exact, and the authority of a NATIVE account adapter is covered above.

Revocation is per principal and permanent, and takes effect only when the revoke transaction is included; it does not pause the executor. A delegate that sees a pending revoke in a public mempool, or anyone holding an unused authorization from that delegate, can place spends ahead of it, up to what is spendable at that moment in each asset (see Registry); on a rollup whose sequencer censors or halts, inclusion can wait for the rollup's forced-inclusion delay while window spend keeps freeing up. Lowering the executor allowance instead has the same exposure and affects every grant on that executor, and a delegate invalidating an unused authorization nonce is in the same position. Sending revoke through a channel that does not expose pending transactions removes the warning; it does not stop spends already pending. A grant reserves nothing: until a spend is included, the principal can revoke, lower the allowance, or move the balance, and other spends or grants can use what the views showed, so a recipient that delivers before the spend is included carries that risk. There is no admin to freeze a stolen delegate; the principal revokes hashes they signed, and remaining caps bound a stolen delegate until then.

Revocation needs the grant. revoke takes grantHash, and a grant that has never been used appears nowhere on chain, so a principal who no longer has the signed terms cannot compute the hash; there is no revoke-all. A grant that has been used can be found again: GrantConsumed indexes the principal, and the calldata of that use holds the terms. A wallet SHOULD keep chainId, revocationRegistry, and the grant for every grant it signs, and SHOULD let the principal revoke from that record. revoke accepts any value, so a revocation of a wrongly computed hash succeeds and emits GrantRevoked while the grant stays live; a wallet SHOULD confirm a revocation by simulating consume from executor() with the stored grant and expecting REVOKED. revoke is also a call from the principal's own address, so a principal with no native currency for gas and no account that can relay the call cannot revoke, and a contract principal that can sign but cannot make calls has no revocation path, since there is no signed revocation. On a chain whose native currency is also an ERC-20, such as USDC on Arc, a grant over that ERC-20 spends the principal's gas balance, and spending it to the cap can leave the principal unable to pay for revoke; wallets SHOULD warn and suggest caps that leave a gas reserve. Setting the principal's ERC-20 allowance to the executor to zero, or disabling a NATIVE account adapter, stops every grant on that executor at once; a contract principal can also change what its ERC-1271 accepts, which invalidates every outstanding grant it signed that way (see above).

Code-bearing principals other than EIP-7702 accounts are ERC-1271 only. An implementation that falls back to ECDSA when isValidSignature fails would treat a contract with an owner key as that key. The EIP-7702 exception is safe only because the designator identifies an account whose key already controls it; implementations MUST match the exact 23-byte designator rather than any code that begins with 0xef. ERC-1271 is evaluated at execution, so a principal that rotates its validation logic can invalidate outstanding grants without the registry's help. An EIP-7702 principal cannot invalidate grants its key signed by changing its delegation; it revokes them.

For a contract principal, the account's ERC-1271 policy is its grant-signing policy. Every signer the account accepts, whether an owner key, a session key, a passkey, or a guardian, can sign a grant naming any delegate, with any caps and recipientMode == 1, and that grant draws on every allowance the principal has already given the executor. The same holds for an EIP-7702 account under rule (b), where the delegated code's ERC-1271 decides. An account that scopes session keys SHOULD keep the SpendGrant typed-data domain, like any domain outside the key's purpose, out of what those keys may sign; a principal's exposure through such a key is bounded only by the allowance.

The 1024-live-debit bound (or any similar bound) is a grief surface: many small in-window consumes can fill the window and force WINDOW_FULL until the oldest debit expires. Only the executor can call consume, and a conformant executor spends only with the delegate's authorization, so the grief requires the delegate itself or a non-conformant executor. This is an intentional fail-closed behavior.

block.timestamp is the chain's clock, not wall-clock time; window expiry, validAfter, validUntil, and the expiresAt values from liveDebits are all measured on it. On Ethereum the slot fixes it; on some rollups the sequencer sets it within bounds that are a per-chain setting (Arbitrum One currently allows a block to be stamped up to 86400 seconds behind or 768 seconds ahead of the L1 clock, and some ZK rollups allow days behind), so a trailing window is exact in block time but only as close to real time as the chain keeps its clock. A lagging clock can also stall at its bound and then jump: a spend force-included during a sequencer outage keeps the earlier time, so a window no shorter than the lag can be spent twice within a short real-time span, and validUntil and authorization deadlines can still pass up to the lag after their real-time moment. Short windowSeconds values are the most exposed; lifetime caps are not affected. Blocks that share a timestamp, as on chains with sub-second blocks, are one instant for every rule here.

Reentrant consume during a token hook in the executor's movement can apply several per-call amounts in one transaction if remaining allows; the static ERC-1271 call rules out reentry during signature validation. The per-call cap limits each call, not the transaction. Registry authors who want a single debit per transaction add their own lock; this specification does not.

The registry cannot be paused or upgraded. A bug in remaining arithmetic is permanent for that deployment. A new registry is a new domain and requires new signatures. A registry MUST NOT be redeployed at an address a previous registry occupied on a chain whose state was reset but kept its chain ID: the domain would be the same while revocations and usage are gone, so every grant ever signed for it, including revoked ones, would be valid again.

Salt reuse with identical terms is the same grant. It does not reset remaining. Distinct grants need a distinct salt or a distinct field.

Copyright and related rights waived via CC0.

EIP.tools

EIP.tools

Search, read, and map Ethereum improvement proposals, ERCs, RIPs, and CAIPs from one focused interface.

Farcaster
by @apoorveth