ERC-7858 NFTs and SBTs with an Expiry (Expirable NFTs and SBTs)
Final · NFTs & multi-token · created 2024-01-04
An ERC-721 extension standard that adds a validity period to NFTs and soulbound tokens (SBTs). When a set block number or time passes, the token becomes expired, which makes it possible to represent time-limited uses such as memberships, tickets, and subscriptions.
Why it is needed
To make something that must be valid only for a certain period, such as a membership, subscription, event ticket, coupon, or lease, into an NFT or SBT, the token needs an expiry point. Before this standard, each project implemented expiry with its own mapping. As a result, integration with other smart contracts was inconsistent, and ERC-721 implementations that deviated from the standard could behave incorrectly on NFT marketplaces. Wallets and apps had no common way to check whether a token was still valid. This standard solves the problem by building in an expiry interface that is compatible with existing infrastructure. It supports both a per-token expiry and a scheme that manages tokens grouped by epoch (a unit of time period), so you can choose the one that fits the use case.
What to implement
function expiryType() external view returns (EXPIRY_TYPE);: Returns whether the expiry basis this contract uses is block number (BLOCKS_BASED) or time (TIME_BASED).function isTokenExpired(uint256 tokenId) external view returns (bool);: Returns true if the token has expired and false otherwise. Reverts if the tokenId does not exist.function startTime(uint256 tokenId) external view returns (uint256);: Returns the point at which the token becomes valid. Interpreted as a block number or a timestamp depending on expiryType.function endTime(uint256 tokenId) external view returns (uint256);: Returns the point at which the token's validity ends. Interpreted as a block number or a timestamp depending on expiryType.function currentEpoch() external view returns (uint256);: Returns the contract's current epoch. It is used to determine valid and expired status.function epochLength() external view returns (uint256);: Returns the length of one epoch. The unit is blocks or seconds, depending on expiryType.function isEpochExpired(uint256 epoch) external view returns (bool);: Returns true if the specified epoch has expired. The implementation must define and document the determination logic.function unexpiredBalanceOf(address account) external view returns (uint256);: Returns the total number of unexpired (usable) tokens an account holds.function unexpiredBalanceOfAtEpoch(uint256 epoch, address account) external view returns (uint256);: Returns the number of unexpired tokens an account holds in a specific epoch. It must return 0 if that epoch has expired.function validityDuration() external view returns (uint256);: Returns the validity period of a token in number of epochs.function getRemainingDurationBeforeTokenExpired(uint256 tokenId) public view returns (uint256);: Optional function. Returns the time or number of blocks remaining until the token expires.function getEpochBalance(uint256 epoch) public view returns (uint256);: Optional function. Returns the quantity of tokens stored in the specified epoch, regardless of whether it has expired.function getEpochInfo(uint256 epoch) public view returns (uint256,uint256);: Optional function. Returns the start and end of the specified epoch together.function getNearestExpiryOf(address account) public view returns (uint256);: Optional function. Returns a list of the tokenIds closest to expiry along with their expected expiry block numbers or timestamps.function getRemainingDurationBeforeEpochChange() public view returns (uint256);: Optional function. Returns the time or number of blocks remaining until the next epoch begins.
In depth
An Extension Standard That Inherits ERC-721
A compliant implementation must implement the IERC7858 interface and must inherit the ERC-721 interface. It must have every function defined in the interface, and the behavior of the functions must also satisfy the requirements of the specification. The interface IDs for ERC-165 supportsInterface are 0x3ebdfa31 for IERC7858 and 0xec7ffd66 for the epoch extension IERC7858Epoch. This standard is fully compatible with ERC-721 and other SBTs, including ERC-5484.
Expiry Basis: Block Number or Timestamp
expiryType must return the expiry basis the contract uses. The EXPIRY_TYPE enum has two values, BLOCKS_BASED (based on block.number) and TIME_BASED (based on block.timestamp), and the unit in which startTime and endTime are interpreted is also determined by this value. According to the design rationale, block-based expiry depends on network activity and suits applications that need precise consistency, while time-based expiry suits networks where block intervals are not constant. The return type of startTime and endTime is uint256, and the source comment notes that returning uint64 as ERC-5007 does may not be suitable for block.number-based expiry.
startTime and endTime Rules and Perpetual Tokens
startTime must be less than or equal to endTime, and except when both are 0, it is recommended that startTime be strictly less than endTime. If startTime and endTime are both 0, that tokenId has no expiry. A token whose startTime is not 0 and whose endTime is 0 is also treated as having no expiry. If a nonexistent tokenId is queried, these functions must revert in the same way as isTokenExpired. The TokenExpiryUpdated event must be emitted when a token is minted and when startTime or endTime is updated.
isTokenExpired Return Value and Error Handling
isTokenExpired queries the state of the given tokenId. It must return true if the token has expired and must revert if the tokenId does not exist. An implementation that uses custom errors is recommended to revert with ERC721NonexistentToken, following ERC-6093. An implementation that uses a Solidity version below v0.8.4 or prefers string errors is recommended to revert with NonexistToken. If the tokenId exists and has not expired, it must return false.
Transfers and Balance Counts Stay the Same After Expiry
balanceOf, inherited from ERC-721, must return all tokens, including expired ones. Because expired tokens are hard to track on-chain, a token remains in existence but in an unusable state. The NFT's transferFrom and safeTransferFrom must also allow transfers of expired tokens. This is to preserve compatibility with already deployed applications and tradability. However, a contract that checks validity must treat an expired token as invalid and unusable. The source text explains this design with the principle "First, do no harm." Expiry is added as an extra layer that does not interfere with existing uses, treating expiry as an enhancement rather than a fundamental change.
Epoch Extension: Expiry Grouped by Period
An epoch refers to the period or block range during which certain tokens are valid, and the concept is borrowed from ERC-7818. Tokens are grouped under one epoch and share the same validityDuration. An implementation that needs epoch-based expiry must implement IERC7858Epoch together with the base IERC7858 and must satisfy the requirements of both interfaces. currentEpoch must return the current epoch, epochLength the length between epochs in blocks or seconds, and validityDuration the token's validity period in number of epochs. isEpochExpired must return true if the epoch has expired. unexpiredBalanceOf must return the total number of unexpired tokens an account holds, and unexpiredBalanceOfAtEpoch must return the number of unexpired tokens held in a specific epoch, returning 0 if that epoch has expired even if tokens were previously held there.
Convenience Functions That Are Not Required
The standard also defines OPTIONAL functions that can be implemented depending on requirements. On the base side, getRemainingDurationBeforeTokenExpired returns the time or number of blocks remaining until the given tokenId expires. The epoch extension has four. getEpochBalance returns the token amount stored in an epoch even if that epoch has expired, and getEpochInfo returns both the start and end of a specified epoch. getNearestExpiryOf returns the list of tokenIds closest to expiry along with the expected expiry block number or timestamp, and getRemainingDurationBeforeEpochChange returns the time or number of blocks remaining until the epoch changes.
Things to watch out for
- Unauthorized renewal after burn and reissue: When a token is burned and then reissued with the same tokenId, the implementation must ensure that no unauthorized renewal occurs.
- Unauthorized change at expiry: startTime and endTime must be implemented so that only an authorized party can update them.