diff --git a/cspell.json b/cspell.json index 07237c723..326d1e1ac 100644 --- a/cspell.json +++ b/cspell.json @@ -140,6 +140,7 @@ "twap", "typechain", "TYPEHASH", + "Twocrypto", "Ubiqui", "UbiquiStick", "Unassigns", diff --git a/packages/contracts/src/dollar/facets/UbiquityPoolFacet.sol b/packages/contracts/src/dollar/facets/UbiquityPoolFacet.sol index e9cd91a37..6f9b7b058 100644 --- a/packages/contracts/src/dollar/facets/UbiquityPoolFacet.sol +++ b/packages/contracts/src/dollar/facets/UbiquityPoolFacet.sol @@ -32,6 +32,11 @@ contract UbiquityPoolFacet is IUbiquityPool, Modifiers { return LibUbiquityPool.collateralInformation(collateralAddress); } + /// @inheritdoc IUbiquityPool + function collateralRatio() external view returns (uint256) { + return LibUbiquityPool.collateralRatio(); + } + /// @inheritdoc IUbiquityPool function collateralUsdBalance() external @@ -41,6 +46,15 @@ contract UbiquityPoolFacet is IUbiquityPool, Modifiers { return LibUbiquityPool.collateralUsdBalance(); } + /// @inheritdoc IUbiquityPool + function ethUsdPriceFeedInformation() + external + view + returns (address, uint256) + { + return LibUbiquityPool.ethUsdPriceFeedInformation(); + } + /// @inheritdoc IUbiquityPool function freeCollateralBalance( uint256 collateralIndex @@ -69,6 +83,15 @@ contract UbiquityPoolFacet is IUbiquityPool, Modifiers { return LibUbiquityPool.getDollarPriceUsd(); } + /// @inheritdoc IUbiquityPool + function getGovernancePriceUsd() + external + view + returns (uint256 governancePriceUsd) + { + return LibUbiquityPool.getGovernancePriceUsd(); + } + /// @inheritdoc IUbiquityPool function getRedeemCollateralBalance( address userAddress, @@ -81,6 +104,18 @@ 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(); + } + //==================== // Public functions //==================== @@ -90,18 +125,26 @@ contract UbiquityPoolFacet is IUbiquityPool, Modifiers { uint256 collateralIndex, uint256 dollarAmount, uint256 dollarOutMin, - uint256 maxCollateralIn + uint256 maxCollateralIn, + uint256 maxGovernanceIn, + bool isOneToOne ) external nonReentrant - returns (uint256 totalDollarMint, uint256 collateralNeeded) + returns ( + uint256 totalDollarMint, + uint256 collateralNeeded, + uint256 governanceNeeded + ) { return LibUbiquityPool.mintDollar( collateralIndex, dollarAmount, dollarOutMin, - maxCollateralIn + maxCollateralIn, + maxGovernanceIn, + isOneToOne ); } @@ -109,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 ); } @@ -122,7 +171,11 @@ contract UbiquityPoolFacet is IUbiquityPool, Modifiers { /// @inheritdoc IUbiquityPool function collectRedemption( uint256 collateralIndex - ) external nonReentrant returns (uint256 collateralAmount) { + ) + external + nonReentrant + returns (uint256 governanceAmount, uint256 collateralAmount) + { return LibUbiquityPool.collectRedemption(collateralIndex); } @@ -180,6 +233,22 @@ contract UbiquityPoolFacet is IUbiquityPool, Modifiers { ); } + /// @inheritdoc IUbiquityPool + function setCollateralRatio(uint256 newCollateralRatio) external onlyAdmin { + LibUbiquityPool.setCollateralRatio(newCollateralRatio); + } + + /// @inheritdoc IUbiquityPool + function setEthUsdChainLinkPriceFeed( + address newPriceFeedAddress, + uint256 newStalenessThreshold + ) external onlyAdmin { + LibUbiquityPool.setEthUsdChainLinkPriceFeed( + newPriceFeedAddress, + newStalenessThreshold + ); + } + /// @inheritdoc IUbiquityPool function setFees( uint256 collateralIndex, @@ -189,6 +258,15 @@ contract UbiquityPoolFacet is IUbiquityPool, Modifiers { LibUbiquityPool.setFees(collateralIndex, newMintFee, newRedeemFee); } + /// @inheritdoc IUbiquityPool + function setGovernanceEthPoolAddress( + address newGovernanceEthPoolAddress + ) external onlyAdmin { + LibUbiquityPool.setGovernanceEthPoolAddress( + newGovernanceEthPoolAddress + ); + } + /// @inheritdoc IUbiquityPool function setPoolCeiling( uint256 collateralIndex, diff --git a/packages/contracts/src/dollar/interfaces/ICurveTwocryptoOptimized.sol b/packages/contracts/src/dollar/interfaces/ICurveTwocryptoOptimized.sol new file mode 100644 index 000000000..e6ddce950 --- /dev/null +++ b/packages/contracts/src/dollar/interfaces/ICurveTwocryptoOptimized.sol @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.19; + +import {ICurveStableSwapMetaNG} from "./ICurveStableSwapMetaNG.sol"; + +/** + * @notice Curve's CurveTwocryptoOptimized interface + * + * @dev Differences between Curve's crypto and stable swap meta pools (and how Ubiquity organization uses them): + * 1. They contain different tokens: + * a) Curve's stable swap metapool containts Dollar/3CRVLP pair + * b) Curve's crypto pool contains Governance/ETH pair + * 2. They use different bonding curve shapes: + * a) Curve's stable swap metapool is more straight (because underlying tokens are pegged to USD) + * b) Curve's crypto pool resembles Uniswap's bonding curve (because underlying tokens are not USD pegged) + * 3. The `price_oracle()` method works differently: + * a) Curve's stable swap metapool `price_oracle(uint256 i)` accepts coin index parameter + * b) Curve's crypto pool `price_oracle()` doesn't accept coin index parameter and always returns oracle price for coin at index 1 + * + * @dev Basically `ICurveTwocryptoOptimized` has the same interface as `ICurveStableSwapMetaNG` + * but we distinguish them in the code for clarity. + */ +interface ICurveTwocryptoOptimized is ICurveStableSwapMetaNG { + /** + * @notice Getter for the oracle price of the coin at index 1 with regard to the coin at index 0. + * The price oracle is an exponential moving average with a periodicity determined by `ma_time`. + * @return Price oracle + */ + function price_oracle() external view returns (uint256); +} diff --git a/packages/contracts/src/dollar/interfaces/IUbiquityPool.sol b/packages/contracts/src/dollar/interfaces/IUbiquityPool.sol index 23e934e4f..aa41b7e29 100644 --- a/packages/contracts/src/dollar/interfaces/IUbiquityPool.sol +++ b/packages/contracts/src/dollar/interfaces/IUbiquityPool.sol @@ -32,6 +32,12 @@ interface IUbiquityPool { view returns (LibUbiquityPool.CollateralInformation memory returnData); + /** + * @notice Returns current collateral ratio + * @return Collateral ratio + */ + function collateralRatio() external view returns (uint256); + /** * @notice Returns USD value of all collateral tokens held in the pool, in E18 * @return balanceTally USD value of all collateral tokens @@ -41,6 +47,15 @@ interface IUbiquityPool { view returns (uint256 balanceTally); + /** + * @notice Returns chainlink price feed information for ETH/USD pair + * @return Price feed address and staleness threshold in seconds + */ + function ethUsdPriceFeedInformation() + external + view + returns (address, uint256); + /** * @notice Returns free collateral balance (i.e. that can be borrowed by AMO minters) * @param collateralIndex collateral token index @@ -67,6 +82,19 @@ interface IUbiquityPool { */ function getDollarPriceUsd() external view returns (uint256 dollarPriceUsd); + /** + * @notice Returns Governance token price in USD (6 decimals precision) + * @dev How it works: + * 1. Fetch ETH/USD price from chainlink oracle + * 2. Fetch Governance/ETH price from Curve's oracle + * 3. Calculate Governance token price in USD + * @return governancePriceUsd Governance token price in USD + */ + function getGovernancePriceUsd() + external + view + returns (uint256 governancePriceUsd); + /** * @notice Returns user's balance available for redemption * @param userAddress User address @@ -78,6 +106,21 @@ 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 + */ + function governanceEthPoolAddress() external view returns (address); + //==================== // Public functions //==================== @@ -88,15 +131,26 @@ interface IUbiquityPool { * @param dollarAmount Amount of dollars to mint * @param dollarOutMin Min amount of dollars to mint (slippage protection) * @param maxCollateralIn Max amount of collateral to send (slippage protection) + * @param maxGovernanceIn Max amount of Governance tokens to send (slippage protection) + * @param isOneToOne Force providing only collateral without Governance tokens * @return totalDollarMint Amount of Dollars minted * @return collateralNeeded Amount of collateral sent to the pool + * @return governanceNeeded Amount of Governance tokens burnt from sender */ function mintDollar( uint256 collateralIndex, uint256 dollarAmount, uint256 dollarOutMin, - uint256 maxCollateralIn - ) external returns (uint256 totalDollarMint, uint256 collateralNeeded); + uint256 maxCollateralIn, + uint256 maxGovernanceIn, + bool isOneToOne + ) + external + returns ( + uint256 totalDollarMint, + uint256 collateralNeeded, + uint256 governanceNeeded + ); /** * @notice Burns redeemable Ubiquity Dollars and sends back 1 USD of collateral token for every 1 Ubiquity Dollar burned @@ -106,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 @@ -122,11 +178,18 @@ interface IUbiquityPool { * @dev 2. `collectRedemption()` * @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 collected + * @return governanceAmount Amount of Governance tokens redeemed * @return collateralAmount Amount of collateral tokens redeemed */ function collectRedemption( uint256 collateralIndex - ) external returns (uint256 collateralAmount); + ) external returns (uint256 governanceAmount, uint256 collateralAmount); + + /** + * @notice Updates collateral token price in USD from ChainLink price feed + * @param collateralIndex Collateral token index + */ + function updateChainLinkCollateralPrice(uint256 collateralIndex) external; //========================= // AMO minters functions @@ -181,10 +244,30 @@ interface IUbiquityPool { ) external; /** - * @notice Updates collateral token price in USD from ChainLink price feed - * @param collateralIndex Collateral token index + * @notice Sets collateral ratio + * @dev How much collateral/governance tokens user should provide/get to mint/redeem Dollar tokens, 1e6 precision + * + * @dev Example (1_000_000 = 100%): + * - Mint: user provides 1 collateral token to get 1 Dollar + * - Redeem: user gets 1 collateral token for 1 Dollar + * + * @dev Example (900_000 = 90%): + * - Mint: user provides 0.9 collateral token and 0.1 Governance token to get 1 Dollar + * - Redeem: user gets 0.9 collateral token and 0.1 Governance token for 1 Dollar + * + * @param newCollateralRatio New collateral ratio */ - function updateChainLinkCollateralPrice(uint256 collateralIndex) external; + function setCollateralRatio(uint256 newCollateralRatio) external; + + /** + * @notice Sets chainlink params for ETH/USD price feed + * @param newPriceFeedAddress New chainlink price feed address for ETH/USD pair + * @param newStalenessThreshold New threshold in seconds when chainlink's ETH/USD price feed answer should be considered stale + */ + function setEthUsdChainLinkPriceFeed( + address newPriceFeedAddress, + uint256 newStalenessThreshold + ) external; /** * @notice Sets mint and redeem fees, 1_000_000 = 100% @@ -198,6 +281,21 @@ interface IUbiquityPool { uint256 newRedeemFee ) external; + /** + * @notice Sets a new pool address for Governance/ETH pair + * + * @dev Based on Curve's CurveTwocryptoOptimized contract. Used for fetching Governance token USD price. + * How it works: + * 1. Fetch Governance/ETH price from CurveTwocryptoOptimized's built-in oracle + * 2. Fetch ETH/USD price from chainlink feed + * 3. Calculate Governance token price in USD + * + * @param newGovernanceEthPoolAddress New pool address for Governance/ETH pair + */ + function setGovernanceEthPoolAddress( + address newGovernanceEthPoolAddress + ) external; + /** * @notice Sets max amount of collateral for a particular collateral token * @param collateralIndex Collateral token index diff --git a/packages/contracts/src/dollar/libraries/LibUbiquityPool.sol b/packages/contracts/src/dollar/libraries/LibUbiquityPool.sol index d3447d50f..ae736accf 100644 --- a/packages/contracts/src/dollar/libraries/LibUbiquityPool.sol +++ b/packages/contracts/src/dollar/libraries/LibUbiquityPool.sol @@ -7,6 +7,7 @@ import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import {SafeMath} from "@openzeppelin/contracts/utils/math/SafeMath.sol"; import {ICurveStableSwapMetaNG} from "../interfaces/ICurveStableSwapMetaNG.sol"; +import {ICurveTwocryptoOptimized} from "../interfaces/ICurveTwocryptoOptimized.sol"; import {IDollarAmoMinter} from "../interfaces/IDollarAmoMinter.sol"; import {IERC20Ubiquity} from "../interfaces/IERC20Ubiquity.sol"; import {UBIQUITY_POOL_PRICE_PRECISION} from "./Constants.sol"; @@ -48,6 +49,8 @@ library LibUbiquityPool { uint256[] collateralPriceFeedStalenessThresholds; // collateral index -> collateral price uint256[] collateralPrices; + // how much collateral/governance tokens user should provide/get to mint/redeem Dollar tokens, 1e6 precision + uint256 collateralRatio; // array collateral symbols string[] collateralSymbols; // collateral address -> is it enabled @@ -67,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 //================ @@ -87,6 +94,15 @@ library LibUbiquityPool { bool[] isMintPaused; // whether redeeming is paused for a particular collateral index bool[] isRedeemPaused; + //==================================== + // Governance token pricing related + //==================================== + // chainlink price feed for ETH/USD pair + address ethUsdPriceFeedAddress; + // threshold in seconds when chainlink's ETH/USD price feed answer should be considered stale + uint256 ethUsdPriceFeedStalenessThreshold; + // Curve's CurveTwocryptoOptimized contract for Governance/ETH pair + address governanceEthPoolAddress; } /// @notice Struct used for detailed collateral information @@ -138,14 +154,23 @@ library LibUbiquityPool { ); /// @notice Emitted on setting a collateral price event CollateralPriceSet(uint256 collateralIndex, uint256 newPrice); + /// @notice Emitted on setting a collateral ratio + event CollateralRatioSet(uint256 newCollateralRatio); /// @notice Emitted on enabling/disabling a particular collateral token event CollateralToggled(uint256 collateralIndex, bool newState); + /// @notice Emitted on setting chainlink's price feed for ETH/USD pair + event EthUsdPriceFeedSet( + address newPriceFeedAddress, + uint256 newStalenessThreshold + ); /// @notice Emitted when fees are updated event FeesSet( uint256 collateralIndex, uint256 newMintFee, uint256 newRedeemFee ); + /// @notice Emitted on setting a pool for Governance/ETH pair + event GovernanceEthPoolSet(address newGovernanceEthPoolAddress); /// @notice Emitted on toggling pause for mint/redeem/borrow event MintRedeemBorrowToggled(uint256 collateralIndex, uint8 toggleIndex); /// @notice Emitted when new pool ceiling (i.e. max amount of collateral) is set @@ -258,6 +283,15 @@ library LibUbiquityPool { ); } + /** + * @notice Returns current collateral ratio + * @return Collateral ratio + */ + function collateralRatio() internal view returns (uint256) { + UbiquityPoolStorage storage poolStorage = ubiquityPoolStorage(); + return poolStorage.collateralRatio; + } + /** * @notice Returns USD value of all collateral tokens held in the pool, in E18 * @return balanceTally USD value of all collateral tokens @@ -278,6 +312,22 @@ library LibUbiquityPool { } } + /** + * @notice Returns chainlink price feed information for ETH/USD pair + * @return Price feed address and staleness threshold in seconds + */ + function ethUsdPriceFeedInformation() + internal + view + returns (address, uint256) + { + UbiquityPoolStorage storage poolStorage = ubiquityPoolStorage(); + return ( + poolStorage.ethUsdPriceFeedAddress, + poolStorage.ethUsdPriceFeedStalenessThreshold + ); + } + /** * @notice Returns free collateral balance (i.e. that can be borrowed by AMO minters) * @param collateralIndex collateral token index @@ -332,6 +382,55 @@ library LibUbiquityPool { .div(1e18); } + /** + * @notice Returns Governance token price in USD (6 decimals precision) + * @dev How it works: + * 1. Fetch ETH/USD price from chainlink oracle + * 2. Fetch Governance/ETH price from Curve's oracle + * 3. Calculate Governance token price in USD + * @return governancePriceUsd Governance token price in USD + */ + function getGovernancePriceUsd() + internal + view + returns (uint256 governancePriceUsd) + { + UbiquityPoolStorage storage poolStorage = ubiquityPoolStorage(); + + // fetch latest ETH/USD price + AggregatorV3Interface ethUsdPriceFeed = AggregatorV3Interface( + poolStorage.ethUsdPriceFeedAddress + ); + (, int256 answer, , uint256 updatedAt, ) = ethUsdPriceFeed + .latestRoundData(); + uint256 ethUsdPriceFeedDecimals = ethUsdPriceFeed.decimals(); + + // validate ETH/USD chainlink response + require(answer > 0, "Invalid price"); + require( + block.timestamp - updatedAt < + poolStorage.ethUsdPriceFeedStalenessThreshold, + "Stale data" + ); + + // convert ETH/USD chainlink price to 6 decimals + uint256 ethUsdPrice = uint256(answer) + .mul(UBIQUITY_POOL_PRICE_PRECISION) + .div(10 ** ethUsdPriceFeedDecimals); + + // fetch ETH/Governance price (18 decimals) + uint256 ethGovernancePriceD18 = ICurveTwocryptoOptimized( + poolStorage.governanceEthPoolAddress + ).price_oracle(); + // calculate Governance/ETH price (18 decimals) + uint256 governanceEthPriceD18 = uint256(1e18).mul(1e18).div( + ethGovernancePriceD18 + ); + + // calculate Governance token price in USD (6 decimals) + governancePriceUsd = governanceEthPriceD18.mul(ethUsdPrice).div(1e18); + } + /** * @notice Returns user's balance available for redemption * @param userAddress User address @@ -347,6 +446,27 @@ 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 + */ + function governanceEthPoolAddress() internal view returns (address) { + UbiquityPoolStorage storage poolStorage = ubiquityPoolStorage(); + return poolStorage.governanceEthPoolAddress; + } + //==================== // Public functions //==================== @@ -357,44 +477,79 @@ library LibUbiquityPool { * @param dollarAmount Amount of dollars to mint * @param dollarOutMin Min amount of dollars to mint (slippage protection) * @param maxCollateralIn Max amount of collateral to send (slippage protection) + * @param maxGovernanceIn Max amount of Governance tokens to send (slippage protection) + * @param isOneToOne Force providing only collateral without Governance tokens * @return totalDollarMint Amount of Dollars minted * @return collateralNeeded Amount of collateral sent to the pool + * @return governanceNeeded Amount of Governance tokens burnt from sender */ function mintDollar( uint256 collateralIndex, uint256 dollarAmount, uint256 dollarOutMin, - uint256 maxCollateralIn + uint256 maxCollateralIn, + uint256 maxGovernanceIn, + bool isOneToOne ) internal collateralEnabled(collateralIndex) - returns (uint256 totalDollarMint, uint256 collateralNeeded) + returns ( + uint256 totalDollarMint, + uint256 collateralNeeded, + uint256 governanceNeeded + ) { - UbiquityPoolStorage storage poolStorage = ubiquityPoolStorage(); - require( - poolStorage.isMintPaused[collateralIndex] == false, + ubiquityPoolStorage().isMintPaused[collateralIndex] == false, "Minting is paused" ); - // prevent unnecessary mints require( - getDollarPriceUsd() >= poolStorage.mintPriceThreshold, + getDollarPriceUsd() >= ubiquityPoolStorage().mintPriceThreshold, "Dollar price too low" ); // update collateral price updateChainLinkCollateralPrice(collateralIndex); - // get amount of collateral for minting Dollars - collateralNeeded = getDollarInCollateral(collateralIndex, dollarAmount); - require(collateralNeeded > 0, "Cannot mint with zero collateral"); + // user forces 1-to-1 override or collateral ratio >= 100% + if ( + isOneToOne || + ubiquityPoolStorage().collateralRatio >= + UBIQUITY_POOL_PRICE_PRECISION + ) { + // get amount of collateral for minting Dollars + collateralNeeded = getDollarInCollateral( + collateralIndex, + dollarAmount + ); + governanceNeeded = 0; + } else if (ubiquityPoolStorage().collateralRatio == 0) { + // collateral ratio is 0%, Dollar tokens can be minted by providing only Governance tokens (i.e. fully algorithmic stablecoin) + collateralNeeded = 0; + governanceNeeded = dollarAmount + .mul(UBIQUITY_POOL_PRICE_PRECISION) + .div(getGovernancePriceUsd()); + } else { + // fractional, user has to provide both collateral and Governance tokens + uint256 dollarForCollateral = dollarAmount + .mul(ubiquityPoolStorage().collateralRatio) + .div(UBIQUITY_POOL_PRICE_PRECISION); + uint256 dollarForGovernance = dollarAmount.sub(dollarForCollateral); + collateralNeeded = getDollarInCollateral( + collateralIndex, + dollarForCollateral + ); + governanceNeeded = dollarForGovernance + .mul(UBIQUITY_POOL_PRICE_PRECISION) + .div(getGovernancePriceUsd()); + } // subtract the minting fee totalDollarMint = dollarAmount .mul( UBIQUITY_POOL_PRICE_PRECISION.sub( - poolStorage.mintingFee[collateralIndex] + ubiquityPoolStorage().mintingFee[collateralIndex] ) ) .div(UBIQUITY_POOL_PRICE_PRECISION); @@ -402,23 +557,26 @@ library LibUbiquityPool { // check slippages require((totalDollarMint >= dollarOutMin), "Dollar slippage"); require((collateralNeeded <= maxCollateralIn), "Collateral slippage"); + require((governanceNeeded <= maxGovernanceIn), "Governance slippage"); // check the pool ceiling require( freeCollateralBalance(collateralIndex).add(collateralNeeded) <= - poolStorage.poolCeilings[collateralIndex], + ubiquityPoolStorage().poolCeilings[collateralIndex], "Pool ceiling" ); - // take collateral first - IERC20(poolStorage.collateralAddresses[collateralIndex]) + // burn Governance tokens from sender and send collateral to the pool + IERC20Ubiquity(LibAppStorage.appStorage().governanceTokenAddress) + .burnFrom(msg.sender, governanceNeeded); + IERC20(ubiquityPoolStorage().collateralAddresses[collateralIndex]) .safeTransferFrom(msg.sender, address(this), collateralNeeded); // mint Dollars - IERC20Ubiquity ubiquityDollarToken = IERC20Ubiquity( - LibAppStorage.appStorage().dollarTokenAddress + IERC20Ubiquity(LibAppStorage.appStorage().dollarTokenAddress).mint( + msg.sender, + totalDollarMint ); - ubiquityDollarToken.mint(msg.sender, totalDollarMint); } /** @@ -429,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(); @@ -465,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( @@ -477,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 @@ -489,22 +675,36 @@ 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); } /** - * @notice Used to collect collateral tokens after redeeming/burning Ubiquity Dollars + * @notice Used to collect collateral and Governance tokens after redeeming/burning Ubiquity Dollars * @dev Redeem process is split in two steps: * @dev 1. `redeemDollar()` * @dev 2. `collectRedemption()` * @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 collected + * @return governanceAmount Amount of Governance tokens redeemed * @return collateralAmount Amount of collateral tokens redeemed */ function collectRedemption( @@ -512,7 +712,7 @@ library LibUbiquityPool { ) internal collateralEnabled(collateralIndex) - returns (uint256 collateralAmount) + returns (uint256 governanceAmount, uint256 collateralAmount) { UbiquityPoolStorage storage poolStorage = ubiquityPoolStorage(); @@ -529,8 +729,18 @@ library LibUbiquityPool { "Too soon to collect redemption" ); + bool sendGovernance = false; bool sendCollateral = false; + if (poolStorage.redeemGovernanceBalances[msg.sender] > 0) { + governanceAmount = poolStorage.redeemGovernanceBalances[msg.sender]; + poolStorage.redeemGovernanceBalances[msg.sender] = 0; + poolStorage.unclaimedPoolGovernance = poolStorage + .unclaimedPoolGovernance + .sub(governanceAmount); + sendGovernance = true; + } + if ( poolStorage.redeemCollateralBalances[msg.sender][collateralIndex] > 0 @@ -547,7 +757,11 @@ library LibUbiquityPool { sendCollateral = true; } - // send out the tokens + // send out tokens + if (sendGovernance) { + IERC20(LibAppStorage.appStorage().governanceTokenAddress) + .safeTransfer(msg.sender, governanceAmount); + } if (sendCollateral) { IERC20(poolStorage.collateralAddresses[collateralIndex]) .safeTransfer(msg.sender, collateralAmount); @@ -778,6 +992,45 @@ library LibUbiquityPool { ); } + /** + * @notice Sets collateral ratio + * @dev How much collateral/governance tokens user should provide/get to mint/redeem Dollar tokens, 1e6 precision + * + * @dev Example (1_000_000 = 100%): + * - Mint: user provides 1 collateral token to get 1 Dollar + * - Redeem: user gets 1 collateral token for 1 Dollar + * + * @dev Example (900_000 = 90%): + * - Mint: user provides 0.9 collateral token and 0.1 Governance token to get 1 Dollar + * - Redeem: user gets 0.9 collateral token and 0.1 Governance token for 1 Dollar + * + * @param newCollateralRatio New collateral ratio + */ + function setCollateralRatio(uint256 newCollateralRatio) internal { + UbiquityPoolStorage storage poolStorage = ubiquityPoolStorage(); + + poolStorage.collateralRatio = newCollateralRatio; + + emit CollateralRatioSet(newCollateralRatio); + } + + /** + * @notice Sets chainlink params for ETH/USD price feed + * @param newPriceFeedAddress New chainlink price feed address for ETH/USD pair + * @param newStalenessThreshold New threshold in seconds when chainlink's ETH/USD price feed answer should be considered stale + */ + function setEthUsdChainLinkPriceFeed( + address newPriceFeedAddress, + uint256 newStalenessThreshold + ) internal { + UbiquityPoolStorage storage poolStorage = ubiquityPoolStorage(); + + poolStorage.ethUsdPriceFeedAddress = newPriceFeedAddress; + poolStorage.ethUsdPriceFeedStalenessThreshold = newStalenessThreshold; + + emit EthUsdPriceFeedSet(newPriceFeedAddress, newStalenessThreshold); + } + /** * @notice Sets mint and redeem fees, 1_000_000 = 100% * @param collateralIndex Collateral token index @@ -797,6 +1050,27 @@ library LibUbiquityPool { emit FeesSet(collateralIndex, newMintFee, newRedeemFee); } + /** + * @notice Sets a new pool address for Governance/ETH pair + * + * @dev Based on Curve's CurveTwocryptoOptimized contract. Used for fetching Governance token USD price. + * How it works: + * 1. Fetch Governance/ETH price from CurveTwocryptoOptimized's built-in oracle + * 2. Fetch ETH/USD price from chainlink feed + * 3. Calculate Governance token price in USD + * + * @param newGovernanceEthPoolAddress New pool address for Governance/ETH pair + */ + function setGovernanceEthPoolAddress( + address newGovernanceEthPoolAddress + ) internal { + UbiquityPoolStorage storage poolStorage = ubiquityPoolStorage(); + + poolStorage.governanceEthPoolAddress = newGovernanceEthPoolAddress; + + emit GovernanceEthPoolSet(newGovernanceEthPoolAddress); + } + /** * @notice Sets max amount of collateral for a particular collateral token * @param collateralIndex Collateral token index diff --git a/packages/contracts/src/dollar/mocks/MockCurveTwocryptoOptimized.sol b/packages/contracts/src/dollar/mocks/MockCurveTwocryptoOptimized.sol new file mode 100644 index 000000000..d03c66fa8 --- /dev/null +++ b/packages/contracts/src/dollar/mocks/MockCurveTwocryptoOptimized.sol @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.19; + +import {ICurveTwocryptoOptimized} from "../interfaces/ICurveTwocryptoOptimized.sol"; +import {MockCurveStableSwapMetaNG} from "./MockCurveStableSwapMetaNG.sol"; + +contract MockCurveTwocryptoOptimized is + ICurveTwocryptoOptimized, + MockCurveStableSwapMetaNG +{ + constructor( + address _token0, + address _token1 + ) MockCurveStableSwapMetaNG(_token0, _token1) {} + + function price_oracle() external view returns (uint256) { + return priceOracle; + } +} diff --git a/packages/contracts/test/diamond/DiamondTestSetup.sol b/packages/contracts/test/diamond/DiamondTestSetup.sol index 7073c5ac0..c15c8edfa 100644 --- a/packages/contracts/test/diamond/DiamondTestSetup.sol +++ b/packages/contracts/test/diamond/DiamondTestSetup.sol @@ -446,11 +446,19 @@ abstract contract DiamondTestSetup is DiamondTestHelper, UUPSTestHelper { CREDIT_TOKEN_BURNER_ROLE, address(diamond) ); - // grant diamond token admin 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) + ); // grant diamond token minter rights accessControlFacet.grantRole( STAKING_SHARE_MINTER_ROLE, diff --git a/packages/contracts/test/diamond/facets/UbiquityPoolFacet.t.sol b/packages/contracts/test/diamond/facets/UbiquityPoolFacet.t.sol index 78309d118..ae481d082 100644 --- a/packages/contracts/test/diamond/facets/UbiquityPoolFacet.t.sol +++ b/packages/contracts/test/diamond/facets/UbiquityPoolFacet.t.sol @@ -8,6 +8,7 @@ import {LibUbiquityPool} from "../../../src/dollar/libraries/LibUbiquityPool.sol import {MockChainLinkFeed} from "../../../src/dollar/mocks/MockChainLinkFeed.sol"; import {MockERC20} from "../../../src/dollar/mocks/MockERC20.sol"; import {MockCurveStableSwapMetaNG} from "../../../src/dollar/mocks/MockCurveStableSwapMetaNG.sol"; +import {MockCurveTwocryptoOptimized} from "../../../src/dollar/mocks/MockCurveTwocryptoOptimized.sol"; contract MockDollarAmoMinter is IDollarAmoMinter { function collateralDollarBalance() external pure returns (uint256) { @@ -24,7 +25,10 @@ contract UbiquityPoolFacetTest is DiamondTestSetup { MockERC20 collateralToken; MockChainLinkFeed collateralTokenPriceFeed; MockCurveStableSwapMetaNG curveDollarMetaPool; + MockCurveTwocryptoOptimized curveGovernanceEthPool; MockERC20 curveTriPoolLpToken; + MockChainLinkFeed ethUsdPriceFeed; + MockERC20 wethToken; address user = address(1); @@ -37,12 +41,18 @@ contract UbiquityPoolFacetTest is DiamondTestSetup { uint256 stalenessThreshold ); event CollateralPriceSet(uint256 collateralIndex, uint256 newPrice); + event CollateralRatioSet(uint256 newCollateralRatio); event CollateralToggled(uint256 collateralIndex, bool newState); + event EthUsdPriceFeedSet( + address newPriceFeedAddress, + uint256 newStalenessThreshold + ); event FeesSet( uint256 collateralIndex, uint256 newMintFee, uint256 newRedeemFee ); + event GovernanceEthPoolSet(address newGovernanceEthPoolAddress); event MintRedeemBorrowToggled(uint256 collateralIndex, uint8 toggleIndex); event PoolCeilingSet(uint256 collateralIndex, uint256 newCeiling); event PriceThresholdsSet( @@ -62,6 +72,12 @@ contract UbiquityPoolFacetTest is DiamondTestSetup { // init collateral price feed collateralTokenPriceFeed = new MockChainLinkFeed(); + // init ETH/USD price feed + ethUsdPriceFeed = new MockChainLinkFeed(); + + // init WETH token + wethToken = new MockERC20("WETH", "WETH", 18); + // init Curve 3CRV-LP token curveTriPoolLpToken = new MockERC20("3CRV", "3CRV", 18); @@ -71,6 +87,12 @@ contract UbiquityPoolFacetTest is DiamondTestSetup { address(curveTriPoolLpToken) ); + // init Curve Governance-WETH crypto pool + curveGovernanceEthPool = new MockCurveTwocryptoOptimized( + address(governanceToken), + address(wethToken) + ); + // add collateral token to the pool uint256 poolCeiling = 50_000e18; // max 50_000 of collateral tokens is allowed ubiquityPoolFacet.addCollateralToken( @@ -88,6 +110,18 @@ contract UbiquityPoolFacetTest is DiamondTestSetup { 1 // answered in round ); + // set ETH/USD price feed mock params + ethUsdPriceFeed.updateMockParams( + 1, // round id + 3000_00000000, // answer, 3000_00000000 = $3000 (8 decimals) + block.timestamp, // started at + block.timestamp, // updated at + 1 // answered in round + ); + + // set ETH/Governance price to 30k in Curve pool mock + curveGovernanceEthPool.updateMockParams(30_000e18); + // set price feed for collateral token ubiquityPoolFacet.setCollateralChainLinkPriceFeed( address(collateralToken), // collateral token address @@ -95,6 +129,12 @@ contract UbiquityPoolFacetTest is DiamondTestSetup { 1 days // price feed staleness threshold in seconds ); + // set price feed for ETH/USD pair + ubiquityPoolFacet.setEthUsdChainLinkPriceFeed( + address(ethUsdPriceFeed), // price feed address + 1 days // price feed staleness threshold in seconds + ); + // enable collateral at index 0 ubiquityPoolFacet.toggleCollateral(0); // set mint and redeem fees @@ -107,6 +147,12 @@ contract UbiquityPoolFacetTest is DiamondTestSetup { ubiquityPoolFacet.setRedemptionDelayBlocks(2); // set mint price threshold to $1.01 and redeem price to $0.99 ubiquityPoolFacet.setPriceThresholds(1010000, 990000); + // set collateral ratio to 100% + ubiquityPoolFacet.setCollateralRatio(1_000_000); + // set Governance-ETH pool + ubiquityPoolFacet.setGovernanceEthPoolAddress( + address(curveGovernanceEthPool) + ); // init AMO minter dollarAmoMinter = new MockDollarAmoMinter(); @@ -119,6 +165,8 @@ contract UbiquityPoolFacetTest is DiamondTestSetup { // stop being admin vm.stopPrank(); + // mint 2000 Governance tokens to the user + deal(address(governanceToken), user, 2000e18); // mint 100 collateral tokens to the user collateralToken.mint(address(user), 100e18); // user approves the pool to transfer collateral @@ -140,7 +188,7 @@ contract UbiquityPoolFacetTest is DiamondTestSetup { // user tries to mint Dollars vm.prank(user); vm.expectRevert("Collateral disabled"); - ubiquityPoolFacet.mintDollar(0, 1, 1, 1); + ubiquityPoolFacet.mintDollar(0, 1, 1, 1, 1, false); } function testOnlyAmoMinter_ShouldRevert_IfCalledNoByAmoMinter() public { @@ -197,6 +245,11 @@ contract UbiquityPoolFacetTest is DiamondTestSetup { assertEq(info.redemptionFee, 20000); } + function testCollateralRatio_ShouldReturnCollateralRatio() public { + uint256 collateralRatio = ubiquityPoolFacet.collateralRatio(); + assertEq(collateralRatio, 1_000_000); + } + function testCollateralUsdBalance_ShouldReturnTotalAmountOfCollateralInUsd() public { @@ -212,13 +265,24 @@ contract UbiquityPoolFacetTest is DiamondTestSetup { 0, // collateral index 100e18, // Dollar amount 99e18, // min amount of Dollars to mint - 100e18 // max collateral to send + 100e18, // max collateral to send, + 0, // max Governance tokens to send + false // force 1-to-1 mint (i.e. provide only collateral without Governance tokens) ); uint256 balanceTally = ubiquityPoolFacet.collateralUsdBalance(); assertEq(balanceTally, 100e18); } + function testEthUsdPriceFeedInformation_ShouldReturnEthUsdPriceFeedInformation() + public + { + (address priceFeed, uint256 stalenessThreshold) = ubiquityPoolFacet + .ethUsdPriceFeedInformation(); + assertEq(priceFeed, address(ethUsdPriceFeed)); + assertEq(stalenessThreshold, 1 days); + } + function testFreeCollateralBalance_ShouldReturnCollateralAmountAvailableForBorrowingByAmoMinters() public { @@ -234,7 +298,9 @@ contract UbiquityPoolFacetTest is DiamondTestSetup { 0, // collateral index 100e18, // Dollar amount 99e18, // min amount of Dollars to mint - 100e18 // max collateral to send + 100e18, // max collateral to send + 0, // max Governance tokens to send + false // force 1-to-1 mint (i.e. provide only collateral without Governance tokens) ); // user redeems 99 Dollars for 97.02 (accounts for 2% redemption fee) collateral tokens @@ -242,6 +308,7 @@ contract UbiquityPoolFacetTest is DiamondTestSetup { ubiquityPoolFacet.redeemDollar( 0, // collateral index 99e18, // Dollar amount + 0, // min Governance out 90e18 // min collateral out ); @@ -263,6 +330,50 @@ contract UbiquityPoolFacetTest is DiamondTestSetup { assertEq(dollarPriceUsd, 1_000_000); } + function testGetGovernancePriceUsd_ShouldRevertOnInvalidChainlinkAnswer() + public + { + // set invalid answer from chainlink + ethUsdPriceFeed.updateMockParams( + 1, // round id + 0, // invalid answer + block.timestamp, // started at + block.timestamp, // updated at + 1 // answered in round + ); + + vm.expectRevert("Invalid price"); + ubiquityPoolFacet.getGovernancePriceUsd(); + } + + function testGetGovernancePriceUsd_ShouldRevertIfChainlinkAnswerIsStale() + public + { + // set stale answer from chainlink + collateralTokenPriceFeed.updateMockParams( + 1, // round id + 100_000_000, // answer, 100_000_000 = $1.00 + block.timestamp, // started at + block.timestamp, // updated at + 1 // answered in round + ); + + // wait 1 day + vm.warp(block.timestamp + 1 days); + + vm.expectRevert("Stale data"); + ubiquityPoolFacet.getGovernancePriceUsd(); + } + + function testGetGovernancePriceUsd_ShouldReturnGovernanceTokenPriceInUsd() + public + { + uint256 governancePriceUsd = ubiquityPoolFacet.getGovernancePriceUsd(); + // 1 ETH = $3000, 1 ETH = 30_000 Governance tokens + // Governance token USD price = (1 / 30000) * 3000 = 0.1 + assertEq(governancePriceUsd, 99999); // ~$0.09 + } + function testGetRedeemCollateralBalance_ShouldReturnRedeemCollateralBalance() public { @@ -278,7 +389,9 @@ contract UbiquityPoolFacetTest is DiamondTestSetup { 0, // collateral index 100e18, // Dollar amount 99e18, // min amount of Dollars to mint - 100e18 // max collateral to send + 100e18, // max collateral to send + 0, // max Governance tokens to send + false // force 1-to-1 mint (i.e. provide only collateral without Governance tokens) ); // user redeems 99 Dollars for 97.02 (accounts for 2% redemption fee) collateral tokens @@ -286,6 +399,7 @@ contract UbiquityPoolFacetTest is DiamondTestSetup { ubiquityPoolFacet.redeemDollar( 0, // collateral index 99e18, // Dollar amount + 0, // min Governance out 90e18 // min collateral out ); @@ -294,6 +408,53 @@ 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 + { + address governanceEthPoolAddress = ubiquityPoolFacet + .governanceEthPoolAddress(); + assertEq(governanceEthPoolAddress, address(curveGovernanceEthPool)); + } + //==================== // Public functions //==================== @@ -309,7 +470,9 @@ contract UbiquityPoolFacetTest is DiamondTestSetup { 0, // collateral index 100e18, // Dollar amount 90e18, // min amount of Dollars to mint - 100e18 // max collateral to send + 100e18, // max collateral to send + 0, // max Governance tokens to send + false // force 1-to-1 mint (i.e. provide only collateral without Governance tokens) ); } @@ -320,32 +483,32 @@ contract UbiquityPoolFacetTest is DiamondTestSetup { 0, // collateral index 100e18, // Dollar amount 90e18, // min amount of Dollars to mint - 100e18 // max collateral to send + 100e18, // max collateral to send + 0, // max Governance tokens to send + false // force 1-to-1 mint (i.e. provide only collateral without Governance tokens) ); } - function testMintDollar_ShouldRevert_IfZeroCollateralAvailable() public { + function testMintDollar_ShouldRevert_OnDollarAmountSlippage() public { vm.prank(admin); ubiquityPoolFacet.setPriceThresholds( 1000000, // mint threshold - 1000000 // redeem threshold + 990000 // redeem threshold ); - // reset collateral fees to 0 - vm.prank(admin); - ubiquityPoolFacet.setFees(0, 0, 0); - // user tries to mint with zero collateral vm.prank(user); - vm.expectRevert("Cannot mint with zero collateral"); + vm.expectRevert("Dollar slippage"); ubiquityPoolFacet.mintDollar( 0, // collateral index - 0, // Dollar amount + 100e18, // Dollar amount 100e18, // min amount of Dollars to mint - 0 // max collateral to send + 100e18, // max collateral to send + 0, // max Governance tokens to send + false // force 1-to-1 mint (i.e. provide only collateral without Governance tokens) ); } - function testMintDollar_ShouldRevert_OnDollarAmountSlippage() public { + function testMintDollar_ShouldRevert_OnCollateralAmountSlippage() public { vm.prank(admin); ubiquityPoolFacet.setPriceThresholds( 1000000, // mint threshold @@ -353,29 +516,37 @@ contract UbiquityPoolFacetTest is DiamondTestSetup { ); vm.prank(user); - vm.expectRevert("Dollar slippage"); + vm.expectRevert("Collateral slippage"); ubiquityPoolFacet.mintDollar( 0, // collateral index 100e18, // Dollar amount - 100e18, // min amount of Dollars to mint - 100e18 // max collateral to send + 90e18, // min amount of Dollars to mint + 10e18, // max collateral to send + 0, // max Governance tokens to send + false // force 1-to-1 mint (i.e. provide only collateral without Governance tokens) ); } - function testMintDollar_ShouldRevert_OnCollateralAmountSlippage() public { + function testMintDollar_ShouldRevert_OnGovernanceAmountSlippage() public { vm.prank(admin); ubiquityPoolFacet.setPriceThresholds( 1000000, // mint threshold 990000 // redeem threshold ); + // admin sets collateral ratio to 0% + vm.prank(admin); + ubiquityPoolFacet.setCollateralRatio(0); + vm.prank(user); - vm.expectRevert("Collateral slippage"); + vm.expectRevert("Governance slippage"); ubiquityPoolFacet.mintDollar( 0, // collateral index 100e18, // Dollar amount 90e18, // min amount of Dollars to mint - 10e18 // max collateral to send + 10e18, // max collateral to send + 0, // max Governance tokens to send + false // force 1-to-1 mint (i.e. provide only collateral without Governance tokens) ); } @@ -392,35 +563,167 @@ contract UbiquityPoolFacetTest is DiamondTestSetup { 0, // collateral index 60_000e18, // Dollar amount 59_000e18, // min amount of Dollars to mint - 60_000e18 // max collateral to send + 60_000e18, // max collateral to send + 0, // max Governance tokens to send + false // force 1-to-1 mint (i.e. provide only collateral without Governance tokens) ); } - function testMintDollar_ShouldMintDollars() public { + function testMintDollar_ShouldMintDollars_IfUserForcesOneToOneOverride() + public + { vm.prank(admin); ubiquityPoolFacet.setPriceThresholds( 1000000, // mint threshold 990000 // redeem threshold ); + // admin sets collateral ratio to 0% + vm.prank(admin); + ubiquityPoolFacet.setCollateralRatio(0); + // balances before assertEq(collateralToken.balanceOf(address(ubiquityPoolFacet)), 0); assertEq(dollarToken.balanceOf(user), 0); + assertEq(governanceToken.balanceOf(user), 2000e18); vm.prank(user); - (uint256 totalDollarMint, uint256 collateralNeeded) = ubiquityPoolFacet - .mintDollar( + ( + uint256 totalDollarMint, + uint256 collateralNeeded, + uint256 governanceNeeded + ) = ubiquityPoolFacet.mintDollar( 0, // collateral index 100e18, // Dollar amount 99e18, // min amount of Dollars to mint - 100e18 // max collateral to send + 100e18, // max collateral to send + 1100e18, // max Governance tokens to send + true // force 1-to-1 mint (i.e. provide only collateral without Governance tokens) ); + assertEq(totalDollarMint, 99e18); assertEq(collateralNeeded, 100e18); + assertEq(governanceNeeded, 0); // balances after assertEq(collateralToken.balanceOf(address(ubiquityPoolFacet)), 100e18); assertEq(dollarToken.balanceOf(user), 99e18); + assertEq(governanceToken.balanceOf(user), 2000e18); + } + + function testMintDollar_ShouldMintDollars_IfCollateralRatioIs100() public { + vm.prank(admin); + ubiquityPoolFacet.setPriceThresholds( + 1000000, // mint threshold + 990000 // redeem threshold + ); + + // balances before + assertEq(collateralToken.balanceOf(address(ubiquityPoolFacet)), 0); + assertEq(dollarToken.balanceOf(user), 0); + assertEq(governanceToken.balanceOf(user), 2000e18); + + vm.prank(user); + ( + uint256 totalDollarMint, + uint256 collateralNeeded, + uint256 governanceNeeded + ) = ubiquityPoolFacet.mintDollar( + 0, // collateral index + 100e18, // Dollar amount + 99e18, // min amount of Dollars to mint + 100e18, // max collateral to send + 0, // max Governance tokens to send + false // force 1-to-1 mint (i.e. provide only collateral without Governance tokens) + ); + assertEq(totalDollarMint, 99e18); + assertEq(collateralNeeded, 100e18); + assertEq(governanceNeeded, 0); + + // balances after + assertEq(collateralToken.balanceOf(address(ubiquityPoolFacet)), 100e18); + assertEq(dollarToken.balanceOf(user), 99e18); + assertEq(governanceToken.balanceOf(user), 2000e18); + } + + function testMintDollar_ShouldMintDollars_IfCollateralRatioIs0() public { + vm.prank(admin); + ubiquityPoolFacet.setPriceThresholds( + 1000000, // mint threshold + 990000 // redeem threshold + ); + + // admin sets collateral ratio to 0% + vm.prank(admin); + ubiquityPoolFacet.setCollateralRatio(0); + + // balances before + assertEq(collateralToken.balanceOf(address(ubiquityPoolFacet)), 0); + assertEq(dollarToken.balanceOf(user), 0); + assertEq(governanceToken.balanceOf(user), 2000e18); + + vm.prank(user); + ( + uint256 totalDollarMint, + uint256 collateralNeeded, + uint256 governanceNeeded + ) = 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) + ); + + assertEq(totalDollarMint, 99e18); + assertEq(collateralNeeded, 0); + assertEq(governanceNeeded, 1000010000100001000010); // ~1000.01 = 100 Dollar * $0.1 Governance from oracle + + // balances after + assertEq(collateralToken.balanceOf(address(ubiquityPoolFacet)), 0); + assertEq(dollarToken.balanceOf(user), 99e18); + assertEq(governanceToken.balanceOf(user), 2000e18 - governanceNeeded); + } + + function testMintDollar_ShouldMintDollars_IfCollateralRatioIs95() public { + vm.prank(admin); + ubiquityPoolFacet.setPriceThresholds( + 1000000, // mint threshold + 990000 // redeem threshold + ); + + // admin sets collateral ratio to 95% + vm.prank(admin); + ubiquityPoolFacet.setCollateralRatio(950_000); + + // balances before + assertEq(collateralToken.balanceOf(address(ubiquityPoolFacet)), 0); + assertEq(dollarToken.balanceOf(user), 0); + assertEq(governanceToken.balanceOf(user), 2000e18); + + vm.prank(user); + ( + uint256 totalDollarMint, + uint256 collateralNeeded, + uint256 governanceNeeded + ) = 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) + ); + + assertEq(totalDollarMint, 99e18); + assertEq(collateralNeeded, 95e18); + assertEq(governanceNeeded, 50000500005000050000); // ~50 Governance tokens = $5 USD / $0.1 Governance from oracle + + // balances after + assertEq(collateralToken.balanceOf(address(ubiquityPoolFacet)), 95e18); + assertEq(dollarToken.balanceOf(user), 99e18); + assertEq(governanceToken.balanceOf(user), 2000e18 - governanceNeeded); } function testRedeemDollar_ShouldRevert_IfRedeemingIsPaused() public { @@ -433,6 +736,7 @@ contract UbiquityPoolFacetTest is DiamondTestSetup { ubiquityPoolFacet.redeemDollar( 0, // collateral index 100e18, // Dollar amount + 0, // min Governance out 90e18 // min collateral out ); } @@ -443,6 +747,7 @@ contract UbiquityPoolFacetTest is DiamondTestSetup { ubiquityPoolFacet.redeemDollar( 0, // collateral index 100e18, // Dollar amount + 0, // min Governance out 90e18 // min collateral out ); } @@ -461,6 +766,7 @@ contract UbiquityPoolFacetTest is DiamondTestSetup { ubiquityPoolFacet.redeemDollar( 0, // collateral index 100e18, // Dollar amount + 0, // min Governance out 90e18 // min collateral out ); } @@ -478,7 +784,9 @@ contract UbiquityPoolFacetTest is DiamondTestSetup { 0, // collateral index 100e18, // Dollar amount 99e18, // min amount of Dollars to mint - 100e18 // max collateral to send + 100e18, // max collateral to send + 0, // max Governance tokens to send + false // force 1-to-1 mint (i.e. provide only collateral without Governance tokens) ); vm.prank(user); @@ -486,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 @@ -503,21 +846,144 @@ contract UbiquityPoolFacetTest is DiamondTestSetup { 0, // collateral index 100e18, // Dollar amount 99e18, // min amount of Dollars to mint - 100e18 // max collateral to send + 100e18, // max collateral to send + 0, // 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), 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 { @@ -545,20 +1011,27 @@ contract UbiquityPoolFacetTest is DiamondTestSetup { 1000000 // redeem threshold ); - // user sends 100 collateral tokens and gets 99 Dollars (-1% mint fee) + // 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 + 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) ); - // user redeems 99 Dollars for collateral + // user redeems 99 Dollars for collateral and Governance tokens vm.prank(user); ubiquityPoolFacet.redeemDollar( 0, // collateral index 99e18, // Dollar amount + 0, // min Governance out 90e18 // min collateral out ); @@ -566,19 +1039,28 @@ contract UbiquityPoolFacetTest is DiamondTestSetup { vm.roll(block.number + 3); // balances before - assertEq(collateralToken.balanceOf(address(ubiquityPoolFacet)), 100e18); - assertEq(collateralToken.balanceOf(user), 0); + assertEq(collateralToken.balanceOf(address(ubiquityPoolFacet)), 95e18); + assertEq(collateralToken.balanceOf(user), 5e18); + assertEq( + governanceToken.balanceOf(address(ubiquityPoolFacet)), + 48510485104851048510 + ); // ~48 + assertEq(governanceToken.balanceOf(user), 1949999499994999950000); // ~1950 vm.prank(user); - uint256 collateralAmount = ubiquityPoolFacet.collectRedemption(0); - assertEq(collateralAmount, 97.02e18); // $99 - 2% redemption fee + (uint256 governanceAmount, uint256 collateralAmount) = ubiquityPoolFacet + .collectRedemption(0); + assertEq(governanceAmount, 48510485104851048510); // ~48 + assertEq(collateralAmount, 92169000000000000000); // ~92 = $95 - 2% redemption fee // balances after assertEq( collateralToken.balanceOf(address(ubiquityPoolFacet)), - 2.98e18 - ); - assertEq(collateralToken.balanceOf(user), 97.02e18); + 2.831 ether + ); // redemption fee left in the pool + assertEq(collateralToken.balanceOf(user), 97.169 ether); + assertEq(governanceToken.balanceOf(address(ubiquityPoolFacet)), 0); + assertEq(governanceToken.balanceOf(user), 1998509985099850998510); // ~1998 } function testCollectRedemption_ShouldRevert_IfCollateralDisabled() public { @@ -593,13 +1075,16 @@ contract UbiquityPoolFacetTest is DiamondTestSetup { 0, // collateral index 100e18, // Dollar amount 99e18, // min amount of Dollars to mint - 100e18 // max collateral to send + 100e18, // max collateral to send + 0, // max Governance tokens to send + false // force 1-to-1 mint (i.e. provide only collateral without Governance tokens) ); vm.prank(user); ubiquityPoolFacet.redeemDollar( 0, // collateral index 99e18, // Dollar amount + 0, // min Governance out 90e18 // min collateral out ); @@ -717,7 +1202,9 @@ contract UbiquityPoolFacetTest is DiamondTestSetup { 0, // collateral index 100e18, // Dollar amount 99e18, // min amount of Dollars to mint - 100e18 // max collateral to send + 100e18, // max collateral to send + 0, // max Governance tokens to send + false // force 1-to-1 mint (i.e. provide only collateral without Governance tokens) ); // user redeems 99 Dollars for 97.02 (accounts for 2% redemption fee) collateral tokens @@ -725,6 +1212,7 @@ contract UbiquityPoolFacetTest is DiamondTestSetup { ubiquityPoolFacet.redeemDollar( 0, // collateral index 99e18, // Dollar amount + 0, // min Governance out 90e18 // min collateral out ); @@ -883,6 +1371,53 @@ contract UbiquityPoolFacetTest is DiamondTestSetup { vm.stopPrank(); } + function testSetCollateralRatio_ShouldSetCollateralRatio() public { + vm.startPrank(admin); + + uint256 oldCollateralRatio = ubiquityPoolFacet.collateralRatio(); + assertEq(oldCollateralRatio, 1_000_000); + + uint256 newCollateralRatio = 900_000; + vm.expectEmit(address(ubiquityPoolFacet)); + emit CollateralRatioSet(newCollateralRatio); + ubiquityPoolFacet.setCollateralRatio(newCollateralRatio); + + assertEq(ubiquityPoolFacet.collateralRatio(), newCollateralRatio); + + vm.stopPrank(); + } + + function testSetEthUsdChainLinkPriceFeed_ShouldSetEthUsdChainLinkPriceFeed() + public + { + vm.startPrank(admin); + + ( + address oldPriceFeedAddress, + uint256 oldStalenessThreshold + ) = ubiquityPoolFacet.ethUsdPriceFeedInformation(); + assertEq(oldPriceFeedAddress, address(ethUsdPriceFeed)); + assertEq(oldStalenessThreshold, 1 days); + + address newPriceFeedAddress = address(1); + uint256 newStalenessThreshold = 2 days; + vm.expectEmit(address(ubiquityPoolFacet)); + emit EthUsdPriceFeedSet(newPriceFeedAddress, newStalenessThreshold); + ubiquityPoolFacet.setEthUsdChainLinkPriceFeed( + newPriceFeedAddress, + newStalenessThreshold + ); + + ( + address updatedPriceFeedAddress, + uint256 updatedStalenessThreshold + ) = ubiquityPoolFacet.ethUsdPriceFeedInformation(); + assertEq(updatedPriceFeedAddress, newPriceFeedAddress); + assertEq(updatedStalenessThreshold, newStalenessThreshold); + + vm.stopPrank(); + } + function testSetFees_ShouldSetMintAndRedeemFees() public { vm.startPrank(admin); @@ -893,6 +1428,30 @@ contract UbiquityPoolFacetTest is DiamondTestSetup { vm.stopPrank(); } + function testSetGovernanceEthPoolAddress_ShouldSetGovernanceEthPoolAddress() + public + { + vm.startPrank(admin); + + address oldGovernanceEthPoolAddress = ubiquityPoolFacet + .governanceEthPoolAddress(); + assertEq(oldGovernanceEthPoolAddress, address(curveGovernanceEthPool)); + + address newGovernanceEthPoolAddress = address(1); + vm.expectEmit(address(ubiquityPoolFacet)); + emit GovernanceEthPoolSet(newGovernanceEthPoolAddress); + ubiquityPoolFacet.setGovernanceEthPoolAddress( + newGovernanceEthPoolAddress + ); + + assertEq( + ubiquityPoolFacet.governanceEthPoolAddress(), + newGovernanceEthPoolAddress + ); + + vm.stopPrank(); + } + function testSetPoolCeiling_ShouldSetMaxAmountOfTokensAllowedForCollateral() public {