Abstract
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. If the token also implements ERC-2612, approvals created through permit expire under the same default-duration rule 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 stored 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 each authorization. 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:
interface IERC8255 /* is IERC20 */ {
/// @notice Optional event emitted when an approval expiration is set.
event ApprovalExpiration(
address indexed owner,
address indexed spender,
uint64 expiration
);
/// @notice Returns the contract-defined constant maximum approval duration, in seconds.
function maxApprovalDuration() external pure returns (uint32);
/// @notice Returns the stored expiration timestamp and stored allowance, even if expired.
function allowanceAndExpiration(address owner, address spender)
external
view
returns (uint64 expiration, uint256 allowance);
/// @notice Approves `spender` for `amount` tokens for `duration` seconds.
function approveForDuration(address spender, uint256 amount, uint32 duration)
external
returns (bool success);
}
Approval expiration
maxApprovalDuration() MUST return the contract-defined constant maximum duration, in seconds, that any 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), 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), 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 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) MUST set spender’s allowance from owner to value and MUST set its expiration to block.timestamp + maxApprovalDuration().
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
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.
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).
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.
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:
uint256 packed = (uint256(expiration) << 192) | allowance;
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.
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 users receive 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 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.
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 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 or query allowanceAndExpiration.
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.
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()returns86400andapprove(spender, 100)is called at timestamp1_000_000, thenallowanceAndExpiration(owner, spender)returns expiration1_086_400and allowance100. -
If
approveForDuration(spender, 100, 3600)is called at timestamp1_000_000, thenallowanceAndExpiration(owner, spender)returns expiration1_003_600and allowance100. -
If
approveForDuration(spender, 100, maxApprovalDuration() + 1)is called, the call reverts or returnsfalse. -
If an allowance has expiration
1_003_600and the current timestamp is1_003_600,allowance(owner, spender)returns the stored allowance andtransferFrom(owner, to, 1)may succeed if the allowance is otherwise sufficient. -
If an allowance has expiration
1_003_600, stored allowance100, and the current timestamp is1_003_601,allowance(owner, spender)returns0,allowanceAndExpiration(owner, spender)returns expiration1_003_600and allowance100, andtransferFrom(owner, to, 1)fails unless another authorization applies. -
If
approveForDuration(spender, 100, 0)is called andtransferFrom(owner, to, 100)is executed in the same block, the approval is unexpired during that block. -
If an unexpired allowance is
100andtransferFrom(owner, to, 25)succeeds,allowanceAndExpiration(owner, spender)returns the same expiration timestamp and allowance75. -
If an unexpired allowance is
25andtransferFrom(owner, to, 25)succeeds, the implementation may clear the storage slot soallowanceAndExpiration(owner, spender)returns expiration0and allowance0. -
If
approve(spender, type(uint256).max)succeeds,allowance(owner, spender)returnstype(uint256).maxuntil the approval expires andtransferFrommay 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 returnsfalse. -
If a packed implementation reserves
type(uint192).maxto representtype(uint256).max,approve(spender, type(uint192).max)reverts or returnsfalse. -
If an ERC-2612
permit(owner, spender, 100, deadline, v, r, s)succeeds at timestamp1_000_000andmaxApprovalDuration()returns86400, thenallowanceAndExpiration(owner, spender)returns expiration1_086_400and allowance100. -
If an ERC-2612
permithasdeadline1_200_000and succeeds at timestamp1_000_000, the approval expiration is stillblock.timestamp + maxApprovalDuration(), not1_200_000.
Reference Implementation
The following example shows the core packing behavior. It omits unrelated ERC-20 balance and supply logic.
abstract contract ERC20ExpiringApprovals {
uint32 internal constant _MAX_APPROVAL_DURATION = 86400;
uint256 internal constant _AMOUNT_MASK = (uint256(1) << 192) - 1;
uint256 internal constant _MAX_AMOUNT_SENTINEL = _AMOUNT_MASK;
mapping(address owner => mapping(address spender => uint256 packed)) internal _allowances;
event Approval(address indexed owner, address indexed spender, uint256 value);
event ApprovalExpiration(address indexed owner, address indexed spender, uint64 expiration);
function maxApprovalDuration() public pure returns (uint32) {
return _MAX_APPROVAL_DURATION;
}
function allowance(address owner, address spender) public view returns (uint256) {
(uint64 expiration, uint256 amount) = allowanceAndExpiration(owner, spender);
return expiration < block.timestamp ? 0 : amount;
}
function allowanceAndExpiration(address owner, address spender)
public
view
returns (uint64 expiration, uint256 amount)
{
uint256 packed = _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;
}
}
function approve(address spender, uint256 amount) public returns (bool) {
_approve(msg.sender, spender, amount, maxApprovalDuration());
return true;
}
function approveForDuration(address spender, uint256 amount, uint32 duration) public returns (bool) {
_approve(msg.sender, spender, amount, duration);
return true;
}
// ERC-2612 implementations call this after validating the permit signature and nonce.
function _approveWithDefaultDuration(address owner, address spender, uint256 amount) internal {
_approve(owner, spender, amount, maxApprovalDuration());
}
function _approve(address owner, address spender, uint256 amount, uint32 duration) internal {
require(duration <= maxApprovalDuration(), "duration exceeds maximum");
require(
amount < type(uint192).max || amount == type(uint256).max,
"unsupported allowance"
);
uint256 expirationValue = amount == 0 ? 0 : block.timestamp + duration;
require(expirationValue <= type(uint64).max, "expiration exceeds 64 bits");
uint64 expiration = uint64(expirationValue);
uint256 storedAmount = amount == type(uint256).max ? _MAX_AMOUNT_SENTINEL : amount;
_allowances[owner][spender] = (uint256(expiration) << 192) | storedAmount;
emit Approval(owner, spender, amount);
emit ApprovalExpiration(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.
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.
Copyright
Copyright and related rights waived via CC0.