Abstract
This ERC defines a signed transfer authorization scheme for ERC-20 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's "Semi-abstracted Nonce Support" exactly. Signatures are passed as bytes and validated through ERC-1271, giving both EOAs and smart-contract accounts first-class support.
It addresses the same use case as ERC-3009 "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:
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
event KeyedAuthorizationUsed(
address indexed authorizer,
uint192 indexed nonceKey,
uint64 indexed nonceSequence
);The OPTIONAL cancellation event:
event KeyedAuthorizationCanceled(
address indexed authorizer,
uint192 indexed nonceKey,
uint64 indexed nonceSequence
);Type hashes
Constant type hashes for the no-witness entry points:
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):
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
/**
* @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, which extends ERC-3009's authorization functions through name-overloading rather than coining a parallel surface.
OPTIONAL:
/**
* @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
receiveWithKeyedAuthorizationonly:to == msg.sender.
On a successful call, the implementation MUST execute the following steps in order:
- Set
_nonceSequences[from][nonceKey] = nonceSequence + 1. - Validate
signatureagainst the EIP-712 digest of the appropriate type hash and parameters under the signerfrom. Validation MUST accept ECDSA signatures from EOAs and contract-account signatures per ERC-1271 (signer'sisValidSignature(digest, signature)returns the magic value0x1626ba7e). The unified verification pattern (e.g., OpenZeppelin'sSignatureChecker.isValidSignatureNow) is RECOMMENDED. If validation fails, the implementation MUST revert; the revert unwinds step 1. - Emit
KeyedAuthorizationUsed(from, nonceKey, nonceSequence). - Execute the underlying ERC-20 transfer of
valuefromfromtoto.
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:
- Set
_nonceSequences[authorizer][nonceKey] = nonceSequence + 1. - Validate
signatureagainst the EIP-712 digest ofCANCEL_KEYED_AUTHORIZATION_TYPEHASHunder the signerauthorizer, accepting ECDSA and ERC-1271 signatures as above. If validation fails, the implementation MUST revert. - 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 trailingbytes32 witnessfield. - No additional event is emitted for the witness payload; the standard
KeyedAuthorizationUsedevent 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 (close the parent type with <TypeName> witness), then append all nested type definitions in alphabetical order).
EIP-712 message construction
The no-witness signed digest follows EIP-712:
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
nonceSequencevalue with no coordination beyond readingkeyedAuthorizationNonce(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, and bolting it on later via parallel function variants (cf. ERC-7598 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:
- 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.
- The caller supplies a
witnessTypeStringthat closes the parent type with<TypeName> witness)and appends the witness's full EIP-712 type tree in alphabetical order. - The runtime computes the full type hash as
keccak256(STUB β witnessTypeString)and the struct hash withbytes32 witnessas the trailing field. - The contract treats
witnessas opaque. GarbagewitnessTypeStringproduces 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'sEIP712) that combines the contract's EIP-712 domain separator withstructHashto 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 toisValidSignaturefor contract accounts per ERC-1271.
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
isValidSignatureis 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
witnessas the EIP-712 hash of the data structure they intend the user to authorize, using the type definition declared inwitnessTypeString. 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
witnessTypeStringcorrectly per EIP-712: close the parent type with<TypeName> 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.
