diff --git a/EIPS/eip-5725.md b/EIPS/eip-5725.md index 06db9e4431ba77..f48f39c63ee3da 100644 --- a/EIPS/eip-5725.md +++ b/EIPS/eip-5725.md @@ -2,7 +2,7 @@ eip: 5725 title: Transferable Vesting NFT description: An interface for transferable vesting NFTs which release underlying tokens over time. -author: Apeguru (@Apegurus), Marco De Vries , Mario , DeFiFoFum (@DeFiFoFum) +author: Apeguru (@Apegurus), Marco De Vries , Mario , DeFiFoFum (@DeFiFoFum), Elliott Green (@elliott-green) discussions-to: https://ethereum-magicians.org/t/eip-5725-transferable-vesting-nft/11099 status: Last Call last-call-deadline: 2023-08-11 @@ -14,14 +14,14 @@ requires: 721 ## Abstract -A **Non-Fungible Token** (NFT) standard used to vest [ERC-20](./eip-20.md) tokens over a vesting release curve. +A **Non-Fungible Token** (NFT) standard used to vest tokens ([ERC-20](./eip-20.md) or otherwise) over a vesting release curve. -The following standard allows for the implementation of a standard API for NFT based contracts which represent the vested and locked properties of underlying [ERC-20](./eip-20.md) tokens that are emitted to respective NFT owners. This standard is an extension of the [ERC-721](./eip-721.md) token which provides basic functionality for creating vesting NFTs, claiming underlying tokens and reading vesting curve properties. +The following standard allows for the implementation of a standard API for NFT based contracts that hold and represent the vested and locked properties of any underlying token ([ERC-20](./eip-20.md) or otherwise) that is emitted to the NFT holder. This standard is an extension of the [ERC-721](./eip-721.md) token that provides basic functionality for creating vesting NFTs, claiming the tokens and reading vesting curve properties. ## Motivation Vesting contracts, including timelock contracts, lack a standard and unified interface, which results in diverse implementations of such contracts. Standardizing such contracts into a single interface would allow for the creation of an ecosystem of on- and off-chain tooling around these contracts. In addition, liquid vesting in the form of non-fungible assets can prove to be a huge improvement over traditional **Simple Agreement for Future Tokens** (SAFTs) or **Externally Owned Account** (EOA)-based vesting as it enables transferability and the ability to attach metadata similar to the existing functionality offered by with traditional NFTs. - + Such a standard will not only provide a much-needed [ERC-20](./eip-20.md) token lock standard, but will also enable the creation of secondary marketplaces tailored for semi-liquid SAFTs. This standard also allows for a variety of different vesting curves to be implement easily. @@ -35,22 +35,21 @@ These curves could represent: ### Use Cases -1. A framework to release tokens over a set period of time that can be used to build many kinds of NFT financial products such as bonds, treasury bills, and many others. +1. A framework to release tokens over a set period of time that can be used to build many kinds of NFT financial products such as bonds, treasury bills, and many others. 2. Replicating SAFT contracts in a standardized form of semi-liquid vesting NFT assets. - - SAFTs are generally off-chain, while today's on-chain versions are mainly address-based, which makes distributing vesting shares to many representatives difficult. Standardization simplifies this convoluted process. + - SAFTs are generally off-chain, while today's on-chain versions are mainly address-based, which makes distributing vesting shares to many representatives difficult. Standardization simplifies this convoluted process. 3. Providing a path for the standardization of vesting and token timelock contracts. - - There are many such contracts in the wild and most of them differ in both interface and implementation. + - There are many such contracts in the wild and most of them differ in both interface and implementation. 4. NFT marketplaces dedicated to vesting NFTs. - - Whole new sets of interfaces and analytics could be created from a common standard for token vesting NFTs. -5. Integrating vesting NFTs into services like Gnosis Safe. - - A standard would mean services like Gnosis Safe could more easily and uniformly support interactions with these types of contracts inside of a multisig contract. + - Whole new sets of interfaces and analytics could be created from a common standard for token vesting NFTs. +5. Integrating vesting NFTs into services like Safe Wallet. + - A standard would mean services like Safe Wallet could more easily and uniformly support interactions with these types of contracts inside of a multisig contract. 6. Enable standardized fundraising implementations and general fundraising that sell vesting tokens (eg. SAFTs) in a more transparent manner. 7. Allows tools, front-end apps, aggregators, etc. to show a more holistic view of the vesting tokens and the properties available to users. - - Currently, every project needs to write their own visualization of the vesting schedule of their vesting assets. If this is standardized, third-party tools could be developed aggregate all vesting NFTs from all projects for the user, display their schedules and allow the user to take aggregated vesting actions. - - Such tooling can easily discover compliance through the [ERC-165](./eip-165.md) `supportsInterface(InterfaceID)` check. + - Currently, every project needs to write their own visualization of the vesting schedule of their vesting assets. If this is standardized, third-party tools could be developed to aggregate all vesting NFTs from all projects for the user, display their schedules and allow the user to take aggregated vesting actions. + - Such tooling can easily discover compliance through the [ERC-165](./eip-165.md) `supportsInterface(InterfaceID)` check. 8. Makes it easier for a single wrapping implementation to be used across all vesting standards that defines multiple recipients, periodic renting of vesting tokens etc. - ## Specification The key words “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. @@ -61,15 +60,16 @@ pragma solidity ^0.8.0; import "@openzeppelin/contracts/token/ERC721/IERC721.sol"; /** - * @title Non-Fungible Vesting Token Standard - * @notice A non-fungible token standard used to vest ERC-20 tokens over a vesting release curve + * @title Non-Fungible Vesting Token Standard. + * @notice A non-fungible token standard used to vest ERC-20 tokens over a vesting release curve * scheduled using timestamps. - * @dev Because this standard relies on timestamps for the vesting schedule, it's important to keep track of the - * tokens claimed per Vesting NFT so that a user cannot withdraw more tokens than alloted for a specific Vesting NFT. + * @dev Because this standard relies on timestamps for the vesting schedule, it's important to keep track of the + * tokens claimed per Vesting NFT so that a user cannot withdraw more tokens than allotted for a specific Vesting NFT. + * @custom:interface-id 0xbd3a202b */ interface IERC5725 is IERC721 { /** - * This event is emitted when the payout is claimed through the claim function + * This event is emitted when the payout is claimed through the claim function. * @param tokenId the NFT tokenId of the assets being claimed. * @param recipient The address which is receiving the payout. * @param claimAmount The amount of tokens being claimed. @@ -77,77 +77,123 @@ interface IERC5725 is IERC721 { event PayoutClaimed(uint256 indexed tokenId, address indexed recipient, uint256 claimAmount); /** - * @notice Claim the pending payout for the NFT - * @dev MUST grant the claimablePayout value at the time of claim being called - * MUST revert if not called by the token owner or approved users - * MUST emit PayoutClaimed - * SHOULD revert if there is nothing to claim - * @param tokenId The NFT token id + * This event is emitted when an `owner` sets an address to manage token claims for all tokens. + * @param owner The address setting a manager to manage all tokens. + * @param spender The address being permitted to manage all tokens. + * @param approved A boolean indicating whether the spender is approved to claim for all tokens. + */ + event ClaimApprovalForAll(address indexed owner, address indexed spender, bool approved); + + /** + * This event is emitted when an `owner` sets an address to manage token claims for a `tokenId`. + * @param owner The `owner` of `tokenId`. + * @param spender The address being permitted to manage a tokenId. + * @param tokenId The unique identifier of the token being managed. + * @param approved A boolean indicating whether the spender is approved to claim for `tokenId`. + */ + event ClaimApproval(address indexed owner, address indexed spender, uint256 indexed tokenId, bool approved); + + /** + * @notice Claim the pending payout for the NFT. + * @dev MUST grant the claimablePayout value at the time of claim being called to `msg.sender`. + * MUST revert if not called by the token owner or approved users. + * MUST emit PayoutClaimed. + * SHOULD revert if there is nothing to claim. + * @param tokenId The NFT token id. */ function claim(uint256 tokenId) external; /** - * @notice Number of tokens for the NFT which have been claimed at the current timestamp - * @param tokenId The NFT token id - * @return payout The total amount of payout tokens claimed for this NFT + * @notice Number of tokens for the NFT which have been claimed at the current timestamp. + * @param tokenId The NFT token id. + * @return payout The total amount of payout tokens claimed for this NFT. */ function claimedPayout(uint256 tokenId) external view returns (uint256 payout); /** - * @notice Number of tokens for the NFT which can be claimed at the current timestamp + * @notice Number of tokens for the NFT which can be claimed at the current timestamp. * @dev It is RECOMMENDED that this is calculated as the `vestedPayout()` subtracted from `payoutClaimed()`. - * @param tokenId The NFT token id - * @return payout The amount of unlocked payout tokens for the NFT which have not yet been claimed + * @param tokenId The NFT token id. + * @return payout The amount of unlocked payout tokens for the NFT which have not yet been claimed. */ function claimablePayout(uint256 tokenId) external view returns (uint256 payout); /** - * @notice Total amount of tokens which have been vested at the current timestamp. - * This number also includes vested tokens which have been claimed. - * @dev It is RECOMMENDED that this function calls `vestedPayoutAtTime` with - * `block.timestamp` as the `timestamp` parameter. - * @param tokenId The NFT token id + * @notice Total amount of tokens which have been vested at the current timestamp. + * This number also includes vested tokens which have been claimed. + * @dev It is RECOMMENDED that this function calls `vestedPayoutAtTime` + * with `block.timestamp` as the `timestamp` parameter. + * @param tokenId The NFT token id. * @return payout Total amount of tokens which have been vested at the current timestamp. */ function vestedPayout(uint256 tokenId) external view returns (uint256 payout); /** - * @notice Total amount of vested tokens at the provided timestamp. - * This number also includes vested tokens which have been claimed. - * @dev `timestamp` MAY be both in the future and in the past. - * Zero MUST be returned if the timestamp is before the token was minted. - * @param tokenId The NFT token id - * @param timestamp The timestamp to check on, can be both in the past and the future - * @return payout Total amount of tokens which have been vested at the provided timestamp + * @notice Total amount of vested tokens at the provided timestamp. + * This number also includes vested tokens which have been claimed. + * @dev `timestamp` MAY be both in the future and in the past. + * Zero MUST be returned if the timestamp is before the token was minted. + * @param tokenId The NFT token id. + * @param timestamp The timestamp to check on, can be both in the past and the future. + * @return payout Total amount of tokens which have been vested at the provided timestamp. */ function vestedPayoutAtTime(uint256 tokenId, uint256 timestamp) external view returns (uint256 payout); /** * @notice Number of tokens for an NFT which are currently vesting. * @dev The sum of vestedPayout and vestingPayout SHOULD always be the total payout. - * @param tokenId The NFT token id + * @param tokenId The NFT token id. * @return payout The number of tokens for the NFT which are vesting until a future date. */ function vestingPayout(uint256 tokenId) external view returns (uint256 payout); /** - * @notice The start and end timestamps for the vesting of the provided NFT - * MUST return the timestamp where no further increase in vestedPayout occurs for `vestingEnd`. - * @param tokenId The NFT token id - * @return vestingStart The beginning of the vesting as a unix timestamp - * @return vestingEnd The ending of the vesting as a unix timestamp + * @notice The start and end timestamps for the vesting of the provided NFT. + * MUST return the timestamp where no further increase in vestedPayout occurs for `vestingEnd`. + * @param tokenId The NFT token id. + * @return vestingStart The beginning of the vesting as a unix timestamp. + * @return vestingEnd The ending of the vesting as a unix timestamp. */ function vestingPeriod(uint256 tokenId) external view returns (uint256 vestingStart, uint256 vestingEnd); /** - * @notice Token which is used to pay out the vesting claims - * @param tokenId The NFT token id - * @return token The token which is used to pay out the vesting claims + * @notice Token which is used to pay out the vesting claims. + * @param tokenId The NFT token id. + * @return token The token which is used to pay out the vesting claims. */ function payoutToken(uint256 tokenId) external view returns (address token); + + /** + * @notice Sets a global `operator` with permission to manage all tokens owned by the current `msg.sender`. + * @param operator The address to let manage all tokens. + * @param approved A boolean indicating whether the spender is approved to claim for all tokens. + */ + function setClaimApprovalForAll(address operator, bool approved) external; + + /** + * @notice Sets a tokenId `operator` with permission to manage a single `tokenId` owned by the `msg.sender`. + * @param operator The address to let manage a single `tokenId`. + * @param tokenId the `tokenId` to be managed. + * @param approved A boolean indicating whether the spender is approved to claim for all tokens. + */ + function setClaimApproval(address operator, bool approved, uint256 tokenId) external; + + /** + * @notice Returns true if `owner` has set `operator` to manage all `tokenId`s. + * @param owner The owner allowing `operator` to manage all `tokenId`s. + * @param operator The address who is given permission to spend tokens on behalf of the `owner`. + */ + function isClaimApprovedForAll(address owner, address operator) external view returns (bool isClaimApproved); + + /** + * @notice Returns the operating address for a `tokenId`. + * If `tokenId` is not managed, then returns the zero address. + * @param tokenId The NFT `tokenId` to query for a `tokenId` manager. + */ + function getClaimApproved(uint256 tokenId) external view returns (address operator); } -``` +``` ## Rationale @@ -165,10 +211,9 @@ These are base terms used around the specification which function names and defi **`vestingPayout` + `vestedPayout`** -`vestingPayout(uint256 tokenId)` and `vestedPayout(uint256 tokenId)` add up to the total number of tokens which can be claimed by the end of of the vesting schedule. This is also equal to `vestedPayoutAtTime(uint256 tokenId, uint256 timestamp)` with `type(uint256).max` as the `timestamp`. - -The rationale for this is to guarantee that the tokens `vested` and tokens `vesting` are always in sync. The intent is that the vesting curves created are deterministic across the `vestingPeriod`. This creates useful opportunities for integration with these NFTs. For example: A vesting schedule can be iterated through and a vesting curve could be visualized, either on-chain or off-chain. +`vestingPayout(uint256 tokenId)` and `vestedPayout(uint256 tokenId)` add up to the total number of tokens which can be claimed by the end of of the vesting schedule. This is also equal to `vestedPayoutAtTime(uint256 tokenId, uint256 timestamp)` with `type(uint256).max` as the `timestamp`. +The rationale for this is to guarantee that the tokens `vested` and tokens `vesting` are always in sync. The intent is that the vesting curves created are deterministic across the `vestingPeriod`. This creates useful opportunities for integration with these NFTs. For example: A vesting schedule can be iterated through and a vesting curve could be visualized, either on-chain or off-chain. **`vestedPayout` vs `claimedPayout` & `claimablePayout`** @@ -186,7 +231,7 @@ The rationale for providing three functions is to support a number of features: 2. `claimablePayout(uint256 tokenId)` can be used to easily see the current payout unlock amount and allow for unlock cliffs by returning zero until a `timestamp` has been passed. 3. `claimedPayout(uint256 tokenId)` is helpful to see tokens unlocked from an NFT and it is also necessary for the calculation of vested-but-locked payout tokens: `vestedPayout - claimedPayout - claimablePayout = lockedPayout`. This would depend on how the vesting curves are configured by the an implementation of this standard. -`vestedPayoutAtTime(uint256 tokenId, uint256 timestamp)` provides functionality to iterate through the `vestingPeriod(uint256 tokenId)` and provide a visual of the release curve. The intent is that release curves are created which makes `vestedPayoutAtTime(uint256 tokenId, uint256 timestamp)` deterministic. +`vestedPayoutAtTime(uint256 tokenId, uint256 timestamp)` provides functionality to iterate through the `vestingPeriod(uint256 tokenId)` and provide a visual of the release curve. The intent is that release curves are created which makes `vestedPayoutAtTime(uint256 tokenId, uint256 timestamp)` deterministic. ### Timestamps @@ -196,22 +241,21 @@ The `timestamp` makes cross chain integration easy, but internally, the referenc ### Limitation of Scope -- **Historical claims**: While historical vesting schedules can be determined on-chain with `vestedPayoutAtTime(uint256 tokenId, uint256 timestamp)`, historical claims would need to be calculated through historical transaction data. Most likely querying for `PayoutClaimed` events to build a historical graph. +- **Historical claims**: While historical vesting schedules can be determined on-chain with `vestedPayoutAtTime(uint256 tokenId, uint256 timestamp)`, historical claims would need to be calculated through historical transaction data. Most likely querying for `PayoutClaimed` events to build a historical graph. ### Extension Possibilities These feature are not supported by the standard as is, but the standard could be extended to support these more advanced features. - **Custom Vesting Curves**: This standard intends on returning deterministic `vesting` values given NFT `tokenId` and a **timestamp** as inputs. This is intentional as it provides for flexibility in how the vesting curves work under the hood which doesn't constrain projects who intend on building a complex smart contract vesting architecture. -- **Beneficiary**: This standard could be extended to provide for a `beneficiary` address who may `claim` unlocked tokens. -- **NFT Rentals**: Further complex DeFi tool can be created if vesting NFTs could be rented. +- **NFT Rentals**: Further complex DeFi tools can be created if vesting NFTs could be rented. This is done intentionally to keep the base standard simple. These features can and likely will be added through extensions of this standard. ## Backwards Compatibility - The Vesting NFT standard is meant to be fully backwards compatible with any current [ERC-721](./eip-721.md) integrations and marketplaces. -- The Vesting NFT standard also supports [ERC-165](./eip-165.md) interface detection for detecting `ERC-721` compatibility, as well as Vesting NFT compatibility. +- The Vesting NFT standard also supports [ERC-165](./eip-165.md) interface detection for detecting `EIP-721` compatibility, as well as Vesting NFT compatibility. ## Test Cases @@ -219,7 +263,7 @@ The reference vesting NFT repository includes tests written in Hardhat. ## Reference Implementation -A reference implementation of this EIP can be found in [ERC-5725 assets](../assets/eip-5725/README.md). +A reference implementation of this EIP can be found in [ERC-5725 assets](../assets/eip-5725/README.md/). ## Security Considerations @@ -230,7 +274,8 @@ A reference implementation of this EIP can be found in [ERC-5725 assets](../asse **approvals** -- When an approval is made on a Vesting NFT, the operator would have the rights to transfer the Vesting NFT to themselves and then claim the vested tokens. +- When an [ERC-721](./eip-721.md) approval is made on a Vesting NFT, the operator would have the rights to transfer the Vesting NFT to themselves and then claim the vested tokens. +- When a ERC-5725 approval is made on a Vesting NFT, the operator would have the rights to claim the vested tokens, but not transfer the NFT away from the owner. ## Copyright diff --git a/assets/eip-5725/contracts/ERC5725.sol b/assets/eip-5725/contracts/ERC5725.sol index ff030b10119419..298421487a8ab9 100644 --- a/assets/eip-5725/contracts/ERC5725.sol +++ b/assets/eip-5725/contracts/ERC5725.sol @@ -12,8 +12,13 @@ abstract contract ERC5725 is IERC5725, ERC721 { using SafeERC20 for IERC20; /// @dev mapping for claimed payouts - mapping(uint256 => uint256) /*tokenId*/ /*claimed*/ - internal _payoutClaimed; + mapping(uint256 => uint256) /*tokenId*/ /*claimed*/ internal _payoutClaimed; + + /// @dev Mapping from token ID to approved tokenId operator + mapping(uint256 => address) private _tokenIdApprovals; + + /// @dev Mapping from owner to operator approvals + mapping(address => mapping(address => bool)) /* owner */ /*(operator, isApproved)*/ internal _operatorApprovals; /** * @notice Checks if the tokenId exists and its valid @@ -28,7 +33,8 @@ abstract contract ERC5725 is IERC5725, ERC721 { * @dev See {IERC5725}. */ function claim(uint256 tokenId) external override(IERC5725) validToken(tokenId) { - require(ownerOf(tokenId) == msg.sender, "Not owner of NFT"); + require(isApprovedClaimOrOwner(msg.sender, tokenId), "ERC5725: not owner or operator"); + uint256 amountClaimed = claimablePayout(tokenId); require(amountClaimed > 0, "ERC5725: No pending payout"); @@ -38,6 +44,26 @@ abstract contract ERC5725 is IERC5725, ERC721 { IERC20(payoutToken(tokenId)).safeTransfer(msg.sender, amountClaimed); } + /** + * @dev See {IERC5725}. + */ + function setClaimApprovalForAll(address operator, bool approved) external override(IERC5725) { + _setClaimApprovalForAll(operator, approved); + emit ClaimApprovalForAll(msg.sender, operator, approved); + } + + /** + * @dev See {IERC5725}. + */ + function setClaimApproval( + address operator, + bool approved, + uint256 tokenId + ) external override(IERC5725) validToken(tokenId) { + _setClaimApproval(operator, tokenId); + emit ClaimApproval(msg.sender, operator, tokenId, approved); + } + /** * @dev See {IERC5725}. */ @@ -48,62 +74,44 @@ abstract contract ERC5725 is IERC5725, ERC721 { /** * @dev See {IERC5725}. */ - function vestedPayoutAtTime(uint256 tokenId, uint256 timestamp) - public - view - virtual - override(IERC5725) - returns (uint256 payout); + function vestedPayoutAtTime( + uint256 tokenId, + uint256 timestamp + ) public view virtual override(IERC5725) returns (uint256 payout); /** * @dev See {IERC5725}. */ - function vestingPayout(uint256 tokenId) - public - view - override(IERC5725) - validToken(tokenId) - returns (uint256 payout) - { + function vestingPayout( + uint256 tokenId + ) public view override(IERC5725) validToken(tokenId) returns (uint256 payout) { return _payout(tokenId) - vestedPayout(tokenId); } /** * @dev See {IERC5725}. */ - function claimablePayout(uint256 tokenId) - public - view - override(IERC5725) - validToken(tokenId) - returns (uint256 payout) - { + function claimablePayout( + uint256 tokenId + ) public view override(IERC5725) validToken(tokenId) returns (uint256 payout) { return vestedPayout(tokenId) - _payoutClaimed[tokenId]; } /** * @dev See {IERC5725}. */ - function claimedPayout(uint256 tokenId) - public - view - override(IERC5725) - validToken(tokenId) - returns (uint256 payout) - { + function claimedPayout( + uint256 tokenId + ) public view override(IERC5725) validToken(tokenId) returns (uint256 payout) { return _payoutClaimed[tokenId]; } /** * @dev See {IERC5725}. */ - function vestingPeriod(uint256 tokenId) - public - view - override(IERC5725) - validToken(tokenId) - returns (uint256 vestingStart, uint256 vestingEnd) - { + function vestingPeriod( + uint256 tokenId + ) public view override(IERC5725) validToken(tokenId) returns (uint256 vestingStart, uint256 vestingEnd) { return (_startTime(tokenId), _endTime(tokenId)); } @@ -116,18 +124,81 @@ abstract contract ERC5725 is IERC5725, ERC721 { /** * @dev See {IERC165-supportsInterface}. - * IERC5725 interfaceId = 0x7c89676d - */ - function supportsInterface(bytes4 interfaceId) - public - view - virtual - override(ERC721, IERC165) - returns (bool supported) - { + * IERC5725 interfaceId = 0xbd3a202b + */ + function supportsInterface( + bytes4 interfaceId + ) public view virtual override(ERC721, IERC165) returns (bool supported) { return interfaceId == type(IERC5725).interfaceId || super.supportsInterface(interfaceId); } + /** + * @dev See {IERC5725}. + */ + function getClaimApproved(uint256 tokenId) public view returns (address operator) { + return _tokenIdApprovals[tokenId]; + } + + /** + * @dev Returns true if `owner` has set `operator` to manage all `tokenId`s. + * @param owner The owner allowing `operator` to manage all `tokenId`s. + * @param operator The address who is given permission to spend tokens on behalf of the `owner`. + */ + function isClaimApprovedForAll(address owner, address operator) public view returns (bool isClaimApproved) { + return _operatorApprovals[owner][operator]; + } + + /** + * @dev Public view which returns true if the operator has permission to claim for `tokenId` + * @notice To remove permissions, set operator to zero address. + * + * @param operator The address that has permission for a `tokenId`. + * @param tokenId The NFT `tokenId`. + */ + function isApprovedClaimOrOwner(address operator, uint256 tokenId) public view virtual returns (bool) { + address owner = ownerOf(tokenId); + return (operator == owner || isClaimApprovedForAll(owner, operator) || getClaimApproved(tokenId) == operator); + } + + /** + * @dev Internal function to set the operator status for a given owner to manage all `tokenId`s. + * @notice To remove permissions, set approved to false. + * + * @param operator The address who is given permission to spend vested tokens. + * @param approved The approved status. + */ + function _setClaimApprovalForAll(address operator, bool approved) internal virtual { + _operatorApprovals[msg.sender][operator] = approved; + } + + /** + * @dev Internal function to set the operator status for a given tokenId. + * @notice To remove permissions, set operator to zero address. + * + * @param operator The address who is given permission to spend vested tokens. + * @param tokenId The NFT `tokenId`. + */ + function _setClaimApproval(address operator, uint256 tokenId) internal virtual { + require(ownerOf(tokenId) == msg.sender, "ERC5725: not owner of tokenId"); + _tokenIdApprovals[tokenId] = operator; + } + + /** + * @dev Internal function to hook into {IERC721-_afterTokenTransfer}, when a token is being transferred. + * Removes permissions to _tokenIdApprovals[tokenId] when the tokenId is transferred, burnt, but not on mint. + * + * @param from The address from which the tokens are being transferred. + * @param to The address to which the tokens are being transferred. + * @param firstTokenId The first tokenId in the batch that is being transferred. + * @param batchSize The number of tokens being transferred in this batch. + */ + function _beforeTokenTransfer(address from, address to, uint256 firstTokenId, uint256 batchSize) internal override { + super._beforeTokenTransfer(from, to, firstTokenId, batchSize); + if (from != address(0)) { + delete _tokenIdApprovals[firstTokenId]; + } + } + /** * @dev Internal function to get the payout token of a given vesting NFT * diff --git a/assets/eip-5725/contracts/IERC5725.sol b/assets/eip-5725/contracts/IERC5725.sol index adbda0a66be26c..ba71f10e531cd2 100644 --- a/assets/eip-5725/contracts/IERC5725.sol +++ b/assets/eip-5725/contracts/IERC5725.sol @@ -3,15 +3,16 @@ pragma solidity ^0.8.0; import "@openzeppelin/contracts/token/ERC721/IERC721.sol"; /** - * @title Non-Fungible Vesting Token Standard + * @title Non-Fungible Vesting Token Standard. * @notice A non-fungible token standard used to vest ERC-20 tokens over a vesting release curve * scheduled using timestamps. * @dev Because this standard relies on timestamps for the vesting schedule, it's important to keep track of the - * tokens claimed per Vesting NFT so that a user cannot withdraw more tokens than alloted for a specific Vesting NFT. + * tokens claimed per Vesting NFT so that a user cannot withdraw more tokens than allotted for a specific Vesting NFT. + * @custom:interface-id 0xbd3a202b */ interface IERC5725 is IERC721 { /** - * This event is emitted when the payout is claimed through the claim function + * This event is emitted when the payout is claimed through the claim function. * @param tokenId the NFT tokenId of the assets being claimed. * @param recipient The address which is receiving the payout. * @param claimAmount The amount of tokens being claimed. @@ -19,72 +20,118 @@ interface IERC5725 is IERC721 { event PayoutClaimed(uint256 indexed tokenId, address indexed recipient, uint256 claimAmount); /** - * @notice Claim the pending payout for the NFT - * @dev MUST grant the claimablePayout value at the time of claim being called - * MUST revert if not called by the token owner or approved users - * MUST emit PayoutClaimed - * SHOULD revert if there is nothing to claim - * @param tokenId The NFT token id + * This event is emitted when an `owner` sets an address to manage token claims for all tokens. + * @param owner The address setting a manager to manage all tokens. + * @param spender The address being permitted to manage all tokens. + * @param approved A boolean indicating whether the spender is approved to claim for all tokens. + */ + event ClaimApprovalForAll(address indexed owner, address indexed spender, bool approved); + + /** + * This event is emitted when an `owner` sets an address to manage token claims for a `tokenId`. + * @param owner The `owner` of `tokenId`. + * @param spender The address being permitted to manage a tokenId. + * @param tokenId The unique identifier of the token being managed. + * @param approved A boolean indicating whether the spender is approved to claim for `tokenId`. + */ + event ClaimApproval(address indexed owner, address indexed spender, uint256 indexed tokenId, bool approved); + + /** + * @notice Claim the pending payout for the NFT. + * @dev MUST grant the claimablePayout value at the time of claim being called to `msg.sender`. + * MUST revert if not called by the token owner or approved users. + * MUST emit PayoutClaimed. + * SHOULD revert if there is nothing to claim. + * @param tokenId The NFT token id. */ function claim(uint256 tokenId) external; /** - * @notice Number of tokens for the NFT which have been claimed at the current timestamp - * @param tokenId The NFT token id - * @return payout The total amount of payout tokens claimed for this NFT + * @notice Number of tokens for the NFT which have been claimed at the current timestamp. + * @param tokenId The NFT token id. + * @return payout The total amount of payout tokens claimed for this NFT. */ function claimedPayout(uint256 tokenId) external view returns (uint256 payout); /** - * @notice Number of tokens for the NFT which can be claimed at the current timestamp + * @notice Number of tokens for the NFT which can be claimed at the current timestamp. * @dev It is RECOMMENDED that this is calculated as the `vestedPayout()` subtracted from `payoutClaimed()`. - * @param tokenId The NFT token id - * @return payout The amount of unlocked payout tokens for the NFT which have not yet been claimed + * @param tokenId The NFT token id. + * @return payout The amount of unlocked payout tokens for the NFT which have not yet been claimed. */ function claimablePayout(uint256 tokenId) external view returns (uint256 payout); /** * @notice Total amount of tokens which have been vested at the current timestamp. - * This number also includes vested tokens which have been claimed. - * @dev It is RECOMMENDED that this function calls `vestedPayoutAtTime` with - * `block.timestamp` as the `timestamp` parameter. - * @param tokenId The NFT token id + * This number also includes vested tokens which have been claimed. + * @dev It is RECOMMENDED that this function calls `vestedPayoutAtTime` + * with `block.timestamp` as the `timestamp` parameter. + * @param tokenId The NFT token id. * @return payout Total amount of tokens which have been vested at the current timestamp. */ function vestedPayout(uint256 tokenId) external view returns (uint256 payout); /** * @notice Total amount of vested tokens at the provided timestamp. - * This number also includes vested tokens which have been claimed. + * This number also includes vested tokens which have been claimed. * @dev `timestamp` MAY be both in the future and in the past. - * Zero MUST be returned if the timestamp is before the token was minted. - * @param tokenId The NFT token id - * @param timestamp The timestamp to check on, can be both in the past and the future - * @return payout Total amount of tokens which have been vested at the provided timestamp + * Zero MUST be returned if the timestamp is before the token was minted. + * @param tokenId The NFT token id. + * @param timestamp The timestamp to check on, can be both in the past and the future. + * @return payout Total amount of tokens which have been vested at the provided timestamp. */ function vestedPayoutAtTime(uint256 tokenId, uint256 timestamp) external view returns (uint256 payout); /** * @notice Number of tokens for an NFT which are currently vesting. * @dev The sum of vestedPayout and vestingPayout SHOULD always be the total payout. - * @param tokenId The NFT token id + * @param tokenId The NFT token id. * @return payout The number of tokens for the NFT which are vesting until a future date. */ function vestingPayout(uint256 tokenId) external view returns (uint256 payout); /** - * @notice The start and end timestamps for the vesting of the provided NFT - * MUST return the timestamp where no further increase in vestedPayout occurs for `vestingEnd`. - * @param tokenId The NFT token id - * @return vestingStart The beginning of the vesting as a unix timestamp - * @return vestingEnd The ending of the vesting as a unix timestamp + * @notice The start and end timestamps for the vesting of the provided NFT. + * MUST return the timestamp where no further increase in vestedPayout occurs for `vestingEnd`. + * @param tokenId The NFT token id. + * @return vestingStart The beginning of the vesting as a unix timestamp. + * @return vestingEnd The ending of the vesting as a unix timestamp. */ function vestingPeriod(uint256 tokenId) external view returns (uint256 vestingStart, uint256 vestingEnd); /** - * @notice Token which is used to pay out the vesting claims - * @param tokenId The NFT token id - * @return token The token which is used to pay out the vesting claims + * @notice Token which is used to pay out the vesting claims. + * @param tokenId The NFT token id. + * @return token The token which is used to pay out the vesting claims. */ function payoutToken(uint256 tokenId) external view returns (address token); + + /** + * @notice Sets a global `operator` with permission to manage all tokens owned by the current `msg.sender`. + * @param operator The address to let manage all tokens. + * @param approved A boolean indicating whether the spender is approved to claim for all tokens. + */ + function setClaimApprovalForAll(address operator, bool approved) external; + + /** + * @notice Sets a tokenId `operator` with permission to manage a single `tokenId` owned by the `msg.sender`. + * @param operator The address to let manage a single `tokenId`. + * @param tokenId the `tokenId` to be managed. + * @param approved A boolean indicating whether the spender is approved to claim for all tokens. + */ + function setClaimApproval(address operator, bool approved, uint256 tokenId) external; + + /** + * @notice Returns true if `owner` has set `operator` to manage all `tokenId`s. + * @param owner The owner allowing `operator` to manage all `tokenId`s. + * @param operator The address who is given permission to spend tokens on behalf of the `owner`. + */ + function isClaimApprovedForAll(address owner, address operator) external view returns (bool isClaimApproved); + + /** + * @notice Returns the operating address for a `tokenId`. + * If `tokenId` is not managed, then returns the zero address. + * @param tokenId The NFT `tokenId` to query for a `tokenId` manager. + */ + function getClaimApproved(uint256 tokenId) external view returns (address operator); } diff --git a/assets/eip-5725/contracts/mocks/ERC20Mock.sol b/assets/eip-5725/contracts/mocks/ERC20Mock.sol index c006a24ea0f7c7..f69ceed1206c5d 100644 --- a/assets/eip-5725/contracts/mocks/ERC20Mock.sol +++ b/assets/eip-5725/contracts/mocks/ERC20Mock.sol @@ -1,4 +1,4 @@ -// SPDX-License-Identifier: CC0-1.0 +// SPDX-License-Identifier: GPL-3.0 pragma solidity 0.8.17; import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; @@ -6,12 +6,7 @@ import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; contract ERC20Mock is ERC20 { uint8 private _decimals; - constructor( - uint256 supply_, - uint8 decimals_, - string memory name_, - string memory symbol_ - ) ERC20(name_, symbol_) { + constructor(uint256 supply_, uint8 decimals_, string memory name_, string memory symbol_) ERC20(name_, symbol_) { _mint(msg.sender, supply_); _decimals = decimals_; } diff --git a/assets/eip-5725/contracts/reference/LinearVestingNFT.sol b/assets/eip-5725/contracts/reference/LinearVestingNFT.sol index a87fd9e062145e..b8977a8c10d125 100644 --- a/assets/eip-5725/contracts/reference/LinearVestingNFT.sol +++ b/assets/eip-5725/contracts/reference/LinearVestingNFT.sol @@ -1,4 +1,4 @@ -// SPDX-License-Identifier: CC0-1.0 +// SPDX-License-Identifier: GPL-3.0 pragma solidity ^0.8.17; import "../ERC5725.sol"; @@ -63,13 +63,10 @@ contract LinearVestingNFT is ERC5725 { /** * @dev See {IERC5725}. */ - function vestedPayoutAtTime(uint256 tokenId, uint256 timestamp) - public - view - override(ERC5725) - validToken(tokenId) - returns (uint256 payout) - { + function vestedPayoutAtTime( + uint256 tokenId, + uint256 timestamp + ) public view override(ERC5725) validToken(tokenId) returns (uint256 payout) { if (timestamp < _cliff(tokenId)) { return 0; } diff --git a/assets/eip-5725/contracts/reference/VestingNFT.sol b/assets/eip-5725/contracts/reference/VestingNFT.sol index 1b50d85552bc98..4b181f4bab19cb 100644 --- a/assets/eip-5725/contracts/reference/VestingNFT.sol +++ b/assets/eip-5725/contracts/reference/VestingNFT.sol @@ -1,4 +1,4 @@ -// SPDX-License-Identifier: CC0-1.0 +// SPDX-License-Identifier: GPL-3.0 pragma solidity ^0.8.17; import "../ERC5725.sol"; @@ -30,12 +30,7 @@ contract VestingNFT is ERC5725 { * @param releaseTimestamp When the full amount of tokens get released * @param token The ERC20 token to vest over time */ - function create( - address to, - uint256 amount, - uint128 releaseTimestamp, - IERC20 token - ) public virtual { + function create(address to, uint256 amount, uint128 releaseTimestamp, IERC20 token) public virtual { require(to != address(0), "to cannot be address 0"); require(releaseTimestamp > block.timestamp, "release must be in future"); @@ -56,13 +51,10 @@ contract VestingNFT is ERC5725 { /** * @dev See {IERC5725}. */ - function vestedPayoutAtTime(uint256 tokenId, uint256 timestamp) - public - view - override(ERC5725) - validToken(tokenId) - returns (uint256 payout) - { + function vestedPayoutAtTime( + uint256 tokenId, + uint256 timestamp + ) public view override(ERC5725) validToken(tokenId) returns (uint256 payout) { if (timestamp >= _endTime(tokenId)) { return _payout(tokenId); } diff --git a/assets/eip-5725/test/LinearVestingNFT.test.ts b/assets/eip-5725/test/LinearVestingNFT.test.ts index 7d1b23cc04fa21..4eb55e03a69338 100644 --- a/assets/eip-5725/test/LinearVestingNFT.test.ts +++ b/assets/eip-5725/test/LinearVestingNFT.test.ts @@ -1,7 +1,7 @@ import { ethers } from 'hardhat' import { Signer } from 'ethers' import { expect } from 'chai' -import { increaseTime } from './helpers/time' +import { time } from '@nomicfoundation/hardhat-network-helpers' // typechain import { ERC20Mock__factory, @@ -56,7 +56,7 @@ describe('LinearVestingNFT', function () { // TODO: More extensive testing of linear vesting functionality const totalPayout = await linearVestingNFT.vestedPayoutAtTime(0, unlockTime) expect(await linearVestingNFT.vestedPayout(0)).to.equal(0) - await increaseTime(testValues.totalLock) + await time.increase(testValues.totalLock) expect(await linearVestingNFT.vestedPayout(0)).to.equal(totalPayout) }) diff --git a/assets/eip-5725/test/VestingNFT.test.ts b/assets/eip-5725/test/VestingNFT.test.ts index cc2893e77e16f2..05567a3eed393b 100644 --- a/assets/eip-5725/test/VestingNFT.test.ts +++ b/assets/eip-5725/test/VestingNFT.test.ts @@ -1,12 +1,16 @@ import { ethers } from 'hardhat' -import { Signer } from 'ethers' +import { BigNumber, Signer } from 'ethers' import { expect } from 'chai' -import { increaseTime } from './helpers/time' +import { time } from '@nomicfoundation/hardhat-network-helpers' // typechain import { ERC20Mock, VestingNFT } from '../typechain-types' +import { IERC5725_InterfaceId } from '../src/erc5725' + +const IERC721_InterfaceId = '0x80ac58cd' const testValues = { payout: '1000000000', + payoutDecimals: 18, lockTime: 60, } @@ -15,7 +19,10 @@ describe('VestingNFT', function () { let vestingNFT: VestingNFT let mockToken: ERC20Mock let receiverAccount: string + let operatorAccount: string + let transferToAccount: string let unlockTime: number + let invalidTokenID = 1337 beforeEach(async function () { const VestingNFT = await ethers.getContractFactory('VestingNFT') @@ -25,7 +32,7 @@ describe('VestingNFT', function () { const ERC20Mock = await ethers.getContractFactory('ERC20Mock') mockToken = await ERC20Mock.deploy( '1000000000000000000000', - 18, + testValues.payoutDecimals, 'LockedToken', 'LOCK' ) @@ -34,56 +41,63 @@ describe('VestingNFT', function () { accounts = await ethers.getSigners() receiverAccount = await accounts[1].getAddress() - unlockTime = await createVestingNft(vestingNFT, receiverAccount, mockToken) + operatorAccount = await accounts[2].getAddress() + transferToAccount = await accounts[3].getAddress() + unlockTime = await createVestingNft( + vestingNFT, + receiverAccount, + mockToken, + 5 + ) }) + /** + * https://eips.ethereum.org/EIPS/eip-165#how-interfaces-are-identified + * // Solidity export interface id: + * bytes4 public constant IID_TEST = type(IERC5725).interfaceId; + * // Pull out the interfaceId in tests + * const interfaceId = await vestingNFT.IID_TEST(); + */ it('Supports ERC721 and IERC5725 interfaces', async function () { - expect(await vestingNFT.supportsInterface('0x80ac58cd')).to.equal(true) - - /** - * https://eips.ethereum.org/EIPS/eip-165#how-interfaces-are-identified - * // Solidity export interface id: - * bytes4 public constant IID_ITEST = type(IERC5725).interfaceId; - * // Pull out the interfaceId in tests - * const interfaceId = await vestingNFT.IID_ITEST(); - */ - // Vesting NFT Interface ID - expect(await vestingNFT.supportsInterface('0xf8600f8b')).to.equal(true) + // IERC721 + expect(await vestingNFT.supportsInterface(IERC721_InterfaceId)).to.equal( + true + ) + // Vesting NFT Interface IERC5725 + expect(await vestingNFT.supportsInterface(IERC5725_InterfaceId)).to.equal( + true + ) }) it('Returns a valid vested payout', async function () { const totalPayout = await vestingNFT.vestedPayoutAtTime(0, unlockTime) expect(await vestingNFT.vestedPayout(0)).to.equal(0) - await increaseTime(testValues.lockTime) + await time.increase(testValues.lockTime) expect(await vestingNFT.vestedPayout(0)).to.equal(totalPayout) }) it('Reverts with invalid ID', async function () { - await expect(vestingNFT.vestedPayout(1)).to.revertedWith( - 'VestingNFT: invalid token ID' - ) - await expect(vestingNFT.vestedPayoutAtTime(1, unlockTime)).to.revertedWith( - 'VestingNFT: invalid token ID' + await expect(vestingNFT.vestedPayout(invalidTokenID)).to.revertedWith( + 'ERC5725: invalid token ID' ) - await expect(vestingNFT.vestingPayout(1)).to.revertedWith( - 'VestingNFT: invalid token ID' + await expect( + vestingNFT.vestedPayoutAtTime(invalidTokenID, unlockTime) + ).to.revertedWith('ERC5725: invalid token ID') + await expect(vestingNFT.vestingPayout(invalidTokenID)).to.revertedWith( + 'ERC5725: invalid token ID' ) - await expect(vestingNFT.claimablePayout(1)).to.revertedWith( - 'VestingNFT: invalid token ID' + await expect(vestingNFT.claimablePayout(invalidTokenID)).to.revertedWith( + 'ERC5725: invalid token ID' ) - await expect(vestingNFT.vestingPeriod(1)).to.revertedWith( - 'VestingNFT: invalid token ID' + await expect(vestingNFT.vestingPeriod(invalidTokenID)).to.revertedWith( + 'ERC5725: invalid token ID' ) - await expect(vestingNFT.payoutToken(1)).to.revertedWith( - 'VestingNFT: invalid token ID' + await expect(vestingNFT.payoutToken(invalidTokenID)).to.revertedWith( + 'ERC5725: invalid token ID' ) - await expect(vestingNFT.claim(1)).to.revertedWith( - 'VestingNFT: invalid token ID' + await expect(vestingNFT.claim(invalidTokenID)).to.revertedWith( + 'ERC5725: invalid token ID' ) - // NOTE: Removed claimTo from spec - // await expect(vestingNFT.claimTo(1, receiverAccount)).to.revertedWith( - // "VestingNFT: invalid token ID" - // ); }) it('Returns a valid pending payout', async function () { @@ -93,7 +107,7 @@ describe('VestingNFT', function () { it('Returns a valid releasable payout', async function () { const totalPayout = await vestingNFT.vestedPayoutAtTime(0, unlockTime) expect(await vestingNFT.claimablePayout(0)).to.equal(0) - await increaseTime(testValues.lockTime) + await time.increase(testValues.lockTime) expect(await vestingNFT.claimablePayout(0)).to.equal(totalPayout) }) @@ -108,43 +122,30 @@ describe('VestingNFT', function () { it('Is able to claim', async function () { const connectedVestingNft = vestingNFT.connect(accounts[1]) - await increaseTime(testValues.lockTime) + expect(await vestingNFT.claimedPayout(0)).to.equal(0) + await time.increase(testValues.lockTime) const txReceipt = await connectedVestingNft.claim(0) await txReceipt.wait() expect(await mockToken.balanceOf(receiverAccount)).to.equal( testValues.payout ) + expect(await vestingNFT.claimedPayout(0)).to.equal(testValues.payout) }) it('Reverts claim when payout is 0', async function () { const connectedVestingNft = vestingNFT.connect(accounts[1]) await expect(connectedVestingNft.claim(0)).to.revertedWith( - 'VestingNFT: No pending payout' + 'ERC5725: No pending payout' ) }) - it('Reverts claim when payout is not from owner', async function () { + it('Reverts claim when payout is not from owner or account with permission', async function () { const connectedVestingNft = vestingNFT.connect(accounts[2]) await expect(connectedVestingNft.claim(0)).to.revertedWith( - 'Not owner of NFT' + 'ERC5725: not owner or operator' ) }) - // NOTE: Removed claimTo from spec - // it("Is able to claim to other account", async function () { - // const connectedVestingNft = vestingNFT.connect(accounts[1]); - // const otherReceiverAddress = await accounts[2].getAddress(); - // await increaseTime(testValues.lockTime); - // const txReceipt = await connectedVestingNft.claimTo( - // 0, - // otherReceiverAddress - // ); - // await txReceipt.wait(); - // expect(await mockToken.balanceOf(otherReceiverAddress)).to.equal( - // testValues.payout - // ); - // }); - it('Reverts when creating to account 0', async function () { await expect( vestingNFT.create( @@ -155,21 +156,186 @@ describe('VestingNFT', function () { ) ).to.revertedWith('to cannot be address 0') }) + + it('Revert when setting an setClaimApproval for a tokenId you do not own', async function () { + // Account without permission tries to call setClaimApproval(self,tokenId) + const connectedVestingNft = vestingNFT.connect(accounts[2]) + + await expect( + connectedVestingNft.setClaimApproval(operatorAccount, true, 1) + ).to.revertedWith('ERC5725: not owner of tokenId') + }) + + it("Should allow a designated operator to manage specific tokenId's owned by the owner through _tokenIdApprovals, until such rights are revoked", async function () { + // Give permission for SPECIFIC tokenId to be managed + const ownersConnectedVestingNft = vestingNFT.connect(accounts[1]) + const operatorsConnectedVestingNft = vestingNFT.connect(accounts[2]) + let approveToken1 = ownersConnectedVestingNft.setClaimApproval( + operatorAccount, + true, + 1 + ) + await expect(approveToken1).to.be.fulfilled + await expect(approveToken1) + .to.emit(ownersConnectedVestingNft, 'ClaimApproval') + .withArgs(receiverAccount, operatorAccount, 1, true) + + // Elapse time, operator can claim + await time.increase(testValues.lockTime) + await expect(operatorsConnectedVestingNft.claim(1)).to.be.fulfilled + + // Owner revokes permission for SPECIFIC tokenId + let unapprovedToken1 = ownersConnectedVestingNft.setClaimApproval( + operatorAccount, + false, + 1 + ) + await expect(unapprovedToken1).to.be.fulfilled + await expect(unapprovedToken1) + .to.emit(ownersConnectedVestingNft, 'ClaimApproval') + .withArgs(receiverAccount, operatorAccount, 1, false) + + // Elapse time, operator can't claim for that specific tokenId because no permissions + await time.increase(testValues.lockTime) + await expect(operatorsConnectedVestingNft.claim(0)).to.revertedWith( + 'ERC5725: not owner or operator' + ) + }) + + it("Should allow a designated operator to manage all tokenId's owned by the owner through _operatorApprovals, until such rights are revoked", async function () { + // Give permission for ALL tokenId to be managed + const ownersConnectedVestingNft = vestingNFT.connect(accounts[1]) + const operatorsConnectedVestingNft = vestingNFT.connect(accounts[2]) + let approveGlobalOperator = + ownersConnectedVestingNft.setClaimApprovalForAll(operatorAccount, true) + await expect(approveGlobalOperator).to.be.fulfilled + await expect(approveGlobalOperator) + .to.emit(ownersConnectedVestingNft, 'ClaimApprovalForAll') + .withArgs(receiverAccount, operatorAccount, true) + + // Elapse time, operator can claim + await time.increase(testValues.lockTime) + await expect(operatorsConnectedVestingNft.claim(1)).to.be.fulfilled + await expect(operatorsConnectedVestingNft.claim(2)).to.be.fulfilled + await expect(operatorsConnectedVestingNft.claim(3)).to.be.fulfilled + + // Owner revokes permission for SPECIFIC tokenId + let unapprovedGlobalOperator = + ownersConnectedVestingNft.setClaimApprovalForAll(operatorAccount, false) + await expect(unapprovedGlobalOperator).to.be.fulfilled + await expect(unapprovedGlobalOperator) + .to.emit(ownersConnectedVestingNft, 'ClaimApprovalForAll') + .withArgs(receiverAccount, operatorAccount, false) + + // Elapse time, operator can't claim for that specific tokenId because no permissions + await time.increase(testValues.lockTime) + await expect(operatorsConnectedVestingNft.claim(1)).to.revertedWith( + 'ERC5725: not owner or operator' + ) + await expect(operatorsConnectedVestingNft.claim(2)).to.revertedWith( + 'ERC5725: not owner or operator' + ) + await expect(operatorsConnectedVestingNft.claim(3)).to.revertedWith( + 'ERC5725: not owner or operator' + ) + }) + + it("Should revoke an operator's management rights from _tokenIdApprovals for a specific tokenId when the token is transferred", async function () { + // Give permission for SPECIFIC tokenId to be managed + const ownersConnectedVestingNft = vestingNFT.connect(accounts[1]) + const operatorsConnectedVestingNft = vestingNFT.connect(accounts[2]) + let approveToken1 = ownersConnectedVestingNft.setClaimApproval( + operatorAccount, + true, + 1 + ) + await expect(approveToken1).to.be.fulfilled + + // permissions added + expect(await ownersConnectedVestingNft.getClaimApproved(1)).to.equal( + operatorAccount + ) + + // Transfer tokenId 1 to other address which removes the _tokenIdApprovals permission but we keep the global OP status + const transferNft = ownersConnectedVestingNft.transferFrom( + receiverAccount, + transferToAccount, + 1 + ) + await expect(transferNft).to.be.fulfilled + + // permissions removed + expect(await ownersConnectedVestingNft.getClaimApproved(1)).to.equal( + '0x0000000000000000000000000000000000000000' + ) + + // Operator can't claim for tokenId 1 permissions removed + await expect(operatorsConnectedVestingNft.claim(1)).to.revertedWith( + 'ERC5725: not owner or operator' + ) + }) + + it("Should keep an operator's management rights to _operatorApprovals for all tokenIds when one or more are transfered", async function () { + // Give permission for ALL tokenId to be managed + const ownersConnectedVestingNft = vestingNFT.connect(accounts[1]) + const operatorsConnectedVestingNft = vestingNFT.connect(accounts[2]) + let approveGlobalOperator = + ownersConnectedVestingNft.setClaimApprovalForAll(operatorAccount, true) + await expect(approveGlobalOperator).to.be.fulfilled + + // permissions added + expect( + await ownersConnectedVestingNft.isClaimApprovedForAll( + receiverAccount, + operatorAccount + ) + ).to.equal(true) + + // Transfer tokenId 1 to other address which removes the _tokenIdApprovals permission but we keep the global OP status + const transferNft = ownersConnectedVestingNft.transferFrom( + receiverAccount, + transferToAccount, + 1 + ) + await expect(transferNft).to.be.fulfilled + + // permissions kept + expect( + await ownersConnectedVestingNft.isClaimApprovedForAll( + receiverAccount, + operatorAccount + ) + ).to.equal(true) + + // Operator can't claim for tokenId 1 they don't own it anymore + await expect(operatorsConnectedVestingNft.claim(1)).to.revertedWith( + 'ERC5725: not owner or operator' + ) + // Operator can claim for other tokenIds + await time.increase(testValues.lockTime) + await expect(operatorsConnectedVestingNft.claim(2)).to.be.fulfilled + await expect(operatorsConnectedVestingNft.claim(3)).to.be.fulfilled + }) }) async function createVestingNft( vestingNFT: VestingNFT, receiverAccount: string, - mockToken: ERC20Mock + mockToken: ERC20Mock, + batchMintAmount: number = 1 ) { const latestBlock = await ethers.provider.getBlock('latest') const unlockTime = latestBlock.timestamp + testValues.lockTime - const txReceipt = await vestingNFT.create( - receiverAccount, - testValues.payout, - unlockTime, - mockToken.address - ) - await txReceipt.wait() + + for (let i = 0; i <= batchMintAmount; i++) { + const txReceipt = await vestingNFT.create( + receiverAccount, + testValues.payout, + unlockTime, + mockToken.address + ) + await txReceipt.wait() + } + return unlockTime } diff --git a/assets/eip-5725/test/helpers/time.ts b/assets/eip-5725/test/helpers/time.ts deleted file mode 100644 index 6e99622c19a765..00000000000000 --- a/assets/eip-5725/test/helpers/time.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { ethers } from 'hardhat' - -async function mineNBlocks(n: number) { - for (let index = 0; index < n; index++) { - await ethers.provider.send('evm_mine', []) - } -} - -export async function increaseTime(seconds: number) { - await ethers.provider.send('evm_increaseTime', [seconds]) - await mineNBlocks(1) -}