--- eip: 8248 title: Keyed Transfer With Authorization description: ERC-20 transfer authorization with two-dimensional sequential nonces author: Brian Bland (@brianbland) discussions-to: https://ethereum-magicians.org/t/erc-8248-keyed-transfer-with-authorization/28434 status: Draft type: Standards Track category: ERC created: 2026-04-22 requires: 20, 712, 1271 --- ## Abstract This ERC defines a signed transfer authorization scheme for [ERC-20](./eip-20.md) tokens that uses a two-dimensional nonce: a 192-bit `nonceKey` selects an independent sequence, and a 64-bit `nonceSequence` is consumed sequentially within that sequence. The encoding matches [ERC-4337](./eip-4337.md)'s "Semi-abstracted Nonce Support" exactly. Signatures are passed as `bytes` and validated through [ERC-1271](./eip-1271.md), giving both EOAs and smart-contract accounts first-class support. It addresses the same use case as [ERC-3009](./eip-3009.md) "Transfer With Authorization" — gasless meta-transaction transfers signed off-chain — but is optimized for repeat-counterparty access patterns. When a payer authorizes many transfers to the same counterparty (e.g. a buyer agent paying a single seller), all of those authorizations consume one storage slot per `(payer, key)` pair instead of one slot per authorization. This reduces persistent storage growth by orders of magnitude for the repeat-counterparty patterns now common in agentic and machine-to-machine payments. ## Motivation ERC-3009's random `bytes32` nonces require one persistent storage slot per authorization, with no opportunity for reuse. This works well for one-shot transfers but accumulates storage indefinitely under high-frequency repeat-counterparty patterns. Two access patterns are observed in practice: - **Loyal-customer**: a single payer issues many authorizations to a small set of counterparties. - **Explorer**: a single payer (typically a marketplace or aggregator contract) issues authorizations across many distinct counterparties. An analysis of 60 days of USDC `transferWithAuthorization` traffic on Base (4,403,045 authorization events across 80,374 unique authorizers) shows that the loyal-customer pattern strongly dominates among high-volume payers: **17 of the top 20 authorizers** paid a small fixed set of counterparties, with 13 of those paying exactly one counterparty. The top authorizer issued 81,731 authorizations to a single payee over the period. Three authorizers followed the explorer pattern, paying thousands of distinct counterparties. For the loyal-customer pattern, two-dimensional sequential nonces collapse storage to one slot per `(payer, counterparty)` pair: | Scheme | Slots (81,731 authorizations, 1 counterparty) | vs. ERC-3009 | | --- | --- | --- | | ERC-3009 (random `bytes32`) | 81,731 | 1× | | Bitmap (256 nonces per slot) | 320 | 256× | | Keyed sequential (this ERC) | 1 | 81,731× | Across all 17 loyal-customer buyers in the sample, keyed sequential nonces require fewer than 60 total nonce storage slots for over 780,000 authorizations — compared to over 2,800 slots for a bitmap scheme. For the explorer pattern a bitmap is a better match, and implementations MAY expose a bitmap entry point alongside this one. Specifying both schemes in a single ERC was considered and rejected as overscoped; this ERC standardizes only the keyed sequential entry point. The `(uint192, uint64)` partition is identical to the encoding ERC-4337 uses for UserOperation nonces (see its "Semi-abstracted Nonce Support" section), and is a natural fit for any payer that holds independent payment relationships with multiple counterparties. The 192-bit key is wide enough to hold a counterparty address (160 bits) with 32 bits of subkey for channel separation; payers SHOULD use the upper 32 bits as a channel identifier to keep distinct agents acting under the same payee on independent counters. ## 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. ### Storage A compliant contract MUST maintain a mapping from `(authorizer, nonceKey)` to the next expected sequence value: ```solidity mapping(address => mapping(uint192 => uint64)) internal _nonceSequences; ``` For any `(authorizer, nonceKey)` pair never used, the value MUST be `0`. The first authorization signed under a new key therefore uses `nonceSequence = 0`. ### Events ```solidity event KeyedAuthorizationUsed( address indexed authorizer, uint192 indexed nonceKey, uint64 indexed nonceSequence ); ``` The OPTIONAL cancellation event: ```solidity event KeyedAuthorizationCanceled( address indexed authorizer, uint192 indexed nonceKey, uint64 indexed nonceSequence ); ``` ### Type hashes Constant type hashes for the no-witness entry points: ```solidity bytes32 public constant TRANSFER_WITH_KEYED_AUTHORIZATION_TYPEHASH = keccak256( "TransferWithKeyedAuthorization(address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,uint192 nonceKey,uint64 nonceSequence)" ); bytes32 public constant RECEIVE_WITH_KEYED_AUTHORIZATION_TYPEHASH = keccak256( "ReceiveWithKeyedAuthorization(address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,uint192 nonceKey,uint64 nonceSequence)" ); // OPTIONAL bytes32 public constant CANCEL_KEYED_AUTHORIZATION_TYPEHASH = keccak256( "CancelKeyedAuthorization(address authorizer,uint192 nonceKey,uint64 nonceSequence)" ); ``` Type hash **stubs** for the witness-bound entry points. The full type hash is computed at runtime by appending the caller-supplied `witnessTypeString` (see *Witness-bound entry points* below): ```solidity string internal constant TRANSFER_WITH_KEYED_AUTHORIZATION_AND_WITNESS_TYPEHASH_STUB = "TransferWithKeyedAuthorizationAndWitness(address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,uint192 nonceKey,uint64 nonceSequence,"; string internal constant RECEIVE_WITH_KEYED_AUTHORIZATION_AND_WITNESS_TYPEHASH_STUB = "ReceiveWithKeyedAuthorizationAndWitness(address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,uint192 nonceKey,uint64 nonceSequence,"; ``` Each type hash binds the full parameter set including the split `(uint192 nonceKey, uint64 nonceSequence)`, and the witness-bound variants additionally bind a trailing `bytes32 witness` field. A digest signed under one of these type hashes cannot be reinterpreted as a digest under any type hash with a different parameter shape; this provides structural isolation between the no-witness and witness variants and between this ERC and any other signed-authorization scheme a token contract might also implement. ### Functions ```solidity /** * @notice Returns the next expected nonce for an (authorizer, nonceKey) pair, * packed in the ERC-4337 form: (uint256(nonceKey) << 64) | nonceSequence. * @dev The low 64 bits hold the next expected `nonceSequence`; the high 192 * bits echo `nonceKey`. For an unused key, the return value equals * `uint256(nonceKey) << 64`. The next valid signed authorization under * that key must specify `nonceSequence` equal to the low 64 bits. */ function keyedAuthorizationNonce(address authorizer, uint192 nonceKey) external view returns (uint256); /** * @notice Execute a transfer with a signed keyed authorization. * @param from Payer's address (Authorizer) * @param to Payee's address * @param value Amount to be transferred * @param validAfter The time after which this is valid (unix time) * @param validBefore The time before which this is valid (unix time) * @param nonceKey Independent 192-bit sequence selector (e.g. payee address) * @param nonceSequence Sequential 64-bit counter within nonceKey * @param signature EOA ECDSA signature (`r ‖ s ‖ v`, 65 bytes) or any * opaque bytes accepted by `from`'s ERC-1271 * `isValidSignature` method. */ function transferWithKeyedAuthorization( address from, address to, uint256 value, uint256 validAfter, uint256 validBefore, uint192 nonceKey, uint64 nonceSequence, bytes calldata signature ) external; /** * @notice Receive a transfer with a signed keyed authorization from the payer. * @dev Requires `to == msg.sender` to prevent front-running of wrapper calls. * All other parameters as in transferWithKeyedAuthorization. */ function receiveWithKeyedAuthorization( address from, address to, uint256 value, uint256 validAfter, uint256 validBefore, uint192 nonceKey, uint64 nonceSequence, bytes calldata signature ) external; /** * @notice Execute a transfer with a signed keyed authorization that additionally * binds an opaque witness hash. The signature covers the standard * authorization parameters AND `witness`, allowing integrators to bind * an authorization to off-chain context (an order ID, an invoice, an * agentic task hash, etc.). * @param witness Caller-supplied hash of the witness struct. * @param witnessTypeString EIP-712 type definition fragment that closes the * parent type and appends the witness's full type * tree in alphabetical order. For a witness struct * `OrderContext(bytes32 orderId,address seller)` the * string is: * `"OrderContext witness)OrderContext(bytes32 orderId,address seller)"`. * All other parameters as in transferWithKeyedAuthorization. */ function transferWithKeyedAuthorization( address from, address to, uint256 value, uint256 validAfter, uint256 validBefore, uint192 nonceKey, uint64 nonceSequence, bytes32 witness, string calldata witnessTypeString, bytes calldata signature ) external; /** * @notice Receive a transfer with a signed keyed authorization that additionally * binds an opaque witness hash. * @dev Requires `to == msg.sender` to prevent front-running of wrapper calls. * All other parameters as in the witness-bound transfer overload above. */ function receiveWithKeyedAuthorization( address from, address to, uint256 value, uint256 validAfter, uint256 validBefore, uint192 nonceKey, uint64 nonceSequence, bytes32 witness, string calldata witnessTypeString, bytes calldata signature ) external; ``` The witness overloads share function names with the no-witness entry points; Solidity dispatches by selector on the differing parameter shape. This follows the precedent in [ERC-7598](./eip-7598.md), which extends [ERC-3009](./eip-3009.md)'s authorization functions through name-overloading rather than coining a parallel surface. OPTIONAL: ```solidity /** * @notice Cancel all unused authorizations under (authorizer, nonceKey) with * sequence values up to and including `nonceSequence`. * @dev Reverts unless `nonceSequence` is greater than or equal to the current * stored sequence for `(authorizer, nonceKey)`. On success, sets the * stored sequence to `nonceSequence + 1` without performing a transfer. * Any signed authorization with sequence value <= `nonceSequence` that * was not yet consumed becomes permanently unusable. */ function cancelKeyedAuthorization( address authorizer, uint192 nonceKey, uint64 nonceSequence, bytes calldata signature ) external; ``` ### Validation rules A compliant `transferWithKeyedAuthorization` (and `receiveWithKeyedAuthorization`) implementation MUST reject the call unless ALL of the following hold: - `from != address(0)`. - `block.timestamp > validAfter`. - `block.timestamp < validBefore`. - `nonceSequence == _nonceSequences[from][nonceKey]`. (This single equality enforces both replay protection and in-order consumption.) - For `receiveWithKeyedAuthorization` only: `to == msg.sender`. On a successful call, the implementation MUST execute the following steps in order: 1. Set `_nonceSequences[from][nonceKey] = nonceSequence + 1`. 2. Validate `signature` against the [EIP-712](./eip-712.md) digest of the appropriate type hash and parameters under the signer `from`. Validation MUST accept ECDSA signatures from EOAs and contract-account signatures per [ERC-1271](./eip-1271.md) (signer's `isValidSignature(digest, signature)` returns the magic value `0x1626ba7e`). The unified verification pattern (e.g., OpenZeppelin's `SignatureChecker.isValidSignatureNow`) is RECOMMENDED. If validation fails, the implementation MUST revert; the revert unwinds step 1. 3. Emit `KeyedAuthorizationUsed(from, nonceKey, nonceSequence)`. 4. Execute the underlying ERC-20 transfer of `value` from `from` to `to`. The counter advance in step 1 precedes signature validation in step 2 to defend against reentrancy: `isValidSignature` is an external call that MAY re-enter the entry point. With the counter already advanced, any re-entrant call observes the new sequence value and fails the equality check above. If signature validation reverts, the entire transaction reverts and the counter advance is unwound, preserving denial-of-service protection against garbage signatures. Implementations MUST NOT accept `nonceSequence` values that skip ahead of or fall behind the stored counter. The 64-bit width of `nonceSequence` admits 2⁶⁴ authorizations per key before saturation; in Solidity 0.8+ the increment in step 1 reverts on overflow. Payers approaching saturation can rotate to a new `nonceKey`. A compliant `cancelKeyedAuthorization` implementation MUST reject the call unless ALL of the following hold: - `authorizer != address(0)`. - `nonceSequence >= _nonceSequences[authorizer][nonceKey]`. On a successful cancel, the implementation MUST execute the following steps in order: 1. Set `_nonceSequences[authorizer][nonceKey] = nonceSequence + 1`. 2. Validate `signature` against the EIP-712 digest of `CANCEL_KEYED_AUTHORIZATION_TYPEHASH` under the signer `authorizer`, accepting ECDSA and ERC-1271 signatures as above. If validation fails, the implementation MUST revert. 3. Emit `KeyedAuthorizationCanceled(authorizer, nonceKey, nonceSequence)`. The witness-bound overloads of `transferWithKeyedAuthorization` and `receiveWithKeyedAuthorization` follow the same rejection conditions and the same ordered steps, with two differences: - The struct hash is computed against the runtime-constructed type hash (`keccak256(STUB ‖ witnessTypeString)`) and includes a trailing `bytes32 witness` field. - No additional event is emitted for the witness payload; the standard `KeyedAuthorizationUsed` event records the consumption. The witness hash is recoverable from the transaction calldata for indexers that need it. The contract performs no validation of the contents of `witness` or of the format of `witnessTypeString`. A malformed `witnessTypeString` produces a type hash that no one ever signed against, so signature validation simply fails and the call reverts. Integrators are responsible for computing `witness` from the data they intend the user to authorize and for constructing `witnessTypeString` correctly per [EIP-712](./eip-712.md) (close the parent type with ` witness)`, then append all nested type definitions in alphabetical order). ### EIP-712 message construction The no-witness signed digest follows [EIP-712](./eip-712.md): ``` TypeHash := keccak256( "TransferWithKeyedAuthorization(address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,uint192 nonceKey,uint64 nonceSequence)" ) StructHash := keccak256(abi.encode(TypeHash, from, to, value, validAfter, validBefore, nonceKey, nonceSequence)) Digest := keccak256(0x1901 ‖ DomainSeparator ‖ StructHash) signature := Sign(Digest, Signer) ``` For witness-bound transfers, the type hash is constructed at runtime from the constant stub and the caller-supplied `witnessTypeString`: ``` TypeHashStub := "TransferWithKeyedAuthorizationAndWitness(address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,uint192 nonceKey,uint64 nonceSequence," TypeHash := keccak256(TypeHashStub ‖ witnessTypeString) StructHash := keccak256(abi.encode(TypeHash, from, to, value, validAfter, validBefore, nonceKey, nonceSequence, witness)) Digest := keccak256(0x1901 ‖ DomainSeparator ‖ StructHash) signature := Sign(Digest, Signer) ``` For example, a witness struct `OrderContext(bytes32 orderId,address seller)` produces: ``` witnessTypeString := "OrderContext witness)OrderContext(bytes32 orderId,address seller)" TypeHash := keccak256( "TransferWithKeyedAuthorizationAndWitness(address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,uint192 nonceKey,uint64 nonceSequence,OrderContext witness)OrderContext(bytes32 orderId,address seller)" ) witness := keccak256(abi.encode(ORDER_CONTEXT_TYPEHASH, orderId, seller)) ``` For an EOA signer, `signature` is the standard 65-byte ECDSA encoding `r ‖ s ‖ v`. For a contract-account signer, `signature` is whatever opaque bytes the account's ERC-1271 `isValidSignature` method recognizes for that digest. ## Rationale ### Why `(uint192, uint64)` and not a bitmap The two designs target different patterns: - **Keyed sequential** (this ERC): one slot per `(payer, key)` pair. Optimal when a payer issues many authorizations to a small number of counterparties. - **Bitmap** (256 nonces per slot, allowing out-of-order consumption): optimal when a payer issues authorizations to many distinct counterparties. The Motivation section explains why the loyal-customer pattern dominates and is the primary target of this ERC. The two designs are not mutually exclusive: a token contract MAY expose a bitmap entry point alongside this ERC for payers whose access pattern fits the explorer model. ### Equivalence with ERC-4337 nonce encoding The pair `(nonceKey, nonceSequence)` corresponds bit-for-bit to the `uint256` nonce used by ERC-4337 (see its "Semi-abstracted Nonce Support" section): ``` uint256 packed = (uint256(nonceKey) << 64) | uint256(nonceSequence); uint192 nonceKey = uint192(packed >> 64); uint64 nonceSequence = uint64(packed); ``` Implementations MAY expose helper views that accept the packed `uint256` form for tooling that already speaks ERC-4337 nonces, but the canonical EIP-712 type uses the split form for clarity and type safety. ### Why per-counterparty as the canonical key The 192-bit key is opaque to the contract; payers MAY use any value. The recommended convention for payer-to-payee transfers is `nonceKey = uint192(uint160(payeeAddress))`, with the upper 32 bits available as a subkey for channel separation (e.g. distinguishing different agents that share a payer key). This convention: - Lets the payee propose its own `nonceSequence` value with no coordination beyond reading `keyedAuthorizationNonce(payer, key)`. - Preserves independence across counterparties: a stuck or dropped authorization to one payee does not block authorizations to any other payee. - Maps naturally onto request/response payment protocols where the payee specifies payment terms. ### Why sequential within a key (not random) Random nonces within a key would defeat the storage saving: the contract would have to retain a per-nonce used-bit, collapsing the design back to one storage slot per authorization. Sequential consumption is what makes the storage collapse to a single counter. The downside is that out-of-order submission within a key fails. This is acceptable for the target pattern because the payee proposes the `nonceSequence` as part of the payment request, so concurrent in-flight authorizations under the same key are already a coordination error on the payee's side. Payers that need parallelism can use distinct keys (e.g. one per concurrent payment channel). ### Signature format and ERC-1271 support Signatures are passed as `bytes` so that the same entry points serve both EOAs and smart-contract accounts. EOAs sign the EIP-712 digest with ECDSA and pass the standard 65-byte `r ‖ s ‖ v` encoding; contract accounts pass whatever opaque bytes their own `isValidSignature` method recognizes per ERC-1271. Implementations SHOULD route through a unified validator that calls `ecrecover` for EOAs and `isValidSignature` for contracts (e.g., the pattern codified in OpenZeppelin's `SignatureChecker`). The `bytes` representation costs slightly more calldata than the unpacked `(uint8 v, bytes32 r, bytes32 s)` triple (one offset word and one length word — about 64 bytes), but smart-contract-wallet support has become foundational with the rise of account abstraction and [EIP-7702](./eip-7702.md), and bolting it on later via parallel function variants (cf. [ERC-7598](./eip-7598.md) as a retroactive extension to ERC-3009) is more disruptive than including it from the start. ### Why the counter advances before signature verification ERC-1271 signature verification is an external call to the signer contract, opening a reentrancy window. The natural ordering — verify signature, then advance counter — would let a malicious account contract re-enter the entry point with the same `(from, nonceKey, nonceSequence)`: the inner call would observe the not-yet-advanced counter, pass its own equality check, and execute a second transfer. Advancing the counter first closes this window. A re-entrant call observes the new sequence value and fails the equality check immediately. If signature validation in the outer call subsequently reverts, the EVM's revert semantics unwind the counter advance atomically — so the denial-of-service protection that motivated post-validation advancement (an attacker burning the counter with garbage signatures) is preserved without needing a separate reentrancy guard. This is the same pattern used in OpenZeppelin's `ERC20Permit._useNonce`. ### Cancellation semantics `cancelKeyedAuthorization` advances the sequence to `nonceSequence + 1` and accepts any `nonceSequence` greater than or equal to the current expected value. This invalidates every signed authorization with sequence value at most `nonceSequence` that has not yet been consumed. Cancellation operates on the counter directly because the keyed scheme stores no per-nonce used-bit (which would defeat the storage benefit). To invalidate a single in-flight authorization, the payer signs a cancel with `nonceSequence` equal to the current expected value. To invalidate every outstanding authorization at once, the payer signs a cancel with `nonceSequence` equal to the highest sequence value they have ever signed under the key. A cancel with a `nonceSequence` less than the current expected value is stale and reverts; replays of an executed cancel are therefore impossible because the counter only advances. ### Witness-bound authorizations Several payment patterns are not expressible without binding the authorization to additional context: - A marketplace creates listing #1234. The buyer should sign a payment that is *only* valid as the buyer-side of listing #1234, not for any unrelated transfer to the seller. - An invoicing protocol issues invoice #abc. The customer should sign a payment that settles invoice #abc specifically. - An agentic payment protocol settles a task with deliverable hash D. The payment should bind to D, so a different deliverable cannot be substituted. Today this binding has to happen via off-chain coordination plus heavyweight on-chain verification (e.g., the payee's contract reads its order book before pulling the payment, or the payer uses an ERC-1271 contract account whose `isValidSignature` enforces the context). Witnesses make context binding a first-class primitive at the signature layer. The mechanism follows Uniswap's `Permit2` design exactly: 1. The contract holds a constant **type hash stub** for the witness-bound parent type, ending with a trailing comma where the witness field will go. 2. The caller supplies a `witnessTypeString` that closes the parent type with ` witness)` and appends the witness's full EIP-712 type tree in alphabetical order. 3. The runtime computes the full type hash as `keccak256(STUB ‖ witnessTypeString)` and the struct hash with `bytes32 witness` as the trailing field. 4. The contract treats `witness` as opaque. Garbage `witnessTypeString` produces a type hash no one signed against, so signature verification fails harmlessly. The integrator (the contract calling the witness-bound entry point) is the trusted party for: computing `witness` from the data they intend the user to authorize, verifying off-chain that the witness data matches the on-chain context the call is settling, and constructing `witnessTypeString` correctly. The receive variant is the most useful integration point: a contract acting as the payee can require that the witness matches its current state, refusing the call if the user signed for a different context. The transfer variant is useful when a relayer needs to bind context the payer agreed to (e.g., a request hash) without acting as the payee. ### Function name overloading vs distinct names The witness-bound entry points share function names with the no-witness ones, distinguished only by parameter shape. This follows ERC-7598, which extends ERC-3009's authorization functions through name-overloading rather than coining a parallel surface. The selectors are distinct (different parameter lists), the EIP-712 type hashes are distinct, and the type names are distinct — so cross-replay between the variants is structurally impossible. Caller code reads identically: `token.transferWithKeyedAuthorization(...)` works for both, with overload resolution selecting the right entry point at compile time. Permit2's alternative pattern — coining `permitWitnessTransferFrom` alongside `permitTransferFrom` — was considered. ERC-7598's overloading model was preferred because the verb-suffix naming convention used here (`transferWith...`) does not have a clean infix slot for a `Witness` keyword, and because ERC-7598 is the more directly analogous prior art (extending an ERC-3009-family authorization scheme). ## Backwards Compatibility No backward compatibility issues found. This ERC introduces new function selectors, a new EIP-712 type hash, and a new storage region; it does not modify any prior standard. ## Test Cases ### Authorization consumption | # | Setup | Action | Expected | | --- | --- | --- | --- | | 1 | Fresh `(payer, key)`; signed authorization with `nonceSequence = 0` | Submit | Succeeds; counter → 1 | | 2 | Counter at 1; signed authorization with `nonceSequence = 0` | Submit | Reverts (replay) | | 3 | Counter at 1; signed authorization with `nonceSequence = 1` | Submit | Succeeds; counter → 2 | | 4 | Counter at 1; signed authorization with `nonceSequence = 2` | Submit | Reverts (skip ahead) | | 5 | Counter at 0 for keyA; submit authorization for keyB with `nonceSequence = 0` | Submit | Succeeds; keyA counter unchanged | | 6 | Two authorizations under same key with `nonceSequence = 0` | Submit both | First succeeds, second reverts | | 7 | Authorization with `validAfter = block.timestamp` | Submit | Reverts (not yet valid) | | 8 | Authorization with `validBefore = block.timestamp` | Submit | Reverts (expired) | ### Cross-replay isolation | # | Setup | Action | Expected | | --- | --- | --- | --- | | 9 | Signed `TransferWithKeyedAuthorization` digest | Submit to ERC-3009 `transferWithAuthorization` | Reverts (parameter shape mismatch; signature does not validate against `from`) | | 10 | Signed ERC-3009 `TransferWithAuthorization` digest | Submit to `transferWithKeyedAuthorization` | Reverts (parameter shape mismatch; signature does not validate against `from`) | | 11 | Signed `TransferWithKeyedAuthorization` digest from chain A | Submit on chain B with same contract address | Reverts (EIP-712 chainId binding rejects cross-chain replay) | ### Receive front-running protection | # | Setup | Action | Expected | | --- | --- | --- | --- | | 12 | Signed `ReceiveWithKeyedAuthorization`; `to = X`; submitted by `Y` | Submit | Reverts (`to != msg.sender`) | | 13 | Signed `ReceiveWithKeyedAuthorization`; `to = X`; submitted by `X` | Submit | Succeeds | | 14 | Signed `TransferWithKeyedAuthorization`; `to = X`; submitted by `Y` | Submit | Succeeds (transfer variant has no caller restriction) | ### Cancellation | # | Setup | Action | Expected | | --- | --- | --- | --- | | 15 | Counter at 5; signed cancel with `nonceSequence = 5` | Submit | Succeeds; counter → 6 | | 16 | Counter at 5; signed cancel with `nonceSequence = 7` | Submit | Succeeds; counter → 8 (invalidates outstanding sequences 5, 6, 7) | | 17 | Counter at 5; signed cancel with `nonceSequence = 4` | Submit | Reverts (stale) | | 18 | After cancel(7); previously-signed authorization with `nonceSequence = 6` | Submit | Reverts (counter has advanced past) | | 19 | After cancel(7); previously-signed authorization with `nonceSequence = 8` | Submit | Succeeds (above the cancel threshold) | | 20 | Counter at 8; replay of executed cancel(`nonceSequence = 7`) | Submit | Reverts (stale) | ### ERC-1271 contract-account signatures | # | Setup | Action | Expected | | --- | --- | --- | --- | | 21 | `from` is a contract; `isValidSignature(digest, signature)` returns `0x1626ba7e` | Submit | Succeeds; counter → next | | 22 | `from` is a contract; `isValidSignature` returns any other 4-byte value | Submit | Reverts; counter unchanged | | 23 | `from` is a contract; `isValidSignature` reverts | Submit | Reverts; counter unchanged | | 24 | `from` is a contract whose `isValidSignature` re-enters `transferWithKeyedAuthorization` with the same `(from, nonceKey, nonceSequence)` | Submit | Inner call reverts (nonce mismatch — counter already advanced); outer call's signature validation also reverts; net result: full transaction reverts, no double-spend | | 25 | `from = address(0)`; any signature | Submit | Reverts (zero `from` rejected before signature path) | ### Witness binding | # | Setup | Action | Expected | | --- | --- | --- | --- | | 26 | Signed witness-bound authorization with witness W, type string T | Submit witness-bound overload with witness W, type string T | Succeeds; counter → next | | 27 | Signed witness-bound authorization with witness W | Submit witness-bound overload with witness W' ≠ W (same other params) | Reverts (digest mismatch) | | 28 | Signed witness-bound authorization with type string T | Submit witness-bound overload with type string T' ≠ T (same witness, same other params) | Reverts (digest mismatch) | | 29 | Signed witness-bound authorization | Submit to no-witness overload (drop `witness` and `witnessTypeString`) | Reverts (signature was over a different type hash; wrong selector also makes this a different function) | | 30 | Signed no-witness authorization | Submit to witness-bound overload with arbitrary witness | Reverts (signature was over a different type hash) | | 31 | Witness-bound authorization succeeds at `nonceSequence = N` | Resubmit any authorization (witness or not) at `nonceSequence = N` | Reverts (counter has advanced; both overloads share `_nonceSequences` storage) | | 32 | Caller passes `witnessTypeString` that does not close the parent type correctly (e.g. missing trailing `)` after the witness type name) | Submit | Reverts (resulting type hash matches no signature) | ## Reference Implementation The reference implementation assumes the surrounding token contract provides: - An internal `_transfer(address from, address to, uint256 value)` performing the underlying ERC-20 transfer. - An `_hashTypedDataV4(bytes32 structHash) returns (bytes32)` helper (e.g., from OpenZeppelin's `EIP712`) that combines the contract's [EIP-712](./eip-712.md) domain separator with `structHash` to produce the signed digest. - A `SignatureChecker.isValidSignatureNow(address signer, bytes32 hash, bytes memory signature) returns (bool)` helper (e.g., from OpenZeppelin) that validates ECDSA signatures from EOAs and dispatches to `isValidSignature` for contract accounts per [ERC-1271](./eip-1271.md). ```solidity abstract contract KeyedTransferAuthorization is IERC20Transfer, EIP712 { using SignatureChecker for address; bytes32 public constant TRANSFER_WITH_KEYED_AUTHORIZATION_TYPEHASH = keccak256( "TransferWithKeyedAuthorization(address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,uint192 nonceKey,uint64 nonceSequence)" ); bytes32 public constant RECEIVE_WITH_KEYED_AUTHORIZATION_TYPEHASH = keccak256( "ReceiveWithKeyedAuthorization(address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,uint192 nonceKey,uint64 nonceSequence)" ); bytes32 public constant CANCEL_KEYED_AUTHORIZATION_TYPEHASH = keccak256( "CancelKeyedAuthorization(address authorizer,uint192 nonceKey,uint64 nonceSequence)" ); string internal constant TRANSFER_WITH_KEYED_AUTHORIZATION_AND_WITNESS_TYPEHASH_STUB = "TransferWithKeyedAuthorizationAndWitness(address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,uint192 nonceKey,uint64 nonceSequence,"; string internal constant RECEIVE_WITH_KEYED_AUTHORIZATION_AND_WITNESS_TYPEHASH_STUB = "ReceiveWithKeyedAuthorizationAndWitness(address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,uint192 nonceKey,uint64 nonceSequence,"; mapping(address => mapping(uint192 => uint64)) internal _nonceSequences; event KeyedAuthorizationUsed( address indexed authorizer, uint192 indexed nonceKey, uint64 indexed nonceSequence ); event KeyedAuthorizationCanceled( address indexed authorizer, uint192 indexed nonceKey, uint64 indexed nonceSequence ); function keyedAuthorizationNonce(address authorizer, uint192 nonceKey) external view returns (uint256) { return (uint256(nonceKey) << 64) | uint256(_nonceSequences[authorizer][nonceKey]); } // ---------- No-witness entry points ---------- function transferWithKeyedAuthorization( address from, address to, uint256 value, uint256 validAfter, uint256 validBefore, uint192 nonceKey, uint64 nonceSequence, bytes calldata signature ) external { bytes32 structHash = keccak256( abi.encode( TRANSFER_WITH_KEYED_AUTHORIZATION_TYPEHASH, from, to, value, validAfter, validBefore, nonceKey, nonceSequence ) ); _consume(from, validAfter, validBefore, nonceKey, nonceSequence, structHash, signature); _transfer(from, to, value); } function receiveWithKeyedAuthorization( address from, address to, uint256 value, uint256 validAfter, uint256 validBefore, uint192 nonceKey, uint64 nonceSequence, bytes calldata signature ) external { require(to == msg.sender, "KeyedAuth: caller is not payee"); bytes32 structHash = keccak256( abi.encode( RECEIVE_WITH_KEYED_AUTHORIZATION_TYPEHASH, from, to, value, validAfter, validBefore, nonceKey, nonceSequence ) ); _consume(from, validAfter, validBefore, nonceKey, nonceSequence, structHash, signature); _transfer(from, to, value); } // ---------- Witness-bound entry points (overloads) ---------- function transferWithKeyedAuthorization( address from, address to, uint256 value, uint256 validAfter, uint256 validBefore, uint192 nonceKey, uint64 nonceSequence, bytes32 witness, string calldata witnessTypeString, bytes calldata signature ) external { bytes32 structHash = _witnessStructHash( TRANSFER_WITH_KEYED_AUTHORIZATION_AND_WITNESS_TYPEHASH_STUB, from, to, value, validAfter, validBefore, nonceKey, nonceSequence, witness, witnessTypeString ); _consume(from, validAfter, validBefore, nonceKey, nonceSequence, structHash, signature); _transfer(from, to, value); } function receiveWithKeyedAuthorization( address from, address to, uint256 value, uint256 validAfter, uint256 validBefore, uint192 nonceKey, uint64 nonceSequence, bytes32 witness, string calldata witnessTypeString, bytes calldata signature ) external { require(to == msg.sender, "KeyedAuth: caller is not payee"); bytes32 structHash = _witnessStructHash( RECEIVE_WITH_KEYED_AUTHORIZATION_AND_WITNESS_TYPEHASH_STUB, from, to, value, validAfter, validBefore, nonceKey, nonceSequence, witness, witnessTypeString ); _consume(from, validAfter, validBefore, nonceKey, nonceSequence, structHash, signature); _transfer(from, to, value); } // ---------- Cancel ---------- function cancelKeyedAuthorization( address authorizer, uint192 nonceKey, uint64 nonceSequence, bytes calldata signature ) external { require(authorizer != address(0), "KeyedAuth: zero authorizer"); require( nonceSequence >= _nonceSequences[authorizer][nonceKey], "KeyedAuth: stale cancel" ); // Counter-first ordering: advance before the external signature call. _nonceSequences[authorizer][nonceKey] = nonceSequence + 1; bytes32 structHash = keccak256( abi.encode(CANCEL_KEYED_AUTHORIZATION_TYPEHASH, authorizer, nonceKey, nonceSequence) ); bytes32 digest = _hashTypedDataV4(structHash); require( authorizer.isValidSignatureNow(digest, signature), "KeyedAuth: invalid signature" ); emit KeyedAuthorizationCanceled(authorizer, nonceKey, nonceSequence); } // ---------- Internals ---------- /// @dev Shared by every transfer/receive entry point (witness and no-witness). /// Public functions compute the appropriate `structHash` and delegate here. /// Counter-first ordering: advance before the external signature call so /// that any reentrant call observes the already-incremented sequence and /// fails its own equality check. A revert in the signature step below /// unwinds this advance atomically. function _consume( address from, uint256 validAfter, uint256 validBefore, uint192 nonceKey, uint64 nonceSequence, bytes32 structHash, bytes calldata signature ) internal { require(from != address(0), "KeyedAuth: zero from"); require(block.timestamp > validAfter, "KeyedAuth: not yet valid"); require(block.timestamp < validBefore, "KeyedAuth: expired"); require( _nonceSequences[from][nonceKey] == nonceSequence, "KeyedAuth: nonce mismatch" ); _nonceSequences[from][nonceKey] = nonceSequence + 1; bytes32 digest = _hashTypedDataV4(structHash); require( from.isValidSignatureNow(digest, signature), "KeyedAuth: invalid signature" ); emit KeyedAuthorizationUsed(from, nonceKey, nonceSequence); } /// @dev Builds the EIP-712 struct hash for a witness-bound authorization. /// Computes the runtime type hash from the constant stub and the /// caller-supplied `witnessTypeString`, then encodes the struct. function _witnessStructHash( string memory typeHashStub, address from, address to, uint256 value, uint256 validAfter, uint256 validBefore, uint192 nonceKey, uint64 nonceSequence, bytes32 witness, string calldata witnessTypeString ) internal pure returns (bytes32) { bytes32 typeHash = keccak256(abi.encodePacked(typeHashStub, witnessTypeString)); return keccak256( abi.encode( typeHash, from, to, value, validAfter, validBefore, nonceKey, nonceSequence, witness ) ); } } ``` ## Security Considerations ### Front-running of wrapper contracts Smart contracts that wrap a keyed authorization in their own logic (e.g. a `deposit(authData)` function that calls `transferWithKeyedAuthorization` internally) are vulnerable to front-running. An observer can submit the raw `transferWithKeyedAuthorization` call directly, advancing the payer's counter and stranding the wrapper's downstream logic. Wrapper contracts MUST use `receiveWithKeyedAuthorization` (which enforces `to == msg.sender`) instead. ### Counter advance and signature check atomicity This ERC mandates that the counter advance precede signature validation (see *Validation rules*). This ordering serves two purposes: - **Reentrancy protection.** ERC-1271 signature validation is an external call to the signer contract. Because the counter is already advanced when that call happens, any re-entrant call to the entry point with the same `(from, nonceKey, nonceSequence)` observes the new sequence value and fails its equality check. Without this ordering, a malicious signer contract could re-enter to execute the transfer twice from one signature. - **Denial-of-service protection.** A reverting signature check unwinds the counter advance atomically, so an attacker cannot burn a payer's counter by submitting garbage signatures: failed signatures leave the counter unchanged, exactly as if the transaction never executed. Implementations that diverge from this ordering — for example by adding a separate reentrancy guard and validating the signature first — MUST justify the divergence; the pattern in the reference implementation is the recommended default. ### Choice of `nonceKey` The `nonceKey` is opaque and chosen by the payer. With the recommended convention `nonceKey = uint192(uint160(payeeAddress))`, the cast is zero-extending and lossless: distinct payee addresses always produce distinct keys. A payer that issues authorizations to multiple distinct agents acting on behalf of the same payee SHOULD use the upper 32 bits as a channel identifier to keep those agents on independent counters. ### Zero-address signer handling `ecrecover` returns the zero address on invalid ECDSA signatures, and the zero address can never be a valid contract account (no code, no `isValidSignature` implementation). Implementations MUST reject `from == address(0)` (or `authorizer == address(0)` for cancel) explicitly *before* invoking signature validation. Without this check, a malformed ECDSA signature could resolve to `address(0)`, which a naive implementation might treat as a successful recovery against `from = address(0)`. The reference implementation enforces this with a dedicated `require` at the top of `_consume` and `cancelKeyedAuthorization`. ### Signature malleability ECDSA signatures are malleable: for any valid signature there exists a second signature valid for the same digest. This ERC does not require the canonical `s ≤ secp256k1n / 2` form because replay protection comes from the counter, not from signature uniqueness. A malleated signature for an unconsumed authorization simply consumes the same authorization; no double-spend is possible. Contract-account signatures via ERC-1271 use whatever scheme the account implements and are not subject to ECDSA malleability concerns. ### ERC-1271 considerations A contract account's `isValidSignature` is an external call whose return value MAY depend on mutable state. Two implications follow: - **Signature validity is bound by execution-time state, not signing-time state.** A contract account that rotates its authorized keys, owners, or signing policy between signing and execution invalidates any previously-signed but not-yet-consumed authorization under the old configuration. Wallets and SDKs SHOULD treat any unconsumed authorization as conditional on the signer's current configuration, and SHOULD surface this conditional validity to users. - **Reentrancy is mitigated by counter-first ordering.** As described in *Counter advance and signature check atomicity*, the counter is advanced before `isValidSignature` is invoked. A malicious account that re-enters this ERC's entry points cannot double-spend; the inner call's nonce equality check fails. Implementations that depart from counter-first ordering forfeit this guarantee. ### Witness binding is the integrator's responsibility For witness-bound entry points, the contract treats `witness` as an opaque 32-byte value and `witnessTypeString` as opaque text. The contract verifies that the signer signed over exactly this `(parameters, witness)` tuple under exactly this type hash, and nothing more. It is the calling integrator's job to: - Compute `witness` as the EIP-712 hash of the data structure they intend the user to authorize, using the type definition declared in `witnessTypeString`. A mismatch between the hashed data and the type definition produces a witness no signer ever signed against, and the call reverts harmlessly. - Verify off-chain (or on-chain via the integrator's own contract logic) that the witness data corresponds to the on-chain context the call is settling. The token contract has no view into the integrator's domain (orders, invoices, tasks), so the integrator must enforce this match before treating the call as legitimate. - Construct `witnessTypeString` correctly per EIP-712: close the parent type with ` witness)` and append all nested type definitions in alphabetical order. SDK helpers SHOULD provide this construction; hand-constructing it is error-prone but mistakes only cause signature verification failure, not exploits. The witness-bound and no-witness entry points share `_nonceSequences` storage. A successful witness-bound consumption advances the same counter a no-witness consumption would, and vice versa; the two variants do not maintain parallel counters. This is intentional: the nonce identifies the slot, not the schema. ### Cross-chain and cross-contract replay EIP-712 domain separation (chainId + verifyingContract) prevents replay across chains and across distinct token contracts. ### Sequence saturation and key bricking via cancel A 64-bit counter permits 2⁶⁴ authorizations per key before saturation; in Solidity 0.8+ the counter increment reverts on overflow. Payers approaching this limit can rotate to a new `nonceKey`. The same overflow path can be triggered immediately by a single signed cancel: a cancel with `nonceSequence = type(uint64).max` advances the counter to `type(uint64).max`, after which any further consumption attempt under that key (transfer, receive, or cancel) reverts. This is intended — it is the key-revocation primitive — but it also means that a payer who signs a cancel for a counter value derived from a stale view of on-chain state can permanently brick the key. Wallets and SDKs that surface cancellation SHOULD warn users when the requested cancel `nonceSequence` is significantly higher than the current `keyedAuthorizationNonce` value, and SHOULD prefer a sequence of small cancels over one large jump when invalidating a known set of in-flight authorizations. ## Copyright Copyright and related rights waived via [CC0](../LICENSE.md).