ERC-7818: Expirable ERC20

Update 14/11/2024 Link to PR on GitHub
Update 18/11/2024 Revised to make it more straightforward design, moving away from a library-based structure
Update 20/11/2024 Update in section Security Considerations
Update 24/11/2024 Update change on Natspec
Update 27/11/2024 Update resolve some comments from EIP Editing Office Hour 47
Update 29/11/2024 Update specification interface and rationale
Update 30/11/2024 Update in section Motivation and fix typo

Simple Summary

An extended interface enables fungible tokens to possess expiration capabilities, allowing them to expire after a predetermined period.

Abstract

Introduces an extension for ERC-20 tokens, which facilitates the implementation of an expiration mechanism. Through this extension, tokens have a predetermined validity period, after which they become invalid and can no longer be transferred or used. This functionality proves beneficial in scenarios such as time-limited bonds, loyalty rewards, or game tokens necessitating automatic invalidation after a specific duration. The extension is crafted to seamlessly align with the existing ERC-20 standard, ensuring smooth integration with the prevailing token smart contract while introducing the capability to govern and enforce token expiration at the contract level.

Motivation

This extension facilitates the development of ERC-20 standard compatible tokens featuring expiration dates. This capability broadens the scope of potential applications, particularly those involving time-sensitive assets. Expirable tokens are well-suited for scenarios necessitating temporary validity, including

  • Bonds or financial instruments with defined maturity dates
  • Time-constrained assets within gaming ecosystems
  • Next-gen loyalty programs incorporating expiring rewards or points
  • Prepaid credits for utilities or services (e.g., cashback, data packages, fuel, computing resources) that expire if not used within a specified time frame
  • Postpaid telecom data package allocations that expire at the end of the billing cycle, motivating users to utilize their data before it resets
  • Tokenized e-Money for a closed-loop ecosystem, such as transportation, food court, and retail payments
  • Insurance claim credits, which are time-sensitive credits issued to policyholders that can be used to offset deductibles or excess charges, and expire after a defined period (e.g., 6 months)

Specification

Epoch Mechanism

Epochs represent a specific period or block range during which certain tokens are valid. They can be categorized into two types

  • block-based Defined by a specific number of blocks (e.g., 1000 blocks).
  • time-based Defined by a specific duration in seconds (e.g., 1000 seconds).

Tokens linked to an epoch remain valid as long as the epoch is active. Once the specified number of blocks or the duration in seconds has passed, the epoch expires, and any tokens associated with it are considered expired.

Balance Look Back Over Epochs

To retrieve the usable balance, tokens are checked from the current epoch against a past epoch (which can be any n epochs back). The past epoch can be set to any value n, allowing flexibility in tracking and summing tokens that are still valid from previous epochs, up to n epochs back.

The usable balance is the sum of tokens valid between the current epoch and the past epoch, ensuring that only non-expired tokens are considered.

Example Scenario

epoch balance
1 100
2 150
3 200
  • Current Epoch: 3
  • Past Epoch: 1 epoch back
  • Usable Balance: 350

Tokens from Epoch 2 and Epoch 3 are valid. The same logic applies for any n epochs back, where the usable balance includes tokens from the current epoch and all prior valid epochs.

The keywords “MUST”, “MUST NOT”, “REQUIRED”, “SHALL”, “SHALL NOT”, “SHOULD”, “SHOULD NOT”, “RECOMMENDED”, “MAY”, and “OPTIONAL” in this document are to be interpreted as described in RFC 2119.

Compatible implementations MUST inherit from ERC-20’s interface and MUST have all the following functions and all function behavior MUST meet the specification.

// SPDX-License-Identifier: CC0-1.0
pragma solidity >=0.8.0 <0.9.0;

/**
 * @title ERC-7818 interface
 * @dev Interface for adding expirable functionality to ERC20 tokens.
 */

import "./IERC20.sol";

