--- eip: 8427 title: Portable Spend Grants description: Signed multi-asset spend grants with rolling and lifetime caps author: Chris Madison (@tankcdr) discussions-to: https://ethereum-magicians.org/t/erc-8427-portable-spend-grants/29776 status: Draft type: Standards Track category: ERC created: 2026-09-17 requires: 20, 712, 1271, 7528, 7702 --- ## 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](./eip-7528.md) address; every other asset is an [ERC-20](./eip-20.md) contract. The principal signs an [EIP-712](./eip-712.md) 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` in the same transaction as the value movement. Contract principals validate signatures with [ERC-1271](./eip-1271.md); [EIP-7702](./eip-7702.md) 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](./eip-20.md) 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](./eip-712.md) 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](./eip-20.md), 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)). - **Executor:** the immutable address returned by the registry `executor()` function. Only this address may call `consume`. - **Recipient:** the payee of a single `consume`. - **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](./eip-7528.md) address `0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE`, which denotes the execution chain's native currency. ### Grant object The exact types, field order, and integer widths are: ```solidity 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. ### 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)`. - At `consume` execution, every asset other than `NATIVE` MUST have code at the evaluated block (`EXTCODESIZE > 0`). `NATIVE` MUST NOT be rewritten to a wrapped-token address and MUST NOT be required to have code. ### 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 `amount`s and `lifetimeSpent` be the sum of all debit `amount`s. `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. 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](./eip-1271.md) `isValidSignature(grantHash, grantSignature)` against execution-time state. 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](./eip-7702.md) 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](./eip-1271.md) 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](./eip-7730.md) 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): ```json { "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`. ```solidity interface ISpendGrantRegistry { event GrantRevoked(address indexed principal, bytes32 indexed grantHash); event GrantConsumed( bytes32 indexed grantHash, address indexed 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 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](#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](#reason-names): 1. `msg.sender == executor` (`UNAUTHORIZED_EXECUTOR`). 1. `authorizer == grant.delegate` (`UNAUTHORIZED_DELEGATE`). 1. The grant is structurally valid, including the execution-time code check on nonzero assets (`INVALID_GRANT`). 1. The signature is valid for `grant.principal` over `grantHash` (`BAD_SIGNATURE`). 1. `block.timestamp >= grant.validAfter` (`NOT_YET_VALID`). 1. `block.timestamp < grant.validUntil` (`EXPIRED`). 1. `revoked[grant.principal][grantHash]` is false (`REVOKED`). 1. If `recipientMode == 0`, the `recipient` argument equals `grant.recipient`; if `recipientMode == 1`, this check does not constrain the argument (`WRONG_RECIPIENT`). 1. `asset` equals `grant.assets[j].asset` for exactly one `j` (`WRONG_ASSET`). 1. `amount > 0`, `amount <= grant.assets[j].maxPerCall`, and `amount` is within any implementation amount bound (`OVER_TX_CAP`). 1. The spend does not exceed the window cap as defined in [Caps](#caps) (`OVER_WINDOW_CAP`). 1. The spend does not exceed the lifetime cap (`OVER_CUMULATIVE_CAP`). 1. 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. 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 lesser of `maxPerWindow` minus `rollingUsage`'s `spent` and `maxTotal` minus `usage`'s `spent`, and is zero while the registry's live-debit bound, if any, is reached (in the reference, while `rollingUsage`'s `calls` is 1024). A single payment is further limited to `maxPerCall` 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. ### 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`. | Name | Condition | | --- | --- | | `OK` | Success | | `INVALID_GRANT` | Structural validation failed, including a nonzero asset with no code | | `BAD_SIGNATURE` | EOA recovery, EIP-7702 recovery, or ERC-1271 validation failed | | `NOT_YET_VALID` | `block.timestamp < validAfter` | | `EXPIRED` | `block.timestamp >= validUntil` | | `REVOKED` | Principal has revoked `grantHash` | | `WRONG_ASSET` | `asset` is not in `grant.assets` | | `WRONG_RECIPIENT` | `recipientMode == 0` and `recipient` is not the signed recipient | | `OVER_TX_CAP` | `amount == 0`, `amount > maxPerCall`, or `amount` exceeds an implementation amount bound | | `OVER_WINDOW_CAP` | Trailing-window cap would be exceeded | | `OVER_CUMULATIVE_CAP` | Lifetime cap would be exceeded | | `WINDOW_FULL` | Live unexpired debit bound would be exceeded | | `UNAUTHORIZED_EXECUTOR` | `msg.sender` is not the immutable executor | | `UNAUTHORIZED_DELEGATE` | `authorizer` is not `grant.delegate` | ### Executor A conformant executor MUST call `consume` in the same transaction as the movement of `amount` of `asset` from `grant.principal` to `recipient`, and MUST revert the whole transaction if `consume` fails or the movement fails. 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 move any asset out of `grant.principal`, in any function, other than the movement of `amount` paired with a successful `consume`; a fee or other charge taken from the principal falls outside every cap the principal signed. 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 SHOULD be [EIP-712](./eip-712.md) typed data whose `verifyingContract` is the executor. The executor MUST validate the signature by the rules in [Signatures](#signatures), applied 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 let the delegate invalidate an unused nonce. `authorizer` is the signer the executor recovered, or, for a delegate with code, the address whose ERC-1271 validation succeeded. - **Delegation framework.** A delegation framework contract that the executor trusts by address, for example an [ERC-7710](./eip-7710.md) delegation manager or a caveat enforcer it calls, reports the redeemer it authenticated. `authorizer` is that redeemer. A redemption by a sub-delegate reports the sub-delegate, and `consume` rejects it. An executor MUST NOT pass as `authorizer` an address it has not authenticated for this spend. 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 an ERC-20 transfer of `amount` raw units from `grant.principal`. The executor MUST NOT substitute a wrapped native token for `NATIVE`. An executor MUST NOT call `consume` with `asset == NATIVE` unless the same transaction 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. ## 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](./eip-7710.md) 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](./eip-7715.md) 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](./eip-8226.md) 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 unlimited value. `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](./eip-7528.md) 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, and a zero recipient only ever means "any" in `recipientMode == 1`. 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. Without that rule, 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](./eip-7739.md)) 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](./eip-7710.md)). 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. 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 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](./eip-7730.md) descriptor for the reference deployment is in [`spend-grant.json`](../assets/eip-8427/clear-signing/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. The drop cannot be split across transactions: a spend with too little gas, or whose movement fails, reverts without dropping anything, and the next attempt pays in full. 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](./eip-712.md), [ERC-1271](./eip-1271.md), or [ERC-20](./eip-20.md) token behavior. Existing tokens need not implement hooks. Native currency is identified by the [ERC-7528](./eip-7528.md) address, as in other ERC-7528 systems; an executor MUST NOT treat `address(0)` as native. ## Test Cases Golden vectors are in [`v1.json`](../assets/eip-8427/vectors/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](../assets/eip-8427/). It is an aid to implementers. 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](./eip-20.md) 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` in the same transaction as movement and reverting 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 framework. 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](#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: | `recipientMode` | What a third party can do with a copied grant | | --- | --- | | `0` | Pay 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 | | `1` | Send 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 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. 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](#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` is not payable-for-value and does not inspect balance deltas. Fee-on-transfer, elastic-supply, or malicious ERC-20 tokens can make the recorded `amount` differ from the principal's balance change. That is an executor and token-selection problem; the remaining store tracks the `amount` argument. 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](#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 wallet SHOULD keep `chainId`, `revocationRegistry`, and the grant for every grant it signs, and SHOULD let the principal revoke from that record. `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. 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 and is the only bulk stop. 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. 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 wide bounds (on Arbitrum chains, up to 24 hours behind or one hour ahead of real time), so a trailing window is exact in block time but only as close to real time as the chain keeps its clock. 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 an ERC-1271 callback, or during a later token hook in the executor's movement, can apply several per-call amounts in one transaction if remaining allows. 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. 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 Copyright and related rights waived via [CC0](../LICENSE.md).