diff --git a/contracts/manifold/staking/ERC721StakingPoints.sol b/contracts/manifold/staking/ERC721StakingPoints.sol new file mode 100644 index 00000000..a69b957c --- /dev/null +++ b/contracts/manifold/staking/ERC721StakingPoints.sol @@ -0,0 +1,99 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.0; + +/// @author: manifold.xyz + +import "@openzeppelin/contracts/token/ERC721/IERC721.sol"; +import "@manifoldxyz/creator-core-solidity/contracts/core/IERC721CreatorCore.sol"; +import "../../libraries/IERC721CreatorCoreVersion.sol"; +import "./IERC721StakingPoints.sol"; +import "./StakingPointsCore.sol"; +import "./IStakingPointsCore.sol"; + +/** + * @title ERC721 Staking Points + * @author manifold.xyz + * @notice logic for Staking Points for ERC721 extension. + */ +contract ERC721StakingPoints is StakingPointsCore, IERC721StakingPoints { + using SafeMath for uint256; + // { creatorContractAddress => {instanceId => uint256 } } + mapping(address => mapping(uint256 => uint256)) public totalPointsClaimed; + + function supportsInterface(bytes4 interfaceId) public view virtual override(StakingPointsCore, IERC165) returns (bool) { + return interfaceId == type(IERC721StakingPoints).interfaceId || super.supportsInterface(interfaceId); + } + + function initializeStakingPoints( + address creatorContractAddress, + uint256 instanceId, + StakingPointsParams calldata stakingPointsParams + ) external creatorAdminRequired(creatorContractAddress) nonReentrant { + // Max uint56 for instanceId + require(instanceId > 0 && instanceId <= MAX_UINT_56, "Invalid instanceId"); + require(stakingPointsParams.paymentReceiver != address(0), "Cannot initialize without payment receiver"); + + uint8 creatorContractVersion; + try IERC721CreatorCoreVersion(creatorContractAddress).VERSION() returns (uint256 version) { + require(version <= 255, "Unsupported contract version"); + creatorContractVersion = uint8(version); + } catch {} + StakingPoints storage instance = _stakingPointsInstances[creatorContractAddress][instanceId]; + require(instance.paymentReceiver == address(0), "StakingPoints already initialized"); + _initialize(creatorContractAddress, creatorContractVersion, instanceId, stakingPointsParams); + + emit StakingPointsInitialized(creatorContractAddress, instanceId, msg.sender); + } + + function updateStakingPoints( + address creatorContractAddress, + uint256 instanceId, + StakingPointsParams calldata stakingPointsParams + ) external creatorAdminRequired(creatorContractAddress) nonReentrant { + // Max uint56 for instanceId + require(instanceId > 0 && instanceId <= MAX_UINT_56, "Invalid instanceId"); + require(stakingPointsParams.paymentReceiver != address(0), "Cannot update without payment receiver"); + + uint8 creatorContractVersion; + try IERC721CreatorCoreVersion(creatorContractAddress).VERSION() returns (uint256 version) { + require(version <= 255, "Unsupported contract version"); + creatorContractVersion = uint8(version); + } catch {} + StakingPoints storage instance = _stakingPointsInstances[creatorContractAddress][instanceId]; + require(instance.stakers.length == (0), "StakingPoints cannot be updated when 1 or more wallets have staked"); + _update(creatorContractAddress, creatorContractVersion, instanceId, stakingPointsParams); + + emit StakingPointsUpdated(creatorContractAddress, instanceId, msg.sender); + } + + /** + * @dev was originally using safeTransferFrom but was getting a reentrancy error + */ + function _transfer(address contractAddress, uint256 tokenId, address from, address to) internal override { + require( + IERC721(contractAddress).ownerOf(tokenId) == from && + (IERC721(contractAddress).getApproved(tokenId) == address(this) || + IERC721(contractAddress).isApprovedForAll(from, address(this))), + "Token not owned or not approved" + ); + require(IERC721(contractAddress).ownerOf(tokenId) == from, "Token not in sender possesion"); + IERC721(contractAddress).transferFrom(from, to, tokenId); + } + + /** + * @dev was originally using safeTransferFrom but was getting a reentrancy error + */ + function _transferBack(address contractAddress, uint256 tokenId, address from, address to) internal override { + require(IERC721(contractAddress).ownerOf(tokenId) == from, "Token not in sender possesion"); + IERC721(contractAddress).transferFrom(from, to, tokenId); + } + + /** + * @dev + */ + function _redeem(address creatorContractAddress, uint256 instanceId, uint256 pointsAmount) internal override { + uint256 currTotal = totalPointsClaimed[creatorContractAddress][instanceId]; + totalPointsClaimed[creatorContractAddress][instanceId] = currTotal.add(pointsAmount); + } +} diff --git a/contracts/manifold/staking/IERC721StakingPoints.sol b/contracts/manifold/staking/IERC721StakingPoints.sol new file mode 100644 index 00000000..cf6d395c --- /dev/null +++ b/contracts/manifold/staking/IERC721StakingPoints.sol @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.0; + +/// @author: manifold.xyz + +import "./IStakingPointsCore.sol"; +import "./StakingPointsCore.sol"; + +interface IERC721StakingPoints is IStakingPointsCore { + /** + * @notice initialize a new staking points, emit initialize event + * @param creatorContractAddress the address of the creator contract + * @param instanceId the instanceId of the staking points for the creator contract + * @param stakingPointsParams the stakingPointsParams object + */ + function initializeStakingPoints( + address creatorContractAddress, + uint256 instanceId, + StakingPointsParams calldata stakingPointsParams + ) external; + + /** + * @notice update existing staking points, emit update event + * @param creatorContractAddress the address of the creator contract + * @param instanceId the instanceId of the staking points for the creator contract + * @param stakingPointsParams the stakingPointsParams object + */ + function updateStakingPoints( + address creatorContractAddress, + uint256 instanceId, + StakingPointsParams calldata stakingPointsParams + ) external; +} diff --git a/contracts/manifold/staking/IStakingPointsCore.sol b/contracts/manifold/staking/IStakingPointsCore.sol new file mode 100644 index 00000000..dfe1bdd5 --- /dev/null +++ b/contracts/manifold/staking/IStakingPointsCore.sol @@ -0,0 +1,118 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.0; + +/// @author: manifold.xyz + +import "@openzeppelin/contracts/security/ReentrancyGuard.sol"; +import "@openzeppelin/contracts/utils/introspection/IERC165.sol"; +import "@openzeppelin/contracts/token/ERC721/IERC721Receiver.sol"; + +// import "@openzeppelin/contracts/token/ERC1155/IERC1155Receiver.sol"; + +/** + * StakingPointsCore interface + */ +interface IStakingPointsCore is IERC165, IERC721Receiver { + /** TODO: CONFIRM STRUCTS */ + + struct StakedToken { + uint256 tokenId; + address contractAddress; + address stakerAddress; + uint256 timeStaked; + uint256 timeUnstaked; + uint256 tokenIdx; + } + + struct StakedTokenIdx { + uint256 tokenIdx; + address stakerAddress; + } + + struct StakedTokenParams { + address tokenAddress; + uint256 tokenId; + } + + struct Staker { + uint256 pointsRedeemed; + uint256 stakerIdx; + StakedToken[] stakersTokens; + address stakerAddress; + } + + struct StakingRule { + address tokenAddress; + uint256 pointsRatePerDay; + uint256 startTime; + uint256 endTime; + } + + struct StakingPoints { + address payable paymentReceiver; + uint8 contractVersion; + StakingRule[] stakingRules; + Staker[] stakers; + } + + struct StakingPointsParams { + address payable paymentReceiver; + StakingRule[] stakingRules; + } + + event StakingPointsInitialized(address indexed creatorContract, uint256 indexed instanceId, address initializer); + event StakingPointsUpdated(address indexed creatorContract, uint256 indexed instanceId, address updater); + event TokensStaked(address indexed creatorContract, uint256 indexed instanceId, StakedToken[] stakedTokens, address owner); + event TokensUnstaked(address indexed creatorContract, uint256 indexed instanceId, StakedToken[] stakedTokens, address owner); + event PointsDistributed(address indexed creatorContract, uint256 indexed instanceId, address user, uint256 amount); + + /** + * @notice stake tokens + * @param creatorContractAddress the address of the creator contract + * @param instanceId the staking points instanceId for the creator contract + * @param stakingTokens a list of tokenIds with token contract addresses + */ + function stakeTokens( + address creatorContractAddress, + uint256 instanceId, + StakedTokenParams[] calldata stakingTokens + ) external; + + /** + * @notice unstake tokens + * @param creatorContractAddress the address of the creator contract + * @param instanceId the staking points instanceId for the creator contract + * @param unstakingTokens a list of tokenIds with token contract addresses + */ + function unstakeTokens( + address creatorContractAddress, + uint256 instanceId, + StakedTokenParams[] calldata unstakingTokens + ) external; + + /** + * @notice get a staking points instance corresponding to a creator contract and instanceId + * @param creatorContractAddress the address of the creator contract + * @param instanceId the staking points instanceId for the creator contract + * @return StakingPoints the staking points object + */ + function getStakingPointsInstance( + address creatorContractAddress, + uint256 instanceId + ) external view returns (StakingPoints memory); + + /** + * @notice recover a token that was sent to the contract without safeTransferFrom + * @param tokenAddress the address of the token contract + * @param tokenId the id of the token + * @param destination the address to send the token to + */ + function recoverERC721(address tokenAddress, uint256 tokenId, address destination) external; + + /** + * @notice set the Manifold Membership contract address + * @param addr the address of the Manifold Membership contract + */ + function setMembershipAddress(address addr) external; +} diff --git a/contracts/manifold/staking/StakingPointsCore.sol b/contracts/manifold/staking/StakingPointsCore.sol new file mode 100644 index 00000000..fa91a8b9 --- /dev/null +++ b/contracts/manifold/staking/StakingPointsCore.sol @@ -0,0 +1,450 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.0; + +import "@manifoldxyz/libraries-solidity/contracts/access/AdminControl.sol"; +import "@manifoldxyz/libraries-solidity/contracts/access/IAdminControl.sol"; + +import "@openzeppelin/contracts/security/ReentrancyGuard.sol"; +import "@openzeppelin/contracts/token/ERC721/IERC721.sol"; +import "@openzeppelin/contracts/utils/math/SafeMath.sol"; +import "@openzeppelin/contracts/utils/math/Math.sol"; + +import "./IStakingPointsCore.sol"; + +/** + * @title Staking Points Core + * @author manifold.xyz + * @notice Core logic for Staking Points shared extensions. Currently only handles ERC721, next steps could include + * implementing batch fns, ERC1155 support, using a ERC20 token to represent points, and explore more point dynamics + */ +abstract contract StakingPointsCore is ReentrancyGuard, ERC165, AdminControl, IStakingPointsCore { + using SafeMath for uint256; + uint256 internal constant MAX_UINT_24 = 0xffffff; + uint256 internal constant MAX_UINT_32 = 0xffffffff; + uint256 internal constant MAX_UINT_56 = 0xffffffffffffff; + uint256 internal constant MAX_UINT_256 = 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff; + address private constant ADDRESS_ZERO = 0x0000000000000000000000000000000000000000; + + // { creatorContractAddress => { instanceId => StakingPoints } } + mapping(address => mapping(uint256 => StakingPoints)) internal _stakingPointsInstances; + + // { creatorContractAddress => { instanceId => { walletAddress => stakerIdx } } } + mapping(address => mapping(uint256 => mapping(address => uint256))) internal _stakerIdxs; + + // { creatorContractAddress => { instanceId => { tokenAddress => ruleIdx } } } + mapping(address => mapping(uint256 => mapping(address => uint256))) internal _stakingRulesIdxs; + + // { walletAddress => { tokenAddress => { tokenId => StakedTokenIdx } } } + mapping(address => mapping(address => mapping(uint256 => StakedTokenIdx))) internal _stakedTokenIdxs; + + // { creatorContractAddress => {instanceId => { walletAddress => bool } } } + mapping(address => mapping(uint256 => mapping(address => bool))) internal _isStakerIndexed; + + address public manifoldMembershipContract; + + function supportsInterface(bytes4 interfaceId) public view virtual override(IERC165, ERC165, AdminControl) returns (bool) { + return + interfaceId == type(IStakingPointsCore).interfaceId || + interfaceId == type(IERC721Receiver).interfaceId || + super.supportsInterface(interfaceId); + } + + /** + * @notice This extension is shared, not single-creator. So we must ensure + * that a staking points's initializer is an admin on the creator contract + * @param creatorContractAddress the address of the creator contract to check the admin against + */ + modifier creatorAdminRequired(address creatorContractAddress) { + require(IAdminControl(creatorContractAddress).isAdmin(msg.sender), "Wallet is not an admin"); + _; + } + + /** + * Initialiazes a StakingPoints with base parameters + */ + function _initialize( + address creatorContractAddress, + uint8 creatorContractVersion, + uint256 instanceId, + StakingPointsParams calldata stakingPointsParams + ) internal { + _update(creatorContractAddress, creatorContractVersion, instanceId, stakingPointsParams); + } + + /** + * Updates a stakingPionts with params + */ + function _update( + address creatorContractAddress, + uint8 creatorContractVersion, + uint256 instanceId, + StakingPointsParams calldata stakingPointsParams + ) internal { + StakingPoints storage instance = _stakingPointsInstances[creatorContractAddress][instanceId]; + _validateStakingPointsParams(stakingPointsParams); + instance.paymentReceiver = stakingPointsParams.paymentReceiver; + instance.contractVersion = creatorContractVersion; + require(stakingPointsParams.stakingRules.length > 0, "Needs at least one stakingRule"); + _setStakingRules(creatorContractAddress, instance, instanceId, stakingPointsParams.stakingRules); + } + + /** + * Abstract helper to transfer tokens. To be implemented by inheriting contracts. + */ + function _transfer(address contractAddress, uint256 tokenId, address fromAddress, address toAddress) internal virtual; + + /** + * Abstract helper to transfer tokens. To be implemented by inheriting contracts. + */ + function _transferBack(address contractAddress, uint256 tokenId, address fromAddress, address toAddress) internal virtual; + + /** + * Abstract helper to redeem points. To be implemented by inheriting contracts. + */ + function _redeem(address creatorContractAddress, uint256 instanceId, uint256 pointsAmount) internal virtual; + + /** STAKING */ + + function stakeTokens( + address creatorContractAddress, + uint256 instanceId, + StakedTokenParams[] calldata stakingTokens + ) external nonReentrant { + _stakeTokens(creatorContractAddress, instanceId, stakingTokens); + } + + function _stakeTokens( + address creatorContractAddress, + uint256 instanceId, + StakedTokenParams[] calldata stakingTokens + ) private { + // get instance + StakingPoints storage instance = _getStakingPointsInstance(creatorContractAddress, instanceId); + bool isIndexed = _isStakerIndexed[creatorContractAddress][instanceId][msg.sender]; + if (!isIndexed) { + uint256 newStakerIdx = instance.stakers.length; + instance.stakers.push(); + instance.stakers[newStakerIdx].stakerAddress = msg.sender; + instance.stakers[newStakerIdx].stakerIdx = newStakerIdx; + // add staker to index map + _stakerIdxs[creatorContractAddress][instanceId][msg.sender] = newStakerIdx; + _isStakerIndexed[creatorContractAddress][instanceId][msg.sender] = true; + } + + StakedToken[] memory newlyStaked = new StakedToken[](stakingTokens.length); + int256 stakerIdx = _getStakerIdx(creatorContractAddress, instanceId, msg.sender); + require(stakerIdx > -1, "Error with staker idx"); + StakedToken[] storage userTokens = instance.stakers[uint256(stakerIdx)].stakersTokens; + uint256 length = stakingTokens.length; + for (uint256 i = 0; i < length; ) { + StakedTokenIdx storage currStakedTokenIdx = _stakedTokenIdxs[msg.sender][stakingTokens[i].tokenAddress][ + stakingTokens[i].tokenId + ]; + require(currStakedTokenIdx.stakerAddress == address(0), "Token already staked"); + StakedToken memory currToken; + currToken.tokenId = stakingTokens[i].tokenId; + currToken.contractAddress = stakingTokens[i].tokenAddress; + currToken.stakerAddress = msg.sender; + currToken.timeStaked = block.timestamp; + currToken.tokenIdx = userTokens.length; + + _stakedTokenIdxs[msg.sender][stakingTokens[i].tokenAddress][stakingTokens[i].tokenId] = StakedTokenIdx( + currToken.tokenIdx, + msg.sender + ); + userTokens.push(currToken); + newlyStaked[i] = currToken; + + _stake(creatorContractAddress, instanceId, instance, stakingTokens[i].tokenAddress, stakingTokens[i].tokenId); + unchecked { + ++i; + } + } + emit TokensStaked(creatorContractAddress, instanceId, newlyStaked, msg.sender); + } + + function _stake( + address creatorContractAddress, + uint256 instanceId, + StakingPoints storage stakingPointsInstance, + address tokenAddress, + uint256 tokenId + ) private { + StakingRule memory ruleExists = _getTokenRule(creatorContractAddress, instanceId, stakingPointsInstance, tokenAddress); + require(ruleExists.tokenAddress == tokenAddress, "Token does not match existing rule"); + require( + block.timestamp > ruleExists.startTime && block.timestamp < ruleExists.endTime, + "Outside staking rule time limit" + ); + _transfer(tokenAddress, tokenId, msg.sender, address(this)); + } + + /** UNSTAKING */ + + function unstakeTokens( + address creatorContractAddress, + uint256 instanceId, + StakedTokenParams[] calldata unstakingTokens + ) external nonReentrant { + _unstakeTokens(creatorContractAddress, instanceId, unstakingTokens); + } + + function _unstakeTokens( + address creatorContractAddress, + uint256 instanceId, + StakedTokenParams[] calldata unstakingTokens + ) private { + require(unstakingTokens.length != 0, "Cannot unstake 0 tokens"); + + StakingPoints storage instance = _getStakingPointsInstance(creatorContractAddress, instanceId); + StakedToken[] memory unstakedTokens = new StakedToken[](unstakingTokens.length); + + int256 stakerIdx = _getStakerIdx(creatorContractAddress, instanceId, msg.sender); + require(stakerIdx > -1, "Cannot unstake tokens for someone who has not staked"); + + for (uint256 i = 0; i < unstakingTokens.length; ++i) { + StakedTokenIdx storage currStakedTokenIdx = _stakedTokenIdxs[msg.sender][unstakingTokens[i].tokenAddress][ + unstakingTokens[i].tokenId + ]; + StakedToken storage currToken = instance.stakers[uint256(stakerIdx)].stakersTokens[currStakedTokenIdx.tokenIdx]; + require( + msg.sender != address(0) && msg.sender == currToken.stakerAddress, + "No sender address or not the original staker" + ); + currToken.timeUnstaked = block.timestamp; + delete _stakedTokenIdxs[msg.sender][unstakingTokens[i].tokenAddress][unstakingTokens[i].tokenId]; + unstakedTokens[i] = currToken; + _unstakeToken(unstakingTokens[i].tokenAddress, unstakingTokens[i].tokenId); + } + + // add redeem functionality if staker has not redeemed past qualifying amount for unstaking tokens + // ensure that staker is coming from right place + Staker storage staker = instance.stakers[uint256(stakerIdx)]; + uint256 totalUnstakingTokensPoints = _calculateTotalQualifyingPoints(creatorContractAddress, instanceId, staker.stakersTokens); + uint256 diffRedeemed = totalUnstakingTokensPoints.sub(staker.pointsRedeemed); + + if (diffRedeemed > 0) { + staker.pointsRedeemed = totalUnstakingTokensPoints; + _redeemPointsAmount(creatorContractAddress, instanceId, diffRedeemed); + } + emit TokensUnstaked(creatorContractAddress, instanceId, unstakedTokens, msg.sender); + } + + /** + * @dev assumes that fn that calls protects against sender not matching original owner + */ + function _unstakeToken(address tokenAddress, uint256 tokenId) private { + _transferBack(tokenAddress, tokenId, address(this), msg.sender); + } + + function redeemPoints(address creatorContractAddress, uint256 instanceId) external nonReentrant { + _redeemPoints(creatorContractAddress, instanceId); + } + + function _redeemPoints(address creatorContractAddress, uint256 instanceId) private { + StakingPoints storage instance = _getStakingPointsInstance(creatorContractAddress, instanceId); + int256 stakerIdx = _getStakerIdx(creatorContractAddress, instanceId, msg.sender); + require(stakerIdx > -1, "Cannot redeem points for someone who has not staked"); + + Staker storage staker = instance.stakers[uint256(stakerIdx)]; + uint256 totalQualifyingPoints = _calculateTotalQualifyingPoints( + creatorContractAddress, + instanceId, + staker.stakersTokens + ); + uint256 diff = totalQualifyingPoints.sub(staker.pointsRedeemed); + require(totalQualifyingPoints != 0 && diff >= 0, "Need more than zero points"); + // compare with pointsRedeemed + staker.pointsRedeemed = totalQualifyingPoints; + _redeem(creatorContractAddress, instanceId, diff); + emit PointsDistributed(creatorContractAddress, instanceId, msg.sender, diff); + } + + /** + * @dev assumes that the sender is qualified to redeem amount and not in excess of points already redeemed + */ + function _redeemPointsAmount(address creatorContractAddress, uint256 instanceId, uint256 amount) private { + _redeem(creatorContractAddress, instanceId, amount); + emit PointsDistributed(creatorContractAddress, instanceId, msg.sender, amount); + } + + function getPointsForWallet( + address creatorContractAddress, + uint256 instanceId, + address walletAddress + ) external view returns (uint256) { + return _getPointsForWallet(creatorContractAddress, instanceId, walletAddress); + } + + function _getPointsForWallet( + address creatorContractAddress, + uint256 instanceId, + address walletAddress + ) private view returns (uint256 walletPoints) { + StakingPoints storage instance = _getStakingPointsInstance(creatorContractAddress, instanceId); + int256 stakerIdx = _getStakerIdx(creatorContractAddress, instanceId, walletAddress); + require(stakerIdx > -1, "Cannot get points for someone who has not staked"); + + Staker storage staker = instance.stakers[uint256(stakerIdx)]; + walletPoints = _calculateTotalQualifyingPoints(creatorContractAddress, instanceId, staker.stakersTokens); + } + + /** + * @notice assumes points + */ + function _calculateTotalQualifyingPoints( + address creatorContractAddress, + uint256 instanceId, + StakedToken[] memory stakingTokens + ) private view returns (uint256 points) { + uint256 length = stakingTokens.length; + StakingPoints storage instance = _getStakingPointsInstance(creatorContractAddress, instanceId); + StakingRule[] storage rules = instance.stakingRules; + for (uint256 i = 0; i < length; ) { + uint256 ruleIdx = _stakingRulesIdxs[creatorContractAddress][instanceId][stakingTokens[i].contractAddress]; + StakingRule storage rule = rules[ruleIdx]; + require(rule.startTime > 0 && rule.endTime > 0, "Invalid rule values"); + uint256 tokenEnd = stakingTokens[i].timeUnstaked > 0 ? stakingTokens[i].timeUnstaked : block.timestamp; + uint256 start = Math.max(rule.startTime, stakingTokens[i].timeStaked); + uint256 end = Math.min(tokenEnd, rule.endTime); + uint256 diff = end.sub(start); + uint256 qualified = _calculateTotalPoints(diff, rule.pointsRatePerDay, 86400); + points = points.add(qualified); + + unchecked { + ++i; + } + } + } + + /** VIEW HELPERS */ + + function getStaker( + address creatorContractAddress, + uint256 instanceId, + address stakerAddress + ) external view returns (Staker memory) { + int256 stakerIdx = _getStakerIdx(creatorContractAddress, instanceId, stakerAddress); + StakedToken[] memory emptyStakedTokens; + if (stakerIdx < 0) return Staker(0, 0, emptyStakedTokens, ADDRESS_ZERO); + StakingPoints storage instance = _getStakingPointsInstance(creatorContractAddress, instanceId); + return instance.stakers[uint256(stakerIdx)]; + } + + function _getTokenRule( + address creatorContractAddress, + uint256 instanceId, + StakingPoints storage stakingPointsInstance, + address tokenAddress + ) internal view returns (StakingRule memory) { + uint256 ruleIdx = _stakingRulesIdxs[creatorContractAddress][instanceId][tokenAddress]; + return stakingPointsInstance.stakingRules[ruleIdx]; + } + + /** + * See {IStakingPointsCore-getStakingPointsInstance}. + */ + function getStakingPointsInstance( + address creatorContractAddress, + uint256 instanceId + ) external view override returns (StakingPoints memory _stakingPoints) { + _stakingPoints = _getStakingPointsInstance(creatorContractAddress, instanceId); + } + + /** + * Helper to get Staker + * returns the index if staker is indexed otherwise -1 + */ + function _getStakerIdx( + address creatorContractAddress, + uint256 instanceId, + address walletAddress + ) internal view returns (int256) { + bool isIndexed = _isStakerIndexed[creatorContractAddress][instanceId][walletAddress]; + return isIndexed ? int256(_stakerIdxs[creatorContractAddress][instanceId][walletAddress]) : -1; + } + + /** + * Helper to get staking points instance + */ + function _getStakingPointsInstance( + address creatorContractAddress, + uint256 instanceId + ) internal view returns (StakingPoints storage stakingPointsInstance) { + stakingPointsInstance = _stakingPointsInstances[creatorContractAddress][instanceId]; + require(stakingPointsInstance.paymentReceiver != address(0), "Staking points not initialized"); + } + + /** HELPERS */ + + function _calculateTotalPoints(uint256 diff, uint256 rate, uint256 div) private pure returns (uint256) { + return diff.mul(rate).div(div); + } + + /** + * @dev See {IStakingPointsCore-recoverERC721}. + */ + function recoverERC721(address tokenAddress, uint256 tokenId, address destination) external override adminRequired { + IERC721(tokenAddress).transferFrom(address(this), destination, tokenId); + } + + /** + * @dev See {IERC721Receiver-onERC721Received}. + */ + function onERC721Received( + address, + address from, + uint256 id, + bytes calldata data + ) external override nonReentrant returns (bytes4) { + _onERC721Received(from, id, data); + return this.onERC721Received.selector; + } + + function _onERC721Received(address from, uint256 id, bytes calldata data) private { + /** TODO attempt to stake */ + } + + /** + * @dev See {IstakingPointsCore-setManifoldMembership}. + */ + function setMembershipAddress(address addr) external override adminRequired { + manifoldMembershipContract = addr; + } + + function _validateStakingPointsParams(StakingPointsParams calldata stakingPointsParams) internal pure { + require(stakingPointsParams.paymentReceiver != address(0), "Payment receiver required"); + } + + function _setStakingRules( + address creatorContractAddress, + StakingPoints storage stakingPointsInstance, + uint256 instanceId, + StakingRule[] calldata newStakingRules + ) private { + StakingRule[] memory oldRules = stakingPointsInstance.stakingRules; + uint256 oldLength = oldRules.length; + for (uint256 i; i < oldLength; ) { + delete _stakingRulesIdxs[creatorContractAddress][instanceId][oldRules[i].tokenAddress]; + unchecked { + ++i; + } + } + delete stakingPointsInstance.stakingRules; + StakingRule[] storage rules = stakingPointsInstance.stakingRules; + uint256 length = newStakingRules.length; + uint256 timestamp = block.timestamp; + for (uint256 i; i < length; ) { + StakingRule memory rule = newStakingRules[i]; + require(rule.tokenAddress != address(0), "Staking rule: Contract address required"); + require((rule.endTime > timestamp) && (rule.startTime < rule.endTime), "Staking rule: Invalid time range"); + require(rule.pointsRatePerDay > 0, "Staking rule: Invalid points rate"); + _stakingRulesIdxs[creatorContractAddress][instanceId][rule.tokenAddress] = rules.length; + rules.push(rule); + unchecked { + ++i; + } + } + } +} diff --git a/test/manifold/stakingpoints721.js b/test/manifold/stakingpoints721.js new file mode 100644 index 00000000..25a0c5ba --- /dev/null +++ b/test/manifold/stakingpoints721.js @@ -0,0 +1,681 @@ +const truffleAssert = require("truffle-assertions"); +const ERC721Creator = artifacts.require("@manifoldxyz/creator-core-extensions-solidity/ERC721Creator"); +const ERC721StakingPoints = artifacts.require("ERC721StakingPoints"); +const ERC1155Creator = artifacts.require("@manifoldxyz/creator-core-extensions-solidity/ERC1155Creator"); +const MockManifoldMembership = artifacts.require("MockManifoldMembership"); + +contract("ERC721StakingPoints", function ([...accounts]) { + const [owner, anotherOwner, anyone1, anyone2] = accounts; + + describe("StakingPoints", function () { + let creator; + + beforeEach(async function () { + creator = await ERC721Creator.new("Test", "TEST", { from: owner }); + stakingPoints721 = await ERC721StakingPoints.new({ from: owner }); + manifoldMembership = await MockManifoldMembership.new({ from: owner }); + await stakingPoints721.setMembershipAddress(manifoldMembership.address); + + mock721 = await ERC721Creator.new("721", "721", { from: owner }); + mock721_2 = await ERC721Creator.new("721_2", "721_2", { from: owner }); + mock1155 = await ERC1155Creator.new("1155.com", { from: owner }); + + await mock721.mintBase(anyone1, { from: owner }); + await mock721.mintBase(anyone2, { from: owner }); + await mock721.mintBase(anyone2, { from: owner }); + await mock721.mintBase(anyone1, { from: owner }); + await mock721_2.mintBase(anyone1, { from: owner }); + await mock721_2.mintBase(anyone2, { from: owner }); + await mock721_2.mintBase(anotherOwner, { from: owner }); + + await creator.registerExtension(stakingPoints721.address, { from: owner }); + }); + + it("Admin creates new stakingpoints with rate", async function () { + // must be an admin + await truffleAssert.reverts( + stakingPoints721.initializeStakingPoints( + creator.address, + 1, + { + paymentReceiver: owner, + stakingRules: [ + { + tokenAddress: manifoldMembership.address, + pointsRatePerDay: 1234, + startTime: 1680680461, + endTime: 95625733261, + }, + ], + }, + { from: anyone1 } + ), + "Wallet is not an admin" + ); + // has invalid staking rule (missing pointsRatePerDay value) + await truffleAssert.reverts( + stakingPoints721.initializeStakingPoints( + creator.address, + 1, + { + paymentReceiver: owner, + stakingRules: [ + { + tokenAddress: manifoldMembership.address, + pointsRatePerDay: 0, + startTime: 1680680461, + endTime: 95625733261, + }, + ], + }, + { from: owner } + ), + "Staking rule: Invalid points rate" + ); + // has invalid staking rule (endTime is less than startTime) + await truffleAssert.reverts( + stakingPoints721.initializeStakingPoints( + creator.address, + 1, + { + paymentReceiver: owner, + stakingRules: [ + { + tokenAddress: manifoldMembership.address, + pointsRatePerDay: 1234, + startTime: 1680680461, + endTime: 1582541275, + }, + ], + }, + { from: owner } + ), + "Staking rule: Invalid time range" + ); + // has valid staking rule (token spec is not erc721) + + await stakingPoints721.initializeStakingPoints( + creator.address, + 1, + { + paymentReceiver: owner, + stakingRules: [ + { + tokenAddress: manifoldMembership.address, + pointsRatePerDay: 1234, + startTime: 1680680461, + endTime: 95625733261, + }, + ], + }, + { from: owner } + ); + stakingPointsInstance = await stakingPoints721.getStakingPointsInstance(creator.address, 1); + assert.equal(stakingPointsInstance.stakingRules.length, 1); + }); + + it("Will update a staking points instance if there are not any stakers", async function () { + await stakingPoints721.initializeStakingPoints( + creator.address, + 1, + { + paymentReceiver: owner, + stakingRules: [ + { + tokenAddress: manifoldMembership.address, + pointsRatePerDay: 2222, + startTime: 1680680461, + endTime: 95625733261, + }, + { + tokenAddress: mock721.address, + pointsRatePerDay: 1111, + startTime: 1680680461, + endTime: 95625733261, + }, + ], + }, + { from: owner } + ); + + await truffleAssert.reverts( + stakingPoints721.updateStakingPoints( + creator.address, + 1, + { + paymentReceiver: owner, + stakingRules: [ + { + tokenAddress: manifoldMembership.address, + pointsRatePerDay: 3333, + startTime: 1680680461, + endTime: 95625733261, + }, + { + tokenAddress: mock721.address, + pointsRatePerDay: 4444, + startTime: 1680680461, + endTime: 95625733261, + }, + ], + }, + { from: anyone2 } + ), + "Wallet is not an admin" + ); + + //update + await stakingPoints721.updateStakingPoints( + creator.address, + 1, + { + paymentReceiver: owner, + stakingRules: [ + { + tokenAddress: manifoldMembership.address, + pointsRatePerDay: 1234, + startTime: 1680680461, + endTime: 95625733261, + }, + { + tokenAddress: mock721.address, + pointsRatePerDay: 125, + startTime: 1680680461, + endTime: 95625733261, + }, + ], + }, + { from: owner } + ); + + // someone stakes + await mock721.setApprovalForAll(stakingPoints721.address, true, { from: anyone2 }); + await stakingPoints721.stakeTokens( + creator.address, + 1, + [ + { + tokenAddress: mock721.address, + tokenId: 2, + }, + ], + { from: anyone2 } + ); + + // unable to update + await truffleAssert.reverts( + stakingPoints721.updateStakingPoints( + creator.address, + 1, + { + paymentReceiver: owner, + stakingRules: [ + { + tokenAddress: manifoldMembership.address, + pointsRatePerDay: 3333, + startTime: 1680680461, + endTime: 95625733261, + }, + { + tokenAddress: mock721.address, + pointsRatePerDay: 4444, + startTime: 1680680461, + endTime: 95625733261, + }, + ], + }, + { from: owner } + ), + "StakingPoints cannot be updated when 1 or more wallets have staked" + ); + }); + it("Will not stake if not owned or approved", async function () { + await stakingPoints721.initializeStakingPoints( + creator.address, + 1, + { + paymentReceiver: owner, + stakingRules: [ + { + tokenAddress: manifoldMembership.address, + pointsRatePerDay: 1234, + startTime: 1680680461, + endTime: 95625733261, + }, + { + tokenAddress: mock721.address, + pointsRatePerDay: 125, + startTime: 1680680461, + endTime: 95625733261, + }, + ], + }, + { from: owner } + ); + await truffleAssert.reverts( + stakingPoints721.stakeTokens( + creator.address, + 1, + [ + { + tokenAddress: mock721.address, + tokenId: 1, + }, + ], + { from: owner } + ), + "Token not owned or not approved" + ); + }); + it("Stakes if owned and approved", async function () { + await stakingPoints721.initializeStakingPoints( + creator.address, + 1, + { + paymentReceiver: owner, + stakingRules: [ + { + tokenAddress: manifoldMembership.address, + pointsRatePerDay: 1234, + startTime: 1680680461, + endTime: 95625733261, + }, + { + tokenAddress: mock721.address, + pointsRatePerDay: 125, + startTime: 1680680461, + endTime: 95625733261, + }, + { + tokenAddress: mock721_2.address, + pointsRatePerDay: 125, + startTime: 1680680461, + endTime: 95625733261, + }, + ], + }, + { from: owner } + ); + await mock721.setApprovalForAll(stakingPoints721.address, true, { from: anyone2 }); + await mock721_2.setApprovalForAll(stakingPoints721.address, true, { from: anyone2 }); + await stakingPoints721.stakeTokens( + creator.address, + 1, + [ + { + tokenAddress: mock721.address, + tokenId: 2, + }, + { + tokenAddress: mock721.address, + tokenId: 3, + }, + { + tokenAddress: mock721_2.address, + tokenId: 2, + }, + ], + { from: anyone2 } + ); + + let staker1 = await stakingPoints721.getStaker(creator.address, 1, anyone2); + + assert.equal(staker1.stakersTokens.length, 3); + assert.equal(staker1.stakersTokens[0].tokenId, 2); + assert.equal(staker1.stakersTokens[1].tokenId, 3); + assert.equal(staker1.stakersTokens[1].contractAddress, mock721.address); + assert.equal(staker1.stakersTokens[2].tokenId, 2); + assert.equal(staker1.stakersTokens[2].contractAddress, mock721_2.address); + + let staker2 = await stakingPoints721.getStaker(creator.address, 1, anyone1); + assert.equal(staker2.stakersTokens.length, 0); + }); + it("Stakes and unstakes", async function () { + await stakingPoints721.initializeStakingPoints( + creator.address, + 1, + { + paymentReceiver: owner, + stakingRules: [ + { + tokenAddress: manifoldMembership.address, + pointsRatePerDay: 1234, + startTime: 1680680461, + endTime: 95625733261, + }, + { + tokenAddress: mock721.address, + pointsRatePerDay: 125, + startTime: 1680680461, + endTime: 95625733261, + }, + { + tokenAddress: mock721_2.address, + pointsRatePerDay: 125, + startTime: 1680680461, + endTime: 95625733261, + }, + ], + }, + { from: owner } + ); + await mock721.setApprovalForAll(stakingPoints721.address, true, { from: anyone2 }); + await mock721_2.setApprovalForAll(stakingPoints721.address, true, { from: anyone2 }); + await stakingPoints721.stakeTokens( + creator.address, + 1, + [ + { + tokenAddress: mock721.address, + tokenId: 2, + }, + { + tokenAddress: mock721.address, + tokenId: 3, + }, + { + tokenAddress: mock721_2.address, + tokenId: 2, + }, + ], + { from: anyone2 } + ); + + let staker = await stakingPoints721.getStaker(creator.address, 1, anyone2); + + assert.equal(staker.stakersTokens.length, 3); + assert.equal(staker.stakersTokens[0].tokenId, 2); + assert.equal(staker.stakersTokens[0].timeUnstaked, 0); + assert.equal(staker.stakersTokens[1].tokenId, 3); + assert.equal(staker.stakersTokens[1].contractAddress, mock721.address); + assert.equal(staker.stakersTokens[1].timeUnstaked, 0); + assert.equal(staker.stakersTokens[2].tokenId, 2); + assert.equal(staker.stakersTokens[2].contractAddress, mock721_2.address); + assert.equal(staker.stakersTokens[2].timeUnstaked, 0); + assert.equal(staker.pointsRedeemed, 0); + + // unstake #1 + await truffleAssert.reverts( + stakingPoints721.unstakeTokens( + creator.address, + 1, + [ + { + tokenAddress: mock721.address, + tokenId: 2, + }, + { + tokenAddress: mock721.address, + tokenId: 3, + }, + { + tokenAddress: mock721_2.address, + tokenId: 2, + }, + ], + { from: anyone1 } + ), + "Cannot unstake tokens for someone who has not staked" + ); + + await stakingPoints721.unstakeTokens( + creator.address, + 1, + [ + { + tokenAddress: mock721.address, + tokenId: 2, + }, + { + tokenAddress: mock721_2.address, + tokenId: 2, + }, + ], + { from: anyone2 } + ); + + let staker_again = await stakingPoints721.getStaker(creator.address, 1, anyone2); + assert.equal(staker_again.stakersTokens.length, 3); + assert.equal(staker_again.stakersTokens[0].timeUnstaked != 0, true); + assert.equal(staker_again.stakersTokens[1].timeUnstaked, 0); + assert.equal(staker_again.stakersTokens[2].timeUnstaked != 0, true); + assert.equal(staker_again.pointsRedeemed !== 0, true); + }); + + it("Redeems points", async function () { + await stakingPoints721.initializeStakingPoints( + creator.address, + 1, + { + paymentReceiver: owner, + stakingRules: [ + { + tokenAddress: manifoldMembership.address, + pointsRatePerDay: 1234000, + startTime: 1680680461, + endTime: 95625733261, + }, + { + tokenAddress: mock721.address, + pointsRatePerDay: 12500000, + startTime: 1680680461, + endTime: 95625733261, + }, + { + tokenAddress: mock721_2.address, + pointsRatePerDay: 12000000, + startTime: 1680680461, + endTime: 95625733261, + }, + ], + }, + { from: owner } + ); + await mock721.setApprovalForAll(stakingPoints721.address, true, { from: anyone2 }); + await mock721_2.setApprovalForAll(stakingPoints721.address, true, { from: anyone2 }); + await mock721_2.setApprovalForAll(stakingPoints721.address, true, { from: anotherOwner }); + await mock721_2.setApprovalForAll(stakingPoints721.address, true, { from: anyone1 }); + await mock721.setApprovalForAll(stakingPoints721.address, true, { from: anyone1 }); + await stakingPoints721.stakeTokens( + creator.address, + 1, + [ + { + tokenAddress: mock721.address, + tokenId: 2, + }, + { + tokenAddress: mock721.address, + tokenId: 3, + }, + { + tokenAddress: mock721_2.address, + tokenId: 2, + }, + ], + { from: anyone2 } + ); + + await stakingPoints721.stakeTokens( + creator.address, + 1, + [ + { + tokenAddress: mock721_2.address, + tokenId: 3, + }, + ], + { from: anotherOwner } + ); + await stakingPoints721.stakeTokens( + creator.address, + 1, + [ + { + tokenAddress: mock721.address, + tokenId: 1, + }, + { + tokenAddress: mock721_2.address, + tokenId: 1, + }, + ], + { from: anyone1 } + ); + + let user1 = await stakingPoints721.getStaker(creator.address, 1, anyone1); + let user2 = await stakingPoints721.getStaker(creator.address, 1, anyone2); + assert.equal(0, user1.pointsRedeemed); + assert.equal(0, user2.pointsRedeemed); + + function timeout(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); + } + + await timeout(1000); + + await stakingPoints721.redeemPoints(creator.address, 1, { from: anyone1 }); + await stakingPoints721.redeemPoints(creator.address, 1, { from: anyone2 }); + + let user1Updated = await stakingPoints721.getStaker(creator.address, 1, anyone1); + let user2Updated = await stakingPoints721.getStaker(creator.address, 1, anyone2); + assert.equal(true, user1Updated.pointsRedeemed != 0); + assert.equal(true, user2Updated.pointsRedeemed != 0); + }); + it("Redeems points at unstaking", async function () { + await stakingPoints721.initializeStakingPoints( + creator.address, + 1, + { + paymentReceiver: owner, + stakingRules: [ + { + tokenAddress: manifoldMembership.address, + pointsRatePerDay: 1234000, + startTime: 1680680461, + endTime: 95625733261, + }, + { + tokenAddress: mock721.address, + pointsRatePerDay: 12500000, + startTime: 1680680461, + endTime: 95625733261, + }, + { + tokenAddress: mock721_2.address, + pointsRatePerDay: 12000000, + startTime: 1680680461, + endTime: 95625733261, + }, + ], + }, + { from: owner } + ); + await mock721.setApprovalForAll(stakingPoints721.address, true, { from: anyone2 }); + await mock721_2.setApprovalForAll(stakingPoints721.address, true, { from: anyone2 }); + await mock721_2.setApprovalForAll(stakingPoints721.address, true, { from: anotherOwner }); + await mock721_2.setApprovalForAll(stakingPoints721.address, true, { from: anyone1 }); + await mock721.setApprovalForAll(stakingPoints721.address, true, { from: anyone1 }); + await stakingPoints721.stakeTokens( + creator.address, + 1, + [ + { + tokenAddress: mock721.address, + tokenId: 2, + }, + { + tokenAddress: mock721.address, + tokenId: 3, + }, + { + tokenAddress: mock721_2.address, + tokenId: 2, + }, + ], + { from: anyone2 } + ); + + await stakingPoints721.stakeTokens( + creator.address, + 1, + [ + { + tokenAddress: mock721_2.address, + tokenId: 3, + }, + ], + { from: anotherOwner } + ); + await stakingPoints721.stakeTokens( + creator.address, + 1, + [ + { + tokenAddress: mock721.address, + tokenId: 1, + }, + { + tokenAddress: mock721_2.address, + tokenId: 1, + }, + ], + { from: anyone1 } + ); + + let user1 = await stakingPoints721.getStaker(creator.address, 1, anyone1); + let user2 = await stakingPoints721.getStaker(creator.address, 1, anyone2); + assert.equal(0, user1.pointsRedeemed); + assert.equal(0, user2.pointsRedeemed); + + function timeout(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); + } + + await timeout(5000); + + let points1 = await stakingPoints721.getPointsForWallet(creator.address, 1, anyone2); + assert.strictEqual(points1.gt(0), true); + + await stakingPoints721.unstakeTokens( + creator.address, + 1, + [ + { + tokenAddress: mock721_2.address, + tokenId: 2, + }, + ], + { from: anyone2 } + ); + await stakingPoints721.unstakeTokens( + creator.address, + 1, + [ + { + tokenAddress: mock721_2.address, + tokenId: 1, + }, + ], + { from: anyone1 } + ); + await stakingPoints721.unstakeTokens( + creator.address, + 1, + [ + { + tokenAddress: mock721.address, + tokenId: 2, + }, + ], + { from: anyone2 } + ); + let points = await stakingPoints721.getPointsForWallet(creator.address, 1, anyone2); + let user1Updated = await stakingPoints721.getStaker(creator.address, 1, anyone1); + let user2Updated = await stakingPoints721.getStaker(creator.address, 1, anyone2); + assert.strictEqual(points.gt(points1), true); + assert.strictEqual(true, user1Updated.pointsRedeemed != 0); + assert.strictEqual(true, user2Updated.pointsRedeemed != 0); + }); + }); +});