interface IERC7818 is IERC20 {
    /**
     * @dev Enum represents the types of `epoch` that can be used.
     * @notice The implementing contract may use one of these types to define how the `epoch` is measured.
     */
    enum EPOCH_TYPE {
        BLOCKS_BASED, // measured in the number of blocks (e.g., 1000 blocks)
        TIME_BASED // measured in seconds (UNIX time) (e.g., 1000 seconds)
    }

    /**
     * @dev Retrieves the balance of a specific `epoch` owned by an account.
     * @param account The address of the account.
     * @param epoch The `epoch for which the balance is checked.
     * @return uint256 The balance of the specified `epoch`.
     * @notice "MUST" return 0 if the specified `epoch` is expired.
     */
    function balanceOfAtEpoch(
        address account,
        uint256 epoch
    ) external view returns (uint256);

    /**
     * @dev Retrieves the latest epoch currently tracked by the contract.
     * @return uint256 The latest epoch of the contract.
     */
    function currentEpoch() external view returns (uint256);

    /**
     * @dev Retrieves the duration of a single epoch.
     * @return uint256 The duration of a single epoch.
     * @notice The unit of the epoch length is determined by the `validityPeriodType()` function.
     */
    function epochLength() external view returns (uint256);

    /**
     * @dev Returns the type of the epoch.
     * @return EPOCH_TYPE  Enum value indicating the unit of epoch.
     */
    function epochType() external view returns (EPOCH_TYPE);

    /**
     * @dev Checks whether a specific `epoch` is expired.
     * @param epoch The `epoch` to check.
     * @return bool True if the token is expired, false otherwise.
     * @notice Implementing contracts "MUST" define and document the logic for determining expiration,
     * typically by comparing the latest epoch with the given `epoch` value,
     * based on the `EPOCH_TYPE` measurement (e.g., block count or time duration).
     */
    function isEpochExpired(uint256 epoch) external view returns (bool);

    /**
     * @dev Transfers a specific `epoch` and value to a recipient.
     * @param to The recipient address.
     * @param epoch The `epoch` for the transfer.
     * @param value The amount to transfer.
     * @return bool True if the transfer succeeded, otherwise false.
     */
    function transferAtEpoch(
        address to,
        uint256 epoch,
        uint256 value
    ) external returns (bool);

    /**
     * @dev Transfers a specific `epoch` and value from one account to another.
     * @param from The sender's address.
     * @param to The recipient's address.
     * @param epoch The `epoch` for the transfer.
     * @param value The amount to transfer.
     * @return bool True if the transfer succeeded, otherwise false.
     */
    function transferFromAtEpoch(
        address from,
        address to,
        uint256 epoch,
        uint256 value
    ) external returns (bool);
}

Behavior specification

  • balanceOf MUST return the total balance of tokens held by an account that are still valid (i.e., have not expired). This includes any tokens associated with specific epochs, provided they remain within their validity duration. Expired tokens MUST NOT be included in the returned balance, ensuring that only actively usable tokens are reflected in the result.
  • balanceOfEpoch returns the balance of tokens held by an account at the specified epoch, If the specified epoch is expired, this function MUST return 0.
    For example, if epoch 5 has expired, calling balanceOfByEpoch(address, 5) MUST return 0 even if there were tokens previously held in that epoch."
  • transfer and transferFrom MUST exclusively transfer tokens that remain non-expired at the time of the transaction. Attempting to transfer expired tokens MUST revert the transaction or return false. Additionally, implementations MAY include logic to prioritize the automatic transfer of tokens closest to expiration, ensuring that the earliest expiring tokens are used first, provided they meet the non-expired condition.
  • transferByEpoch and transferFromByEpoch MUST transfer the specified number of tokens held by an account at the specified epoch to the recipient, If the epoch has expired, the transaction MUST revert or return false
  • totalSupply SHOULD be set to 0 or type(uint256).max due to the challenges of tracking only valid (non-expired) tokens.
  • The implementation MAY use a standardized custom error, such as ERC7818TransferredExpiredToken or ERC7818TransferredExpiredToken(address sender, uint256 epoch), to clearly indicate that the operation failed due to attempting to transfer expired tokens.

