diff --git a/contracts/interfaces/ICover.sol b/contracts/interfaces/ICover.sol index d95df62674..bb697ce151 100644 --- a/contracts/interfaces/ICover.sol +++ b/contracts/interfaces/ICover.sol @@ -147,6 +147,13 @@ interface ICover { function globalCapacityRatio() external view returns (uint24); + function getPriceAndCapacityRatios(uint[] calldata productIds) external view returns ( + uint _globalCapacityRatio, + uint _globalMinPriceRatio, + uint[] memory _initialPriceRatios, + uint[] memory _capacityReductionRatios + ); + /* === MUTATIVE FUNCTIONS ==== */ function migrateCovers(uint[] calldata coverIds, address newOwner) external returns (uint[] memory newCoverIds); diff --git a/contracts/interfaces/IStakingPool.sol b/contracts/interfaces/IStakingPool.sol index 4a59eec3e9..763480dab9 100644 --- a/contracts/interfaces/IStakingPool.sol +++ b/contracts/interfaces/IStakingPool.sol @@ -33,10 +33,11 @@ struct DepositRequest { struct ProductParams { uint productId; - bool setWeight; - uint targetWeight; - bool setPrice; - uint targetPrice; + bool recalculateEffectiveWeight; + bool setTargetWeight; + uint8 targetWeight; + bool setTargetPrice; + uint96 targetPrice; } struct ProductInitializationParams { @@ -71,8 +72,8 @@ interface IStakingPool { uint rewardsShares; } - struct Product { - uint8 lastWeight; + struct StakedProduct { + uint8 lastEffectiveWeight; uint8 targetWeight; uint96 targetPrice; uint96 nextPrice; @@ -115,16 +116,12 @@ interface IStakingPool { WithdrawRequest[] memory params ) external returns (uint stakeToWithdraw, uint rewardsToWithdraw); - function addProducts(ProductParams[] memory params) external; - - function removeProducts(uint[] memory productIds) external; - - function setProductDetails(ProductParams[] memory params) external; - function setPoolFee(uint newFee) external; function setPoolPrivacy(bool isPrivatePool) external; + function setProducts(ProductParams[] memory params) external; + function manager() external view returns (address); function getActiveStake() external view returns (uint); diff --git a/contracts/mocks/Cover/CoverMockStakingPool.sol b/contracts/mocks/Cover/CoverMockStakingPool.sol index 3ca38cd1bd..7e5c2671d3 100644 --- a/contracts/mocks/Cover/CoverMockStakingPool.sol +++ b/contracts/mocks/Cover/CoverMockStakingPool.sol @@ -2,10 +2,11 @@ pragma solidity ^0.8.16; -import "../Tokens/ERC721Mock.sol"; import "@openzeppelin/contracts-v4/token/ERC20/IERC20.sol"; import "@openzeppelin/contracts-v4/utils/Strings.sol"; +import "../../interfaces/IStakingPool.sol"; import "../../modules/staking/StakingPool.sol"; +import "../Tokens/ERC721Mock.sol"; contract CoverMockStakingPool is IStakingPool, ERC721Mock { @@ -22,12 +23,13 @@ contract CoverMockStakingPool is IStakingPool, ERC721Mock { mapping (uint => uint) public usedCapacity; mapping (uint => uint) public stakedAmount; - // product id => Product - mapping(uint => Product) public products; + // product id => StakedProduct + mapping(uint => StakedProduct) public products; mapping (uint => uint) public mockPrices; uint public constant MAX_PRICE_RATIO = 10_000; uint constant REWARDS_DENOMINATOR = 10_000; + uint public constant GLOBAL_MIN_PRICE_RATIO = 100; // 1% uint public poolId; // erc721 supply @@ -92,6 +94,11 @@ contract CoverMockStakingPool is IStakingPool, ERC721Mock { ) external { } + function setProducts(ProductParams[] memory params) external { + totalSupply = totalSupply; + params; + } + function calculatePremium(uint priceRatio, uint coverAmount, uint period) public pure returns (uint) { return priceRatio * coverAmount / MAX_PRICE_RATIO * period / 365 days; } diff --git a/contracts/mocks/StakingPool/SPMockCoverProducts.sol b/contracts/mocks/StakingPool/SPMockCoverProducts.sol new file mode 100644 index 0000000000..f93d890f44 --- /dev/null +++ b/contracts/mocks/StakingPool/SPMockCoverProducts.sol @@ -0,0 +1,88 @@ +// SPDX-License-Identifier: GPL-3.0-only + +pragma solidity ^0.8.0; + +import "../../interfaces/IStakingPool.sol"; +import "../../interfaces/ICover.sol"; +import "../../modules/cover/CoverUtilsLib.sol"; + +contract SPMockCoverProducts { + uint24 public constant globalCapacityRatio = 2; + uint256 public constant globalRewardsRatio = 1; + + uint public constant GLOBAL_MIN_PRICE_RATIO = 100; // 1% + + mapping(uint => address) public stakingPool; + mapping(uint256 => Product) public products; + mapping(uint256 => ProductType) public productTypes; + + function setStakingPool(address addr, uint id) public { + stakingPool[id] = addr; + } + + function setProduct(Product memory product, uint256 id) public { + products[id] = product; + } + + function setProductType(ProductType calldata product, uint256 id) public { + productTypes[id] = product; + } + + + function getPriceAndCapacityRatios(uint[] calldata productIds) public view returns ( + uint _globalCapacityRatio, + uint _globalMinPriceRatio, + uint[] memory _initialPrices, + uint[] memory _capacityReductionRatios + ) { + _globalCapacityRatio = uint(globalCapacityRatio); + _globalMinPriceRatio = GLOBAL_MIN_PRICE_RATIO; + _capacityReductionRatios = new uint[](productIds.length); + _initialPrices = new uint[](productIds.length); + for (uint i = 0; i < productIds.length; i++) { + Product memory product = products[productIds[i]]; + require(product.initialPriceRatio > 0, "Cover: Product deprecated or not initialized"); + _initialPrices[i] = uint(product.initialPriceRatio); + _capacityReductionRatios[i] = uint(product.capacityReductionRatio); + } + } + + function allocateCapacity( + BuyCoverParams memory params, + uint256 coverId, + IStakingPool _stakingPool + ) public returns (uint256 coveredAmountInNXM, uint256 premiumInNXM, uint256 rewardsInNXM) { + Product memory product = products[params.productId]; + uint256 gracePeriod = uint256(productTypes[product.productType].gracePeriodInDays) * 1 days; + + return _stakingPool.allocateStake( + CoverRequest( + coverId, + params.productId, + params.amount, + params.period, + gracePeriod, + globalCapacityRatio, + product.capacityReductionRatio, + globalRewardsRatio + ) + ); + } + + function initializeStaking( + address staking_, + address _manager, + bool _isPrivatePool, + uint256 _initialPoolFee, + uint256 _maxPoolFee, + ProductInitializationParams[] memory params, + uint256 _poolId + ) external { + + for (uint i = 0; i < params.length; i++) { + params[i].initialPrice = products[params[i].productId].initialPriceRatio; + require(params[i].targetPrice >= GLOBAL_MIN_PRICE_RATIO, "CoverUtilsLib: Target price below GLOBAL_MIN_PRICE_RATIO"); + } + IStakingPool(staking_).initialize(_manager, _isPrivatePool, _initialPoolFee, _maxPoolFee, params, _poolId); + } +} diff --git a/contracts/mocks/TokenControllerMock.sol b/contracts/mocks/TokenControllerMock.sol index 7554f6f96b..874b344bb8 100644 --- a/contracts/mocks/TokenControllerMock.sol +++ b/contracts/mocks/TokenControllerMock.sol @@ -5,6 +5,10 @@ pragma solidity ^0.5.0; import "../abstract/MasterAware.sol"; import "../modules/token/NXMToken.sol"; +interface ICover { + function stakingPool(uint poolId) external view returns (address); +} + contract TokenControllerMock is MasterAware { struct StakingPoolNXMBalances { @@ -12,7 +16,9 @@ contract TokenControllerMock is MasterAware { uint128 deposits; } + NXMToken public token; + ICover public cover; address public addToWhitelistLastCalledWtih; address public removeFromWhitelistLastCalledWtih; @@ -36,6 +42,7 @@ contract TokenControllerMock is MasterAware { function changeDependentContractAddress() public { token = NXMToken(master.tokenAddress()); + cover = ICover(master.getLatestAddress("CO")); } function operatorTransfer(address _from, address _to, uint _value) onlyInternal external returns (bool) { @@ -58,6 +65,18 @@ contract TokenControllerMock is MasterAware { stakingPoolNXMBalances[poolId].rewards -= uint128(amount); } + function setContractAddresses(address coverAddr, address tokenAddr) public { + cover = ICover(coverAddr); + token = NXMToken(tokenAddr); + } + + function depositStakedNXM(address from, uint amount, uint poolId) external { + require(msg.sender == address(cover.stakingPool(poolId)), "TokenController: msg.sender not staking pool"); + + stakingPoolNXMBalances[poolId].deposits += uint128(amount); + token.operatorTransfer(from, amount); + } + /* unused functions */ modifier unused { diff --git a/contracts/modules/cover/Cover.sol b/contracts/modules/cover/Cover.sol index f56073d352..e1d2a06e46 100644 --- a/contracts/modules/cover/Cover.sol +++ b/contracts/modules/cover/Cover.sol @@ -42,7 +42,7 @@ contract Cover is ICover, MasterAwareV2, IStakingPoolBeacon, ReentrancyGuard { uint private constant MAX_COMMISSION_RATIO = 2500; // 25% - uint private constant GLOBAL_MIN_PRICE_RATIO = 100; // 1% + uint public constant GLOBAL_MIN_PRICE_RATIO = 100; // 1% uint private constant ONE_NXM = 1e18; @@ -550,7 +550,8 @@ contract Cover is ICover, MasterAwareV2, IStakingPoolBeacon, ReentrancyGuard { manager, isPrivatePool, initialPoolFee, - maxPoolFee + maxPoolFee, + GLOBAL_MIN_PRICE_RATIO ); address stakingPoolAddress = CoverUtilsLib.createStakingPool( @@ -787,6 +788,25 @@ contract Cover is ICover, MasterAwareV2, IStakingPoolBeacon, ReentrancyGuard { return (1 << coverAsset) & assetsBitMap > 0; } + function getPriceAndCapacityRatios(uint[] calldata productIds) public view returns ( + uint _globalCapacityRatio, + uint _globalMinPriceRatio, + uint[] memory _initialPrices, + uint[] memory _capacityReductionRatios + ) { + _globalMinPriceRatio = GLOBAL_MIN_PRICE_RATIO; + _globalCapacityRatio = uint(globalCapacityRatio); + _capacityReductionRatios = new uint[](productIds.length); + _initialPrices = new uint[](productIds.length); + + for (uint i = 0; i < productIds.length; i++) { + Product memory product = _products[productIds[i]]; + require(product.initialPriceRatio > 0, "Cover: Product deprecated or not initialized"); + _initialPrices[i] = uint(product.initialPriceRatio); + _capacityReductionRatios[i] = uint(product.capacityReductionRatio); + } + } + function _isCoverAssetDeprecated( uint32 deprecatedCoverAssetsBitmap, uint8 assetId diff --git a/contracts/modules/cover/CoverUtilsLib.sol b/contracts/modules/cover/CoverUtilsLib.sol index 58f3bdb9cf..65236e2dd5 100644 --- a/contracts/modules/cover/CoverUtilsLib.sol +++ b/contracts/modules/cover/CoverUtilsLib.sol @@ -37,6 +37,7 @@ library CoverUtilsLib { bool isPrivatePool; uint initialPoolFee; uint maxPoolFee; + uint globalMinPriceRatio; } function migrateCoverFromOwner( @@ -137,7 +138,8 @@ library CoverUtilsLib { // override with initial price for (uint i = 0; i < productInitParams.length; i++) { - productInitParams[0].initialPrice = products[productInitParams[i].productId].initialPriceRatio; + productInitParams[i].initialPrice = products[productInitParams[i].productId].initialPriceRatio; + require(productInitParams[i].targetPrice >= poolInitParams.globalMinPriceRatio, "CoverUtilsLib: Target price below GLOBAL_MIN_PRICE_RATIO"); } } diff --git a/contracts/modules/staking/StakingPool.sol b/contracts/modules/staking/StakingPool.sol index bccb0a514a..e748fd6c64 100644 --- a/contracts/modules/staking/StakingPool.sol +++ b/contracts/modules/staking/StakingPool.sol @@ -60,6 +60,8 @@ contract StakingPool is IStakingPool, ERC721 { bool public isPrivatePool; uint8 public poolFee; uint8 public maxPoolFee; + uint32 public totalEffectiveWeight; + uint32 public totalTargetWeight; // erc721 supply uint public totalSupply; @@ -83,7 +85,7 @@ contract StakingPool is IStakingPool, ERC721 { mapping(uint => uint) public coverTrancheAllocations; // product id => Product - mapping(uint => Product) public products; + mapping(uint => StakedProduct) public products; // token id => tranche id => deposit data mapping(uint => mapping(uint => Deposit)) public deposits; @@ -102,10 +104,12 @@ contract StakingPool is IStakingPool, ERC721 { uint public constant MAX_ACTIVE_TRANCHES = 8; // 7 whole quarters + 1 partial quarter uint public constant COVER_TRANCHE_GROUP_SIZE = 4; uint public constant BUCKET_TRANCHE_GROUP_SIZE = 8; + uint public constant MAX_WEIGHT_MULTIPLIER = 20; uint public constant REWARD_BONUS_PER_TRANCHE_RATIO = 10_00; // 10.00% uint public constant REWARD_BONUS_PER_TRANCHE_DENOMINATOR = 100_00; uint public constant WEIGHT_DENOMINATOR = 100; + uint public constant MAX_TOTAL_WEIGHT = WEIGHT_DENOMINATOR * MAX_WEIGHT_MULTIPLIER; uint public constant REWARDS_DENOMINATOR = 100_00; uint public constant POOL_FEE_DENOMINATOR = 100; @@ -113,6 +117,7 @@ contract StakingPool is IStakingPool, ERC721 { uint public constant GLOBAL_CAPACITY_DENOMINATOR = 100_00; uint public constant CAPACITY_REDUCTION_DENOMINATOR = 100_00; uint public constant INITIAL_PRICE_DENOMINATOR = 100_00; + uint public constant TARGET_PRICE_DENOMINATOR = 100_00; // base price bump is +0.2% for each 1% of capacity used, ie +20% for 100% // 20% = 0.2 @@ -180,8 +185,7 @@ contract StakingPool is IStakingPool, ERC721 { name = string(abi.encodePacked("Nexus Mutual Staking Pool #", Strings.toString(_poolId))); symbol = string(abi.encodePacked("NMSP-", Strings.toString(_poolId))); - // TODO: initialize products - params; + _setInitialProducts(params); // create ownership nft totalSupply = 1; @@ -598,7 +602,7 @@ contract StakingPool is IStakingPool, ERC721 { trancheCount ); - (uint[] memory totalCapacities, uint totalCapacity) = getTotalCapacities( + (uint[] memory totalCapacities, uint totalCapacity) = getTotalCapacitiesForTranches( request.productId, firstTrancheIdToUse, trancheCount, @@ -606,15 +610,15 @@ contract StakingPool is IStakingPool, ERC721 { request.capacityReductionRatio ); + uint remainingAmount = Math.divCeil(request.amount, NXM_PER_ALLOCATION_UNIT); // total capacity can get below the used capacity as a result of burns require( - totalCapacity > initialCapacityUsed && totalCapacity - initialCapacityUsed >= request.amount, + totalCapacity > initialCapacityUsed && totalCapacity - initialCapacityUsed >= remainingAmount, "StakingPool: Insufficient capacity" ); { uint[] memory coverTrancheAllocation = new uint[](trancheCount); - uint remainingAmount = Math.divCeil(request.amount, NXM_PER_ALLOCATION_UNIT); for (uint i = 0; i < trancheCount; i++) { @@ -886,7 +890,19 @@ contract StakingPool is IStakingPool, ERC721 { return (allocatedCapacities, allocatedCapacity); } - function getTotalCapacities( + function getTotalCapacitiesForActiveTranches(uint productId, uint24 globalCapacityRatio, uint16 capacityReductionRatio) public view returns (uint[] memory totalCapacities, uint totalCapacity) { + uint firstTrancheIdToUse = block.timestamp / TRANCHE_DURATION; + + (totalCapacities, totalCapacity) = getTotalCapacitiesForTranches( + productId, + firstTrancheIdToUse, + MAX_ACTIVE_TRANCHES, + globalCapacityRatio, + capacityReductionRatio + ); + } + + function getTotalCapacitiesForTranches( uint productId, uint firstTrancheId, uint trancheCount, @@ -894,7 +910,7 @@ contract StakingPool is IStakingPool, ERC721 { uint reductionRatio ) internal view returns (uint[] memory totalCapacities, uint totalCapacity) { - uint _activeStake = activeStake; + uint _activeStake = Math.divCeil(activeStake, 1e12); uint _stakeSharesSupply = stakeSharesSupply; if (_stakeSharesSupply == 0) { @@ -1196,14 +1212,6 @@ contract StakingPool is IStakingPool, ERC721 { super.transferFrom(from, to, tokenId); } - /* pool management */ - - function setProductDetails(ProductParams[] memory params) external onlyManager { - // silence compiler warnings - params; - activeStake = activeStake; - // [todo] Implement - } /* views */ @@ -1240,16 +1248,95 @@ contract StakingPool is IStakingPool, ERC721 { return ownerOf(0); } - /* management */ + /* pool management */ + + function setProducts(ProductParams[] memory params) external onlyManager { + uint[] memory productIds = new uint[](params.length); + uint numProducts = params.length; + + for (uint i = 0; i < numProducts; i++) { + productIds[i] = params[i].productId; + } + + ( + uint globalCapacityRatio, + uint globalMinPriceRatio, + uint[] memory initialPriceRatios, + uint[] memory capacityReductionRatios + ) = ICover(coverContract).getPriceAndCapacityRatios(productIds); + + uint _totalTargetWeight = totalTargetWeight; + uint _totalEffectiveWeight = totalEffectiveWeight; + + for (uint i = 0; i < numProducts; i++) { + ProductParams memory _param = params[i]; + StakedProduct memory _product = products[_param.productId]; + + if (_product.nextPriceUpdateTime == 0) { + _product.nextPrice = initialPriceRatios[i].toUint96(); + _product.nextPriceUpdateTime = uint32(block.timestamp); + require(_param.setTargetPrice, "StakingPool: Must set price for new products"); + } + + if (_param.setTargetPrice) { + validateTargetPrice(_param.targetPrice, globalMinPriceRatio); + _product.targetPrice = _param.targetPrice; + } + + require( + !_param.setTargetWeight || _param.recalculateEffectiveWeight, + "StakingPool: Must recalculate effectiveWeight to edit targetWeight" + ); + + // Must recalculate effectiveWeight to adjust targetWeight + if (_param.recalculateEffectiveWeight) { + + if (_param.setTargetWeight) { + require(_param.targetWeight <= WEIGHT_DENOMINATOR, "StakingPool: Cannot set weight beyond 1"); + _totalTargetWeight = _totalTargetWeight - _product.targetWeight + _param.targetWeight; + _product.targetWeight = _param.targetWeight; + } + + uint8 previousEffectiveWeight = _product.lastEffectiveWeight; + _product.lastEffectiveWeight = _getEffectiveWeight( + _param.productId, + _product.targetWeight, + globalCapacityRatio, + capacityReductionRatios[i] + ); + _totalEffectiveWeight = _totalEffectiveWeight - previousEffectiveWeight + _product.lastEffectiveWeight; + } + products[_param.productId] = _product; + } + + require(_totalEffectiveWeight <= MAX_TOTAL_WEIGHT, "StakingPool: Total max effective weight exceeded"); + totalTargetWeight = _totalTargetWeight.toUint32(); + totalEffectiveWeight = _totalEffectiveWeight.toUint32(); + } + + function _setInitialProducts(ProductInitializationParams[] memory params) internal { + uint32 _totalTargetWeight = totalTargetWeight; + + for (uint i = 0; i < params.length; i++) { + ProductInitializationParams memory param = params[i]; + StakedProduct storage _product = products[param.productId]; + require(param.targetPrice <= TARGET_PRICE_DENOMINATOR, "StakingPool: Target price too high"); + require(param.weight <= WEIGHT_DENOMINATOR, "StakingPool: Cannot set weight beyond 1"); + _product.nextPrice = param.initialPrice; + _product.nextPriceUpdateTime = uint32(block.timestamp); + _product.targetPrice = param.targetPrice; + _product.targetWeight = param.weight; + _totalTargetWeight += param.weight; + } - function addProducts(ProductParams[] memory params) external onlyManager { - totalSupply = totalSupply; // To silence view fn warning. Remove once implemented - params; + require(_totalTargetWeight <= MAX_TOTAL_WEIGHT, "StakingPool: Total max target weight exceeded"); + totalTargetWeight = _totalTargetWeight; + totalEffectiveWeight = totalTargetWeight; } - function removeProducts(uint[] memory productIds) external onlyManager { - totalSupply = totalSupply; // To silence view fn warning. Remove once implemented - productIds; + function validateTargetPrice(uint96 targetPrice, uint globalMinPriceRatio) public view { + require(targetPrice <= TARGET_PRICE_DENOMINATOR, "StakingPool: Target price too high"); + require(targetPrice >= globalMinPriceRatio, "StakingPool: Target price below GLOBAL_MIN_PRICE_RATIO"); } function setPoolFee(uint newFee) external onlyManager { @@ -1322,7 +1409,7 @@ contract StakingPool is IStakingPool, ERC721 { uint totalCapacity ) internal returns (uint) { - Product memory product = products[productId]; + StakedProduct memory product = products[productId]; uint basePrice; { @@ -1436,4 +1523,29 @@ contract StakingPool is IStakingPool, ERC721 { // dividing by ALLOCATION_UNITS_PER_NXM (=100) to normalize the result return surgePremium / ALLOCATION_UNITS_PER_NXM; } + + function _getEffectiveWeight( + uint productId, + uint targetWeight, + uint globalCapacityRatio, + uint capacityReductionRatio + ) internal view returns (uint8 effectiveWeight) { + uint firstTrancheIdToUse = block.timestamp / TRANCHE_DURATION; + + (, uint totalAllocatedCapacity) = getAllocatedCapacities( + productId, + firstTrancheIdToUse, + MAX_ACTIVE_TRANCHES + ); + + (, uint totalCapacity) = getTotalCapacitiesForTranches( + productId, + firstTrancheIdToUse, + MAX_ACTIVE_TRANCHES, + globalCapacityRatio, + capacityReductionRatio + ); + uint actualWeight = totalCapacity > 0 ? (totalAllocatedCapacity * WEIGHT_DENOMINATOR / totalCapacity) : 0; + effectiveWeight = (Math.max(targetWeight, actualWeight)).toUint8(); + } } diff --git a/test/unit/StakingPool/index.js b/test/unit/StakingPool/index.js index c722d8e968..ddfe82c119 100644 --- a/test/unit/StakingPool/index.js +++ b/test/unit/StakingPool/index.js @@ -16,4 +16,5 @@ describe('StakingPool unit tests', function () { // require('./interpolatePrice'); // require('./getPrices'); require('./calculateNewRewardShares'); + require('./setProducts'); }); diff --git a/test/unit/StakingPool/setProducts.js b/test/unit/StakingPool/setProducts.js new file mode 100644 index 0000000000..b7ba813e9f --- /dev/null +++ b/test/unit/StakingPool/setProducts.js @@ -0,0 +1,450 @@ +const { expect } = require('chai'); +const { ethers } = require('hardhat'); +const { AddressZero } = ethers.constants; +const { parseEther } = ethers.utils; +const daysToSeconds = days => days * 24 * 60 * 60; + +const ProductTypeFixture = { + claimMethod: 1, + gracePeriodInDays: 7, +}; + +describe('setProducts unit tests', function () { + const initializePool = async function (cover, stakingPool, manager, poolId, productInitParams) { + // Set products in mock cover contract + await Promise.all( + productInitParams.map(p => { + return [ + cover.setProduct(getCoverProduct(p.initialPrice), p.productId), + cover.setProductType(ProductTypeFixture, p.productId), + ]; + }), + ); + await cover.initializeStaking(stakingPool.address, manager, false, 5, 5, productInitParams, poolId); + }; + + const depositRequest = async (stakingPool, amount, tokenId, destination) => { + const block = await ethers.provider.getBlock('latest'); + const currentTrancheId = parseInt(block.timestamp / daysToSeconds(91)); + return { + amount, + trancheId: currentTrancheId + 2, + tokenId, + destination, + }; + }; + + const getInitialProduct = (weight, targetPrice, initialPrice, id) => { + return { + productId: id, + weight, + initialPrice, + targetPrice, + }; + }; + // Staking.ProductParam + const getNewProduct = (weight, price, id) => { + return { + productId: id, + setTargetWeight: true, + recalculateEffectiveWeight: true, + targetWeight: weight, + setTargetPrice: true, + targetPrice: price, + }; + }; + // Cover.Product + const getCoverProduct = initialPriceRatio => { + return { + productType: 1, + yieldTokenAddress: AddressZero, + coverAssets: 1111, + initialPriceRatio, + capacityReductionRatio: 0, + }; + }; + const buyCoverParams = (owner, productId, period, amount) => { + return { + owner, + productId, + coverAsset: 0, // ETH + amount, + period, + maxPremiumInAsset: parseEther('100'), + paymentAsset: 0, + payWithNXM: false, + commissionRatio: 1, + commissionDestination: owner, + ipfsData: 'ipfs data', + }; + }; + + // Get product and set in cover contract + const initProduct = async (cover, initialPriceRatio, weight, price, id) => { + const coverProduct = getCoverProduct(initialPriceRatio); + await cover.setProduct(coverProduct, id); + return getNewProduct(weight, price, id); + }; + + const verifyProduct = (product, weight, price, initialPrice) => { + expect(product.targetWeight).to.be.equal(weight); + expect(product.targetPrice).to.be.equal(price); + // TODO: verify exact nextPriceUpdateTime + expect(product.nextPriceUpdateTime).to.be.greaterThan(0); + expect(product.nextPrice).to.be.equal(initialPrice); + }; + + it('should fail to be called by non manager', async function () { + const { stakingPool, cover } = this; + const { + members: [manager, nonManager], + } = this.accounts; + await initializePool(cover, stakingPool, manager.address, 0, []); + const product = await initProduct(cover, 100, 100, 100, 0); + await expect(stakingPool.connect(nonManager).setProducts([product])).to.be.revertedWith( + 'StakingPool: Only pool manager can call this function', + ); + }); + + it('should initialize products successfully', async function () { + const { stakingPool, cover } = this; + const { + members: [manager], + } = this.accounts; + let i = 0; + const initialProducts = Array.from({ length: 20 }, () => getInitialProduct(100, 100, 500, i++)); + await initializePool(cover, stakingPool, manager.address, 0, initialProducts); + const product = await stakingPool.products(0); + verifyProduct(product, 100, 100, 500); + expect(await stakingPool.totalTargetWeight()).to.be.equal(2000); + }); + + it('should fail to initialize too many products with full weight', async function () { + const { stakingPool, cover } = this; + const { + members: [manager], + } = this.accounts; + let i = 0; + const initialProducts = Array.from({ length: 21 }, () => getInitialProduct(100, 100, 500, i++)); + await expect(initializePool(cover, stakingPool, manager.address, 0, initialProducts)).to.be.revertedWith( + 'StakingPool: Total max target weight exceeded', + ); + }); + + it('should set products and store values correctly', async function () { + const { stakingPool, cover } = this; + const { + members: [manager], + } = this.accounts; + await initializePool(cover, stakingPool, manager.address, 0, []); + const product = await initProduct(cover, 50, 100, 100, 0); + await stakingPool.connect(manager).setProducts([product]); + const product0 = await stakingPool.products(0); + verifyProduct(product0, 100, 100, 50); + }); + + it('should revert if user tries to set targetWeight without recalculating effectiveWeight', async function () { + const { stakingPool, cover } = this; + const { + members: [manager], + } = this.accounts; + await initializePool(cover, stakingPool, manager.address, 0, []); + const product = await initProduct(cover, 50, 100, 100, 0); + product.recalculateEffectiveWeight = false; + await expect(stakingPool.connect(manager).setProducts([product])).to.be.revertedWith( + 'StakingPool: Must recalculate effectiveWeight to edit targetWeight', + ); + }); + + it('should revert if adding a product without setting the targetPrice', async function () { + const { stakingPool, cover } = this; + const { + members: [manager], + } = this.accounts; + await initializePool(cover, stakingPool, manager.address, 0, []); + const product = await initProduct(cover, 50, 100, 100, 0); + product.setTargetPrice = false; + await expect(stakingPool.connect(manager).setProducts([product])).to.be.revertedWith( + 'StakingPool: Must set price for new products', + ); + }); + + it('should add and remove products in same tx', async function () { + const { stakingPool, cover } = this; + const { + members: [manager], + } = this.accounts; + const initialPriceRatio = 1000; + await initializePool(cover, stakingPool, manager.address, 0, []); + const products = [ + await initProduct(cover, initialPriceRatio, 50, 500, 0), + await initProduct(cover, initialPriceRatio, 50, 500, 1), + ]; + await stakingPool.connect(manager).setProducts(products); + + products[0].targetWeight = 0; + products[1] = await initProduct(cover, initialPriceRatio, 50, 500, 2); + // remove product0, add product2 + await stakingPool.connect(manager).setProducts(products); + + const product0 = await stakingPool.products(0); + const product1 = await stakingPool.products(1); + const product2 = await stakingPool.products(2); + verifyProduct(product1, 50, 500, initialPriceRatio); + verifyProduct(product0, 0, 500, initialPriceRatio); + verifyProduct(product2, 50, 500, initialPriceRatio); + }); + + it('should add maximum products with full weight (20)', async function () { + const { stakingPool, cover } = this; + const { + members: [manager], + } = this.accounts; + await initializePool(cover, stakingPool, manager.address, 0, []); + let i = 0; + const products = await Promise.all(Array.from({ length: 20 }, () => initProduct(cover, 999, 100, 100, i++))); + await stakingPool.connect(manager).setProducts(products); + expect(await stakingPool.totalTargetWeight()).to.be.equal(2000); + const product19 = await stakingPool.products(19); + verifyProduct(product19, 100, 100, 999); + }); + + it('should fail to add weights beyond 20x', async function () { + const { stakingPool, cover } = this; + const { + members: [manager], + } = this.accounts; + await initializePool(cover, stakingPool, manager.address, 0, []); + let i = 0; + const products = await Promise.all(Array.from({ length: 20 }, () => initProduct(cover, 1, 100, 100, i++))); + await stakingPool.connect(manager).setProducts(products); + const newProduct = [await initProduct(cover, 1, 1, 100, 50)]; + + await expect(stakingPool.connect(manager).setProducts(newProduct)).to.be.revertedWith( + 'StakingPool: Total max effective weight exceeded', + ); + expect(await stakingPool.totalTargetWeight()).to.be.equal(2000); + const product0 = await stakingPool.products(0); + verifyProduct(product0, 100, 100, 1); + expect(product0.nextPrice).to.be.equal(1); + }); + + it('should fail to initialize product with targetWeight greater that 1', async function () { + const { stakingPool, cover } = this; + const { GLOBAL_MIN_PRICE_RATIO } = this.config; + const { + members: [manager], + } = this.accounts; + const initialProduct = getInitialProduct(101, GLOBAL_MIN_PRICE_RATIO, 1, 1); + await expect(initializePool(cover, stakingPool, manager.address, 0, [initialProduct])).to.be.revertedWith( + 'StakingPool: Cannot set weight beyond 1', + ); + }); + + it('should fail to make product weight higher than 1', async function () { + const { stakingPool, cover } = this; + const { + members: [manager], + } = this.accounts; + await initializePool(cover, stakingPool, manager.address, 0, []); + await expect( + stakingPool.connect(manager).setProducts([await initProduct(cover, 1, 101, 500, 0)]), + ).to.be.revertedWith('StakingPool: Cannot set weight beyond 1'); + }); + + it('should edit weights, and skip price', async function () { + const { stakingPool, cover } = this; + const { + members: [manager], + } = this.accounts; + await initializePool(cover, stakingPool, manager.address, 0, []); + const product = await initProduct(cover, 1, 100, 500, 0); + await stakingPool.connect(manager).setProducts([product]); + verifyProduct(await stakingPool.products(0), 100, 500, 1); + product.setTargetPrice = false; + product.targetPrice = 0; + product.targetWeight = 50; + await stakingPool.connect(manager).setProducts([product]); + verifyProduct(await stakingPool.products(0), 50, 500, 1); + }); + + it('should not be able to change targetWeight without recalculating effectiveWeight ', async function () { + const { stakingPool, cover } = this; + const { + members: [manager], + } = this.accounts; + await initializePool(cover, stakingPool, manager.address, 0, []); + const product = await initProduct(cover, 1, 0, 500, 0); + await stakingPool.connect(manager).setProducts([product]); + product.recalculateEffectiveWeight = false; + product.targetWeight = 100; + stakingPool.connect(manager).setProducts([product]); + const productAfter = await stakingPool.products(0); + expect(productAfter.targetWeight).to.be.equal(0); + expect(productAfter.lastEffectiveWeight).to.be.equal(0); + }); + + it('should not use param.targetWeight if not explicityly setting targetWeight', async function () { + const { stakingPool, cover } = this; + const { + members: [manager], + } = this.accounts; + await initializePool(cover, stakingPool, manager.address, 0, []); + const products = [await initProduct(cover, 1, 0, 200, 0), await initProduct(cover, 1, 100, 200, 1)]; + await stakingPool.connect(manager).setProducts(products); + products[0].targetWeight = 100; + // Product 1 targetWeight shouldn't change, but effectiveWeight recalculated + products[1].targetWeight = 0; + products[1].setTargetWeight = false; + await stakingPool.connect(manager).setProducts(products); + const product0 = await stakingPool.products(0); + const product1 = await stakingPool.products(1); + verifyProduct(product0, 100, 200, 1); + verifyProduct(product1, 100, 200, 1); + expect(product0.lastEffectiveWeight).to.be.equal(100); + expect(product1.lastEffectiveWeight).to.be.equal(100); + }); + + it('should edit prices and skip weights', async function () { + const { stakingPool, cover } = this; + const { + members: [manager], + } = this.accounts; + const { GLOBAL_MIN_PRICE_RATIO } = this.config; + await initializePool(cover, stakingPool, manager.address, 0, []); + const product = await initProduct(cover, 1, 80, 500, 0); + product.setTargetWeight = false; + await stakingPool.connect(manager).setProducts([product]); + verifyProduct(await stakingPool.products(0), 0, 500, 1); + product.targetPrice = GLOBAL_MIN_PRICE_RATIO; + await stakingPool.connect(manager).setProducts([product]); + verifyProduct(await stakingPool.products(0), 0, GLOBAL_MIN_PRICE_RATIO, 1); + }); + + it('should fail with targetPrice too high', async function () { + const { stakingPool, cover } = this; + const { + members: [manager], + } = this.accounts; + await initializePool(cover, stakingPool, manager.address, 0, []); + const product = await initProduct(cover, 1, 80, 10001, 0); + await expect(stakingPool.connect(manager).setProducts([product])).to.be.revertedWith( + 'StakingPool: Target price too high', + ); + }); + + it('should fail to initialize products with targetPrice below global minimum', async function () { + const { stakingPool, cover } = this; + const { + members: [manager], + } = this.accounts; + const product = getInitialProduct(100, 1, 10, 0); + await expect(initializePool(cover, stakingPool, manager.address, 0, [product])).to.be.revertedWith( + 'CoverUtilsLib: Target price below GLOBAL_MIN_PRICE_RATIO', + ); + }); + + it('should fail with targetPrice below global min price ratio', async function () { + const { stakingPool, cover } = this; + const { GLOBAL_MIN_PRICE_RATIO } = this.config; + const { + members: [manager], + } = this.accounts; + await initializePool(cover, stakingPool, manager.address, 0, []); + const product = await initProduct(cover, 1, 80, GLOBAL_MIN_PRICE_RATIO - 1, 0); + await expect(stakingPool.connect(manager).setProducts([product])).to.be.revertedWith( + 'StakingPool: Target price below GLOBAL_MIN_PRICE_RATIO', + ); + }); + + it('should fail to add non-existing product', async function () { + const { stakingPool, cover } = this; + const { + members: [manager], + } = this.accounts; + await initializePool(cover, stakingPool, manager.address, 0, []); + const product = getNewProduct(100, 100, 10); + await expect(stakingPool.connect(manager).setProducts([product])).to.be.revertedWith( + 'Cover: Product deprecated or not initialized', + ); + }); + + it('should fail to change product weights when fully allocated', async function () { + const { stakingPool, cover, nxm, tokenController } = this; + const { + members: [manager, staker, coverBuyer], + } = this.accounts; + const amount = parseEther('1'); + await initializePool(cover, stakingPool, manager.address, 0, []); + + // Get capacity in staking pool + await nxm.connect(staker).approve(tokenController.address, amount); + const request = await depositRequest(stakingPool, amount, 0, staker.address); + await stakingPool.connect(staker).depositTo([request]); + + let i = 0; + const coverId = 1; + + // Initialize Products + const products = await Promise.all(Array.from({ length: 20 }, () => initProduct(cover, 1, 100, 100, i++))); + await stakingPool.connect(manager).setProducts(products); + + // CoverBuy + const coverBuy = Array.from({ length: 20 }, () => { + return buyCoverParams(coverBuyer.address, --i, daysToSeconds('98'), parseEther('2')); + }); + await Promise.all( + coverBuy.map(cb => { + return cover.allocateCapacity(cb, coverId, stakingPool.address); + }), + ); + + products[10].targetWeight = 50; + const newProducts = [products[10], await initProduct(cover, 1, 50, 500, 50)]; + await expect(stakingPool.connect(manager).setProducts(newProducts)).to.be.revertedWith( + 'StakingPool: Total max effective weight exceeded', + ); + }); + + it('should fail to change products when fully allocated after initializing', async function () { + const { stakingPool, cover, nxm, tokenController } = this; + const { + members: [manager, staker, coverBuyer], + } = this.accounts; + const amount = parseEther('1'); + + let i = 0; + const initialProducts = Array.from({ length: 20 }, () => getInitialProduct(100, 100, 500, i++)); + await initializePool(cover, stakingPool, manager.address, 0, initialProducts); + + // Get capacity in staking pool + await nxm.connect(staker).approve(tokenController.address, amount); + const request = await depositRequest(stakingPool, amount, 0, manager.address); + await stakingPool.connect(staker).depositTo([request]); + + const ratio = await cover.getPriceAndCapacityRatios([0]); + const { totalCapacity } = await stakingPool.getTotalCapacitiesForActiveTranches( + 0, + ratio._globalCapacityRatio, + ratio._capacityReductionRatios[0], + ); + expect(totalCapacity).to.be.equal(200); + + // Initialize Products and CoverBuy requests + const coverId = 1; + const coverBuy = Array.from({ length: 20 }, () => { + return buyCoverParams(coverBuyer.address, --i, daysToSeconds('150'), parseEther('2')); + }); + await Promise.all( + coverBuy.map(cb => { + return cover.connect(coverBuyer).allocateCapacity(cb, coverId, stakingPool.address); + }), + ); + + const product10 = getNewProduct(50, 100, 10); + const newProducts = [product10, await initProduct(cover, 1, 50, 500, 50)]; + await expect(stakingPool.connect(manager).setProducts(newProducts)).to.be.revertedWith( + 'StakingPool: Total max effective weight exceeded', + ); + }); +}); diff --git a/test/unit/StakingPool/setup.js b/test/unit/StakingPool/setup.js index eee1579000..8d4adc1864 100644 --- a/test/unit/StakingPool/setup.js +++ b/test/unit/StakingPool/setup.js @@ -1,5 +1,4 @@ const { ethers } = require('hardhat'); -const { ZERO_ADDRESS } = require('@openzeppelin/test-helpers').constants; const { parseEther } = ethers.utils; const { getAccounts } = require('../../utils/accounts'); const { Role } = require('../utils').constants; @@ -9,6 +8,7 @@ async function setup() { const MasterMock = await ethers.getContractFactory('MasterMock'); const ERC20Mock = await ethers.getContractFactory('ERC20Mock'); const QuotationData = await ethers.getContractFactory('CoverMockQuotationData'); + const SPCoverProducts = await ethers.getContractFactory('SPMockCoverProducts'); const MemberRolesMock = await ethers.getContractFactory('MemberRolesMock'); const TokenController = await ethers.getContractFactory('TokenControllerMock'); const NXMToken = await ethers.getContractFactory('NXMTokenMock'); @@ -45,14 +45,22 @@ async function setup() { await mcr.deployed(); await mcr.setMCR(parseEther('600000')); - const stakingPool = await StakingPool.deploy(nxm.address, ZERO_ADDRESS, tokenController.address); - const signers = await ethers.getSigners(); const accounts = getAccounts(signers); + const cover = await SPCoverProducts.deploy(); + await cover.deployed(); + + const stakingPool = await StakingPool.deploy(nxm.address, cover.address, tokenController.address); + + await nxm.setOperator(tokenController.address); + await tokenController.setContractAddresses(cover.address, nxm.address); + await cover.setStakingPool(stakingPool.address, 0); + for (const member of accounts.members) { await master.enrollMember(member.address, Role.Member); await memberRoles.setRole(member.address, Role.Member); + await nxm.mint(member.address, parseEther('100000')); } for (const advisoryBoardMember of accounts.advisoryBoardMembers) { @@ -71,14 +79,19 @@ async function setup() { const REWARD_BONUS_PER_TRANCHE_RATIO = await stakingPool.REWARD_BONUS_PER_TRANCHE_RATIO(); const REWARD_BONUS_PER_TRANCHE_DENOMINATOR = await stakingPool.REWARD_BONUS_PER_TRANCHE_DENOMINATOR(); + const GLOBAL_MIN_PRICE_RATIO = await cover.GLOBAL_MIN_PRICE_RATIO(); + this.tokenController = tokenController; this.master = master; + this.nxm = nxm; this.stakingPool = stakingPool; + this.cover = cover; this.dai = dai; this.accounts = accounts; this.config = { REWARD_BONUS_PER_TRANCHE_DENOMINATOR, REWARD_BONUS_PER_TRANCHE_RATIO, + GLOBAL_MIN_PRICE_RATIO, }; }