This specification extends ERC-20 approvals with an expiration timestamp. Existing approve(address,uint256) calls remain valid, but approvals created through that function expire after the token contract’s default maximum approval duration unless the spender is treated as legacy-compatible. If the token also implements ERC-2612, approvals created through permit use the same default-duration rule and legacy-compatible exception without changing the permit signature. A new function allows token owners to approve a spender for a shorter duration, and a new view function exposes the allowance and its expiration.
Motivation
ERC-20 approvals are commonly granted for values much larger than the intended immediate spend, including unlimited approvals. These allowances remain valid until explicitly changed, creating a durable authorization that can be used long after the user has forgotten the original interaction.
Expiring approvals preserve the existing ERC-20 approval workflow while bounding the lifetime of ordinary authorizations. Wallets and applications can continue to call approve(address,uint256) or, where supported, permit, while contracts and interfaces that understand this extension can request shorter-lived approvals and display expiration information to users.
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.
Compliant contracts MUST implement the following interface in addition to ERC-20:
interfaceIERC8255/* is IERC20 */{/// @notice Optional event emitted when an approval expiration is set.
eventApprovalExpiration(addressindexedowner,addressindexedspender,uint64expiration);/// @notice Returns the contract-defined constant maximum approval duration, in seconds.
functionmaxApprovalDuration()externalpurereturns(uint32);/// @notice Returns the expiration timestamp and stored allowance, even if expired.
functionallowanceAndExpiration(addressowner,addressspender)externalviewreturns(uint64expiration,uint256allowance);/// @notice Approves `spender` for `amount` tokens for `duration` seconds.
functionapproveForDuration(addressspender,uint256amount,uint32duration)externalreturns(boolsuccess);}
Approval expiration
maxApprovalDuration() MUST return the contract-defined constant maximum duration, in seconds, that an ordinary approval can remain valid after it is created. The same value is the default duration used by approve(address spender, uint256 amount).
For every successful call to approve(address spender, uint256 amount) with a non-zero amount, the contract MUST set spender’s allowance from msg.sender to amount and MUST set its expiration to block.timestamp + maxApprovalDuration().
For every successful call to approveForDuration(address spender, uint256 amount, uint32 duration) with a non-zero amount, the contract MUST set spender’s allowance from msg.sender to amount and MUST set its expiration to block.timestamp + duration.
The duration argument MUST be less than or equal to maxApprovalDuration(). A call with a longer duration MUST revert or return false.
If duration is zero, the resulting expiration is equal to the current block.timestamp, and the approval MUST be valid while the chain remains at that timestamp. This allows approveForDuration(spender, amount, 0) to create a single-block approval.
If the approved amount is zero, the contract MUST set the allowance to zero. The contract SHOULD set the corresponding expiration to zero.
Implementations MAY support type(uint256).max as a maximum allowance sentinel. If such a sentinel is used, allowance(owner, spender) MUST return type(uint256).max while the approval is unexpired, and allowanceAndExpiration(owner, spender) MUST return the stored maximum allowance value.
Implementations MUST NOT create an approval whose expiration is greater than type(uint64).max. Implementations MAY revert if block.timestamp + duration cannot be represented as a uint64.
Signed approvals
If a compliant contract also implements ERC-2612, every successful call to permit(address owner, address spender, uint256 value, uint256 deadline, uint8 v, bytes32 r, bytes32 s) with a non-zero value MUST set spender’s allowance from owner to value and MUST set its expiration to block.timestamp + maxApprovalDuration(). A successful permit with a zero value MUST set the allowance to zero and SHOULD set the corresponding expiration to zero.
The permit function signature, signed typed data, and nonce behavior MUST remain unchanged from ERC-2612. This specification does not add a duration parameter to permit.
The ERC-2612 deadline parameter MUST continue to define only the latest timestamp at which the signed permit may be submitted. It MUST NOT be treated as the approval expiration timestamp.
Allowance accounting
Except as described under legacy spender compatibility, the ERC-20 allowance(address owner, address spender) function MUST return zero when the allowance has expired. Otherwise, it MUST return the unexpired allowance.
An allowance is expired only when its expiration timestamp is less than block.timestamp. An allowance with expiration equal to the current block timestamp is unexpired.
Except as described under legacy spender compatibility, the allowanceAndExpiration(address owner, address spender) function MUST return the stored expiration timestamp for the approval and the stored allowance amount, even if the approval has expired. Consumers that need the effective allowance MUST compare expiration < block.timestamp or call allowance(owner, spender).
Except as described under legacy spender compatibility, the ERC-20 transferFrom(address from, address to, uint256 amount) function MUST treat an expired allowance as zero. If the allowance is unexpired and sufficient, transferFrom MUST decrease the allowance by amount unless the implementation uses an allowance sentinel that is not decreased by ERC-20 transfers. Implementations MAY distinguish expired approvals from insufficient approvals when reverting.
When transferFrom decreases an unexpired allowance, the expiration timestamp MUST remain unchanged. If the resulting allowance is zero, the implementation MAY zero the allowance storage slot. If the slot is zeroed, allowanceAndExpiration(owner, spender) returns expiration 0 even if the original approval expiration had not passed.
Legacy spender compatibility
Implementations MAY provide a mechanism to designate specific spenders as legacy-compatible spenders. This mechanism MAY allow a token administrator to designate spenders, MAY allow a spender to designate or undesignate itself, or both. The interface, access control, and selection rules for this mechanism are outside the scope of this specification.
Spenders MUST NOT be treated as legacy-compatible by default. Allowances for a spender that is not designated as legacy-compatible MUST use the ordinary duration-limited approval behavior defined by this specification.
For a legacy-compatible spender, allowanceAndExpiration(owner, spender) MAY return the current block.timestamp as the expiration instead of the stored expiration timestamp. allowance(owner, spender) MAY treat the allowance as unexpired while spender remains designated as legacy-compatible. transferFrom(from, to, amount) MAY treat the allowance as unexpired when called by a legacy-compatible msg.sender.
This exception is intended only for contracts that cannot reasonably support expiring approvals and that assume an ERC-20 approval remains valid until consumed or explicitly revoked. It MUST NOT change the stored allowance amount, and it MUST NOT prevent the token holder from reducing or revoking the allowance.
Events
Every successful call to either approve(address spender, uint256 amount) or approveForDuration(address spender, uint256 amount, uint32 duration) MUST emit the ERC-20 Approval event.
Every successful call to ERC-2612 permit, if supported, MUST emit the ERC-20 Approval event.
Implementations MAY emit ApprovalExpiration(owner, spender, expiration) after each successful approve, approveForDuration, or ERC-2612 permit call that sets an approval expiration. This event is informational only. Consumers MUST use allowance(owner, spender) or allowanceAndExpiration(owner, spender) to determine the current effective allowance.
Storage layout
This specification does not require a particular storage layout.
Implementations MAY store the expiration timestamp in the upper 64 bits of a token allowance storage word and the allowance amount in the lower 192 bits:
This layout leaves 192 bits for the allowance amount. 192 bits is more than enough to represent the total supply of every ERC-20 token in existence at the time of writing, while preserving a single storage slot for the owner-spender allowance entry.
Implementations that use this layout MUST ensure that the stored allowance amount fits in 192 bits, is exactly type(uint256).max, or uses a separate representation for larger allowances. Packed implementations that do not use a separate representation SHOULD reject approval amounts greater than type(uint192).max and less than type(uint256).max.
If a packed implementation represents type(uint256).max by reserving one lower-192-bit value, it MUST also reject approval of that reserved value unless the requested amount is type(uint256).max.
Legacy-compatible spender designation is separate from the packed allowance word. A packed implementation that supports legacy-compatible spenders SHOULD store designation state separately and SHOULD continue to store the ordinary approval expiration in the packed word. When legacy-compatible treatment applies, allowanceAndExpiration returns the current block.timestamp without rewriting the stored expiration.
Rationale
Using approve(address,uint256) as an expiring approval with a default duration preserves the existing ERC-20 approval flow. Applications that are unaware of this extension can keep using the existing ABI, and ordinary approvals give users a bounded authorization instead of a permanent one.
The approveForDuration(address,uint256,uint32) function allows applications to request a shorter duration without changing the meaning of ERC-20 approve. It uses a distinct function name to avoid tooling ambiguity around overloaded approval functions. A uint32 duration is sufficient to express approximately 136 years in seconds, which is longer than any reasonable expiring approval.
The ERC-2612 permit signature is unchanged so that existing wallets, typed-data encoders, and permit-aware applications do not need to support a second signed approval format. This means ordinary signed approvals use the token’s default maximum approval duration. Bundling exact-spend approvals into transactions is expected to become more common over time, which reduces the need for a duration-specific permit variant.
allowanceAndExpiration returns expiration before allowance so callers can decode both values without ambiguity and can present the expiration and stored allowance even when the effective allowance is zero.
An approval expires only when expiration < block.timestamp, rather than when expiration == block.timestamp, so approveForDuration(spender, amount, 0) can authorize a bundled approve-and-spend flow that executes in the same block.
The packed storage layout is optional because some tokens may need to preserve full-width uint256 allowance values or existing storage layouts. For new tokens with bounded supply and ordinary allowance semantics, the packed layout allows this extension to be implemented without adding a second storage slot per allowance.
Some ERC-20 implementations treat type(uint256).max as an infinite-approval sentinel and do not decrement that allowance during transferFrom, saving gas for repeated transfers. Allowing this single full-width value preserves compatibility with applications that request maximum approvals while still rejecting intermediate values that cannot be represented in the 192-bit packed amount field.
Allowing spender self-designation lets integrations choose whether they need legacy approval behavior without requiring action by the token administrator. New and upgraded integrations can leave legacy-compatible treatment disabled and use the duration-limited approval pattern by default. Legacy integrations can opt in when they cannot safely refresh approvals before use, and can opt out later if they add support for expiring approvals.
Backwards Compatibility
The new methods are ABI-compatible with ERC-20 because they use new function selectors. Existing calls to approve(address,uint256), allowance(address,address), and transferFrom(address,address,uint256) remain valid.
This specification changes the long-term behavior of ordinary allowances created by approve(address,uint256) and, if supported, ERC-2612 permit: they expire after maxApprovalDuration() seconds instead of remaining valid indefinitely. Contracts that assume an ERC-20 allowance remains valid forever SHOULD refresh approvals before use, query allowanceAndExpiration, or rely on token-specific legacy-compatible treatment where available.
Applications that use unlimited approvals MAY need to request a new approval after expiration. The approval amount can remain unchanged; only the approval lifetime is bounded.
The main compatibility risk is with contracts that ask the user to approve once and then assume that approval will never expire or be fully consumed. These integrations are usually older contracts, often upgradeable systems whose current logic differs from the logic users originally approved. Many newer integrations instead request an approval for the amount needed, spend that amount, and then call approve(spender, 0), or expose external functions that use safeApprove or equivalent logic to set or refresh approvals immediately before interacting with another protocol. Those newer patterns are naturally compatible with expiring approvals because they do not rely on stale, long-lived allowances.
Token implementations that need to support legacy integrations MAY maintain a list of legacy-compatible spenders. For spenders on that list, the token can expose the current block.timestamp from allowanceAndExpiration and treat the allowance as unexpired. Returning the current timestamp avoids introducing a far-future expiration that may fail validation rules requiring the expiration to be no later than the current timestamp plus maxApprovalDuration() or type(uint32).max. This preserves compatibility for integrations that expect approvals never to expire, while leaving the interface for managing that list to the token implementation.
Maintaining such a list requires operational awareness. If designation is controlled by a token administrator, the administrator needs to know which contracts require indefinite approvals and should provide a workflow for enabling them when needed. If designation is controlled by spenders, each spender can opt itself into legacy-compatible treatment when needed and opt back out after it supports duration-limited approvals.
When affected spender contracts are deployed through a factory pattern, adding each spender individually can be tedious. The token administrator can be another contract that applies its own rules to determine which spenders are eligible for legacy-compatible treatment, or each spender instance can designate itself if the token implementation supports spender-controlled designation.
This specification does not change the ERC-2612 permit ABI or signed typed data.
Migrating existing allowance storage
Upgradeable contracts that already store each allowance as a single uint256 value MAY migrate to the packed layout without rewriting every existing allowance slot. When an existing allowance value is less than type(uint192).max, interpreting that slot as (uint64 expiration, uint192 allowance) yields an expiration of 0 and the original allowance amount. Because 0 < block.timestamp after deployment, those existing allowances are expired by default while remaining visible through allowanceAndExpiration.
Upgradeable contracts that must preserve selected pre-upgrade approvals MAY retain the legacy allowance slot and check it before the new packed approval slot. In that design, all future calls to approve, approveForDuration, and permit write only the packed slot, while the legacy slot is read only as a compatibility fallback for approvals that existed before the upgrade. Implementations using this pattern SHOULD clear or ignore the legacy slot after it is spent, explicitly revoked, or superseded by a packed approval, so that all new approvals receive the expiration behavior defined by this specification.
Existing allowance values greater than or equal to type(uint192).max do not have a meaningful packed interpretation unless the implementation defines one. This behavior does not affect effective allowance safety because such values either expire by default, are rejected or remapped by migration logic, or are treated under the implementation’s maximum-allowance sentinel rules.
Packed implementations that reserve a lower-192-bit sentinel for type(uint256).max MUST NOT treat a legacy single-slot maximum approval as a valid unexpired approval merely because decoding the old slot yields expiration type(uint64).max. An expiration farther than maxApprovalDuration() seconds after the current block timestamp cannot have been produced by compliant post-upgrade approval logic. Implementations SHOULD revert when such a stored value is encountered, or otherwise require explicit migration before treating it as a live approval. Using type(uint32).max as the rejection threshold is a weaker alternative, but maxApprovalDuration() is preferred because it matches the contract’s actual approval bound.
Test Cases
If maxApprovalDuration() returns 86400 and approve(spender, 100) is called at timestamp 1_000_000, then allowanceAndExpiration(owner, spender) returns expiration 1_086_400 and allowance 100.
If approveForDuration(spender, 100, 3600) is called at timestamp 1_000_000, then allowanceAndExpiration(owner, spender) returns expiration 1_003_600 and allowance 100.
If approveForDuration(spender, 100, maxApprovalDuration() + 1) is called, the call reverts or returns false.
If an allowance has expiration 1_003_600 and the current timestamp is 1_003_600, allowance(owner, spender) returns the stored allowance and transferFrom(owner, to, 1) may succeed if the allowance is otherwise sufficient.
If an allowance has expiration 1_003_600, stored allowance 100, and the current timestamp is 1_003_601, allowance(owner, spender) returns 0, allowanceAndExpiration(owner, spender) returns expiration 1_003_600 and allowance 100, and transferFrom(owner, to, 1) fails unless another authorization applies.
If approveForDuration(spender, 100, 0) is called and transferFrom(owner, to, 100) is executed in the same block, the approval is unexpired during that block.
If an unexpired allowance is 100 and transferFrom(owner, to, 25) succeeds, allowanceAndExpiration(owner, spender) returns the same expiration timestamp and allowance 75.
If an unexpired allowance is 25 and transferFrom(owner, to, 25) succeeds, the implementation may clear the storage slot so allowanceAndExpiration(owner, spender) returns expiration 0 and allowance 0.
If approve(spender, type(uint256).max) succeeds, allowance(owner, spender) returns type(uint256).max until the approval expires and transferFrom may leave the allowance unchanged.
If a packed implementation does not use a separate representation for larger allowances, approve(spender, type(uint192).max + 1) reverts or returns false.
If a packed implementation reserves type(uint192).max to represent type(uint256).max, approve(spender, type(uint192).max) reverts or returns false.
If an ERC-2612 permit(owner, spender, 100, deadline, v, r, s) succeeds at timestamp 1_000_000 and maxApprovalDuration() returns 86400, then allowanceAndExpiration(owner, spender) returns expiration 1_086_400 and allowance 100.
If an ERC-2612 permit has deadline1_200_000 and succeeds at timestamp 1_000_000, the approval expiration is still block.timestamp + maxApprovalDuration(), not 1_200_000.
If an implementation designates spender as a legacy-compatible spender, the stored approval has expiration 1_086_400 and allowance 100, and the current timestamp is 1_200_000, then allowanceAndExpiration(owner, spender) may return expiration 1_200_000 and allowance 100.
If an implementation supports spender-controlled designation, spender designates itself as legacy-compatible, and spender later undesignates itself with no other designation remaining, then allowanceAndExpiration(owner, spender) returns the stored expiration and allowance again, and allowance(owner, spender) returns zero once the stored expiration is less than block.timestamp.
Reference Implementation
The following example shows the core packing behavior. It omits unrelated ERC-20 balance and supply logic and the optional legacy spender compatibility mechanism.
abstractcontractERC20ExpiringApprovals{uint32internalconstant_MAX_APPROVAL_DURATION=86400;uint256internalconstant_AMOUNT_MASK=(uint256(1)<<192)-1;uint256internalconstant_MAX_AMOUNT_SENTINEL=_AMOUNT_MASK;mapping(addressowner=>mapping(addressspender=>uint256packed))internal_allowances;eventApproval(addressindexedowner,addressindexedspender,uint256value);eventApprovalExpiration(addressindexedowner,addressindexedspender,uint64expiration);functionmaxApprovalDuration()publicpurereturns(uint32){return_MAX_APPROVAL_DURATION;}functionallowance(addressowner,addressspender)publicviewreturns(uint256){(uint64expiration,uint256amount)=allowanceAndExpiration(owner,spender);returnexpiration<block.timestamp?0:amount;}functionallowanceAndExpiration(addressowner,addressspender)publicviewreturns(uint64expiration,uint256amount){uint256packed=_allowances[owner][spender];expiration=uint64(packed>>192);amount=packed&_AMOUNT_MASK;if(amount==0){return(0,0);}if(amount==_MAX_AMOUNT_SENTINEL){amount=type(uint256).max;}}functionapprove(addressspender,uint256amount)publicreturns(bool){_approve(msg.sender,spender,amount,maxApprovalDuration());returntrue;}functionapproveForDuration(addressspender,uint256amount,uint32duration)publicreturns(bool){_approve(msg.sender,spender,amount,duration);returntrue;}// ERC-2612 implementations call this after validating the permit signature and nonce.
function_approveWithDefaultDuration(addressowner,addressspender,uint256amount)internal{_approve(owner,spender,amount,maxApprovalDuration());}function_approve(addressowner,addressspender,uint256amount,uint32duration)internal{require(duration<=maxApprovalDuration(),"duration exceeds maximum");require(amount<type(uint192).max||amount==type(uint256).max,"unsupported allowance");uint256expirationValue=amount==0?0:block.timestamp+duration;require(expirationValue<=type(uint64).max,"expiration exceeds 64 bits");uint64expiration=uint64(expirationValue);uint256storedAmount=amount==type(uint256).max?_MAX_AMOUNT_SENTINEL:amount;_allowances[owner][spender]=(uint256(expiration)<<192)|storedAmount;emitApproval(owner,spender,amount);emitApprovalExpiration(owner,spender,expiration);}}
Security Considerations
Expiring approvals reduce the duration of approval risk but do not remove the ERC-20 approval race condition. User interfaces SHOULD continue to follow ERC-20 guidance for changing a non-zero allowance to another non-zero allowance.
Contracts that pull tokens using transferFrom SHOULD be prepared for approvals to expire between transaction construction and execution. This is especially relevant for transactions submitted through public mempools or delayed execution systems.
Short approval durations can improve user safety but can also cause failed transactions if a user signs an approval and the intended use is delayed. Wallets and applications SHOULD choose durations that account for expected transaction latency.
Expiring approvals can reduce user losses from compromised, abandoned, or maliciously upgraded spenders. Approval-revocation services track many incidents where active approvals to older contracts, compromised frontends, or upgraded protocol contracts allowed attackers to drain user wallets, with aggregate reported losses reaching hundreds of millions of dollars over multiple years. This risk is not theoretical: persistent approvals create a standing authorization that remains valuable to attackers long after the original interaction is complete.
For upgradeable tokens, expiring existing approvals may break some integrations that depend on durable allowances. Token maintainers SHOULD weigh that compatibility cost against the continuing loss exposure created by indefinite approvals. In many cases, the expected harm from requiring an affected integration to refresh approval is smaller than the user-loss risk of leaving historical approvals valid forever.
Legacy-compatible spender lists can intentionally regress approval security for selected spenders to ordinary ERC-20 behavior. At worst, a token administrator or spender can reintroduce indefinite approval risk for those spenders. Implementations SHOULD make this tradeoff visible to users, MUST leave legacy-compatible treatment disabled by default, and SHOULD limit legacy-compatible treatment to spenders that need it for compatibility.
Wallets and applications displaying ERC-2612 permits SHOULD distinguish the permit submission deadline from the resulting approval expiration. The former controls signature validity; the latter controls allowance validity after the permit is submitted.
Implementations using packed storage MUST avoid truncating allowance values silently. If an approval amount does not fit in the lower 192 bits, the implementation MUST reject it or store it using another representation.
The expiration timestamp is based on block.timestamp, which block producers can influence within normal consensus bounds. Approval durations SHOULD include enough margin that small timestamp variation does not change the user’s expected outcome.