Aditional Potential Useful Function

These OPTIONAL functions provide additional functionality that might be useful depending on the specific use case.

  • epochInfo returns both the start and end of the specified epoch.
  • remainingDurationBeforeEpochChange returns the remaining time or blocks before the epoch change happens, based on the epoch type.

Rationale

Epochs for Flexible

Although the term epoch is an abstract concept, it leaves room for various implementations. For example, epochs can support more granular tracking of tokens within each epoch, allowing for greater control over when tokens are valid or expired on-chain. Alternatively, epochs can support bulk expiration, where all tokens within the same epoch expire simultaneously. This flexibility enables different methods of tracking token expiration, depending on the specific needs of the use case.
epoch also introduces a “lazy” way to simplify token expiration tracking in a flexible and gas-efficient manner. Instead of continuously updating the expiration state with write operations by the user or additional services, the current epoch can be calculated using a read operation.

Reference Implementation

For reference implementation can be found here, But in the reference implementation, we employ a sorted list to automatically select the token that nearest expires first with a First-In-First-Out (FIFO) and sliding window algorithm that operates based on the block.number as opposed to relying on block.timestamp, which has been criticized for its lack of security and resilience, particularly given the increasing usage of Layer 2 (L2) networks over Layer 1 (L1) networks. Many L2 networks exhibit centralization and instability, which directly impacts asset integrity, rendering them potentially unusable during periods of network halting, as they are still reliant on the timestamp.

Security Considerations

Denial Of Service

Run out of gas problem due to the operation consuming higher gas if transferring multiple groups of small tokens or loop transfer.

Gas Limit Vulnerabilities

Exceeds block gas limit if the blockchain has a block gas limit lower than the gas used in the transaction.

Block values as a proxy for time

if using block.timestamp for calculating epoch() and In rare network halts, block production stops, freezing block.timestamp and disrupting time-based logic. This risks asset integrity and inconsistent states.

Fairness Concerns

In a straightforward implementation, where all tokens within the same epoch share the same expiration (e.g., at epoch:x), bulk expiration occurs.

Risks in Liquidity Pools

When tokens with expiration dates are deposited into liquidity pools (e.g., in DEXs), they may expire while still in the pool.

Historical links related to this standard

  • Example Implementation from TokenX
  • Example Implementation from Tokenine
  • Example Implementation from Bitkub
  • Ethereum stack exchange question #27379
  • Ethereum stack exchange question #63937
3 Likes

I like the use cases. My concern is that at the point that the core transfer and balance method signatures are being changed from ERC20, why this wouldn’t make more sense as ERC-1155 extension, especially since there’s identifiers being used. balanceOf is the same siganture in ERC1155 as well

1 Like

Hi @0xTraub, Thanks for your feedback and your concern about the function signature of overloading balanceOf with the identifier did you think it should be changed to something like balanceOfByIdentifier borrowing style form ERC-1400

