From 8b3bfb15b8e465bcf313b668b0ead7c451e9e97b Mon Sep 17 00:00:00 2001 From: rndquu Date: Sat, 6 Apr 2024 01:09:33 +0300 Subject: [PATCH] feat: add fractional redeems --- .../src/dollar/facets/UbiquityPoolFacet.sol | 15 +- .../src/dollar/interfaces/IUbiquityPool.sol | 13 +- .../src/dollar/libraries/LibUbiquityPool.sol | 71 +++++- .../test/diamond/DiamondTestSetup.sol | 6 +- .../diamond/facets/UbiquityPoolFacet.t.sol | 205 +++++++++++++++++- 5 files changed, 299 insertions(+), 11 deletions(-) diff --git a/packages/contracts/src/dollar/facets/UbiquityPoolFacet.sol b/packages/contracts/src/dollar/facets/UbiquityPoolFacet.sol index d47df48e4..2d191c4cd 100644 --- a/packages/contracts/src/dollar/facets/UbiquityPoolFacet.sol +++ b/packages/contracts/src/dollar/facets/UbiquityPoolFacet.sol @@ -104,6 +104,13 @@ contract UbiquityPoolFacet is IUbiquityPool, Modifiers { ); } + /// @inheritdoc IUbiquityPool + function getRedeemGovernanceBalance( + address userAddress + ) external view returns (uint256) { + return LibUbiquityPool.getRedeemGovernanceBalance(userAddress); + } + /// @inheritdoc IUbiquityPool function governanceEthPoolAddress() external view returns (address) { return LibUbiquityPool.governanceEthPoolAddress(); @@ -145,12 +152,18 @@ contract UbiquityPoolFacet is IUbiquityPool, Modifiers { function redeemDollar( uint256 collateralIndex, uint256 dollarAmount, + uint256 governanceOutMin, uint256 collateralOutMin - ) external nonReentrant returns (uint256 collateralOut) { + ) + external + nonReentrant + returns (uint256 collateralOut, uint256 governanceOut) + { return LibUbiquityPool.redeemDollar( collateralIndex, dollarAmount, + governanceOutMin, collateralOutMin ); } diff --git a/packages/contracts/src/dollar/interfaces/IUbiquityPool.sol b/packages/contracts/src/dollar/interfaces/IUbiquityPool.sol index 15cf4ef3d..25c7f7a6f 100644 --- a/packages/contracts/src/dollar/interfaces/IUbiquityPool.sol +++ b/packages/contracts/src/dollar/interfaces/IUbiquityPool.sol @@ -106,6 +106,15 @@ interface IUbiquityPool { uint256 collateralIndex ) external view returns (uint256); + /** + * @notice Returns user's Governance tokens balance available for redemption + * @param userAddress User address + * @return User's Governance tokens balance available for redemption + */ + function getRedeemGovernanceBalance( + address userAddress + ) external view returns (uint256); + /** * @notice Returns pool address for Governance/ETH pair * @return Pool address @@ -151,14 +160,16 @@ interface IUbiquityPool { * @dev This is done in order to prevent someone using a flash loan of a collateral token to mint, redeem, and collect in a single transaction/block * @param collateralIndex Collateral token index being withdrawn * @param dollarAmount Amount of Ubiquity Dollars being burned + * @param governanceOutMin Minimum amount of Governance tokens that'll be withdrawn, used to set acceptable slippage * @param collateralOutMin Minimum amount of collateral tokens that'll be withdrawn, used to set acceptable slippage * @return collateralOut Amount of collateral tokens ready for redemption */ function redeemDollar( uint256 collateralIndex, uint256 dollarAmount, + uint256 governanceOutMin, uint256 collateralOutMin - ) external returns (uint256 collateralOut); + ) external returns (uint256 collateralOut, uint256 governanceOut); /** * @notice Used to collect collateral tokens after redeeming/burning Ubiquity Dollars diff --git a/packages/contracts/src/dollar/libraries/LibUbiquityPool.sol b/packages/contracts/src/dollar/libraries/LibUbiquityPool.sol index 1cb3be24d..9fff2cf5d 100644 --- a/packages/contracts/src/dollar/libraries/LibUbiquityPool.sol +++ b/packages/contracts/src/dollar/libraries/LibUbiquityPool.sol @@ -70,10 +70,14 @@ library LibUbiquityPool { uint256 redeemPriceThreshold; // address -> collateral index -> balance mapping(address user => mapping(uint256 collateralIndex => uint256 amount)) redeemCollateralBalances; + // address -> balance + mapping(address user => uint256 amount) redeemGovernanceBalances; // number of blocks to wait before being able to collectRedemption() uint256 redemptionDelayBlocks; // collateral index -> balance uint256[] unclaimedPoolCollateral; + // total amount of unclaimed Governance tokens in the pool + uint256 unclaimedPoolGovernance; //================ // Fees related //================ @@ -442,6 +446,18 @@ library LibUbiquityPool { poolStorage.redeemCollateralBalances[userAddress][collateralIndex]; } + /** + * @notice Returns user's Governance tokens balance available for redemption + * @param userAddress User address + * @return User's Governance tokens balance available for redemption + */ + function getRedeemGovernanceBalance( + address userAddress + ) internal view returns (uint256) { + UbiquityPoolStorage storage poolStorage = ubiquityPoolStorage(); + return poolStorage.redeemGovernanceBalances[userAddress]; + } + /** * @notice Returns pool address for Governance/ETH pair * @return Pool address @@ -571,17 +587,19 @@ library LibUbiquityPool { * @dev This is done in order to prevent someone using a flash loan of a collateral token to mint, redeem, and collect in a single transaction/block * @param collateralIndex Collateral token index being withdrawn * @param dollarAmount Amount of Ubiquity Dollars being burned + * @param governanceOutMin Minimum amount of Governance tokens that'll be withdrawn, used to set acceptable slippage * @param collateralOutMin Minimum amount of collateral tokens that'll be withdrawn, used to set acceptable slippage * @return collateralOut Amount of collateral tokens ready for redemption */ function redeemDollar( uint256 collateralIndex, uint256 dollarAmount, + uint256 governanceOutMin, uint256 collateralOutMin ) internal collateralEnabled(collateralIndex) - returns (uint256 collateralOut) + returns (uint256 collateralOut, uint256 governanceOut) { UbiquityPoolStorage storage poolStorage = ubiquityPoolStorage(); @@ -607,8 +625,33 @@ library LibUbiquityPool { // update collateral price updateChainLinkCollateralPrice(collateralIndex); - // get collateral output for incoming Dollars - collateralOut = getDollarInCollateral(collateralIndex, dollarAfterFee); + // get current collateral ratio + uint256 currentCollateralRatio = poolStorage.collateralRatio; + + // fully collateralized + if (currentCollateralRatio >= UBIQUITY_POOL_PRICE_PRECISION) { + // get collateral output for incoming Dollars + collateralOut = getDollarInCollateral( + collateralIndex, + dollarAfterFee + ); + governanceOut = 0; + } else if (currentCollateralRatio == 0) { + // algorithmic, fully covered by Governance tokens + collateralOut = 0; + governanceOut = dollarAfterFee + .mul(UBIQUITY_POOL_PRICE_PRECISION) + .div(getGovernancePriceUsd()); + } else { + // fractional, partially covered by collateral and Governance tokens + collateralOut = getDollarInCollateral( + collateralIndex, + dollarAfterFee + ).mul(currentCollateralRatio).div(UBIQUITY_POOL_PRICE_PRECISION); + governanceOut = dollarAfterFee + .mul(UBIQUITY_POOL_PRICE_PRECISION.sub(currentCollateralRatio)) + .div(getGovernancePriceUsd()); + } // checks require( @@ -619,8 +662,9 @@ library LibUbiquityPool { "Insufficient pool collateral" ); require(collateralOut >= collateralOutMin, "Collateral slippage"); + require(governanceOut >= governanceOutMin, "Governance slippage"); - // account for the redeem delay + // increase collateral redemption balances poolStorage.redeemCollateralBalances[msg.sender][ collateralIndex ] = poolStorage @@ -631,13 +675,26 @@ library LibUbiquityPool { .unclaimedPoolCollateral[collateralIndex] .add(collateralOut); + // increase Governance redemption balances + poolStorage.redeemGovernanceBalances[msg.sender] = poolStorage + .redeemGovernanceBalances[msg.sender] + .add(governanceOut); + poolStorage.unclaimedPoolGovernance = poolStorage + .unclaimedPoolGovernance + .add(governanceOut); + poolStorage.lastRedeemedBlock[msg.sender] = block.number; // burn Dollars - IERC20Ubiquity ubiquityDollarToken = IERC20Ubiquity( - LibAppStorage.appStorage().dollarTokenAddress + IERC20Ubiquity(LibAppStorage.appStorage().dollarTokenAddress).burnFrom( + msg.sender, + dollarAmount + ); + // mint Governance tokens to this address + IERC20Ubiquity(LibAppStorage.appStorage().governanceTokenAddress).mint( + address(this), + governanceOut ); - ubiquityDollarToken.burnFrom(msg.sender, dollarAmount); } /** diff --git a/packages/contracts/test/diamond/DiamondTestSetup.sol b/packages/contracts/test/diamond/DiamondTestSetup.sol index eb94fd64a..c15c8edfa 100644 --- a/packages/contracts/test/diamond/DiamondTestSetup.sol +++ b/packages/contracts/test/diamond/DiamondTestSetup.sol @@ -446,11 +446,15 @@ abstract contract DiamondTestSetup is DiamondTestHelper, UUPSTestHelper { CREDIT_TOKEN_BURNER_ROLE, address(diamond) ); - // grant diamond Governance token admin and burner rights + // grant diamond Governance token admin, minter and burner rights accessControlFacet.grantRole( GOVERNANCE_TOKEN_MANAGER_ROLE, address(diamond) ); + accessControlFacet.grantRole( + GOVERNANCE_TOKEN_MINTER_ROLE, + address(diamond) + ); accessControlFacet.grantRole( GOVERNANCE_TOKEN_BURNER_ROLE, address(diamond) diff --git a/packages/contracts/test/diamond/facets/UbiquityPoolFacet.t.sol b/packages/contracts/test/diamond/facets/UbiquityPoolFacet.t.sol index 9d7cd0359..7bc67a450 100644 --- a/packages/contracts/test/diamond/facets/UbiquityPoolFacet.t.sol +++ b/packages/contracts/test/diamond/facets/UbiquityPoolFacet.t.sol @@ -308,6 +308,7 @@ contract UbiquityPoolFacetTest is DiamondTestSetup { ubiquityPoolFacet.redeemDollar( 0, // collateral index 99e18, // Dollar amount + 0, // min Governance out 90e18 // min collateral out ); @@ -398,6 +399,7 @@ contract UbiquityPoolFacetTest is DiamondTestSetup { ubiquityPoolFacet.redeemDollar( 0, // collateral index 99e18, // Dollar amount + 0, // min Governance out 90e18 // min collateral out ); @@ -406,6 +408,45 @@ contract UbiquityPoolFacetTest is DiamondTestSetup { assertEq(redeemCollateralBalance, 97.02e18); } + function testGetRedeemGovernanceBalance_ShouldReturnRedeemGovernanceBalance() + public + { + vm.prank(admin); + ubiquityPoolFacet.setPriceThresholds( + 1000000, // mint threshold + 1000000 // redeem threshold + ); + + // admin sets collateral ratio to 0% + vm.prank(admin); + ubiquityPoolFacet.setCollateralRatio(0); + + // user burns 1000 Governance tokens and gets 99 Dollars (-1% mint fee) + vm.prank(user); + ubiquityPoolFacet.mintDollar( + 0, // collateral index + 100e18, // Dollar amount + 99e18, // min amount of Dollars to mint + 0, // max collateral to send + 1100e18, // max Governance tokens to send + false // force 1-to-1 mint (i.e. provide only collateral without Governance tokens) + ); + + // user redeems 99 Dollars + vm.prank(user); + ubiquityPoolFacet.redeemDollar( + 0, // collateral index + 99e18, // Dollar amount + 0, // min Governance out + 0 // min collateral out + ); + + assertEq( + ubiquityPoolFacet.getRedeemGovernanceBalance(user), + 970209702097020970209 + ); + } + function testGovernanceEthPoolAddress_ShouldReturnGovernanceEthPoolAddress() public { @@ -695,6 +736,7 @@ contract UbiquityPoolFacetTest is DiamondTestSetup { ubiquityPoolFacet.redeemDollar( 0, // collateral index 100e18, // Dollar amount + 0, // min Governance out 90e18 // min collateral out ); } @@ -705,6 +747,7 @@ contract UbiquityPoolFacetTest is DiamondTestSetup { ubiquityPoolFacet.redeemDollar( 0, // collateral index 100e18, // Dollar amount + 0, // min Governance out 90e18 // min collateral out ); } @@ -723,6 +766,7 @@ contract UbiquityPoolFacetTest is DiamondTestSetup { ubiquityPoolFacet.redeemDollar( 0, // collateral index 100e18, // Dollar amount + 0, // min Governance out 90e18 // min collateral out ); } @@ -750,11 +794,46 @@ contract UbiquityPoolFacetTest is DiamondTestSetup { ubiquityPoolFacet.redeemDollar( 0, // collateral index 100e18, // Dollar amount + 0, // min Governance out 100e18 // min collateral out ); } - function testRedeemDollar_ShouldRedeemCollateral() public { + function testRedeemDollar_ShouldRevert_OnGovernanceSlippage() public { + vm.prank(admin); + ubiquityPoolFacet.setPriceThresholds( + 1000000, // mint threshold + 1000000 // redeem threshold + ); + + // admin sets collateral ratio to 0% + vm.prank(admin); + ubiquityPoolFacet.setCollateralRatio(0); + + // user burns ~1000 Governance tokens and gets 99 Dollars (-1% mint fee) + vm.prank(user); + ubiquityPoolFacet.mintDollar( + 0, // collateral index + 100e18, // Dollar amount + 99e18, // min amount of Dollars to mint + 0, // max collateral to send + 1100e18, // max Governance tokens to send + false // force 1-to-1 mint (i.e. provide only collateral without Governance tokens) + ); + + vm.prank(user); + vm.expectRevert("Governance slippage"); + ubiquityPoolFacet.redeemDollar( + 0, // collateral index + 99e18, // Dollar amount + 1100e18, // min Governance out + 0 // min collateral out + ); + } + + function testRedeemDollar_ShouldRedeemCollateral_IfCollateralRatioIs100() + public + { vm.prank(admin); ubiquityPoolFacet.setPriceThresholds( 1000000, // mint threshold @@ -774,16 +853,137 @@ contract UbiquityPoolFacetTest is DiamondTestSetup { // balances before assertEq(dollarToken.balanceOf(user), 99e18); + assertEq(governanceToken.balanceOf(user), 2000e18); + assertEq(governanceToken.balanceOf(address(ubiquityPoolFacet)), 0); + assertEq(ubiquityPoolFacet.getRedeemCollateralBalance(user, 0), 0); + assertEq(ubiquityPoolFacet.getRedeemGovernanceBalance(user), 0); vm.prank(user); ubiquityPoolFacet.redeemDollar( 0, // collateral index 99e18, // Dollar amount + 0, // min Governance out 90e18 // min collateral out ); // balances after assertEq(dollarToken.balanceOf(user), 0); + assertEq(governanceToken.balanceOf(user), 2000e18); + assertEq(governanceToken.balanceOf(address(ubiquityPoolFacet)), 0); + assertEq( + ubiquityPoolFacet.getRedeemCollateralBalance(user, 0), + 97.02 ether + ); + assertEq(ubiquityPoolFacet.getRedeemGovernanceBalance(user), 0); + } + + function testRedeemDollar_ShouldRedeemCollateral_IfCollateralRatioIs0() + public + { + vm.prank(admin); + ubiquityPoolFacet.setPriceThresholds( + 1000000, // mint threshold + 1000000 // redeem threshold + ); + + // admin sets collateral ratio to 0% + vm.prank(admin); + ubiquityPoolFacet.setCollateralRatio(0); + + // user burns 1000 Governance tokens and gets 99 Dollars (-1% mint fee) + vm.prank(user); + ubiquityPoolFacet.mintDollar( + 0, // collateral index + 100e18, // Dollar amount + 99e18, // min amount of Dollars to mint + 0, // max collateral to send + 1100e18, // max Governance tokens to send + false // force 1-to-1 mint (i.e. provide only collateral without Governance tokens) + ); + + // balances before + assertEq(dollarToken.balanceOf(user), 99e18); + assertEq(governanceToken.balanceOf(user), 999989999899998999990); + assertEq(governanceToken.balanceOf(address(ubiquityPoolFacet)), 0); + assertEq(ubiquityPoolFacet.getRedeemCollateralBalance(user, 0), 0); + assertEq(ubiquityPoolFacet.getRedeemGovernanceBalance(user), 0); + + vm.prank(user); + ubiquityPoolFacet.redeemDollar( + 0, // collateral index + 99e18, // Dollar amount + 0, // min Governance out + 0 // min collateral out + ); + + // balances after + assertEq(dollarToken.balanceOf(user), 0); + assertEq(governanceToken.balanceOf(user), 999989999899998999990); + assertEq( + governanceToken.balanceOf(address(ubiquityPoolFacet)), + 970209702097020970209 + ); + assertEq(ubiquityPoolFacet.getRedeemCollateralBalance(user, 0), 0); + assertEq( + ubiquityPoolFacet.getRedeemGovernanceBalance(user), + 970209702097020970209 + ); + } + + function testRedeemDollar_ShouldRedeemCollateral_IfCollateralRatioIs95() + public + { + vm.prank(admin); + ubiquityPoolFacet.setPriceThresholds( + 1000000, // mint threshold + 1000000 // redeem threshold + ); + + // admin sets collateral ratio to 95% + vm.prank(admin); + ubiquityPoolFacet.setCollateralRatio(950_000); + + // user burns 50 Governance tokens (worth $0.1) + 95 collateral tokens and gets 99 Dollars (-1% mint fee) + vm.prank(user); + ubiquityPoolFacet.mintDollar( + 0, // collateral index + 100e18, // Dollar amount + 99e18, // min amount of Dollars to mint + 100e18, // max collateral to send + 1100e18, // max Governance tokens to send + false // force 1-to-1 mint (i.e. provide only collateral without Governance tokens) + ); + + // balances before + assertEq(dollarToken.balanceOf(user), 99e18); + assertEq(governanceToken.balanceOf(user), 1949999499994999950000); // ~1950 + assertEq(governanceToken.balanceOf(address(ubiquityPoolFacet)), 0); + assertEq(ubiquityPoolFacet.getRedeemCollateralBalance(user, 0), 0); + assertEq(ubiquityPoolFacet.getRedeemGovernanceBalance(user), 0); + + vm.prank(user); + ubiquityPoolFacet.redeemDollar( + 0, // collateral index + 99e18, // Dollar amount + 0, // min Governance out + 0 // min collateral out + ); + + // balances after + assertEq(dollarToken.balanceOf(user), 0); + assertEq(governanceToken.balanceOf(user), 1949999499994999950000); // ~1950 + assertEq( + governanceToken.balanceOf(address(ubiquityPoolFacet)), + 48510485104851048510 + ); // ~48.5 + assertEq( + ubiquityPoolFacet.getRedeemCollateralBalance(user, 0), + 92169000000000000000 + ); // ~92 + assertEq( + ubiquityPoolFacet.getRedeemGovernanceBalance(user), + 48510485104851048510 + ); // ~48.5 } function testCollectRedemption_ShouldRevert_IfRedeemingIsPaused() public { @@ -827,6 +1027,7 @@ contract UbiquityPoolFacetTest is DiamondTestSetup { ubiquityPoolFacet.redeemDollar( 0, // collateral index 99e18, // Dollar amount + 0, // min Governance out 90e18 // min collateral out ); @@ -870,6 +1071,7 @@ contract UbiquityPoolFacetTest is DiamondTestSetup { ubiquityPoolFacet.redeemDollar( 0, // collateral index 99e18, // Dollar amount + 0, // min Governance out 90e18 // min collateral out ); @@ -997,6 +1199,7 @@ contract UbiquityPoolFacetTest is DiamondTestSetup { ubiquityPoolFacet.redeemDollar( 0, // collateral index 99e18, // Dollar amount + 0, // min Governance out 90e18 // min collateral out );