ERC-5725 Transferable vesting NFT
Final · Vaults & DeFi · created 2022-09-08
A standard that turns a vesting contract, in which locked tokens are released gradually over time, into an NFT so that it can be bought, sold, or moved.
Why it is needed
Vesting contracts and timelock contracts, which lock tokens for a period and release them gradually, differ in interface and implementation from project to project. As a result, wallets and analytics tools cannot handle these contracts in one way, and users must check their vesting schedules on a different screen for each project. Existing vesting is also tied to a specific address, which makes it hard to hand the right to receive tokens to someone else, and distributing shares to multiple people, as in a Simple Agreement for Future Tokens (SAFT), is complicated. This standard solves these problems by representing the vesting right as an ERC-721 NFT so that it can be transferred, and by defining common functions to query the locked and vested amounts.
What to implement
event PayoutClaimed(uint256 indexed tokenId, address indexed recipient, uint256 claimAmount);: An event emitted when a payout is claimed through the claim function. It records who received how much from which NFT.event ClaimApprovalForAll(address indexed owner, address indexed spender, bool approved);: An event emitted when an owner grants or revokes the right to claim all of their tokens for a specific address.event ClaimApproval(address indexed owner, address indexed spender, uint256 indexed tokenId, bool approved);: An event emitted when an owner grants or revokes the right to claim a single specific tokenId.function claim(uint256 tokenId) external;: Claims the tokens that can currently be received from the NFT. It must fail if the caller is neither the owner nor an approved address.function claimedPayout(uint256 tokenId) external view returns (uint256 payout);: Returns the total amount of tokens claimed so far from this NFT.function claimablePayout(uint256 tokenId) external view returns (uint256 payout);: Returns the amount of tokens that are vested at the current time but have not yet been claimed.function vestedPayout(uint256 tokenId) external view returns (uint256 payout);: Returns the total amount of tokens whose vesting has completed (vested) as of the current time. It includes amounts that have already been claimed.function vestedPayoutAtTime(uint256 tokenId, uint256 timestamp) external view returns (uint256 payout);: Returns the total amount of tokens that will have vested at the specified time (past or future). It must return 0 for a time before issuance.function vestingPayout(uint256 tokenId) external view returns (uint256 payout);: Returns the amount of tokens that are still vesting and will be released in the future.function vestingPeriod(uint256 tokenId) external view returns (uint256 vestingStart, uint256 vestingEnd);: Returns the start and end times of the vesting as Unix timestamps. The vested amount must not increase after the end time.function payoutToken(uint256 tokenId) external view returns (address token);: Returns the address of the token that is paid out on a claim.function setClaimApprovalForAll(address operator, bool approved) external;: Sets or removes an operator that will handle claims for all tokens owned by the caller.function setClaimApproval(address operator, bool approved, uint256 tokenId) external;: Sets or removes an operator that will handle claims for a single specific tokenId.function isClaimApprovedForAll(address owner, address operator) external view returns (bool isClaimApproved);: Returns whether the owner has granted the operator the right to claim all tokens.function getClaimApproved(uint256 tokenId) external view returns (address operator);: Returns the claim operator address for a specific tokenId. If no operator is set, it returns the zero address.
In depth
ERC-721 extension structure
IERC5725 is an interface that inherits IERC721, and its interface ID is 0xbd3a202b. It is designed to be fully compatible with existing ERC-721 integrations and marketplaces, and an ERC-165 supportsInterface check can detect both ERC-721 compatibility and vesting NFT compatibility. The vesting curve can freely implement linear, cliff, exponential, and other deterministic curves.
Accounting relationships among the four state values
The source defines the terms vesting (the amount to be released in the future), vested (the total released so far), claimable (the amount released but not yet claimed), and claimed (the total already received). The sum of vestedPayout and vestingPayout SHOULD always equal the total payout, which is the same as the vestedPayoutAtTime value when type(uint256).max is passed as the timestamp. The amount that is vested but still locked can also be calculated with the formula vestedPayout - claimedPayout - claimablePayout = lockedPayout. claimablePayout is RECOMMENDED to be calculated as vestedPayout minus claimedPayout, and a release cliff can be expressed by returning 0 until a specific time.
Required rules for claim
claim(tokenId) MUST pay the claimablePayout value at the time of the call to msg.sender, MUST revert if the caller is neither the token owner nor an approved address, and MUST emit a PayoutClaimed event. Reverting when there is nothing to claim is RECOMMENDED (SHOULD). Past claims are not kept as chain state, so a historical claim graph must be reconstructed by querying PayoutClaimed events.
The deterministic curve and vestedPayoutAtTime
The timestamp in vestedPayoutAtTime(tokenId, timestamp) MAY be a past or future value, and it MUST return 0 for a time before the NFT was minted. vestedPayout SHOULD return the same value as a vestedPayoutAtTime call made with block.timestamp. The design intent is that, if the curve is deterministic, the release curve can be visualized on-chain or off-chain by sweeping across the vestingPeriod. The standard does not constrain the internal implementation of custom curves.
Why timestamps are used instead of block numbers
In Solidity development the common advice is not to use block.timestamp as a state-dependent variable because miners can manipulate it, but this standard chooses timestamps over block numbers. Block times differ across EVM-compatible networks, so a timestamp basis is needed for the same interface to work on multiple chains. The standard explains that nodes reject blocks with grossly manipulated timestamps, so the room for abuse is negligible. In addition, the reference implementation tracks the claimed amount per NFT internally to prevent claiming more than the allocation.
Claim approval separate from transfer approval
This standard provides a claim-specific approval scheme separate from the ERC-721 transfer approval. setClaimApprovalForAll designates an operator for all of the caller's tokens, and setClaimApproval designates an operator for a single tokenId; they emit the ClaimApprovalForAll and ClaimApproval events respectively. The approval state can be queried with isClaimApprovedForAll and getClaimApproved. An operator approved under ERC-5725 can claim released tokens but cannot move the NFT away from its owner.
Things to watch out for
- Timestamp-based claim tracking: Because the vesting schedule is based on timestamps, implementations must track the number of tokens already claimed and must not pay out more than the amount allocated to a given vesting NFT. For example, vestedPayoutAtTime(tokenId, type(uint256).max) must return the total payout for that tokenId.
- Permission differences between the two kinds of approval: An operator approved under ERC-721 can transfer the vesting NFT to themselves and then claim the released tokens. An operator approved under ERC-5725, by contrast, can only claim released tokens and cannot move the NFT away from its owner.