The reason I am not proposing this as an extension to the ERC-1155. The specification would need to spread down into multiple behaviors and may become more complicated when implemented, I think ERC-7818 is more fungible because the expiration date becomes a shared, uniform trait across tokens.

  • ERC20 can be split into 2 characteristics (Fungible Token) (Uniform)
    • BulkExpire All tokens have the same expiration date. very straight forward

      • Example: seasonal loyalty points, bonds, or simple governance rights issued in limited amounts, which expire together under a single rule. In such cases, the
        ability to mint new tokens continuously may not be suitable.

      Behavior: All tokens expired after block x

    • IndependentExpire Each token has an individual expiration date.

      • Example: Common loyalty points, commodities have a life span, data packages, or e-money where each token can have a unique expiration, allowing more flexibility in managing user rewards.

      Behavior: Each token is valid for n blocks


  • ERC721 can be split into multiple characteristics (Non-Fungible Token) (Unique)
    • BulkExpire All tokenId have the same expiration date.

      • Example: seasonal coupons, vouchers, and rights, intended for services that affect a broad user base, where a uniform expiration is necessary.

      Behavior: All tokens were minted on the different blocks but expired at block x (same block)

    • IndependentExpireByTokenId Each tokenId has an individual expiration date.

      • Example: Very special coupon, or privileges tailored to individual users, where each token can expire independently.

      Behavior:
      tokenId 1 minted on block a and expired at block x
      tokenId 2 minted on block b and expired at block y
      tokenId 3 minted on block c and expired at block z

    • IndependentExpire Each tokenId has an individual expiration date but has the same valid period or duration.

      • Example: a very special coupons, vouchers, or rights can’t be stack

      Behavior: Each tokenId is valid for n blocks


  • ERC1155 can be split into multiple characteristics (Semi-Fungible Token) (Mixed)
    • BulkExpire All tokenId under the same smart contract has the same expiration date.

      • Example: vouchers, coupons, docs, or event tickets multiple types,s in the same contract, where all tokens within the same smart contract expire simultaneously.

      Behavior: All tokens of tokenId1, tokenId2, and tokenId3 expired at block x (same block)

    • BulkExpireByType All tokens under the same type Id have the same expiration date.

      • Example: limited quantity coupons, vouchers, and time-limited game items for events e.g. for loyalty use cases is Black Friday or Flash Sales, all tokens of a specific type (same id) expire simultaneously.

      Behavior:
      All tokens of token type Id 1 are valid for n1 blocks
      All tokens of token type Id 2 are valid for n2 blocks

    • IndependentExpireByType Each token under the same tokenId has an individual expiration date.

      • Example: casual coupon, voucher, and temporary access right within the same category can expire at different times.

      Behavior:
      Each token of token type Id 1 are valid for n1 blocks
      Each token of token type Id 2 are valid for n2 blocks,

erc20 are fungible. They do not have identifiers. So you should not be extending ERC20.

1 Like

All tokens are fungible, with identifiers used to manage expiration. While ERC-1155 can support this, if the type Id in ERC-1155 has a balanceOf of 1, it could behave like an NFT due to the Natural Non-Fungible Tokens Approach, potentially adding complexity if adopting the Split ID Bits Approach. ERC-7818 offers a simpler, more focused solution for fungible tokens.

Ideally, change to identifiers to epoch

  • Resolve collisions in function signatures with ERC-1155.
  • Avoid confusion with Id in ERC-1155.
function transferByEpoch(uint256 epoch address to, uint256 value) external returns (bool);

function transferFromByEpoch(uint256 epoch, address from address to, uint256 value) external returns (bool);

function balanceOfEpoch(uint256 epoch, address account) external returns (uint256);

That the tokens are categorized by epoch/identifier means they are not fungible. The base methods of ERC20 are rendered meaningless. transfer and transferFrom in particular. They don’t make sense if the tokens are not fungible.

  • Avoid confusion with Id in ERC-1155.

Why? Perhaps you should be extending ERC-1155 instead of ERC20.

2 Likes

@wjmelements Any suggestion or solution if using a mix of expirable NFT and FT in the same contract?

1 Like
// To ensure compatibility with both Fungible Tokens (FT) and Non-Fungible Tokens (NFT) in the same contract, 
//The implementation requires s a clear linkage between the token ID and its associated epoch.
mapping(uint256 => uint256) private _tokenEpochRegistry;
// Alternative way
mapping(uint256 id => mapping(uint256 epoch => mapping(address account => uint256))) private _balances;

ERC-1155
safeTransferFrom and safeBatchTransferFrom MUST retrieve the epoch associated with the given token Id and verify whether it has expired internally before transfer. Transfers MUST be permitted only for tokens that are still valid (i.e., unexpired).

balanceOf and balanceOfBatch MUST retrieve the epoch associated with the given token type Id verify whether it has expired internally and return the only valid balance. Expired tokens MUST NOT be included in the returned balance, ensuring the result reflects only actively usable tokens.

