diff --git a/README.md b/README.md index d8633bd..71b62c5 100644 --- a/README.md +++ b/README.md @@ -2,31 +2,32 @@ ## Description -This contract provides a mechanisms for users to lock XDEFI, resulting in non-fungible locked positions, since each position is only un-lockable in its entirety after a certain time from locking. Locked positions have a right to withdraw at least the respective amount of XDEFI deposited, as well as a portion of XDEFI that was airdropped to this contract, and thus dispersed to all locked positions. This portion is based on the relative portion of locked XDEFI in comparison to all locked XDEFI, and the bonus multiplier of the locked position, which is assigned at lock-time based on the lock duration. Further, the locked and unlocked positions exist as NFTs with a score, in which several can be merged/burned to create new NFTs of a larger score. +This contract provides a mechanism for users to lock XDEFI, resulting in non-fungible locked positions, since each position is only un-lockable in its entirety after a certain time. Locked positions have a right to withdraw at least the respective amount of XDEFI deposited, as well as a portion of XDEFI that was airdropped to this contract, and thus dispersed to all locked positions. This portion is based on the relative portion of locked XDEFI in comparison to all locked XDEFI, and the bonus multiplier of the locked position, which is assigned at lock-time, based on the lock duration. Further, the locked and unlocked positions exist as NFTs with a number of "credits", in which several can be merged/burned to consolidate them into one NFT. ## Features and Functionality -- Users can lock in an amount of XDEFI for a duration and cannot unlock/withdraw during the specified duration -- Lock durations and their respective bonus multiplier are definable by the admin, and can be changed. (0-second durations cannot be enabled). "No bonus" is effectively a bonus multiplier of 1x, which still receives a "normal" share of future distributed rewards. -- User can lock in any amount of XDEFI that results in at least 1e18 (1 with 18 decimals) "units" (i.e. 1 XDEFI at a 1x bonus multiplier). -- The lockup becomes a “locked position”, which is an NFT (similar to Uniswap v3's liquidity position NFTs, but simpler). -- The "locked position" is transferable as a NFT during lockup and after it is unlocked/withdrawn. -- After a locked position's lockup time expires, the owner of the NFT can re-lock the amount into a new stake position, or withdraw it, or some combination, in one tx. -- Rewards are accrued while locked up, with a bonus multiplier based on the lockup time. -- Accruing of rewards/revenue with the bonus multiplier persists after the lockup time expires. This is fine since the goal is to reward the initial commitment. Further, one would be better off re-locking their withdrawable token, to compound. -- Upon locking, the NFT locked position is given a “score”, which is some function of amount and lockup time (i.e. `amount * duration`). -- The NFT's score is embedded in the `tokenId`, so the chain enforces it (first/leftmost 128 bits is the score, last/rightmost 128 bits is a sequential identifier, for uniqueness). -- The NFT points to some off-chain server that will serve the correct metadata given the NFTs points (i.e. `tokenId`). This is a stateless process off-chain. -- Once the NFT position has been unlocked and the XDEFI withdrawn, the NFT still exists simply as a transferable loyalty NFT, with its same score, but without any withdrawable XDEFI. -- Users can combine several of these amount-less loyalty NFTs into one, where the resulting NFT’s points is the sum of those burned to produce it. +- Users can lock in an amount of XDEFI for a duration and cannot unlock/withdraw during the specified duration. +- Lock durations and their respective bonus multiplier are definable by the admin, and can be changed. 0-second durations cannot be enabled. "No bonus" is effectively a bonus multiplier of 1x, which still receives a "normal" share of future distributed rewards. Changes to the lock durations do not retroactively affect existing locked positions. +- Users can lock in any amount of XDEFI that results in at least 1e18 (1 with 18 decimals) "units" (i.e. 1 XDEFI at a 1x bonus multiplier). +- Upon creating a locked position, an NFT is minted which is the owner of that locked position. In order words, owning that NFT gives the user the right to eventually withdraw the locked position. +- The NFT is transferable at any time, regardless if the original locked position still exists. +- After a locked position's lockup time expires, the owner of the NFT can re-lock the amount into a new stake position, or withdraw it, or some combination, in one transaction. +- XDEFI Rewards are accrued while locked up, with a bonus multiplier based on the lockup time. +- Accruing of XDEFI rewards with the bonus multiplier persists after the lockup time expires. This is fine since the goal is to reward the initial commitment. Further, one is still better off re-locking their withdrawable token, to compound. +- Upon locking, the NFT locked position is also given "credits”, which is some function of amount and lockup time (`amount * duration`). +- The NFT points to some off-chain server that will serve the correct metadata given the `tokenId`. The metadata (`tier`, `credits`, etc) are enforced by the smart contract. +- Once the locked position has been unlocked and the XDEFI withdrawn, the NFT still exists simply as a transferable loyalty NFT, and retains its credits, but without any withdrawable XDEFI. +- Users can combine several of these position-less loyalty NFTs into one, where the resulting NFT’s credits is the sum of those burned to consolidate. - Contract supports ERC20 Permit, which avoids the need to do ERC20 approvals for XDEFI locking. -- A "no-going-back" emergency mode exists where the contract admin can prevent new locks, allow immediate unlocks of all locked positions, and users to remove just their deposits in the event of severe issues. +- A "no-going-back" emergency mode exists where the contract admin can prevent new locks, allow immediate unlocks of all locked positions, as well as an emergency unlock to alow users to remove just their deposits in the event of severe issues. +- NFT credits can be consumed by the owner, or by anyone via a ConsumePermit, similar to ERC20 Permits. +- Contract support token and account approvals, so any access control logic that is limited to the owner of the NFTs are actually also enabled for approved operators. ## Contracts ### XDEFIDistribution -This contract contains the standalone logic for locking, unlocking, re-locking, batched unlocking, batched re-locking, and merging, as well as the ERC721Enumerable functionality. +This contract contains the standalone logic for locking, unlocking, re-locking, batched unlocking, batched re-locking, merging, and consuming, as well as the ERC721Enumerable functionality. ### XDEFIDistributionHelper diff --git a/backend/index.js b/backend/index.js index e4b7db1..6792935 100644 --- a/backend/index.js +++ b/backend/index.js @@ -2,25 +2,40 @@ const http = require('http'); const url = require('url'); const fs = require('fs'); const path = require('path'); - -// TODO: add trait/attribute indicating if the NFT is backed by withdrawable XDEFI, and how much -// TODO: env for `fee_recipient` and chain read api key +const axios = require('axios'); const host = 'localhost'; const port = 8000; +const contract = '0x0000000000000000000000000000000000000000'; + +const CREATURES = { + 1: { name: 'Ikalgo', file: 'ikalgo' }, + 2: { name: 'Oxtopus', file: 'oxtopus' }, + 3: { name: 'Nautilus', file: 'nautilus' }, + 4: { name: 'Kaurna', file: 'kaurna' }, + 5: { name: 'Haliphron', file: 'haliphron' }, + 6: { name: 'Kanaloa', file: 'kanaloa' }, + 7: { name: 'Taniwha', file: 'taniwha' }, + 8: { name: 'Cthulhu', file: 'cthulhu' }, + 9: { name: 'Yacumama', file: 'yacumama' }, + 10: { name: 'Hafgufa', file: 'hafgufa' }, + 11: { name: 'Akkorokamui', file: 'akkorokamui' }, + 12: { name: 'Nessie', file: 'nessie' }, + 13: { name: 'The Kraken', file: 'thekraken' }, +}; -const errorResponse = (res) => { +const errorResponse = (res, error = '') => { res.writeHead(400); - res.end(); + res.end(error); }; const infoResponse = (res) => { res.writeHead(200, { 'Content-Type': 'application/json' }); const metadata = JSON.stringify({ - name: 'XDEFI Distribution Creatures', - description: 'XDEFI Distribution Creatures are tiered NFTs born from the creation of XDEFI Distribution Positions.', - image: 'https://s2.coinmarketcap.com/static/img/coins/64x64/13472.png', + name: 'XDEFI Badges', + description: 'XDEFI Badges are tiered NFTs born from the creation of XDEFI Distribution Positions.', + image: 'http://localhost:8000/media/xdefi.png', external_link: 'https://www.xdefi.io/', seller_fee_basis_points: 100, // 1% in basis points fee_recipient: '0x0000000000000000000000000000000000000000', @@ -29,65 +44,75 @@ const infoResponse = (res) => { res.end(metadata); }; -const getCreature = (tier) => { - if (tier === '1') return { name: 'Ikalgo', file: 'ikalgo' }; - - if (tier === '2') return { name: 'Oxtopus', file: 'oxtopus' }; - - if (tier === '3') return { name: 'Nautilus', file: 'nautilus' }; - - if (tier === '4') return { name: 'Kaurna', file: 'kaurna' }; - - if (tier === '5') return { name: 'Haliphron', file: 'haliphron' }; +const getAttributes = async (tokenId) => { + const { + data: { result }, + } = await axios.post( + 'http://127.0.0.1:7545', + { + jsonrpc: '2.0', + method: 'eth_call', + params: [ + { + to: contract, + data: `0x09363c44${BigInt(tokenId).toString(16).padStart(64, '0')}`, + }, + 'latest', + ], + id: 1, + }, + { + headers: { + 'Content-Type': 'application/json', + }, + }, + ); + + return { + tier: BigInt('0x' + result.slice(2, 66)).toString(), + credits: BigInt('0x' + result.slice(66, 130)).toString(), + withdrawable: BigInt('0x' + result.slice(130, 194)).toString(), + expiry: Number(BigInt('0x' + result.slice(194, 258))), + }; +}; - if (tier === '6') return { name: 'Kanaloa', file: 'kanaloa' }; +const metadataResponse = async (tokenIdParam, res) => { + if (!tokenIdParam || !/^[0-9]+$/.test(tokenIdParam)) return errorResponse(res, 'INVALID TOKEN ID'); - if (tier === '7') return { name: 'Taniwha', file: 'taniwha' }; + const tokenId = BigInt(tokenIdParam); - if (tier === '8') return { name: 'Cthulhu', file: 'cthulhu' }; + if (tokenId >= 2n ** 256n) return errorResponse(res, 'INVALID TOKEN ID'); - if (tier === '9') return { name: 'Yacumama', file: 'yacumama' }; + const { tier, credits, withdrawable, expiry } = await getAttributes(tokenId).catch(() => errorResponse(res, 'FETCH FAIL')); - if (tier === '10') return { name: 'Hafgufa', file: 'hafgufa' }; + const creature = CREATURES[tier]; - if (tier === '11') return { name: 'Akkorokamui', file: 'akkorokamui' }; + if (!creature) return errorResponse(res, 'INVALID TIER'); - if (tier === '12') return { name: 'Nessie', file: 'nessie' }; + const { name, file } = creature; - if (tier === '13') return { name: 'The Kraken', file: 'thekraken' }; + const attributes = [ + { display_type: 'number', trait_type: 'Tier', value: tier }, + { display_type: 'number', trait_type: 'Credits', value: credits }, + { trait_type: 'Has Locked Position', value: expiry ? 'yes' : 'no' }, + ]; - throw Error('Invalid Tier'); -}; + if (expiry) { + attributes.push({ display_type: 'number', trait_type: 'Withdrawable XDEFI', value: withdrawable }); + attributes.push({ display_type: 'date', trait_type: 'Lock Expiry', value: expiry }); + } -const getMetadata = (tokenId) => { - const mintSequence = (tokenId & (2n ** 128n - 1n)).toString(); - const score = ((tokenId >> 128n) & (2n ** 124n - 1n)).toString(); - const tier = (tokenId >> 252n).toString(); - const { name, file } = getCreature(tier); - - return JSON.stringify({ - attributes: [ - { display_type: 'number', trait_type: 'score', value: score }, - { display_type: 'number', trait_type: 'tier', value: tier }, - { display_type: 'number', trait_type: 'sequence', value: mintSequence }, - ], - description: `${name} is a tier ${tier} XDEFI Distribution Creature.`, + const data = JSON.stringify({ + attributes, + description: `${name} is a tier ${tier} XDEFI Badge`, name, background_color: '2040DF', image: `http://localhost:8000/media/${file}.png`, animation_url: `http://localhost:8000/media/${file}.mp4`, }); -}; - -const metadataResponse = (tokenIdParam, res) => { - if (!tokenIdParam || !/^[0-9]+$/.test(tokenIdParam)) return errorResponse(res); - - const tokenId = BigInt(tokenIdParam); - - if (tokenId >= 2n ** 256n) return errorResponse(res); res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(getMetadata(tokenId)); + res.end(data); }; const mediaResponse = (fileName, res) => { diff --git a/contracts/XDEFIDistribution.sol b/contracts/XDEFIDistribution.sol index dbbd628..2f33b8d 100644 --- a/contracts/XDEFIDistribution.sol +++ b/contracts/XDEFIDistribution.sol @@ -1,6 +1,6 @@ // SPDX-License-Identifier: MIT -pragma solidity =0.8.10; +pragma solidity =0.8.12; import { ERC721, ERC721Enumerable, Strings } from "@openzeppelin/contracts/token/ERC721/extensions/ERC721Enumerable.sol"; import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; @@ -15,11 +15,20 @@ contract XDEFIDistribution is IXDEFIDistribution, ERC721Enumerable { uint256 internal constant ZERO_UINT256 = uint256(0); uint256 internal constant ONE_UINT256 = uint256(1); uint256 internal constant ONE_HUNDRED_UINT256 = uint256(100); - uint256 internal constant ONE_HUNDRED_TWENTY_EIGHT_UINT256 = uint256(128); - uint256 internal constant TWO_HUNDRED_FIFTY_TWO_UINT256 = uint256(252); - uint256 internal constant ONE_HUNDRED_TWENTY_FOUR_BIT_MASK = uint256(type(uint128).max >> 4); - uint256 internal constant ONE_HUNDRED_TWENTY_EIGHT_BIT_MASK = type(uint128).max; + uint256 internal constant TIER_1 = uint256(1); + uint256 internal constant TIER_2 = uint256(2); + uint256 internal constant TIER_3 = uint256(3); + uint256 internal constant TIER_4 = uint256(4); + uint256 internal constant TIER_5 = uint256(5); + uint256 internal constant TIER_6 = uint256(6); + uint256 internal constant TIER_7 = uint256(7); + uint256 internal constant TIER_8 = uint256(8); + uint256 internal constant TIER_9 = uint256(9); + uint256 internal constant TIER_10 = uint256(10); + uint256 internal constant TIER_11 = uint256(11); + uint256 internal constant TIER_12 = uint256(12); + uint256 internal constant TIER_13 = uint256(13); uint256 internal constant TIER_2_THRESHOLD = uint256(150 * 1e18 * 30 days); uint256 internal constant TIER_3_THRESHOLD = uint256(300 * 1e18 * 30 days); @@ -44,7 +53,9 @@ contract XDEFIDistribution is IXDEFIDistribution, ERC721Enumerable { uint256 public totalDepositedXDEFI; uint256 public totalUnits; - mapping(uint256 => Position) public positionOf; + mapping(uint256 => Position) internal _positionOf; + + mapping(uint256 => uint256) public creditsOf; mapping(uint256 => uint256) public bonusMultiplierOf; // Scaled by 100, capped at 255 (i.e. 1.1x is 110, 2.55x is 255). @@ -67,12 +78,34 @@ contract XDEFIDistribution is IXDEFIDistribution, ERC721Enumerable { uint256 public constant MINIMUM_UNITS = uint256(1e18); - constructor(address xdefi_, string memory baseURI_) ERC721("XDEFI Badgies", "bXDEFI") { + bytes32 public immutable DOMAIN_SEPARATOR; + + mapping(uint256 => uint256) public consumePermitNonce; + + string private constant EIP191_PREFIX_FOR_EIP712_STRUCTURED_DATA = "\x19\x01"; + + // keccak256('PermitConsume(uint256 tokenId,address consumer,uint256 limit,uint256 nonce,uint256 deadline)'); + bytes32 private constant CONSUME_PERMIT_SIGNATURE_HASH = bytes32(0xa0a7128942405265cd830695cb06df90c6bfdbbe22677cc592c3d36c3180b079); + + constructor(address xdefi_, string memory baseURI_) ERC721("XDEFI Badges", "bXDEFI") { // Set `xdefi` immutable and check that it's not empty. if ((xdefi = xdefi_) == ZERO_ADDRESS) revert InvalidToken(); owner = msg.sender; baseURI = baseURI_; + + DOMAIN_SEPARATOR = keccak256( + abi.encode( + // keccak256(bytes('EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)')), + 0x8b73c3c69bb8fe3d512ecc4cf759cc79239f7b179b0ffacaa9a75d522b39400f, + // keccak256(bytes('XDEFI Badges')), + 0x4c62db20b6844e29b4686cc489ff0c3aac678cce88f9352a7a0ef17d53feb307, + // keccak256(bytes('1')), + 0xc89efdaa54c0f20c7adf612882df0950f5a951637e0307cdcb4c672f298b8bc6, + block.chainid, + address(this) + ) + ); } /*************/ @@ -159,11 +192,11 @@ contract XDEFIDistribution is IXDEFIDistribution, ERC721Enumerable { // Revert if not in emergency mode. if (!inEmergencyMode) revert NotInEmergencyMode(); - // Revert if account is not the owner of the token. - if (ownerOf(tokenId_) != msg.sender) revert NotTokenOwner(); + // Revert if caller is not the token's owner, not approved for all the owner's token, and not approved for this specific token. + if (!_isApprovedOrOwner(msg.sender, tokenId_)) revert NotApprovedOrOwnerOfToken(); // Fetch position. - Position storage position = positionOf[tokenId_]; + Position storage position = _positionOf[tokenId_]; uint256 units = uint256(position.units); amountUnlocked_ = uint256(position.depositedXDEFI); @@ -179,7 +212,7 @@ contract XDEFIDistribution is IXDEFIDistribution, ERC721Enumerable { totalUnits -= units; } - delete positionOf[tokenId_]; + delete _positionOf[tokenId_]; // Send the unlocked XDEFI to the destination. (Don't need SafeERC20 since XDEFI is standard ERC20). IERC20(xdefi).transfer(destination_, amountUnlocked_); @@ -187,7 +220,7 @@ contract XDEFIDistribution is IXDEFIDistribution, ERC721Enumerable { function getBonusMultiplierOf(uint256 tokenId_) external view returns (uint256 bonusMultiplier_) { // Fetch position. - Position storage position = positionOf[tokenId_]; + Position storage position = _positionOf[tokenId_]; uint256 units = uint256(position.units); uint256 depositedXDEFI = uint256(position.depositedXDEFI); @@ -219,6 +252,10 @@ contract XDEFIDistribution is IXDEFIDistribution, ERC721Enumerable { tokenId_ = _lock(amount_, duration_, bonusMultiplier_, destination_); } + function positionOf(uint256 tokenId_) external view returns (Position memory position_) { + position_ = _positionOf[tokenId_]; + } + function relock( uint256 tokenId_, uint256 lockAmount_, @@ -261,8 +298,8 @@ contract XDEFIDistribution is IXDEFIDistribution, ERC721Enumerable { emit DistributionUpdated(msg.sender, increaseInDistributableXDEFI); } - function withdrawableOf(uint256 tokenId_) external view returns (uint256 withdrawableXDEFI_) { - Position storage position = positionOf[tokenId_]; + function withdrawableOf(uint256 tokenId_) public view returns (uint256 withdrawableXDEFI_) { + Position storage position = _positionOf[tokenId_]; withdrawableXDEFI_ = _withdrawableGiven(position.units, position.depositedXDEFI, position.pointsCorrection); } @@ -300,100 +337,132 @@ contract XDEFIDistribution is IXDEFIDistribution, ERC721Enumerable { view returns ( uint256 tier_, - uint256 score_, - uint256 sequence_ + uint256 credits_, + uint256 withdrawable_, + uint256 expiry_ ) { // Revert if the token does not exist. if (!_exists(tokenId_)) revert TokenDoesNotExist(); - // take the left-most 4 bits as tier, so shift `tokenId_` right by 252 bits, and no need to mask since there are no mote bits to the left. - tier_ = tokenId_ >> TWO_HUNDRED_FIFTY_TWO_UINT256; + credits_ = creditsOf[tokenId_]; + tier_ = getTier(credits_); + withdrawable_ = withdrawableOf(tokenId_); + expiry_ = _positionOf[tokenId_].expiry; + } - score_ = _getScoreFromTokenId(tokenId_); + function consume(uint256 tokenId_, uint256 amount_) external returns (uint256 remainingCredits_) { + // Revert if the caller is not the token's owner, not approved for all the owner's token, and not approved for this specific token. + if (!_isApprovedOrOwner(msg.sender, tokenId_)) revert InvalidConsumePermit(); - // Take the right-most 128 bits of `tokenId_` as the sequence, so just mask it. - sequence_ = tokenId_ & ONE_HUNDRED_TWENTY_EIGHT_BIT_MASK; + // Consume some of the token's credits. + remainingCredits_ = _consume(tokenId_, amount_, msg.sender); } - function consume( + function consumeWithPermit( uint256 tokenId_, uint256 amount_, - address destination_ - ) external returns (uint256 newTokenId_) { - // Revert if position has an expiry property, which means it still exists. - if (positionOf[tokenId_].expiry != ZERO_UINT256) revert PositionStillLocked(); - - // Use `newTokenId_` as current score for now, as it is unused - newTokenId_ = _getScoreFromTokenId(tokenId_); + uint256 limit_, + uint256 deadline_, + uint8 v_, + bytes32 r_, + bytes32 s_ + ) external returns (uint256 remainingCredits_) { + // Revert if the permit's deadline has been elapsed. + if (block.timestamp >= deadline_) revert ConsumePermitExpired(); - // Revert if score to decrement is greater than score of nft. - if (amount_ > newTokenId_) revert InsufficientScore(); + // Revert if the amount being consumed is greater than the permit's defined limit. + if (amount_ > limit_) revert BeyondConsumeLimit(); - // Cache the owner of the token as it will be burned. (This is checks that the token exists.) - address tokenOwner = ownerOf(tokenId_); + // Hash the data as per keccak256("PermitConsume(uint256 tokenId,address consumer,uint256 limit,uint256 nonce,uint256 deadline)"); + bytes32 digest = keccak256(abi.encode(CONSUME_PERMIT_SIGNATURE_HASH, tokenId_, msg.sender, limit_, consumePermitNonce[tokenId_]++, deadline_)); - // Revert if the caller is not the token's owner, not approved for all the owner's token, and not approved for this specific token. - if ((msg.sender != tokenOwner) && !isApprovedForAll(tokenOwner, msg.sender) && (getApproved(tokenId_) != msg.sender)) revert NotApprovedOrOwnerOfToken(); + // Get the digest that was to be signed signed. + digest = keccak256(abi.encodePacked(EIP191_PREFIX_FOR_EIP712_STRUCTURED_DATA, DOMAIN_SEPARATOR, digest)); - unchecked { - // Generate a new token id with a reduced score. Can be unchecked due to check done above. - newTokenId_ = _generateNewTokenId(newTokenId_ - amount_); - } + address recoveredAddress = ecrecover(digest, v_, r_, s_); - // Emit event before state changes and safe mint. - emit ScoreConsumed(tokenId_, amount_, newTokenId_); + // Revert if the account that signed the permit is not the token's owner, not approved for all the owner's token, and not approved for this specific token. + if (!_isApprovedOrOwner(recoveredAddress, tokenId_)) revert InvalidConsumePermit(); - // Burn the token and mint a new one with the reduced score. - _burn(tokenId_); - _safeMint(destination_, newTokenId_); + // Consume some of the token's credits. + remainingCredits_ = _consume(tokenId_, amount_, msg.sender); } function contractURI() external view returns (string memory contractURI_) { contractURI_ = string(abi.encodePacked(baseURI, "info")); } - function getScore(uint256 amount_, uint256 duration_) external pure returns (uint256 score_) { - score_ = _getScore(amount_, duration_); + function getCredits(uint256 amount_, uint256 duration_) public pure returns (uint256 credits_) { + // Credits is implicitly capped at max supply of XDEFI for 10 years locked (less than 2**116). + unchecked { + credits_ = amount_ * duration_; + } } - function getTier(uint256 score_) external pure returns (uint256 tier_) { - tier_ = _getTier(score_); + function getTier(uint256 credits_) public pure returns (uint256 tier_) { + if (credits_ < TIER_2_THRESHOLD) return TIER_1; + + if (credits_ < TIER_3_THRESHOLD) return TIER_2; + + if (credits_ < TIER_4_THRESHOLD) return TIER_3; + + if (credits_ < TIER_5_THRESHOLD) return TIER_4; + + if (credits_ < TIER_6_THRESHOLD) return TIER_5; + + if (credits_ < TIER_7_THRESHOLD) return TIER_6; + + if (credits_ < TIER_8_THRESHOLD) return TIER_7; + + if (credits_ < TIER_9_THRESHOLD) return TIER_8; + + if (credits_ < TIER_10_THRESHOLD) return TIER_9; + + if (credits_ < TIER_11_THRESHOLD) return TIER_10; + + if (credits_ < TIER_12_THRESHOLD) return TIER_11; + + if (credits_ < TIER_13_THRESHOLD) return TIER_12; + + return TIER_13; } - function merge(uint256[] calldata tokenIds_, address destination_) external noReenter returns (uint256 tokenId_) { + function merge(uint256[] calldata tokenIds_) external returns (uint256 tokenId_, uint256 credits_) { // Revert if trying to merge 0 or 1 tokens, which cannot be done. if (tokenIds_.length <= ONE_UINT256) revert MustMergeMultiple(); - // For each NFT, check that it belongs to the caller, burn it, and accumulate the score. - for (uint256 i; i < tokenIds_.length; ) { - uint256 tokenId = tokenIds_[i]; + uint256 iterator = tokenIds_.length - 1; - // Revert if `msg.sender` is not the owner of the token. - if (ownerOf(tokenId) != msg.sender) revert NotTokenOwner(); + // For each NFT from last to second, check that it belongs to the caller, burn it, and accumulate the credits. + while (iterator > ZERO_UINT256) { + tokenId_ = tokenIds_[iterator]; - // Revert if position has an expiry property, which means it still exists. - if (positionOf[tokenId].expiry != ZERO_UINT256) revert PositionStillLocked(); + // Revert if the caller is not the token's owner, not approved for all the owner's token, and not approved for this specific token. + if (!_isApprovedOrOwner(msg.sender, tokenId_)) revert NotApprovedOrOwnerOfToken(); - _burn(tokenId); + // Revert if position has an expiry property, which means it still exists. + if (_positionOf[tokenId_].expiry != ZERO_UINT256) revert PositionStillLocked(); unchecked { - // Max score of a previously locked position is `type(uint128).max`, so `score` is reasonably not going to overflow. - // Note: Using the so-far-unused variable `tokenId_` for now as `score`. - tokenId_ += _getScoreFromTokenId(tokenId); + // Max credits of a previously locked position is `type(uint128).max`, so `credits_` is reasonably not going to overflow. + credits_ += creditsOf[tokenId_]; - ++i; + --iterator; } + + // Clear the credits for this token, and burn the token. + delete creditsOf[tokenId_]; + _burn(tokenId_); } - // Generate a new tokenId based on the accumulated score. - // Note: `tokenId_` was used as `score` up until, this point. - tokenId_ = _generateNewTokenId(tokenId_); + // The resulting token id is the first token. + tokenId_ = tokenIds_[0]; - emit TokensMerged(tokenIds_, tokenId_); + // The total credits merged into the first token is the sum of the first's plus the accumulation of the credits from burned tokens. + credits_ = (creditsOf[tokenId_] += credits_); - // Mine a new NFT to the destinations. - _safeMint(destination_, tokenId_); + emit TokensMerged(tokenIds_, tokenId_, credits_); } function tokenURI(uint256 tokenId_) public view override(IXDEFIDistribution, ERC721) returns (string memory tokenURI_) { @@ -407,6 +476,24 @@ contract XDEFIDistribution is IXDEFIDistribution, ERC721Enumerable { /* Internal Functions */ /**********************/ + function _consume( + uint256 tokenId_, + uint256 amount_, + address consumer_ + ) internal returns (uint256 remainingCredits_) { + remainingCredits_ = creditsOf[tokenId_]; + + // Revert if credits to decrement is greater than credits of nft. + if (amount_ > remainingCredits_) revert InsufficientCredits(); + + unchecked { + // Can be unchecked due to check done above. + creditsOf[tokenId_] = (remainingCredits_ -= amount_); + } + + emit CreditsConsumed(tokenId_, consumer_, amount_); + } + function _createLockedPosition( uint256 amount_, uint256 duration_, @@ -424,14 +511,17 @@ contract XDEFIDistribution is IXDEFIDistribution, ERC721Enumerable { // Revert if the bonus multiplier is not at least what was expected. if (bonusMultiplier < bonusMultiplier_) revert IncorrectBonusMultiplier(); - // Track deposits. - totalDepositedXDEFI += amount_; + unchecked { + // Generate a token id. + tokenId_ = ++_tokensMinted; - // Generate a token id. - tokenId_ = _generateNewTokenId(_getScore(amount_, duration_)); + // Store credits. + creditsOf[tokenId_] = getCredits(amount_, duration_); - // Create Position. - unchecked { + // Track deposits. + totalDepositedXDEFI += amount_; + + // The rest creates the locked position. uint256 units = (amount_ * bonusMultiplier) / ONE_HUNDRED_UINT256; // Revert if position will end up with less than define minimum lockable units. @@ -439,7 +529,7 @@ contract XDEFIDistribution is IXDEFIDistribution, ERC721Enumerable { totalUnits += units; - positionOf[tokenId_] = Position({ + _positionOf[tokenId_] = Position({ units: uint96(units), // 240M * 1e18 * 255 can never be larger than a `uint96`. depositedXDEFI: uint88(amount_), // There are only 240M (18 decimals) XDEFI tokens so can never be larger than a `uint88`. expiry: uint32(block.timestamp + duration_), // For many years, block.timestamp + duration_ will never be larger than a `uint32`. @@ -455,11 +545,11 @@ contract XDEFIDistribution is IXDEFIDistribution, ERC721Enumerable { } function _destroyLockedPosition(address account_, uint256 tokenId_) internal returns (uint256 amountUnlocked_) { - // Revert if account is not the owner of the token. - if (ownerOf(tokenId_) != account_) revert NotTokenOwner(); + // Revert if account_ is not the token's owner, not approved for all the owner's token, and not approved for this specific token. + if (!_isApprovedOrOwner(account_, tokenId_)) revert NotApprovedOrOwnerOfToken(); // Fetch position. - Position storage position = positionOf[tokenId_]; + Position storage position = _positionOf[tokenId_]; uint256 units = uint256(position.units); uint256 depositedXDEFI = uint256(position.depositedXDEFI); uint256 expiry = uint256(position.expiry); @@ -485,60 +575,11 @@ contract XDEFIDistribution is IXDEFIDistribution, ERC721Enumerable { totalUnits -= units; } - delete positionOf[tokenId_]; + delete _positionOf[tokenId_]; emit LockPositionWithdrawn(tokenId_, account_, amountUnlocked_); } - function _generateNewTokenId(uint256 score_) internal returns (uint256 tokenId_) { - // Score is implicitly capped at max supply of XDEFI for 10 years locked (less than 2**119). - // Total minted NFTs is expected to be reasonably capped at `type(uint128).max`. - unchecked { - // Right-most 4 bits is tier (1 - 13), next 124 bits is score (max 240M * 1e18 * 10 years in seconds), and left-most 128 bits is a nonce. - tokenId_ = (_getTier(score_) << TWO_HUNDRED_FIFTY_TWO_UINT256) | (score_ << ONE_HUNDRED_TWENTY_EIGHT_UINT256) | _tokensMinted++; - } - } - - function _getScore(uint256 amount_, uint256 duration_) internal pure returns (uint256 score_) { - // Score is implicitly capped at max supply of XDEFI for 10 years locked (less than 2**116). - unchecked { - score_ = amount_ * duration_; - } - } - - function _getScoreFromTokenId(uint256 tokenId_) internal pure returns (uint256 score_) { - // Shift `tokenId_` right by 128 bits and take the right-most 124 bits (since the first 4 bits are the tier). - score_ = (tokenId_ >> ONE_HUNDRED_TWENTY_EIGHT_UINT256) & ONE_HUNDRED_TWENTY_FOUR_BIT_MASK; - } - - function _getTier(uint256 score_) internal pure returns (uint256 tier_) { - if (score_ < TIER_2_THRESHOLD) return uint256(1); - - if (score_ < TIER_3_THRESHOLD) return uint256(2); - - if (score_ < TIER_4_THRESHOLD) return uint256(3); - - if (score_ < TIER_5_THRESHOLD) return uint256(4); - - if (score_ < TIER_6_THRESHOLD) return uint256(5); - - if (score_ < TIER_7_THRESHOLD) return uint256(6); - - if (score_ < TIER_8_THRESHOLD) return uint256(7); - - if (score_ < TIER_9_THRESHOLD) return uint256(8); - - if (score_ < TIER_10_THRESHOLD) return uint256(9); - - if (score_ < TIER_11_THRESHOLD) return uint256(10); - - if (score_ < TIER_12_THRESHOLD) return uint256(11); - - if (score_ < TIER_13_THRESHOLD) return uint256(12); - - return uint256(13); - } - function _lock( uint256 amount_, uint256 duration_, diff --git a/contracts/XDEFIDistributionHelper.sol b/contracts/XDEFIDistributionHelper.sol index c834d20..c63a680 100644 --- a/contracts/XDEFIDistributionHelper.sol +++ b/contracts/XDEFIDistributionHelper.sol @@ -1,6 +1,6 @@ // SPDX-License-Identifier: MIT -pragma solidity =0.8.10; +pragma solidity =0.8.12; import { IXDEFIDistributionHelper, IXDEFIDistributionLike } from "./interfaces/IXDEFIDistributionHelper.sol"; diff --git a/contracts/interfaces/IEIP2612.sol b/contracts/interfaces/IEIP2612.sol index fc8f6df..d42179e 100644 --- a/contracts/interfaces/IEIP2612.sol +++ b/contracts/interfaces/IEIP2612.sol @@ -1,6 +1,6 @@ // SPDX-License-Identifier: MIT -pragma solidity =0.8.10; +pragma solidity =0.8.12; interface IEIP2612 { function permit( diff --git a/contracts/interfaces/IXDEFIDistribution.sol b/contracts/interfaces/IXDEFIDistribution.sol index 9e84c02..4e23ca7 100644 --- a/contracts/interfaces/IXDEFIDistribution.sol +++ b/contracts/interfaces/IXDEFIDistribution.sol @@ -1,6 +1,6 @@ // SPDX-License-Identifier: MIT -pragma solidity =0.8.10; +pragma solidity =0.8.12; import { IERC721Enumerable } from "@openzeppelin/contracts/token/ERC721/extensions/IERC721Enumerable.sol"; @@ -21,11 +21,14 @@ interface IXDEFIDistribution is IERC721Enumerable { /* Errors */ /**********/ + error BeyondConsumeLimit(); error CannotUnlock(); + error ConsumePermitExpired(); error EmptyArray(); error IncorrectBonusMultiplier(); error InsufficientAmountUnlocked(); - error InsufficientScore(); + error InsufficientCredits(); + error InvalidConsumePermit(); error InvalidDuration(); error InvalidMultiplier(); error InvalidToken(); @@ -36,7 +39,6 @@ interface IXDEFIDistribution is IERC721Enumerable { error NoUnitSupply(); error NotApprovedOrOwnerOfToken(); error NotInEmergencyMode(); - error NotTokenOwner(); error PositionAlreadyUnlocked(); error PositionStillLocked(); error TokenDoesNotExist(); @@ -49,6 +51,9 @@ interface IXDEFIDistribution is IERC721Enumerable { /// @notice Emitted when the base URI is set (or re-set). event BaseURISet(string baseURI); + /// @notice Emitted when some credits of a token are consumed. + event CreditsConsumed(uint256 indexed tokenId, address indexed consumer, uint256 amount); + /// @notice Emitted when a new amount of XDEFI is distributed to all locked positions, by some caller. event DistributionUpdated(address indexed caller, uint256 amount); @@ -70,16 +75,16 @@ interface IXDEFIDistribution is IERC721Enumerable { /// @notice Emitted when owner proposed an account that can accept ownership. event OwnershipProposed(address indexed owner, address indexed pendingOwner); - /// @notice Emitted when some score fo a token is consumed, resulting in a new token with a lesser score. - event ScoreConsumed(uint256 indexed tokenId, uint256 amount, uint256 newTokenId); - /// @notice Emitted when unlocked tokens are merged into one. - event TokensMerged(uint256[] mergedTokenIds, uint256 resultingTokenId); + event TokensMerged(uint256[] mergedTokenIds, uint256 tokenId, uint256 credits); /*************/ /* Constants */ /*************/ + /// @notice The IERC721Permit domain separator. + function DOMAIN_SEPARATOR() external view returns (bytes32 domainSeparator_); + /// @notice The minimum units that can result from a lock of XDEFI. function MINIMUM_UNITS() external view returns (uint256 minimumUnits_); @@ -93,29 +98,26 @@ interface IXDEFIDistribution is IERC721Enumerable { /// @notice The multiplier applied to the deposited XDEFI amount to determine the units of a position, and thus its share of future distributions. function bonusMultiplierOf(uint256 duration_) external view returns (uint256 bonusMultiplier_); + /// @notice Returns the consume permit nonce for a token. + function consumePermitNonce(uint256 tokenId_) external view returns (uint256 nonce_); + + /// @notice Returns the credits of a token. + function creditsOf(uint256 tokenId_) external view returns (uint256 credits_); + /// @notice The amount of XDEFI that is distributable to all currently locked positions. function distributableXDEFI() external view returns (uint256 distributableXDEFI_); /// @notice The contract is no longer allowing locking XDEFI, and is allowing all locked positions to be unlocked effective immediately. function inEmergencyMode() external view returns (bool lockingDisabled_); + /// @notice The account that can set and unset lock periods and transfer ownership of the contract. + function owner() external view returns (address owner_); + /// @notice The account that can take ownership of the contract. function pendingOwner() external view returns (address pendingOwner_); /// @notice Returns the position details (`pointsCorrection_` is a value used in the amortized work pattern for token distribution). - function positionOf(uint256 tokenId_) - external - view - returns ( - uint96 units_, - uint88 depositedXDEFI_, - uint32 expiry_, - uint32 created_, - uint256 pointsCorrection_ - ); - - /// @notice The account that can set and unset lock periods and transfer ownership of the contract. - function owner() external view returns (address owner_); + function positionOf(uint256 tokenId_) external view returns (Position memory position_); /// @notice The amount of XDEFI that was deposited by all currently locked positions. function totalDepositedXDEFI() external view returns (uint256 totalDepositedXDEFI_); @@ -213,34 +215,42 @@ interface IXDEFIDistribution is IERC721Enumerable { /* NFT Functions */ /*****************/ - /// @notice Returns the score, tier, and sequence of an NFT. + /// @notice Returns the tier and credits of an NFT. function attributesOf(uint256 tokenId_) external view returns ( uint256 tier_, - uint256 score_, - uint256 sequence_ + uint256 credits_, + uint256 withdrawable_, + uint256 expiry_ ); - /// @notice Consumes some score from an NFT by burning it and minting a new one with a reduced score. - function consume( + /// @notice Consumes some credits from an NFT, returning the number of credits left. + function consume(uint256 tokenId_, uint256 amount_) external returns (uint256 remainingCredits_); + + /// @notice Consumes some credits from an NFT, with a signed permit from the owner, returning the number of credits left. + function consumeWithPermit( uint256 tokenId_, uint256 amount_, - address destination_ - ) external returns (uint256 newTokenId_); + uint256 limit_, + uint256 deadline_, + uint8 v_, + bytes32 r_, + bytes32 s_ + ) external returns (uint256 remainingCredits_); /// @notice Returns the URI for the contract metadata. function contractURI() external view returns (string memory contractURI_); - /// @notice Returns the score an NFT will have, given some amount locked for some duration. - function getScore(uint256 amount_, uint256 duration_) external pure returns (uint256 score_); + /// @notice Returns the credits an NFT will have, given some amount locked for some duration. + function getCredits(uint256 amount_, uint256 duration_) external pure returns (uint256 credits_); - /// @notice Returns the tier an NFT will have, given some score, which itself can be determined from `getScore`. - function getTier(uint256 score_) external pure returns (uint256 tier_); + /// @notice Returns the tier an NFT will have, given some credits, which itself can be determined from `getCredits`. + function getTier(uint256 credits_) external pure returns (uint256 tier_); - /// @notice Burns several unlocked NFTs to mint a new NFT that has their combined score. - function merge(uint256[] calldata tokenIds_, address destination_) external returns (uint256 tokenId_); + /// @notice Burns several unlocked NFTs to combine their credits into the first. + function merge(uint256[] calldata tokenIds_) external returns (uint256 tokenId_, uint256 credits_); /// @notice Returns the URI for the NFT metadata for a given token ID. function tokenURI(uint256 tokenId_) external view returns (string memory tokenURI_); diff --git a/contracts/interfaces/IXDEFIDistributionHelper.sol b/contracts/interfaces/IXDEFIDistributionHelper.sol index 27195a6..472ca05 100644 --- a/contracts/interfaces/IXDEFIDistributionHelper.sol +++ b/contracts/interfaces/IXDEFIDistributionHelper.sol @@ -1,6 +1,6 @@ // SPDX-License-Identifier: MIT -pragma solidity =0.8.10; +pragma solidity =0.8.12; import { IXDEFIDistribution } from "./IXDEFIDistribution.sol"; diff --git a/contracts/mocks/Receivers.sol b/contracts/mocks/Receivers.sol index e5e38e9..ae640e4 100644 --- a/contracts/mocks/Receivers.sol +++ b/contracts/mocks/Receivers.sol @@ -1,6 +1,6 @@ // SPDX-License-Identifier: MIT -pragma solidity =0.8.10; +pragma solidity =0.8.12; import { IXDEFIDistribution } from "../interfaces/IXDEFIDistribution.sol"; diff --git a/contracts/mocks/XDEFI.sol b/contracts/mocks/XDEFI.sol index 82e29f9..7e28039 100644 --- a/contracts/mocks/XDEFI.sol +++ b/contracts/mocks/XDEFI.sol @@ -1,6 +1,6 @@ // SPDX-License-Identifier: MIT -pragma solidity =0.8.10; +pragma solidity =0.8.12; interface IERC20 { event Approval(address indexed owner, address indexed spender, uint256 value); diff --git a/hardhat.config.js b/hardhat.config.js index a18107b..b790680 100644 --- a/hardhat.config.js +++ b/hardhat.config.js @@ -5,7 +5,7 @@ const secrets = require('./.secrets.json'); module.exports = { solidity: { - version: '0.8.10', + version: '0.8.12', settings: { evmVersion: 'london', optimizer: { diff --git a/package.json b/package.json index 5dbf535..dd068d4 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "deploy:rinkeby": "hardhat run scripts/deploy.js --network rinkeby", "deploy:mainnet": "hardhat run scripts/deploy.js --network mainnet", "deploy:ganache": "hardhat run scripts/deploy.js --network ganache", + "sample:ganache": "hardhat run scripts/sampleLock.js --network ganache", "server": "node ./backend/index.js", "prettier": "prettier --write './'" }, @@ -30,6 +31,7 @@ "solidity-coverage": "^0.7.17" }, "dependencies": { - "@openzeppelin/contracts": "^4.4.0" + "@openzeppelin/contracts": "^4.4.0", + "axios": "^0.26.0" } } diff --git a/scripts/deploy.js b/scripts/deploy.js index 645d13e..dfd59b1 100644 --- a/scripts/deploy.js +++ b/scripts/deploy.js @@ -1,17 +1,25 @@ const hre = require('hardhat'); async function main() { - const { xdefi, baseURI, zeroDurationPointBase } = require('../.secrets.json')[hre.network.name]; + const secrets = require('../.secrets.json')[hre.network.name]; const [deployer] = await ethers.getSigners(); const balance = BigInt((await deployer.getBalance()).toString()); console.log('Deploying contracts with the account:', deployer.address); - console.log('Account balance:', balance); - console.log('Token address:', xdefi); + console.log('Account XDEFI balance:', balance); if (!balance) return; - const XDEFIDistribution = await (await (await ethers.getContractFactory('XDEFIDistribution')).deploy(xdefi, baseURI)).deployed(); + const XDEFI = + secrets.xdefi ?? + (await (await (await ethers.getContractFactory('XDEFI')).deploy('XDEFI', 'XDEFI', '240000000000000000000000000')).deployed()) + .address; + + console.log('XDEFI address:', XDEFI); + + const XDEFIDistribution = await ( + await (await ethers.getContractFactory('XDEFIDistribution')).deploy(XDEFI, secrets.baseURI) + ).deployed(); console.log('XDEFIDistribution address:', XDEFIDistribution.address); diff --git a/scripts/sampleLock.js b/scripts/sampleLock.js new file mode 100644 index 0000000..ef363d8 --- /dev/null +++ b/scripts/sampleLock.js @@ -0,0 +1,32 @@ +const hre = require('hardhat'); + +async function main() { + const { xdefi, xdefiDistribution } = require('../.secrets.json')[hre.network.name]; + const [account] = await ethers.getSigners(); + + console.log('Using account:', account.address); + console.log('ETH balance:', BigInt(await account.getBalance())); + console.log('XDEFI address:', xdefi); + console.log('XDEFIDistribution address:', xdefiDistribution); + + const XDEFI = await (await ethers.getContractFactory('XDEFI')).attach(xdefi); + const XDEFIDistribution = await (await ethers.getContractFactory('XDEFIDistribution')).attach(xdefiDistribution); + + console.log('XDEFI balance:', BigInt(await XDEFI.balanceOf(account.address))); + console.log('XDEFIDistribution balance:', BigInt(await XDEFIDistribution.balanceOf(account.address))); + + await (await XDEFIDistribution.connect(account).setLockPeriods(['86400'], ['100'])).wait(); + await (await XDEFI.connect(account).approve(xdefiDistribution, '100000000000000000000')).wait(); + await (await XDEFIDistribution.connect(account).lock('100000000000000000000', '86400', '100', account.address)).wait(); + + const nftBalance = BigInt(await XDEFIDistribution.balanceOf(account.address)); + + console.log('TokenID:', await XDEFIDistribution.tokenOfOwnerByIndex(account.address, (nftBalance - 1n).toString())); +} + +main() + .then(() => process.exit(0)) + .catch((error) => { + console.error(error); + process.exit(1); + }); diff --git a/test/XDEFIDistribution.js b/test/XDEFIDistribution.js index 52ced28..27c1a46 100644 --- a/test/XDEFIDistribution.js +++ b/test/XDEFIDistribution.js @@ -7,7 +7,8 @@ const MAX_UINT256 = 2n ** 256n - 1n; const totalSupply = '240000000000000000000000000'; const EIP191_PREFIX_FOR_EIP712_STRUCTURED_DATA = '\x19\x01'; -const PERMIT_SIGNATURE_HASH = '0x6e71edae12b1b97f4d1f60370fef10105fa2faae0126114a169c64845d6126c9'; +const ERC20_PERMIT_SIGNATURE_HASH = '0x6e71edae12b1b97f4d1f60370fef10105fa2faae0126114a169c64845d6126c9'; +const CONSUME_PERMIT_SIGNATURE_HASH = '0xa0a7128942405265cd830695cb06df90c6bfdbbe22677cc592c3d36c3180b079'; const toWei = (value, add = 0, sub = 0) => (BigInt(value) * 1_000_000_000_000_000_000n + BigInt(add) - BigInt(sub)).toString(); @@ -27,19 +28,20 @@ describe('XDEFIDistribution', () => { let account1; let account2; let account3; - let domainSeparator; + let xdefiDomainSeparator; + let distributionDomainSeparator; - const createPermitSignature = (owner, spender, amount, nonce, deadline) => { + const createErc20PermitSignature = (owner, spender, amount, nonce, deadline) => { const subData = ethers.utils.defaultAbiCoder.encode( ['bytes32', 'address', 'address', 'uint256', 'uint256', 'uint256'], - [PERMIT_SIGNATURE_HASH, owner.address, spender, amount, nonce, deadline] + [ERC20_PERMIT_SIGNATURE_HASH, owner.address, spender, amount, nonce, deadline] ); const subDataDigest = ethers.utils.keccak256(subData); const dataDigest = ethers.utils.solidityKeccak256( ['string', 'bytes32', 'bytes32'], - [EIP191_PREFIX_FOR_EIP712_STRUCTURED_DATA, domainSeparator, subDataDigest] + [EIP191_PREFIX_FOR_EIP712_STRUCTURED_DATA, xdefiDomainSeparator, subDataDigest] ); const signingKey = new ethers.utils.SigningKey(owner.privateKey); @@ -47,6 +49,24 @@ describe('XDEFIDistribution', () => { return signingKey.signDigest(dataDigest); }; + const createConsumePermitSignature = (signer, tokenId, consumer, limit, nonce, deadline) => { + const subData = ethers.utils.defaultAbiCoder.encode( + ['bytes32', 'uint256', 'address', 'uint256', 'uint256', 'uint256'], + [CONSUME_PERMIT_SIGNATURE_HASH, tokenId, consumer, limit, nonce, deadline] + ); + + const subDataDigest = ethers.utils.keccak256(subData); + + const dataDigest = ethers.utils.solidityKeccak256( + ['string', 'bytes32', 'bytes32'], + [EIP191_PREFIX_FOR_EIP712_STRUCTURED_DATA, distributionDomainSeparator, subDataDigest] + ); + + const signingKey = new ethers.utils.SigningKey(signer.privateKey); + + return signingKey.signDigest(dataDigest); + }; + const getMostRecentNFT = async (account) => { const balance = BigInt(await XDEFIDistribution.balanceOf(account.address)); @@ -60,12 +80,11 @@ describe('XDEFIDistribution', () => { return getMostRecentNFT(destination); }; - const lockWithPermit = async (wallet, amount, duration, bonusMultiplier, destination = wallet, nonce, deadline) => { - const { v, r, s } = createPermitSignature(wallet, XDEFIDistribution.address, amount, nonce, deadline); + const lockWithPermit = async (account, amount, duration, bonusMultiplier, destination = wallet, nonce, deadline) => { + const { v, r, s } = createErc20PermitSignature(account, XDEFIDistribution.address, amount, nonce, deadline); - await (await XDEFI.connect(wallet).approve(XDEFIDistribution.address, amount)).wait(); await ( - await XDEFIDistribution.connect(wallet).lockWithPermit( + await XDEFIDistribution.connect(account).lockWithPermit( amount, duration, bonusMultiplier, @@ -92,6 +111,12 @@ describe('XDEFIDistribution', () => { return getMostRecentNFT(destination); }; + const consumeWithPermit = async (account, tokenId, consumer, amount, nonce, deadline) => { + const { v, r, s } = createConsumePermitSignature(account, tokenId, consumer.address, amount, nonce, deadline); + + await (await XDEFIDistribution.connect(consumer).consumeWithPermit(tokenId, amount, amount, deadline, v, r, s)).wait(); + }; + beforeEach(async () => { [god, account1, account2, account3] = await ethers.getSigners(); @@ -112,8 +137,9 @@ describe('XDEFIDistribution', () => { // Give 100 Ether to `accountWithPrivateKey` await god.sendTransaction({ to: wallet.address, value: ethers.utils.parseEther('100') }); - // Get Domain Separator from contract - domainSeparator = await XDEFI.DOMAIN_SEPARATOR(); + // Get Domain Separators from contracts + xdefiDomainSeparator = await XDEFI.DOMAIN_SEPARATOR(); + distributionDomainSeparator = await XDEFIDistribution.DOMAIN_SEPARATOR(); }); it('Can enter and exit deposited amounts with no distributions (no bonuses)', async () => { @@ -1116,20 +1142,20 @@ describe('XDEFIDistribution', () => { it('Can merge and transfer unlocked positions', async () => { // Position 1 locks - const scoreOfPosition1 = (await XDEFIDistribution.getScore(toWei(1000), durations[0])).toString(); + const creditsOfPosition1 = (await XDEFIDistribution.getCredits(toWei(1000), durations[0])).toString(); const nft1 = await lock(account1, toWei(1000), durations[0], bonusMultipliers[0]); - expect((await XDEFIDistribution.attributesOf(nft1)).score_).to.equal(scoreOfPosition1); + expect((await XDEFIDistribution.attributesOf(nft1)).credits_).to.equal(creditsOfPosition1); // Position 2 locks and is transferred to account 1 - const scoreOfPosition2 = (await XDEFIDistribution.getScore(toWei(1000), durations[1])).toString(); + const creditsOfPosition2 = (await XDEFIDistribution.getCredits(toWei(1000), durations[1])).toString(); const nft2 = await lock(account2, toWei(1000), durations[1], bonusMultipliers[1]); - expect((await XDEFIDistribution.attributesOf(nft2)).score_).to.equal(scoreOfPosition2); + expect((await XDEFIDistribution.attributesOf(nft2)).credits_).to.equal(creditsOfPosition2); await (await XDEFIDistribution.connect(account2).transferFrom(account2.address, account1.address, nft2)).wait(); // Position 3 locks and is transferred to account 1 - const scoreOfPosition3 = (await XDEFIDistribution.getScore(toWei(1000), durations[2])).toString(); + const creditsOfPosition3 = (await XDEFIDistribution.getCredits(toWei(1000), durations[2])).toString(); const nft3 = await lock(account3, toWei(1000), durations[2], bonusMultipliers[2]); - expect((await XDEFIDistribution.attributesOf(nft3)).score_).to.equal(scoreOfPosition3); + expect((await XDEFIDistribution.attributesOf(nft3)).credits_).to.equal(creditsOfPosition3); await (await XDEFIDistribution.connect(account3).transferFrom(account3.address, account1.address, nft3)).wait(); // First distribution (should split between position 1, 2, and 3) @@ -1140,18 +1166,22 @@ describe('XDEFIDistribution', () => { await hre.ethers.provider.send('evm_increaseTime', [durations[2]]); await (await XDEFIDistribution.connect(account1).unlockBatch([nft1, nft2, nft3], account1.address)).wait(); - // Unlocked positions 1, 2, and 3 are merged into unlocked position 4 - await (await XDEFIDistribution.connect(account1).merge([nft1, nft2, nft3], account1.address)).wait(); + // Unlocked positions 1, 2, and 3 are merged into unlocked position 1 + await (await XDEFIDistribution.connect(account1).merge([nft1, nft2, nft3])).wait(); expect(await XDEFIDistribution.balanceOf(account1.address)).to.equal('1'); - const nft4 = (await XDEFIDistribution.tokenOfOwnerByIndex(account1.address, 0)).toString(); - expect((await XDEFIDistribution.positionOf(nft4)).units).to.equal(toWei(0)); - expect(await XDEFIDistribution.withdrawableOf(nft4)).to.equal(toWei(0)); - expect((await XDEFIDistribution.attributesOf(nft4)).score_).to.equal( - BigInt(scoreOfPosition1) + BigInt(scoreOfPosition2) + BigInt(scoreOfPosition3) + expect((await XDEFIDistribution.positionOf(nft1)).units).to.equal(toWei(0)); + expect(await XDEFIDistribution.withdrawableOf(nft1)).to.equal(toWei(0)); + expect((await XDEFIDistribution.attributesOf(nft1)).credits_).to.equal( + BigInt(creditsOfPosition1) + BigInt(creditsOfPosition2) + BigInt(creditsOfPosition3) + ); + expect(await XDEFIDistribution.creditsOf(nft1)).to.equal( + BigInt(creditsOfPosition1) + BigInt(creditsOfPosition2) + BigInt(creditsOfPosition3) ); + expect(await XDEFIDistribution.creditsOf(nft2)).to.equal(0); + expect(await XDEFIDistribution.creditsOf(nft2)).to.equal(0); - // Unlocked position 4 transferred - await (await XDEFIDistribution.connect(account1).transferFrom(account1.address, account2.address, nft4)).wait(); + // Unlocked position 1 transferred + await (await XDEFIDistribution.connect(account1).transferFrom(account1.address, account2.address, nft1)).wait(); expect(await XDEFIDistribution.balanceOf(account1.address)).to.equal('0'); expect(await XDEFIDistribution.balanceOf(account2.address)).to.equal('1'); @@ -1175,16 +1205,12 @@ describe('XDEFIDistribution', () => { const nft3 = await lock(account3, toWei(1000), durations[2], bonusMultipliers[2]); await (await XDEFIDistribution.connect(account3).transferFrom(account3.address, account1.address, nft3)).wait(); - // Attempted to merge locked positions 1, 2, and 3 into unlocked position 4 - await expect(XDEFIDistribution.connect(account1).merge([nft1, nft2, nft3], account1.address)).to.be.revertedWith( - 'PositionStillLocked()' - ); + // Attempted to merge locked positions 1, 2, and 3 into unlocked position 1 + await expect(XDEFIDistribution.connect(account1).merge([nft1, nft2, nft3])).to.be.revertedWith('PositionStillLocked()'); - // Attempted to merge locked positions 1, 2, and 3 into unlocked position 4, even after elapsed time + // Attempted to merge locked positions 1, 2, and 3 into unlocked position 1, even after elapsed time await hre.ethers.provider.send('evm_increaseTime', [durations[2]]); - await expect(XDEFIDistribution.connect(account1).merge([nft1, nft2, nft3], account1.address)).to.be.revertedWith( - 'PositionStillLocked()' - ); + await expect(XDEFIDistribution.connect(account1).merge([nft1, nft2, nft3])).to.be.revertedWith('PositionStillLocked()'); }); it('Can transfer ownership', async () => { @@ -1209,7 +1235,7 @@ describe('XDEFIDistribution', () => { expect(await XDEFIDistribution.owner()).to.equal(god.address); }); - it('Can enter and exit deposited amount, with permits, with no distributions (no bonuses)', async () => { + it('Can enter and exit deposited amount, with erc20 permits, with no distributions (no bonuses)', async () => { // Position 1 locks const nft1 = await lockWithPermit(wallet, toWei(1000), durations[0], bonusMultipliers[0], wallet, 0, maxDeadline); expect(await XDEFI.balanceOf(wallet.address)).to.equal(toWei(0)); @@ -1316,66 +1342,213 @@ describe('XDEFIDistribution', () => { it('Can consume from unlocked positions', async () => { // Position 1 locks - const scoreOfPosition1 = (await XDEFIDistribution.getScore(toWei(1000), durations[0])).toString(); + const creditsOfPosition1 = (await XDEFIDistribution.getCredits(toWei(1000), durations[0])).toString(); const nft1 = await lock(account1, toWei(1000), durations[0], bonusMultipliers[0]); - const [tier1, score1, sequence1] = await XDEFIDistribution.attributesOf(nft1); + const [tier1, credits1] = await XDEFIDistribution.attributesOf(nft1); + expect(nft1).to.equal('1'); expect(tier1).to.equal(1); - expect(score1).to.equal(scoreOfPosition1); - expect(sequence1).to.equal(0); + expect(credits1).to.equal(creditsOfPosition1); // Position 2 locks and is transferred to account 1 - const scoreOfPosition2 = (await XDEFIDistribution.getScore(toWei(1000), durations[1])).toString(); + const creditsOfPosition2 = (await XDEFIDistribution.getCredits(toWei(1000), durations[1])).toString(); const nft2 = await lock(account2, toWei(1000), durations[1], bonusMultipliers[1]); - const [tier2, score2, sequence2] = await XDEFIDistribution.attributesOf(nft2); + const [tier2, credits2] = await XDEFIDistribution.attributesOf(nft2); + expect(nft2).to.equal('2'); expect(tier2).to.equal(1); - expect(score2).to.equal(scoreOfPosition2); - expect(sequence2).to.equal(1); + expect(credits2).to.equal(creditsOfPosition2); await (await XDEFIDistribution.connect(account2).transferFrom(account2.address, account1.address, nft2)).wait(); // Position 3 locks and is transferred to account 1 - const scoreOfPosition3 = (await XDEFIDistribution.getScore(toWei(1000), durations[2])).toString(); + const creditsOfPosition3 = (await XDEFIDistribution.getCredits(toWei(1000), durations[2])).toString(); const nft3 = await lock(account3, toWei(1000), durations[2], bonusMultipliers[2]); - const [tier3, score3, sequence3] = await XDEFIDistribution.attributesOf(nft3); + const [tier3, credits3] = await XDEFIDistribution.attributesOf(nft3); + expect(nft3).to.equal('3'); expect(tier3).to.equal(1); - expect(score3).to.equal(scoreOfPosition3); - expect(sequence3).to.equal(2); + expect(credits3).to.equal(creditsOfPosition3); await (await XDEFIDistribution.connect(account3).transferFrom(account3.address, account1.address, nft3)).wait(); // Position 1, 2, and 3 unlock await hre.ethers.provider.send('evm_increaseTime', [durations[2]]); await (await XDEFIDistribution.connect(account1).unlockBatch([nft1, nft2, nft3], account1.address)).wait(); - // Unlocked position 1 is consumed from by the owner - await (await XDEFIDistribution.connect(account1).consume(nft1, 10, account1.address)).wait(); - const nft4 = await getMostRecentNFT(account1); - const [tier4, score4, sequence4] = await XDEFIDistribution.attributesOf(nft4); - expect(tier4).to.equal(1); - expect(score4).to.equal(BigInt(scoreOfPosition1) - 10n); - expect(sequence4).to.equal(3); + // Unlocked position 1 is consumed from by the owner. + await (await XDEFIDistribution.connect(account1).consume(nft1, 10)).wait(); + const [newTier1, newCredits1] = await XDEFIDistribution.attributesOf(nft1); + expect(newTier1).to.equal(1); + expect(newCredits1).to.equal(BigInt(creditsOfPosition1) - 10n); // Unlocked position 2 is consumed from by account approved on token. await (await XDEFIDistribution.connect(account1).approve(account2.address, nft2)).wait(); - await (await XDEFIDistribution.connect(account2).consume(nft2, 20, account1.address)).wait(); - const nft5 = await getMostRecentNFT(account1); - const [tier5, score5, sequence5] = await XDEFIDistribution.attributesOf(nft5); - expect(tier5).to.equal(1); - expect(score5).to.equal(BigInt(scoreOfPosition2) - 20n); - expect(sequence5).to.equal(4); + await (await XDEFIDistribution.connect(account2).consume(nft2, 20)).wait(); + const [newTier2, newCredits2] = await XDEFIDistribution.attributesOf(nft2); + expect(newTier2).to.equal(1); + expect(newCredits2).to.equal(BigInt(creditsOfPosition2) - 20n); // Unlocked position 3 is consumed from by account approved for all account1's tokens. await (await XDEFIDistribution.connect(account1).setApprovalForAll(account3.address, true)).wait(); - await (await XDEFIDistribution.connect(account3).consume(nft3, 30, account1.address)).wait(); - const nft6 = await getMostRecentNFT(account1); - const [tier6, score6, sequence6] = await XDEFIDistribution.attributesOf(nft6); + await (await XDEFIDistribution.connect(account3).consume(nft3, 30)).wait(); + const [newTier3, newCredits3] = await XDEFIDistribution.attributesOf(nft3); + expect(newTier3).to.equal(1); + expect(newCredits3).to.equal(BigInt(creditsOfPosition3) - 30n); + }); + + it('Can consume from unlocked positions, with permits', async () => { + // Position 1 locks + const creditsOfPosition1 = (await XDEFIDistribution.getCredits(toWei(1000), durations[0])).toString(); + const nft1 = await lock(wallet, toWei(1000), durations[0], bonusMultipliers[0]); + const [tier1, credits1] = await XDEFIDistribution.attributesOf(nft1); + expect(nft1).to.equal('1'); + expect(tier1).to.equal(1); + expect(credits1).to.equal(creditsOfPosition1); + + // Position 2 locks + const creditsOfPosition2 = (await XDEFIDistribution.getCredits(toWei(1000), durations[1])).toString(); + const nft2 = await lock(account2, toWei(1000), durations[1], bonusMultipliers[1]); + const [tier2, credits2] = await XDEFIDistribution.attributesOf(nft2); + expect(nft2).to.equal('2'); + expect(tier2).to.equal(1); + expect(credits2).to.equal(creditsOfPosition2); + + // Position 3 locks + const creditsOfPosition3 = (await XDEFIDistribution.getCredits(toWei(1000), durations[2])).toString(); + const nft3 = await lock(account3, toWei(1000), durations[2], bonusMultipliers[2]); + const [tier3, credits3] = await XDEFIDistribution.attributesOf(nft3); + expect(nft3).to.equal('3'); + expect(tier3).to.equal(1); + expect(credits3).to.equal(creditsOfPosition3); + + // Position 1, 2, and 3 unlock + await hre.ethers.provider.send('evm_increaseTime', [durations[2]]); + await (await XDEFIDistribution.connect(wallet).unlock(nft1, wallet.address)).wait(); + await (await XDEFIDistribution.connect(account2).unlock(nft2, account2.address)).wait(); + await (await XDEFIDistribution.connect(account3).unlock(nft3, account3.address)).wait(); + + // Unlocked position 1 is consumed from with permit from owner. + await consumeWithPermit(wallet, nft1, account1, 10, 0, maxDeadline); + const [newTier1, nerCredits1] = await XDEFIDistribution.attributesOf(nft1); + expect(newTier1).to.equal(1); + expect(nerCredits1).to.equal(BigInt(creditsOfPosition1) - 10n); + + // Unlocked position 2 is consumed from with permit from account approved on token. + await (await XDEFIDistribution.connect(account2).approve(wallet.address, nft2)).wait(); + await consumeWithPermit(wallet, nft2, account1, 20, 0, maxDeadline); + const [tier5, newCredits2] = await XDEFIDistribution.attributesOf(nft2); + expect(tier5).to.equal(1); + expect(newCredits2).to.equal(BigInt(creditsOfPosition2) - 20n); + + // Unlocked position 3 is consumed from with permit from account approved for all account1's tokens. + await (await XDEFIDistribution.connect(account3).setApprovalForAll(wallet.address, true)).wait(); + await consumeWithPermit(wallet, nft3, account1, 30, 0, maxDeadline); + const [tier6, newCredits3] = await XDEFIDistribution.attributesOf(nft3); expect(tier6).to.equal(1); - expect(score6).to.equal(BigInt(scoreOfPosition3) - 30n); - expect(sequence6).to.equal(5); + expect(newCredits3).to.equal(BigInt(creditsOfPosition3) - 30n); }); - it('Cannot consume from locked positions', async () => { + it('Can consume from locked positions', async () => { // Position 1 locks + const creditsOfPosition1 = (await XDEFIDistribution.getCredits(toWei(1000), durations[0])).toString(); const nft1 = await lock(account1, toWei(1000), durations[0], bonusMultipliers[0]); + const [tier1, credits1] = await XDEFIDistribution.attributesOf(nft1); + expect(nft1).to.equal('1'); + expect(tier1).to.equal(1); + expect(credits1).to.equal(creditsOfPosition1); + + // Position 2 locks and is transferred to account 1 + const creditsOfPosition2 = (await XDEFIDistribution.getCredits(toWei(1000), durations[1])).toString(); + const nft2 = await lock(account2, toWei(1000), durations[1], bonusMultipliers[1]); + const [tier2, credits2] = await XDEFIDistribution.attributesOf(nft2); + expect(nft2).to.equal('2'); + expect(tier2).to.equal(1); + expect(credits2).to.equal(creditsOfPosition2); + await (await XDEFIDistribution.connect(account2).transferFrom(account2.address, account1.address, nft2)).wait(); + + // Position 3 locks and is transferred to account 1 + const creditsOfPosition3 = (await XDEFIDistribution.getCredits(toWei(1000), durations[2])).toString(); + const nft3 = await lock(account3, toWei(1000), durations[2], bonusMultipliers[2]); + const [tier3, credits3] = await XDEFIDistribution.attributesOf(nft3); + expect(nft3).to.equal('3'); + expect(tier3).to.equal(1); + expect(credits3).to.equal(creditsOfPosition3); + await (await XDEFIDistribution.connect(account3).transferFrom(account3.address, account1.address, nft3)).wait(); + + // Locked position 1 is consumed from by the owner. + await (await XDEFIDistribution.connect(account1).consume(nft1, 10)).wait(); + const [newTier1, newCredits1] = await XDEFIDistribution.attributesOf(nft1); + expect(newTier1).to.equal(1); + expect(newCredits1).to.equal(BigInt(creditsOfPosition1) - 10n); + + // Locked position 2 is consumed from by account approved on token. + await (await XDEFIDistribution.connect(account1).approve(account2.address, nft2)).wait(); + await (await XDEFIDistribution.connect(account2).consume(nft2, 20)).wait(); + const [newTier2, newCredits2] = await XDEFIDistribution.attributesOf(nft2); + expect(newTier2).to.equal(1); + expect(newCredits2).to.equal(BigInt(creditsOfPosition2) - 20n); + + // Locked position 3 is consumed from by account approved for all account1's tokens. + await (await XDEFIDistribution.connect(account1).setApprovalForAll(account3.address, true)).wait(); + await (await XDEFIDistribution.connect(account3).consume(nft3, 30)).wait(); + const [newTier3, newCredits3] = await XDEFIDistribution.attributesOf(nft3); + expect(newTier3).to.equal(1); + expect(newCredits3).to.equal(BigInt(creditsOfPosition3) - 30n); + + // Position 4, 5, and 6 unlock + await hre.ethers.provider.send('evm_increaseTime', [durations[2]]); + await (await XDEFIDistribution.connect(account1).unlockBatch([nft1, nft2, nft3], account1.address)).wait(); + expect(await XDEFI.balanceOf(account1.address)).to.equal(toWei(3000)); + }); + + it('Can consume from locked positions, with permits', async () => { + // Position 1 locks + const creditsOfPosition1 = (await XDEFIDistribution.getCredits(toWei(1000), durations[0])).toString(); + const nft1 = await lock(wallet, toWei(1000), durations[0], bonusMultipliers[0]); + const [tier1, credits1] = await XDEFIDistribution.attributesOf(nft1); + expect(nft1).to.equal('1'); + expect(tier1).to.equal(1); + expect(credits1).to.equal(creditsOfPosition1); + + // Position 2 locks + const creditsOfPosition2 = (await XDEFIDistribution.getCredits(toWei(1000), durations[1])).toString(); + const nft2 = await lock(account2, toWei(1000), durations[1], bonusMultipliers[1]); + const [tier2, credits2] = await XDEFIDistribution.attributesOf(nft2); + expect(nft2).to.equal('2'); + expect(tier2).to.equal(1); + expect(credits2).to.equal(creditsOfPosition2); - await expect(XDEFIDistribution.connect(account1).consume(nft1, 10, account1.address)).to.be.revertedWith('PositionStillLocked()'); + // Position 3 locks + const creditsOfPosition3 = (await XDEFIDistribution.getCredits(toWei(1000), durations[2])).toString(); + const nft3 = await lock(account3, toWei(1000), durations[2], bonusMultipliers[2]); + const [tier3, credits3] = await XDEFIDistribution.attributesOf(nft3); + expect(nft3).to.equal('3'); + expect(tier3).to.equal(1); + expect(credits3).to.equal(creditsOfPosition3); + + // Locked position 1 is consumed from with permit from owner. + await consumeWithPermit(wallet, nft1, account1, 10, 0, maxDeadline); + const [newTier1, newCredits1] = await XDEFIDistribution.attributesOf(nft1); + expect(newTier1).to.equal(1); + expect(newCredits1).to.equal(BigInt(creditsOfPosition1) - 10n); + + // Unlocked position 2 is consumed from with permit from account approved on token. + await (await XDEFIDistribution.connect(account2).approve(wallet.address, nft2)).wait(); + await consumeWithPermit(wallet, nft2, account1, 20, 0, maxDeadline); + const [newTier2, newCredits2] = await XDEFIDistribution.attributesOf(nft2); + expect(newTier2).to.equal(1); + expect(newCredits2).to.equal(BigInt(creditsOfPosition2) - 20n); + + // Unlocked position 3 is consumed from with permit from account approved for all account1's tokens. + await (await XDEFIDistribution.connect(account3).setApprovalForAll(wallet.address, true)).wait(); + await consumeWithPermit(wallet, nft3, account1, 30, 0, maxDeadline); + const [newTier3, newCredits3] = await XDEFIDistribution.attributesOf(nft3); + expect(newTier3).to.equal(1); + expect(newCredits3).to.equal(BigInt(creditsOfPosition3) - 30n); + + // Position 4, 5, and 6 unlock + await hre.ethers.provider.send('evm_increaseTime', [durations[2]]); + await (await XDEFIDistribution.connect(wallet).unlock(nft1, wallet.address)).wait(); + await (await XDEFIDistribution.connect(account2).unlock(nft2, account2.address)).wait(); + await (await XDEFIDistribution.connect(account3).unlock(nft3, account3.address)).wait(); + expect(await XDEFI.balanceOf(wallet.address)).to.equal(toWei(1000)); + expect(await XDEFI.balanceOf(account2.address)).to.equal(toWei(1000)); + expect(await XDEFI.balanceOf(account3.address)).to.equal(toWei(1000)); }); });