duration()durationOfToken(uint256 id) The duration of each token type id MUST be independently managed to accommodate support for both Non-Fungible Tokens (NFT) and Fungible Tokens (FT) in the same smart contract.

NewepochOfToken(uint256 id)
Return the epoch associated with the given token type Id

expiredtokenExpired Check whether a specific token type Id has expired. If the given token type Id has expired, it MUST return true; otherwise, it MUST return false.

@0xTraub , @wjmelements Can this be applied across all token types?

Implementation on ERC20

balanceOf, transfer, and transferFrom NOT REQUIRED identifiers or require additional arguments. Instead, their internal logic MUST ensure that only tokens valid at the current epoch are processed.

epoch MAY not be exposed externally, as there is no need to make it accessible outside the other contract.

duration and expire MUST NOT be implemented, since all tokens technically share the same lifespan. Each token’s value is tied to its own expiration date, much like topping up a transport card. For instance, if you add 10 bucks today, those tokens will expire after a set time. If you top up again tomorrow with another 10 bucks, that amount will have a different expiration date, separate from yesterday’s.

Recap, this is conceptually similar to a rebase token system, where the total supply or balance of tokens adjusts periodically based on internal rules (e.g., expiration) but without external visibility or intervention. The core ERC20 functions like balanceOf, transfer, and transferFrom MUST NOT expose any additional complexity (such as identifiers or other arguments) to the user, as the expiration handling is managed internally.

Require metadata to flag tokens with expiration mechanisms, warning of risks on DEX platforms?

Implementation on ERC721

approve MUST only allow approval for valid tokens. It MUST internally retrieve the epoch associated with the given token id and verify whether the token has expired. Expired tokens MUST NOT be approved.

** no need to change behavior on setApprovalForAll cause transfer handling the expiration check before the transfer

safeTransferFrom and transferFrom shared the same behavior as in the ERC1155

durationOfToken(uint256 id) shared the same behavior as in the ERC1155

epochOfToken(uint256 id) shared the same behavior as in the ERC1155

expiretokenExpired shared the same behavior as in the ERC1155

ERC1155 and ERC-721 required ERC165 support IERC7818

All the above implementations support various expiration characteristics, whether they are managed independently or in bulk.

If the direction shifts toward ERC1155
Revised interface for ERC1155 and ERC712

Revised interface for ERC20

ERC20 pretty much looks like Schrödinger’s Cat but paradoxically look the same but not the same

Definitely an improvement. A few things

  1. I think the epoch() function should probably return when it started and when it ends, so return (uint256, uint256, uint256) or at least there needs to be some way to get when the epoch started and when it ends.

  2. Validity period, is it representing the amount of time remaining for it being valid, or just how long the validity period is in general?

  3. should validityPeriodType() not take an identifier or is it assumed that all identifiers are on the same time-based system.

  4. Can you describe a situation in which the epoch of the contract would need to be different than the epoch of an individual token and in which both of those would be useful and relevant for different purposes?

  1. epoch → (uint256 start, uint256 end)
    • start returning the block number or timestamp (UNIX)
    • end returning the block number or timestamp (UNIX)

return the current epoch should → currentEpoch or return as args 0 of epoch

  1. validityPeriodType representing the time validity period of each token
  2. When the contract is initialized it’s applied in all tokens, the reason why the opened block and time cause some L2 networks not create empty block.
  3. epochOfToken can be useful if combined with epochLength and validityPeriod can help the application cache the token and calculate if it’s expired off-chain → MAY over-engineered

Which of these three things is the validityPeriod

  1. A length of time/blocks the token is valid for until expiration (i.e 100 blocks).
  2. The amount of time/blocks remaining before the token becomes expired (deadline - block.number)
  3. The time/block at which the token will expire

Definitely 1.

but 2. and 3. can be done but should it be mandated?

I think the problem i’m having is that there’s both this notion of epochs and periods both of which have discrete time intervals. I think you need to pick one or the other perhaps because it’s confusing what the interplay between them is.