From 053cb3237f98822f41720b0ff57335737c6f6c1d Mon Sep 17 00:00:00 2001 From: ohmzeus <93612359+chefomi@users.noreply.github.com> Date: Fri, 29 Nov 2024 08:10:29 -0500 Subject: [PATCH 01/64] Create CDsEmissions.sol --- src/policies/CDsEmissions.sol | 469 ++++++++++++++++++++++++++++++++++ 1 file changed, 469 insertions(+) create mode 100644 src/policies/CDsEmissions.sol diff --git a/src/policies/CDsEmissions.sol b/src/policies/CDsEmissions.sol new file mode 100644 index 00000000..d8d1718a --- /dev/null +++ b/src/policies/CDsEmissions.sol @@ -0,0 +1,469 @@ +// SPDX-License-Identifier: AGPL-3.0 +pragma solidity 0.8.15; + +import "src/Kernel.sol"; + +import {ReentrancyGuard} from "solmate/utils/ReentrancyGuard.sol"; +import {ERC20} from "solmate/tokens/ERC20.sol"; +import {ERC4626} from "solmate/mixins/ERC4626.sol"; + +import {FullMath} from "libraries/FullMath.sol"; + +import {IBondSDA} from "interfaces/IBondSDA.sol"; +import {IgOHM} from "interfaces/IgOHM.sol"; + +import {RolesConsumer, ROLESv1} from "modules/ROLES/OlympusRoles.sol"; +import {TRSRYv1} from "modules/TRSRY/TRSRY.v1.sol"; +import {PRICEv1} from "modules/PRICE/PRICE.v1.sol"; +import {MINTRv1} from "modules/MINTR/MINTR.v1.sol"; +import {CHREGv1} from "modules/CHREG/CHREG.v1.sol"; + +interface BurnableERC20 { + function burn(uint256 amount) external; +} + +interface Clearinghouse { + function principalReceivables() external view returns (uint256); +} + +contract EmissionManager is Policy, RolesConsumer { + using FullMath for uint256; + + // ========== ERRORS ========== // + + error OnlyTeller(); + error InvalidMarket(); + error InvalidCallback(); + error InvalidParam(string parameter); + error CannotRestartYet(uint48 availableAt); + error RestartTimeframePassed(); + error NotActive(); + error AlreadyActive(); + + // ========== EVENTS ========== // + + event SaleCreated(uint256 marketID, uint256 saleAmount); + event BackingUpdated(uint256 newBacking, uint256 supplyAdded, uint256 reservesAdded); + + // ========== DATA STRUCTURES ========== // + + struct BaseRateChange { + uint256 changeBy; + uint48 beatsLeft; + bool addition; + } + + // ========== STATE VARIABLES ========== // + + /// @notice active base emissions rate change information + /// @dev active until beatsLeft is 0 + BaseRateChange public rateChange; + + // Modules + TRSRYv1 public TRSRY; + PRICEv1 public PRICE; + MINTRv1 public MINTR; + CHREGv1 public CHREG; + + // Tokens + // solhint-disable const-name-snakecase + ERC20 public immutable ohm; + IgOHM public immutable gohm; + ERC20 public immutable reserve; + ERC4626 public immutable sReserve; + + // External contracts + IBondSDA public auctioneer; + address public teller; + + // Manager variables + uint256 public baseEmissionRate; + uint256 public minimumPremium; + uint48 public vestingPeriod; // initialized at 0 + uint256 public backing; + uint8 public beatCounter; + bool public locallyActive; + uint256 public activeMarketId; + + uint8 internal _oracleDecimals; + uint8 internal immutable _ohmDecimals; + uint8 internal immutable _reserveDecimals; + + /// @notice timestamp of last shutdown + uint48 public shutdownTimestamp; + /// @notice time in seconds that the manager needs to be restarted after a shutdown, otherwise it must be re-initialized + uint48 public restartTimeframe; + + uint256 internal constant ONE_HUNDRED_PERCENT = 1e18; + + // ========== SETUP ========== // + + constructor( + Kernel kernel_, + address ohm_, + address gohm_, + address reserve_, + address sReserve_, + address auctioneer_, + address teller_ + ) Policy(kernel_) { + // Set immutable variables + if (ohm_ == address(0)) revert("OHM address cannot be 0"); + if (gohm_ == address(0)) revert("gOHM address cannot be 0"); + if (reserve_ == address(0)) revert("DAI address cannot be 0"); + if (sReserve_ == address(0)) revert("sDAI address cannot be 0"); + if (auctioneer_ == address(0)) revert("Auctioneer address cannot be 0"); + + ohm = ERC20(ohm_); + gohm = IgOHM(gohm_); + reserve = ERC20(reserve_); + sReserve = ERC4626(sReserve_); + auctioneer = IBondSDA(auctioneer_); + teller = teller_; + + _ohmDecimals = ohm.decimals(); + _reserveDecimals = reserve.decimals(); + + // Max approve sReserve contract for reserve for deposits + reserve.approve(address(sReserve), type(uint256).max); + } + + function configureDependencies() external override returns (Keycode[] memory dependencies) { + dependencies = new Keycode[](5); + dependencies[0] = toKeycode("TRSRY"); + dependencies[1] = toKeycode("PRICE"); + dependencies[2] = toKeycode("MINTR"); + dependencies[3] = toKeycode("CHREG"); + dependencies[4] = toKeycode("ROLES"); + + TRSRY = TRSRYv1(getModuleAddress(dependencies[0])); + PRICE = PRICEv1(getModuleAddress(dependencies[1])); + MINTR = MINTRv1(getModuleAddress(dependencies[2])); + CHREG = CHREGv1(getModuleAddress(dependencies[3])); + ROLES = ROLESv1(getModuleAddress(dependencies[4])); + + _oracleDecimals = PRICE.decimals(); + } + + function requestPermissions() + external + view + override + returns (Permissions[] memory permissions) + { + Keycode mintrKeycode = toKeycode("MINTR"); + + permissions = new Permissions[](2); + permissions[0] = Permissions(mintrKeycode, MINTR.increaseMintApproval.selector); + permissions[1] = Permissions(mintrKeycode, MINTR.mintOhm.selector); + } + + // ========== HEARTBEAT ========== // + + /// @notice calculate and execute sale, if applicable, once per day (every 3 beats) + function execute() external onlyRole("heart") { + if (!locallyActive) return; + + beatCounter = ++beatCounter % 3; + if (beatCounter != 0) return; + + if (rateChange.beatsLeft != 0) { + --rateChange.beatsLeft; + if (rateChange.addition) baseEmissionRate += rateChange.changeBy; + else baseEmissionRate -= rateChange.changeBy; + } + + // It then calculates the amount to sell for the coming day + (, , uint256 sell) = getNextSale(); + + // And then opens a market if applicable + if (sell != 0) { + MINTR.increaseMintApproval(address(this), sell); + _createMarket(sell); + } + } + + // ========== INITIALIZE ========== // + + /// @notice allow governance to initialize the emission manager + /// @param baseEmissionsRate_ percent of OHM supply to issue per day at the minimum premium, in OHM scale, i.e. 1e9 = 100% + /// @param minimumPremium_ minimum premium at which to issue OHM, a percentage where 1e18 is 100% + /// @param backing_ backing price of OHM in reserve token, in reserve scale + /// @param restartTimeframe_ time in seconds that the manager needs to be restarted after a shutdown, otherwise it must be re-initialized + function initialize( + uint256 baseEmissionsRate_, + uint256 minimumPremium_, + uint256 backing_, + uint48 restartTimeframe_ + ) external onlyRole("emissions_admin") { + // Cannot initialize if currently active + if (locallyActive) revert AlreadyActive(); + + // Cannot initialize if the restart timeframe hasn't passed since the shutdown timestamp + // This is specific to re-initializing after a shutdown + // It will not revert on the first initialization since both values will be zero + if (shutdownTimestamp + restartTimeframe > uint48(block.timestamp)) + revert CannotRestartYet(shutdownTimestamp + restartTimeframe); + + // Validate inputs + if (baseEmissionsRate_ == 0) revert InvalidParam("baseEmissionRate"); + if (minimumPremium_ == 0) revert InvalidParam("minimumPremium"); + if (backing_ == 0) revert InvalidParam("backing"); + if (restartTimeframe_ == 0) revert InvalidParam("restartTimeframe"); + + // Assign + baseEmissionRate = baseEmissionsRate_; + minimumPremium = minimumPremium_; + backing = backing_; + restartTimeframe = restartTimeframe_; + + // Activate + locallyActive = true; + } + + // ========== BOND CALLBACK ========== // + + /// @notice callback function for bond market, only callable by the teller + function callback(uint256 id_, uint256 inputAmount_, uint256 outputAmount_) external { + // Only callable by the bond teller + if (msg.sender != teller) revert OnlyTeller(); + + // Market ID must match the active market ID stored locally, otherwise revert + if (id_ != activeMarketId) revert InvalidMarket(); + + // Reserve balance should have increased by atleast the input amount + uint256 reserveBalance = reserve.balanceOf(address(this)); + if (reserveBalance < inputAmount_) revert InvalidCallback(); + + // Update backing value with the new reserves added and supply added + // We do this before depositing the received reserves and minting the output amount of OHM + // so that the getReserves and getSupply values equal the "previous" values + // This also conforms to the CEI pattern + _updateBacking(outputAmount_, inputAmount_); + + // Deposit the reserve balance into the sReserve contract with the TRSRY as the recipient + // This will sweep any excess reserves into the TRSRY as well + sReserve.deposit(reserveBalance, address(TRSRY)); + + // Mint the output amount of OHM to the Teller + MINTR.mintOhm(teller, outputAmount_); + } + + // ========== INTERNAL FUNCTIONS ========== // + + /// @notice create bond protocol market with given budget + /// @param saleAmount amount of DAI to fund bond market with + function _createMarket(uint256 saleAmount) internal { + // Calculate scaleAdjustment for bond market + // Price decimals are returned from the perspective of the quote token + // so the operations assume payoutPriceDecimal is zero and quotePriceDecimals + // is the priceDecimal value + uint256 minPrice = ((ONE_HUNDRED_PERCENT + minimumPremium) * backing) / + 10 ** _reserveDecimals; + int8 priceDecimals = _getPriceDecimals(minPrice); + int8 scaleAdjustment = int8(_ohmDecimals) - int8(_reserveDecimals) + (priceDecimals / 2); + + // Calculate oracle scale and bond scale with scale adjustment and format prices for bond market + uint256 oracleScale = 10 ** uint8(int8(_oracleDecimals) - priceDecimals); + uint256 bondScale = 10 ** + uint8( + 36 + scaleAdjustment + int8(_reserveDecimals) - int8(_ohmDecimals) - priceDecimals + ); + + // Create new bond market to buy the reserve with OHM + activeMarketId = auctioneer.createMarket( + abi.encode( + IBondSDA.MarketParams({ + payoutToken: ohm, + quoteToken: reserve, + callbackAddr: address(this), + capacityInQuote: false, + capacity: saleAmount, + formattedInitialPrice: PRICE.getLastPrice().mulDiv(bondScale, oracleScale), + formattedMinimumPrice: minPrice.mulDiv(bondScale, oracleScale), + debtBuffer: 100_000, // 100% + vesting: vestingPeriod, + conclusion: uint48(block.timestamp + 1 days), // 1 day from now + depositInterval: uint32(4 hours), // 4 hours + scaleAdjustment: scaleAdjustment + }) + ) + ); + + emit SaleCreated(activeMarketId, saleAmount); + } + + /// @notice allow emission manager to update backing price based on new supply and reserves added + /// @param supplyAdded number of new OHM minted + /// @param reservesAdded number of new DAI added + function _updateBacking(uint256 supplyAdded, uint256 reservesAdded) internal { + uint256 previousReserves = getReserves(); + uint256 previousSupply = getSupply(); + + uint256 percentIncreaseReserves = ((previousReserves + reservesAdded) * + 10 ** _reserveDecimals) / previousReserves; + uint256 percentIncreaseSupply = ((previousSupply + supplyAdded) * 10 ** _reserveDecimals) / + previousSupply; // scaled to reserve decimals to match + + backing = + (backing * percentIncreaseReserves) / // price multiplied by percent increase reserves in reserve scale + percentIncreaseSupply; // divided by percent increase supply in reserve scale + + // Emit event to track backing changes and results of sales offchain + emit BackingUpdated(backing, supplyAdded, reservesAdded); + } + + /// @notice Helper function to calculate number of price decimals based on the value returned from the price feed. + /// @param price_ The price to calculate the number of decimals for + /// @return The number of decimals + function _getPriceDecimals(uint256 price_) internal view returns (int8) { + int8 decimals; + while (price_ >= 10) { + price_ = price_ / 10; + decimals++; + } + + // Subtract the stated decimals from the calculated decimals to get the relative price decimals. + // Required to do it this way vs. normalizing at the beginning since price decimals can be negative. + return decimals - int8(_oracleDecimals); + } + + // ========== ADMIN FUNCTIONS ========== // + + /// @notice shutdown the emission manager locally, burn OHM, and return any reserves to TRSRY + function shutdown() external onlyRole("emergency_shutdown") { + locallyActive = false; + shutdownTimestamp = uint48(block.timestamp); + + uint256 ohmBalance = ohm.balanceOf(address(this)); + if (ohmBalance > 0) BurnableERC20(address(ohm)).burn(ohmBalance); + + uint256 reserveBalance = reserve.balanceOf(address(this)); + if (reserveBalance > 0) sReserve.deposit(reserveBalance, address(TRSRY)); + } + + /// @notice restart the emission manager locally + function restart() external onlyRole("emergency_restart") { + // Restart can be activated only within the specified timeframe since shutdown + // Outside of this span of time, emissions_admin must reinitialize + if (uint48(block.timestamp) >= shutdownTimestamp + restartTimeframe) + revert RestartTimeframePassed(); + + locallyActive = true; + } + + /// @notice set the base emissions rate + /// @param changeBy_ uint256 added or subtracted from baseEmissionRate + /// @param forNumBeats_ uint256 number of times to change baseEmissionRate by changeBy_ + /// @param add bool determining addition or subtraction to baseEmissionRate + function changeBaseRate( + uint256 changeBy_, + uint48 forNumBeats_, + bool add + ) external onlyRole("emissions_admin") { + // Prevent underflow on negative adjustments + if (!add && (changeBy_ * forNumBeats_ > baseEmissionRate)) + revert InvalidParam("changeBy * forNumBeats"); + + // Prevent overflow on positive adjustments + if (add && (type(uint256).max - changeBy_ * forNumBeats_ < baseEmissionRate)) + revert InvalidParam("changeBy * forNumBeats"); + + rateChange = BaseRateChange(changeBy_, forNumBeats_, add); + } + + /// @notice set the minimum premium for emissions + /// @param newMinimumPremium_ uint256 + function setMinimumPremium(uint256 newMinimumPremium_) external onlyRole("emissions_admin") { + if (newMinimumPremium_ == 0) revert InvalidParam("newMinimumPremium"); + + minimumPremium = newMinimumPremium_; + } + + /// @notice set the new vesting period in seconds + /// @param newVestingPeriod_ uint48 + function setVestingPeriod(uint48 newVestingPeriod_) external onlyRole("emissions_admin") { + // Verify that the vesting period isn't more than a year + // This check helps ensure a timestamp isn't input instead of a duration + if (newVestingPeriod_ > uint48(31536000)) revert InvalidParam("newVestingPeriod"); + vestingPeriod = newVestingPeriod_; + } + + /// @notice allow governance to adjust backing price if deviated from reality + /// @dev note if adjustment is more than 33% down, contract should be redeployed + /// @param newBacking to adjust to + /// TODO maybe put in a timespan arg so it can be smoothed over time if desirable + function setBacking(uint256 newBacking) external onlyRole("emissions_admin") { + // Backing cannot be reduced by more than 10% at a time + if (newBacking < (backing * 9) / 10) revert InvalidParam("newBacking"); + backing = newBacking; + } + + /// @notice allow governance to adjust the timeframe for restart after shutdown + /// @param newTimeframe to adjust it to + function setRestartTimeframe(uint48 newTimeframe) external onlyRole("emissions_admin") { + // Restart timeframe must be greater than 0 + if (newTimeframe == 0) revert InvalidParam("newRestartTimeframe"); + + restartTimeframe = newTimeframe; + } + + /// @notice allow governance to set the bond contracts used by the emission manager + /// @param auctioneer_ address of the bond auctioneer contract + /// @param teller_ address of the bond teller contract + function setBondContracts( + address auctioneer_, + address teller_ + ) external onlyRole("emissions_admin") { + // Bond contracts cannot be set to the zero address + if (auctioneer_ == address(0)) revert InvalidParam("auctioneer"); + if (teller_ == address(0)) revert InvalidParam("teller"); + + auctioneer = IBondSDA(auctioneer_); + teller = teller_; + } + + // =========- VIEW FUNCTIONS ========== // + + /// @notice return reserves, measured as clearinghouse receivables and sReserve balances, in reserve denomination + function getReserves() public view returns (uint256 reserves) { + uint256 chCount = CHREG.registryCount(); + for (uint256 i; i < chCount; i++) { + reserves += Clearinghouse(CHREG.registry(i)).principalReceivables(); + uint256 bal = sReserve.balanceOf(CHREG.registry(i)); + if (bal > 0) reserves += sReserve.previewRedeem(bal); + } + + reserves += sReserve.previewRedeem(sReserve.balanceOf(address(TRSRY))); + } + + /// @notice return supply, measured as supply of gOHM in OHM denomination + function getSupply() public view returns (uint256 supply) { + return (gohm.totalSupply() * gohm.index()) / 10 ** _ohmDecimals; + } + + /// @notice return the current premium as a percentage where 1e18 is 100% + function getPremium() public view returns (uint256) { + uint256 price = PRICE.getLastPrice(); + uint256 pbr = (price * 10 ** _reserveDecimals) / backing; + return pbr > ONE_HUNDRED_PERCENT ? pbr - ONE_HUNDRED_PERCENT : 0; + } + + /// @notice return the next sale amount, premium, emission rate, and emissions based on the current premium + function getNextSale() + public + view + returns (uint256 premium, uint256 emissionRate, uint256 emission) + { + // To calculate the sale, it first computes premium (market price / backing price) - 100% + premium = getPremium(); + + // If the premium is greater than the minimum premium, it computes the emission rate and nominal emissions + if (premium >= minimumPremium) { + emissionRate = + (baseEmissionRate * (ONE_HUNDRED_PERCENT + premium)) / + (ONE_HUNDRED_PERCENT + minimumPremium); // in OHM scale + emission = (getSupply() * emissionRate) / 10 ** _ohmDecimals; // OHM Scale * OHM Scale / OHM Scale = OHM Scale + } + } +} From 56b8f3b678b9425078a329d1788b61d44162d0fd Mon Sep 17 00:00:00 2001 From: ohmzeus <93612359+chefomi@users.noreply.github.com> Date: Fri, 29 Nov 2024 08:16:11 -0500 Subject: [PATCH 02/64] Update CDsEmissions.sol --- src/policies/CDsEmissions.sol | 84 +++++++++++++++++++++++++---------- 1 file changed, 61 insertions(+), 23 deletions(-) diff --git a/src/policies/CDsEmissions.sol b/src/policies/CDsEmissions.sol index d8d1718a..1ecdf97a 100644 --- a/src/policies/CDsEmissions.sol +++ b/src/policies/CDsEmissions.sol @@ -26,7 +26,7 @@ interface Clearinghouse { function principalReceivables() external view returns (uint256); } -contract EmissionManager is Policy, RolesConsumer { +contract CDEmissionManager is Policy, RolesConsumer { using FullMath for uint256; // ========== ERRORS ========== // @@ -42,7 +42,7 @@ contract EmissionManager is Policy, RolesConsumer { // ========== EVENTS ========== // - event SaleCreated(uint256 marketID, uint256 saleAmount); + event SaleCreated(uint256 marketID, uint256 saleAmount, bool isCD); event BackingUpdated(uint256 newBacking, uint256 supplyAdded, uint256 reservesAdded); // ========== DATA STRUCTURES ========== // @@ -75,6 +75,7 @@ contract EmissionManager is Policy, RolesConsumer { // External contracts IBondSDA public auctioneer; address public teller; + address public cdEscrow; // Holds CD funds prior to conversion or reversion // Manager variables uint256 public baseEmissionRate; @@ -83,7 +84,9 @@ contract EmissionManager is Policy, RolesConsumer { uint256 public backing; uint8 public beatCounter; bool public locallyActive; - uint256 public activeMarketId; + uint256 public activeBondMarketId; + uint256 public activeCDMarketId; + uint256 public minimumSpread; // Min % above current price for CD market uint8 internal _oracleDecimals; uint8 internal immutable _ohmDecimals; @@ -173,13 +176,18 @@ contract EmissionManager is Policy, RolesConsumer { else baseEmissionRate -= rateChange.changeBy; } - // It then calculates the amount to sell for the coming day - (, , uint256 sell) = getNextSale(); - + uint256 bond = getUnsold(); // And then opens a market if applicable - if (sell != 0) { - MINTR.increaseMintApproval(address(this), sell); - _createMarket(sell); + if (bond != 0) { + // Already has mint approval from prior day + _createMarket(bond); + } + + // It then calculates the amount to sell for the coming day + (, , uint256 cd) = getNextSale(); + if (cd != 0) { + MINTR.increaseMintApproval(address(this), cd); + _createCDMarket(cd); } } @@ -223,43 +231,69 @@ contract EmissionManager is Policy, RolesConsumer { // ========== BOND CALLBACK ========== // + /// @dev Bond logic remains the same, CD proceeds go to CD escrow /// @notice callback function for bond market, only callable by the teller function callback(uint256 id_, uint256 inputAmount_, uint256 outputAmount_) external { // Only callable by the bond teller if (msg.sender != teller) revert OnlyTeller(); // Market ID must match the active market ID stored locally, otherwise revert - if (id_ != activeMarketId) revert InvalidMarket(); + if (id_ != activeBondMarketId || id_ != activeCDMarketId) revert InvalidMarket(); // Reserve balance should have increased by atleast the input amount uint256 reserveBalance = reserve.balanceOf(address(this)); if (reserveBalance < inputAmount_) revert InvalidCallback(); - // Update backing value with the new reserves added and supply added - // We do this before depositing the received reserves and minting the output amount of OHM - // so that the getReserves and getSupply values equal the "previous" values - // This also conforms to the CEI pattern - _updateBacking(outputAmount_, inputAmount_); + address reserveTo; + address outputTo; + if (id_ == activeBondMarketId) { + reserveTo = address(TRSRY); + outputTo = teller; + + // Update backing value with the new reserves added and supply added + // We do this before depositing the received reserves and minting the output amount of OHM + // so that the getReserves and getSupply values equal the "previous" values + // This also conforms to the CEI pattern + _updateBacking(outputAmount_, inputAmount_); + } else { + reserveTo = cdEscrow; + outputTo = cdEscrow; + } // Deposit the reserve balance into the sReserve contract with the TRSRY as the recipient // This will sweep any excess reserves into the TRSRY as well - sReserve.deposit(reserveBalance, address(TRSRY)); + sReserve.deposit(reserveBalance, reserveTo); // Mint the output amount of OHM to the Teller - MINTR.mintOhm(teller, outputAmount_); + MINTR.mintOhm(outputTo, outputAmount_); } // ========== INTERNAL FUNCTIONS ========== // + function _createBondMarket(uint256 saleAmount) internal { + uint256 minPrice = ((ONE_HUNDRED_PERCENT + minimumPremium) * backing) / + 10 ** _reserveDecimals; + + activeBondMarketId = _createMarket(saleAmount, minPrice); + } + + function _createCDMarket(uint256 saleAmount) internal { + uint256 minPrice = (PRICE.getLastPrice * minimumSpread) / 10 ** _reserveDecimals; + + activeCDMarketId = _createMarket(saleAmount, minPrice); + } + /// @notice create bond protocol market with given budget /// @param saleAmount amount of DAI to fund bond market with - function _createMarket(uint256 saleAmount) internal { + function _createMarket( + uint256 saleAmount, + uint256 minPrice, + bool isCD + ) internal returns (uint256 id) { // Calculate scaleAdjustment for bond market // Price decimals are returned from the perspective of the quote token // so the operations assume payoutPriceDecimal is zero and quotePriceDecimals // is the priceDecimal value - uint256 minPrice = ((ONE_HUNDRED_PERCENT + minimumPremium) * backing) / - 10 ** _reserveDecimals; int8 priceDecimals = _getPriceDecimals(minPrice); int8 scaleAdjustment = int8(_ohmDecimals) - int8(_reserveDecimals) + (priceDecimals / 2); @@ -271,10 +305,10 @@ contract EmissionManager is Policy, RolesConsumer { ); // Create new bond market to buy the reserve with OHM - activeMarketId = auctioneer.createMarket( + id = auctioneer.createMarket( abi.encode( IBondSDA.MarketParams({ - payoutToken: ohm, + payoutToken: isCD ? ohm : cdToken, // idt this works quoteToken: reserve, callbackAddr: address(this), capacityInQuote: false, @@ -290,7 +324,7 @@ contract EmissionManager is Policy, RolesConsumer { ) ); - emit SaleCreated(activeMarketId, saleAmount); + emit SaleCreated(activeBondMarketId, saleAmount, isCD); } /// @notice allow emission manager to update backing price based on new supply and reserves added @@ -466,4 +500,8 @@ contract EmissionManager is Policy, RolesConsumer { emission = (getSupply() * emissionRate) / 10 ** _ohmDecimals; // OHM Scale * OHM Scale / OHM Scale = OHM Scale } } + + function getUnsold() public view returns (uint256 tokens) { + // if CD sale undersubscribed, return amount of ohm that was not sold + } } From 49ecd6d07c9b53fe754c472878868cec402828f7 Mon Sep 17 00:00:00 2001 From: ohmzeus <93612359+chefomi@users.noreply.github.com> Date: Tue, 3 Dec 2024 23:05:41 -0500 Subject: [PATCH 03/64] Auctioneer + Facility --- src/policies/CDAuctioneer.sol | 179 ++++++++++++ src/policies/CDFacility.sol | 210 ++++++++++++++ src/policies/CDsEmissions.sol | 507 ---------------------------------- 3 files changed, 389 insertions(+), 507 deletions(-) create mode 100644 src/policies/CDAuctioneer.sol create mode 100644 src/policies/CDFacility.sol delete mode 100644 src/policies/CDsEmissions.sol diff --git a/src/policies/CDAuctioneer.sol b/src/policies/CDAuctioneer.sol new file mode 100644 index 00000000..6f53966c --- /dev/null +++ b/src/policies/CDAuctioneer.sol @@ -0,0 +1,179 @@ +// SPDX-License-Identifier: AGPL-3.0 +pragma solidity 0.8.15; + +import "src/Kernel.sol"; + +import {ReentrancyGuard} from "solmate/utils/ReentrancyGuard.sol"; +import {ERC20} from "solmate/tokens/ERC20.sol"; + +import {RolesConsumer, ROLESv1} from "modules/ROLES/OlympusRoles.sol"; + +import {FullMath} from "libraries/FullMath.sol"; + +import {CDFacility} from "./CDFacility.sol"; + +interface CDRC20 { + function mint(address to, uint256 amount) external; + function burn(address from, uint256 amount) external; + function convertFor(uint256 amount) external view returns (uint256); + function expiry() external view returns (uint256); +} + +contract CDAuctioneer is Policy, RolesConsumer { + using FullMath for uint256; + + // ========== DATA STRUCTURES ========== // + + struct Tick { + uint256 price; + uint256 capacity; + } + + // ========== EVENTS ========== // + + // ========== STATE VARIABLES ========== // + + Tick public currentTick; + uint256 public target; // number of ohm per day + uint256 public tickSize; // number of ohm in a tick + uint256 public tickStep; // percentage increase (decrease) per tick + uint256 public lastUpdate; // timestamp of last update to current tick + uint256 public currentExpiry; // current CD token expiry + uint256 public timeBetweenExpiries; // time between CD token expiries + uint256 public minPrice; // minimum tick price + + uint256 public decimals; + + CDFacility public cdFacility; + + mapping(uint256 => mapping(uint256 => address)) public cdTokens; // mapping(expiry => price => token) + + // ========== SETUP ========== // + + constructor(Kernel kernel_) Policy(kernel_) {} + + function configureDependencies() external override returns (Keycode[] memory dependencies) { + dependencies = new Keycode[](1); + dependencies[2] = toKeycode("ROLES"); + + ROLES = ROLESv1(getModuleAddress(dependencies[0])); + } + + function requestPermissions() + external + view + override + returns (Permissions[] memory permissions) + {} + + // ========== AUCTION ========== // + + /// @notice use a deposit to bid for CDs + /// @param deposit amount of reserve tokens + /// @return tokens CD tokens minted to user + /// @return amounts amounts of CD tokens minted to user + function bid( + uint256 deposit + ) external returns (CDRC20[] memory tokens, uint256[] memory amounts) { + // update state + currentTick = getCurrentTick(); + currentExpiry = getCurrentExpiry(); + lastUpdate = block.timestamp; + + uint256 i; + + // iterate until user has no more reserves to bid + while (deposit > 0) { + // get CD token for tick price + CDRC20 token = CDRC20(tokenFor(currentTick.price)); + + // handle spent/capacity for tick + uint256 amount = currentTick.capacity < token.convertFor(deposit) ? tickSize : deposit; + if (amount != tickSize) currentTick.capacity -= amount; + + // mint amount of CD token + cdFacility.addNewCD(msg.sender, amount, token); + + // decrement bid and increment tick price + deposit -= amount; + currentTick.price *= tickStep / decimals; + + // add to return arrays + tokens[i] = token; + amounts[i] = token.convertFor(amount); + ++i; + } + } + + // ========== INTERNAL FUNCTIONS ========== // + + /// @notice create, or return address for existing, CD token + /// @param price tick price of CD token + /// @return token address of CD token + function tokenFor(uint256 price) internal returns (address token) { + token = cdTokens[currentExpiry][price]; + if (token == address(0)) { + // new token + } + } + + // ========== VIEW FUNCTIONS ========== // + + /// @notice get current tick info + /// @dev time passing changes tick info + /// @return tick info in Tick struct + function getCurrentTick() public view returns (Tick memory tick) { + // find amount of time passed and new capacity to add + uint256 timePassed = block.timestamp - lastUpdate; + uint256 newCapacity = (target * timePassed) / 1 days; + + tick = currentTick; + + // decrement price while ticks are full + while (tick.capacity + newCapacity > tickSize) { + newCapacity -= tickSize; + tick.price *= decimals / tickStep; + + // tick price does not go below the minimum + // tick capacity is full if the min price is exceeded + if (tick.price < minPrice) { + tick.price = minPrice; + newCapacity = tickSize; + break; + } + } + + // decrement capacity by remainder + tick.capacity = newCapacity; + } + + /// @notice get current new CD expiry + /// @return expiry timestamp of expiration + function getCurrentExpiry() public view returns (uint256 expiry) { + uint256 nextExpiry = currentExpiry + timeBetweenExpiries; + expiry = nextExpiry > block.timestamp ? currentExpiry : nextExpiry; + } + + // ========== ADMIN FUNCTIONS ========== // + + /// @notice update auction parameters + /// @dev only callable by the auction admin + /// @param newTarget new target sale per day + /// @param newSize new size per tick + /// @param newMinPrice new minimum tick price + function beat( + uint256 newTarget, + uint256 newSize, + uint256 newMinPrice + ) external onlyRole("CD_Auction_Admin") { + target = newTarget; + tickSize = newSize; + minPrice = newMinPrice; + } + + /// @notice update time between new CD expiries + /// @param newTime number of seconds between expiries + function setTimeBetweenExpiries(uint256 newTime) external onlyRole("CD_Auction_Admin") { + timeBetweenExpiries = newTime; + } +} diff --git a/src/policies/CDFacility.sol b/src/policies/CDFacility.sol new file mode 100644 index 00000000..bccbfc0c --- /dev/null +++ b/src/policies/CDFacility.sol @@ -0,0 +1,210 @@ +// SPDX-License-Identifier: AGPL-3.0 +pragma solidity 0.8.15; + +import "src/Kernel.sol"; + +import {ReentrancyGuard} from "solmate/utils/ReentrancyGuard.sol"; +import {ERC20} from "solmate/tokens/ERC20.sol"; +import {ERC4626} from "solmate/mixins/ERC4626.sol"; + +import {RolesConsumer, ROLESv1} from "modules/ROLES/OlympusRoles.sol"; +import {MINTRv1} from "modules/MINTR/MINTR.v1.sol"; +import {TRSRYv1} from "modules/TRSRY/TRSRY.v1.sol"; + +import {FullMath} from "libraries/FullMath.sol"; + +interface CDRC20 { + function mint(address to, uint256 amount) external; + function convertFor(uint256 amount) external view returns (uint256); +} + +contract CDFacility is Policy, RolesConsumer { + using FullMath for uint256; + + // ========== EVENTS ========== // + + event CreatedCD(address user, uint48 expiry, uint256 deposit, uint256 convert); + event ConvertedCD(address user, uint256 deposit, uint256 convert); + event ReclaimedCD(address user, uint256 deposit); + event SweptYield(address receiver, uint256 amount); + + // ========== DATA STRUCTURES ========== // + + struct ConvertibleDebt { + uint256 deposit; + uint256 convert; + uint256 expiry; + } + + // ========== STATE VARIABLES ========== // + + // Modules + TRSRYv1 public TRSRY; + MINTRv1 public MINTR; + + // Tokens + ERC20 public reserve; + ERC4626 public sReserve; + + // State variables + mapping(address => ConvertibleDebt[]) public cdsFor; + uint256 public totalDeposits; + uint256 public totalShares; + + // ========== SETUP ========== // + + constructor(Kernel kernel_, address reserve_, address sReserve_) Policy(kernel_) { + reserve = ERC20(reserve_); + sReserve = ERC4626(sReserve_); + } + + function configureDependencies() external override returns (Keycode[] memory dependencies) { + dependencies = new Keycode[](3); + dependencies[0] = toKeycode("TRSRY"); + dependencies[1] = toKeycode("MINTR"); + dependencies[2] = toKeycode("ROLES"); + + TRSRY = TRSRYv1(getModuleAddress(dependencies[0])); + MINTR = MINTRv1(getModuleAddress(dependencies[1])); + ROLES = ROLESv1(getModuleAddress(dependencies[2])); + } + + function requestPermissions() + external + view + override + returns (Permissions[] memory permissions) + { + Keycode mintrKeycode = toKeycode("MINTR"); + + permissions = new Permissions[](2); + permissions[0] = Permissions(mintrKeycode, MINTR.increaseMintApproval.selector); + permissions[1] = Permissions(mintrKeycode, MINTR.mintOhm.selector); + } + + // ========== EMISSIONS MANAGER ========== // + + /// @notice allow emissions manager to create new convertible debt + /// @param user owner of the convertible debt + /// @param amount amount of reserve tokens deposited + /// @param token CD Token with terms for deposit + function addNewCD( + address user, + uint256 amount, + CDRC20 token + ) external onlyRole("Emissions_Manager") { + // transfer in debt token + reserve.transferFrom(user, address(this), amount); + + // deploy debt token into vault + totalShares += sReserve.deposit(amount, address(this)); + + // add mint approval for conversion + MINTR.increaseMintApproval(address(this), token.convertFor(amount)); + + // mint CD token + token.mint(user, amount); + } + + // ========== CD Position Owner ========== // + + /// @notice allow user to convert their convertible debt before expiration + /// @param ids IDs of convertible debts to convert + /// @return totalDeposit total reserve tokens relinquished + /// @return totalConvert total convert tokens sent + function convertCD( + CDRC20[] memory tokens, + uint256[] memory amounts + ) external returns (uint256 totalDeposit, uint256 totalConvert) { + // iterate through list of ids, add to totals, and delete cd entries + for (uint256 i; i < ids.length; ++i) { + ConvertibleDebt memory cd = cdsFor[msg.sender][i]; + if (cd.convert > 0 && cd.expiry <= block.timestamp) { + totalDeposit += cd.deposit; + totalConvert += cd.convert; + delete cdsFor[msg.sender][i]; + } + } + + // compute shares to send + uint256 shares = sReserve.previewWithdraw(totalDeposit); + totalShares -= shares; + + // mint convert token, and send wrapped debt token to treasury + MINTR.mintOhm(msg.sender, totalConvert); + sReserve.transfer(address(TRSRY), shares); + + emit ConvertedCD(msg.sender, totalDeposit, totalConvert); + } + + /// @notice allow user to reclaim their convertible debt deposits after expiration + /// @param ids IDs of convertible debts to reclaim + /// @return totalDeposit total reserve tokens relinquished + function reclaimDeposit(uint256[] memory ids) external returns (uint256 totalDeposit) { + // iterate through list of ids, add to total, and delete cd entries + for (uint256 i; i < ids.length; ++i) { + ConvertibleDebt memory cd = cdsFor[msg.sender][i]; + if (cd.expiry > block.timestamp) { + // reduce mint approval + MINTR.decreaseMintApproval(address(this), cd.convert); + + totalDeposit += cd.deposit; + delete cdsFor[msg.sender][i]; + } + } + + // compute shares to redeem + uint256 shares = sReserve.previewWithdraw(totalDeposit); + totalShares -= shares; + + // undeploy and return debt token to user + sReserve.redeem(shares, msg.sender, address(this)); + + emit ReclaimedCD(msg.sender, totalDeposit); + } + + // ========== YIELD MANAGER ========== // + + /// @notice allow yield manager to sweep yield accrued on reserves + /// @return yield yield in reserve token terms + /// @return shares yield in sReserve token terms + function sweepYield() + external + onlyRole("CD_Yield_Manager") + returns (uint256 yield, uint256 shares) + { + yield = sReserve.previewRedeem(totalShares) - totalDeposits; + shares = sReserve.previewWithdraw(yield); + sReserve.transfer(msg.sender, shares); + + emit SweptYield(msg.sender, yield); + } + + // ========== VIEW FUNCTIONS ========== // + + /// @notice get yield accrued on deposited reserve tokens + /// @return yield in reserve token terms + function yieldAccrued() external view returns (uint256) { + return sReserve.previewRedeem(totalShares) - totalDeposits; + } + + /// @notice return all existing CD IDs for user + /// @param user to search for + /// @return ids for user + function idsForUser(address user) external view returns (uint256[] memory ids) { + uint256 j; + for (uint256 i; i < cdsFor[user].length; ++i) { + ConvertibleDebt memory cd = cdsFor[user][i]; + if (cd.deposit > 0) ids[j] = i; + ++j; + } + } + + /// @notice query whether a given CD ID is expired + /// @param user who holds the CD + /// @param id of the CD to query + /// @return status whether the CD is expired + function idExpired(address user, uint256 id) external view returns (bool status) { + status = cdsFor[user][id].expiry > block.timestamp; + } +} diff --git a/src/policies/CDsEmissions.sol b/src/policies/CDsEmissions.sol deleted file mode 100644 index 1ecdf97a..00000000 --- a/src/policies/CDsEmissions.sol +++ /dev/null @@ -1,507 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0 -pragma solidity 0.8.15; - -import "src/Kernel.sol"; - -import {ReentrancyGuard} from "solmate/utils/ReentrancyGuard.sol"; -import {ERC20} from "solmate/tokens/ERC20.sol"; -import {ERC4626} from "solmate/mixins/ERC4626.sol"; - -import {FullMath} from "libraries/FullMath.sol"; - -import {IBondSDA} from "interfaces/IBondSDA.sol"; -import {IgOHM} from "interfaces/IgOHM.sol"; - -import {RolesConsumer, ROLESv1} from "modules/ROLES/OlympusRoles.sol"; -import {TRSRYv1} from "modules/TRSRY/TRSRY.v1.sol"; -import {PRICEv1} from "modules/PRICE/PRICE.v1.sol"; -import {MINTRv1} from "modules/MINTR/MINTR.v1.sol"; -import {CHREGv1} from "modules/CHREG/CHREG.v1.sol"; - -interface BurnableERC20 { - function burn(uint256 amount) external; -} - -interface Clearinghouse { - function principalReceivables() external view returns (uint256); -} - -contract CDEmissionManager is Policy, RolesConsumer { - using FullMath for uint256; - - // ========== ERRORS ========== // - - error OnlyTeller(); - error InvalidMarket(); - error InvalidCallback(); - error InvalidParam(string parameter); - error CannotRestartYet(uint48 availableAt); - error RestartTimeframePassed(); - error NotActive(); - error AlreadyActive(); - - // ========== EVENTS ========== // - - event SaleCreated(uint256 marketID, uint256 saleAmount, bool isCD); - event BackingUpdated(uint256 newBacking, uint256 supplyAdded, uint256 reservesAdded); - - // ========== DATA STRUCTURES ========== // - - struct BaseRateChange { - uint256 changeBy; - uint48 beatsLeft; - bool addition; - } - - // ========== STATE VARIABLES ========== // - - /// @notice active base emissions rate change information - /// @dev active until beatsLeft is 0 - BaseRateChange public rateChange; - - // Modules - TRSRYv1 public TRSRY; - PRICEv1 public PRICE; - MINTRv1 public MINTR; - CHREGv1 public CHREG; - - // Tokens - // solhint-disable const-name-snakecase - ERC20 public immutable ohm; - IgOHM public immutable gohm; - ERC20 public immutable reserve; - ERC4626 public immutable sReserve; - - // External contracts - IBondSDA public auctioneer; - address public teller; - address public cdEscrow; // Holds CD funds prior to conversion or reversion - - // Manager variables - uint256 public baseEmissionRate; - uint256 public minimumPremium; - uint48 public vestingPeriod; // initialized at 0 - uint256 public backing; - uint8 public beatCounter; - bool public locallyActive; - uint256 public activeBondMarketId; - uint256 public activeCDMarketId; - uint256 public minimumSpread; // Min % above current price for CD market - - uint8 internal _oracleDecimals; - uint8 internal immutable _ohmDecimals; - uint8 internal immutable _reserveDecimals; - - /// @notice timestamp of last shutdown - uint48 public shutdownTimestamp; - /// @notice time in seconds that the manager needs to be restarted after a shutdown, otherwise it must be re-initialized - uint48 public restartTimeframe; - - uint256 internal constant ONE_HUNDRED_PERCENT = 1e18; - - // ========== SETUP ========== // - - constructor( - Kernel kernel_, - address ohm_, - address gohm_, - address reserve_, - address sReserve_, - address auctioneer_, - address teller_ - ) Policy(kernel_) { - // Set immutable variables - if (ohm_ == address(0)) revert("OHM address cannot be 0"); - if (gohm_ == address(0)) revert("gOHM address cannot be 0"); - if (reserve_ == address(0)) revert("DAI address cannot be 0"); - if (sReserve_ == address(0)) revert("sDAI address cannot be 0"); - if (auctioneer_ == address(0)) revert("Auctioneer address cannot be 0"); - - ohm = ERC20(ohm_); - gohm = IgOHM(gohm_); - reserve = ERC20(reserve_); - sReserve = ERC4626(sReserve_); - auctioneer = IBondSDA(auctioneer_); - teller = teller_; - - _ohmDecimals = ohm.decimals(); - _reserveDecimals = reserve.decimals(); - - // Max approve sReserve contract for reserve for deposits - reserve.approve(address(sReserve), type(uint256).max); - } - - function configureDependencies() external override returns (Keycode[] memory dependencies) { - dependencies = new Keycode[](5); - dependencies[0] = toKeycode("TRSRY"); - dependencies[1] = toKeycode("PRICE"); - dependencies[2] = toKeycode("MINTR"); - dependencies[3] = toKeycode("CHREG"); - dependencies[4] = toKeycode("ROLES"); - - TRSRY = TRSRYv1(getModuleAddress(dependencies[0])); - PRICE = PRICEv1(getModuleAddress(dependencies[1])); - MINTR = MINTRv1(getModuleAddress(dependencies[2])); - CHREG = CHREGv1(getModuleAddress(dependencies[3])); - ROLES = ROLESv1(getModuleAddress(dependencies[4])); - - _oracleDecimals = PRICE.decimals(); - } - - function requestPermissions() - external - view - override - returns (Permissions[] memory permissions) - { - Keycode mintrKeycode = toKeycode("MINTR"); - - permissions = new Permissions[](2); - permissions[0] = Permissions(mintrKeycode, MINTR.increaseMintApproval.selector); - permissions[1] = Permissions(mintrKeycode, MINTR.mintOhm.selector); - } - - // ========== HEARTBEAT ========== // - - /// @notice calculate and execute sale, if applicable, once per day (every 3 beats) - function execute() external onlyRole("heart") { - if (!locallyActive) return; - - beatCounter = ++beatCounter % 3; - if (beatCounter != 0) return; - - if (rateChange.beatsLeft != 0) { - --rateChange.beatsLeft; - if (rateChange.addition) baseEmissionRate += rateChange.changeBy; - else baseEmissionRate -= rateChange.changeBy; - } - - uint256 bond = getUnsold(); - // And then opens a market if applicable - if (bond != 0) { - // Already has mint approval from prior day - _createMarket(bond); - } - - // It then calculates the amount to sell for the coming day - (, , uint256 cd) = getNextSale(); - if (cd != 0) { - MINTR.increaseMintApproval(address(this), cd); - _createCDMarket(cd); - } - } - - // ========== INITIALIZE ========== // - - /// @notice allow governance to initialize the emission manager - /// @param baseEmissionsRate_ percent of OHM supply to issue per day at the minimum premium, in OHM scale, i.e. 1e9 = 100% - /// @param minimumPremium_ minimum premium at which to issue OHM, a percentage where 1e18 is 100% - /// @param backing_ backing price of OHM in reserve token, in reserve scale - /// @param restartTimeframe_ time in seconds that the manager needs to be restarted after a shutdown, otherwise it must be re-initialized - function initialize( - uint256 baseEmissionsRate_, - uint256 minimumPremium_, - uint256 backing_, - uint48 restartTimeframe_ - ) external onlyRole("emissions_admin") { - // Cannot initialize if currently active - if (locallyActive) revert AlreadyActive(); - - // Cannot initialize if the restart timeframe hasn't passed since the shutdown timestamp - // This is specific to re-initializing after a shutdown - // It will not revert on the first initialization since both values will be zero - if (shutdownTimestamp + restartTimeframe > uint48(block.timestamp)) - revert CannotRestartYet(shutdownTimestamp + restartTimeframe); - - // Validate inputs - if (baseEmissionsRate_ == 0) revert InvalidParam("baseEmissionRate"); - if (minimumPremium_ == 0) revert InvalidParam("minimumPremium"); - if (backing_ == 0) revert InvalidParam("backing"); - if (restartTimeframe_ == 0) revert InvalidParam("restartTimeframe"); - - // Assign - baseEmissionRate = baseEmissionsRate_; - minimumPremium = minimumPremium_; - backing = backing_; - restartTimeframe = restartTimeframe_; - - // Activate - locallyActive = true; - } - - // ========== BOND CALLBACK ========== // - - /// @dev Bond logic remains the same, CD proceeds go to CD escrow - /// @notice callback function for bond market, only callable by the teller - function callback(uint256 id_, uint256 inputAmount_, uint256 outputAmount_) external { - // Only callable by the bond teller - if (msg.sender != teller) revert OnlyTeller(); - - // Market ID must match the active market ID stored locally, otherwise revert - if (id_ != activeBondMarketId || id_ != activeCDMarketId) revert InvalidMarket(); - - // Reserve balance should have increased by atleast the input amount - uint256 reserveBalance = reserve.balanceOf(address(this)); - if (reserveBalance < inputAmount_) revert InvalidCallback(); - - address reserveTo; - address outputTo; - if (id_ == activeBondMarketId) { - reserveTo = address(TRSRY); - outputTo = teller; - - // Update backing value with the new reserves added and supply added - // We do this before depositing the received reserves and minting the output amount of OHM - // so that the getReserves and getSupply values equal the "previous" values - // This also conforms to the CEI pattern - _updateBacking(outputAmount_, inputAmount_); - } else { - reserveTo = cdEscrow; - outputTo = cdEscrow; - } - - // Deposit the reserve balance into the sReserve contract with the TRSRY as the recipient - // This will sweep any excess reserves into the TRSRY as well - sReserve.deposit(reserveBalance, reserveTo); - - // Mint the output amount of OHM to the Teller - MINTR.mintOhm(outputTo, outputAmount_); - } - - // ========== INTERNAL FUNCTIONS ========== // - - function _createBondMarket(uint256 saleAmount) internal { - uint256 minPrice = ((ONE_HUNDRED_PERCENT + minimumPremium) * backing) / - 10 ** _reserveDecimals; - - activeBondMarketId = _createMarket(saleAmount, minPrice); - } - - function _createCDMarket(uint256 saleAmount) internal { - uint256 minPrice = (PRICE.getLastPrice * minimumSpread) / 10 ** _reserveDecimals; - - activeCDMarketId = _createMarket(saleAmount, minPrice); - } - - /// @notice create bond protocol market with given budget - /// @param saleAmount amount of DAI to fund bond market with - function _createMarket( - uint256 saleAmount, - uint256 minPrice, - bool isCD - ) internal returns (uint256 id) { - // Calculate scaleAdjustment for bond market - // Price decimals are returned from the perspective of the quote token - // so the operations assume payoutPriceDecimal is zero and quotePriceDecimals - // is the priceDecimal value - int8 priceDecimals = _getPriceDecimals(minPrice); - int8 scaleAdjustment = int8(_ohmDecimals) - int8(_reserveDecimals) + (priceDecimals / 2); - - // Calculate oracle scale and bond scale with scale adjustment and format prices for bond market - uint256 oracleScale = 10 ** uint8(int8(_oracleDecimals) - priceDecimals); - uint256 bondScale = 10 ** - uint8( - 36 + scaleAdjustment + int8(_reserveDecimals) - int8(_ohmDecimals) - priceDecimals - ); - - // Create new bond market to buy the reserve with OHM - id = auctioneer.createMarket( - abi.encode( - IBondSDA.MarketParams({ - payoutToken: isCD ? ohm : cdToken, // idt this works - quoteToken: reserve, - callbackAddr: address(this), - capacityInQuote: false, - capacity: saleAmount, - formattedInitialPrice: PRICE.getLastPrice().mulDiv(bondScale, oracleScale), - formattedMinimumPrice: minPrice.mulDiv(bondScale, oracleScale), - debtBuffer: 100_000, // 100% - vesting: vestingPeriod, - conclusion: uint48(block.timestamp + 1 days), // 1 day from now - depositInterval: uint32(4 hours), // 4 hours - scaleAdjustment: scaleAdjustment - }) - ) - ); - - emit SaleCreated(activeBondMarketId, saleAmount, isCD); - } - - /// @notice allow emission manager to update backing price based on new supply and reserves added - /// @param supplyAdded number of new OHM minted - /// @param reservesAdded number of new DAI added - function _updateBacking(uint256 supplyAdded, uint256 reservesAdded) internal { - uint256 previousReserves = getReserves(); - uint256 previousSupply = getSupply(); - - uint256 percentIncreaseReserves = ((previousReserves + reservesAdded) * - 10 ** _reserveDecimals) / previousReserves; - uint256 percentIncreaseSupply = ((previousSupply + supplyAdded) * 10 ** _reserveDecimals) / - previousSupply; // scaled to reserve decimals to match - - backing = - (backing * percentIncreaseReserves) / // price multiplied by percent increase reserves in reserve scale - percentIncreaseSupply; // divided by percent increase supply in reserve scale - - // Emit event to track backing changes and results of sales offchain - emit BackingUpdated(backing, supplyAdded, reservesAdded); - } - - /// @notice Helper function to calculate number of price decimals based on the value returned from the price feed. - /// @param price_ The price to calculate the number of decimals for - /// @return The number of decimals - function _getPriceDecimals(uint256 price_) internal view returns (int8) { - int8 decimals; - while (price_ >= 10) { - price_ = price_ / 10; - decimals++; - } - - // Subtract the stated decimals from the calculated decimals to get the relative price decimals. - // Required to do it this way vs. normalizing at the beginning since price decimals can be negative. - return decimals - int8(_oracleDecimals); - } - - // ========== ADMIN FUNCTIONS ========== // - - /// @notice shutdown the emission manager locally, burn OHM, and return any reserves to TRSRY - function shutdown() external onlyRole("emergency_shutdown") { - locallyActive = false; - shutdownTimestamp = uint48(block.timestamp); - - uint256 ohmBalance = ohm.balanceOf(address(this)); - if (ohmBalance > 0) BurnableERC20(address(ohm)).burn(ohmBalance); - - uint256 reserveBalance = reserve.balanceOf(address(this)); - if (reserveBalance > 0) sReserve.deposit(reserveBalance, address(TRSRY)); - } - - /// @notice restart the emission manager locally - function restart() external onlyRole("emergency_restart") { - // Restart can be activated only within the specified timeframe since shutdown - // Outside of this span of time, emissions_admin must reinitialize - if (uint48(block.timestamp) >= shutdownTimestamp + restartTimeframe) - revert RestartTimeframePassed(); - - locallyActive = true; - } - - /// @notice set the base emissions rate - /// @param changeBy_ uint256 added or subtracted from baseEmissionRate - /// @param forNumBeats_ uint256 number of times to change baseEmissionRate by changeBy_ - /// @param add bool determining addition or subtraction to baseEmissionRate - function changeBaseRate( - uint256 changeBy_, - uint48 forNumBeats_, - bool add - ) external onlyRole("emissions_admin") { - // Prevent underflow on negative adjustments - if (!add && (changeBy_ * forNumBeats_ > baseEmissionRate)) - revert InvalidParam("changeBy * forNumBeats"); - - // Prevent overflow on positive adjustments - if (add && (type(uint256).max - changeBy_ * forNumBeats_ < baseEmissionRate)) - revert InvalidParam("changeBy * forNumBeats"); - - rateChange = BaseRateChange(changeBy_, forNumBeats_, add); - } - - /// @notice set the minimum premium for emissions - /// @param newMinimumPremium_ uint256 - function setMinimumPremium(uint256 newMinimumPremium_) external onlyRole("emissions_admin") { - if (newMinimumPremium_ == 0) revert InvalidParam("newMinimumPremium"); - - minimumPremium = newMinimumPremium_; - } - - /// @notice set the new vesting period in seconds - /// @param newVestingPeriod_ uint48 - function setVestingPeriod(uint48 newVestingPeriod_) external onlyRole("emissions_admin") { - // Verify that the vesting period isn't more than a year - // This check helps ensure a timestamp isn't input instead of a duration - if (newVestingPeriod_ > uint48(31536000)) revert InvalidParam("newVestingPeriod"); - vestingPeriod = newVestingPeriod_; - } - - /// @notice allow governance to adjust backing price if deviated from reality - /// @dev note if adjustment is more than 33% down, contract should be redeployed - /// @param newBacking to adjust to - /// TODO maybe put in a timespan arg so it can be smoothed over time if desirable - function setBacking(uint256 newBacking) external onlyRole("emissions_admin") { - // Backing cannot be reduced by more than 10% at a time - if (newBacking < (backing * 9) / 10) revert InvalidParam("newBacking"); - backing = newBacking; - } - - /// @notice allow governance to adjust the timeframe for restart after shutdown - /// @param newTimeframe to adjust it to - function setRestartTimeframe(uint48 newTimeframe) external onlyRole("emissions_admin") { - // Restart timeframe must be greater than 0 - if (newTimeframe == 0) revert InvalidParam("newRestartTimeframe"); - - restartTimeframe = newTimeframe; - } - - /// @notice allow governance to set the bond contracts used by the emission manager - /// @param auctioneer_ address of the bond auctioneer contract - /// @param teller_ address of the bond teller contract - function setBondContracts( - address auctioneer_, - address teller_ - ) external onlyRole("emissions_admin") { - // Bond contracts cannot be set to the zero address - if (auctioneer_ == address(0)) revert InvalidParam("auctioneer"); - if (teller_ == address(0)) revert InvalidParam("teller"); - - auctioneer = IBondSDA(auctioneer_); - teller = teller_; - } - - // =========- VIEW FUNCTIONS ========== // - - /// @notice return reserves, measured as clearinghouse receivables and sReserve balances, in reserve denomination - function getReserves() public view returns (uint256 reserves) { - uint256 chCount = CHREG.registryCount(); - for (uint256 i; i < chCount; i++) { - reserves += Clearinghouse(CHREG.registry(i)).principalReceivables(); - uint256 bal = sReserve.balanceOf(CHREG.registry(i)); - if (bal > 0) reserves += sReserve.previewRedeem(bal); - } - - reserves += sReserve.previewRedeem(sReserve.balanceOf(address(TRSRY))); - } - - /// @notice return supply, measured as supply of gOHM in OHM denomination - function getSupply() public view returns (uint256 supply) { - return (gohm.totalSupply() * gohm.index()) / 10 ** _ohmDecimals; - } - - /// @notice return the current premium as a percentage where 1e18 is 100% - function getPremium() public view returns (uint256) { - uint256 price = PRICE.getLastPrice(); - uint256 pbr = (price * 10 ** _reserveDecimals) / backing; - return pbr > ONE_HUNDRED_PERCENT ? pbr - ONE_HUNDRED_PERCENT : 0; - } - - /// @notice return the next sale amount, premium, emission rate, and emissions based on the current premium - function getNextSale() - public - view - returns (uint256 premium, uint256 emissionRate, uint256 emission) - { - // To calculate the sale, it first computes premium (market price / backing price) - 100% - premium = getPremium(); - - // If the premium is greater than the minimum premium, it computes the emission rate and nominal emissions - if (premium >= minimumPremium) { - emissionRate = - (baseEmissionRate * (ONE_HUNDRED_PERCENT + premium)) / - (ONE_HUNDRED_PERCENT + minimumPremium); // in OHM scale - emission = (getSupply() * emissionRate) / 10 ** _ohmDecimals; // OHM Scale * OHM Scale / OHM Scale = OHM Scale - } - } - - function getUnsold() public view returns (uint256 tokens) { - // if CD sale undersubscribed, return amount of ohm that was not sold - } -} From 356af037023fba70ab80ef47db20be1cc638c36c Mon Sep 17 00:00:00 2001 From: ohmzeus <93612359+chefomi@users.noreply.github.com> Date: Thu, 5 Dec 2024 23:08:46 -0500 Subject: [PATCH 04/64] updates --- src/policies/CDAuctioneer.sol | 120 ++++++++++------------ src/policies/CDFacility.sol | 188 ++++++++++++++++++++++------------ 2 files changed, 174 insertions(+), 134 deletions(-) diff --git a/src/policies/CDAuctioneer.sol b/src/policies/CDAuctioneer.sol index 6f53966c..a8bee333 100644 --- a/src/policies/CDAuctioneer.sol +++ b/src/policies/CDAuctioneer.sol @@ -24,6 +24,15 @@ contract CDAuctioneer is Policy, RolesConsumer { // ========== DATA STRUCTURES ========== // + struct State { + uint256 target; // number of ohm per day + uint256 tickSize; // number of ohm in a tick + uint256 tickStep; // percentage increase (decrease) per tick + uint256 minPrice; // minimum tick price + uint256 timeToExpiry; // time between creation and expiry of deposit + uint256 lastUpdate; // timestamp of last update to current tick + } + struct Tick { uint256 price; uint256 capacity; @@ -34,13 +43,7 @@ contract CDAuctioneer is Policy, RolesConsumer { // ========== STATE VARIABLES ========== // Tick public currentTick; - uint256 public target; // number of ohm per day - uint256 public tickSize; // number of ohm in a tick - uint256 public tickStep; // percentage increase (decrease) per tick - uint256 public lastUpdate; // timestamp of last update to current tick - uint256 public currentExpiry; // current CD token expiry - uint256 public timeBetweenExpiries; // time between CD token expiries - uint256 public minPrice; // minimum tick price + State public state; uint256 public decimals; @@ -69,76 +72,53 @@ contract CDAuctioneer is Policy, RolesConsumer { // ========== AUCTION ========== // /// @notice use a deposit to bid for CDs - /// @param deposit amount of reserve tokens - /// @return tokens CD tokens minted to user - /// @return amounts amounts of CD tokens minted to user - function bid( - uint256 deposit - ) external returns (CDRC20[] memory tokens, uint256[] memory amounts) { + /// @param deposit amount of reserve tokens + /// @return convertable amount of convertable tokens + function bid(uint256 deposit) external returns (uint256 convertable) { // update state currentTick = getCurrentTick(); - currentExpiry = getCurrentExpiry(); - lastUpdate = block.timestamp; - - uint256 i; + state.lastUpdate = block.timestamp; // iterate until user has no more reserves to bid while (deposit > 0) { - // get CD token for tick price - CDRC20 token = CDRC20(tokenFor(currentTick.price)); - // handle spent/capacity for tick - uint256 amount = currentTick.capacity < token.convertFor(deposit) ? tickSize : deposit; - if (amount != tickSize) currentTick.capacity -= amount; - - // mint amount of CD token - cdFacility.addNewCD(msg.sender, amount, token); + uint256 amount = currentTick.capacity < convertFor(deposit, currentTick.price) + ? state.tickSize + : deposit; + if (amount != state.tickSize) currentTick.capacity -= amount; // decrement bid and increment tick price deposit -= amount; - currentTick.price *= tickStep / decimals; - - // add to return arrays - tokens[i] = token; - amounts[i] = token.convertFor(amount); - ++i; + currentTick.price *= state.tickStep / decimals; + convertable += convertFor(amount, currentTick.price); } - } - // ========== INTERNAL FUNCTIONS ========== // - - /// @notice create, or return address for existing, CD token - /// @param price tick price of CD token - /// @return token address of CD token - function tokenFor(uint256 price) internal returns (address token) { - token = cdTokens[currentExpiry][price]; - if (token == address(0)) { - // new token - } + // mint amount of CD token + cdFacility.addNewCD(msg.sender, deposit, convertable, block.timestamp + state.timeToExpiry); } // ========== VIEW FUNCTIONS ========== // /// @notice get current tick info - /// @dev time passing changes tick info + /// @dev time passing changes tick info /// @return tick info in Tick struct function getCurrentTick() public view returns (Tick memory tick) { // find amount of time passed and new capacity to add - uint256 timePassed = block.timestamp - lastUpdate; - uint256 newCapacity = (target * timePassed) / 1 days; + uint256 timePassed = block.timestamp - state.lastUpdate; + uint256 newCapacity = (state.target * timePassed) / 1 days; tick = currentTick; // decrement price while ticks are full - while (tick.capacity + newCapacity > tickSize) { - newCapacity -= tickSize; - tick.price *= decimals / tickStep; + while (tick.capacity + newCapacity > state.tickSize) { + newCapacity -= state.tickSize; + tick.price *= decimals / state.tickStep; // tick price does not go below the minimum // tick capacity is full if the min price is exceeded - if (tick.price < minPrice) { - tick.price = minPrice; - newCapacity = tickSize; + if (tick.price < state.minPrice) { + tick.price = state.minPrice; + newCapacity = state.tickSize; break; } } @@ -147,33 +127,39 @@ contract CDAuctioneer is Policy, RolesConsumer { tick.capacity = newCapacity; } - /// @notice get current new CD expiry - /// @return expiry timestamp of expiration - function getCurrentExpiry() public view returns (uint256 expiry) { - uint256 nextExpiry = currentExpiry + timeBetweenExpiries; - expiry = nextExpiry > block.timestamp ? currentExpiry : nextExpiry; + /// @notice get amount of cdOHM for a deposit at a tick price + /// @return amount convertable + function convertFor(uint256 deposit, uint256 price) public view returns (uint256) { + return (deposit * decimals) / price; } // ========== ADMIN FUNCTIONS ========== // /// @notice update auction parameters - /// @dev only callable by the auction admin - /// @param newTarget new target sale per day - /// @param newSize new size per tick - /// @param newMinPrice new minimum tick price + /// @dev only callable by the auction admin + /// @param newTarget new target sale per day + /// @param newSize new size per tick + /// @param newStep new percentage change per tick + /// @param newMinPrice new minimum tick price function beat( uint256 newTarget, uint256 newSize, + uint256 newStep, uint256 newMinPrice ) external onlyRole("CD_Auction_Admin") { - target = newTarget; - tickSize = newSize; - minPrice = newMinPrice; + state = State( + newTarget, + newSize, + newStep, + newMinPrice, + state.timeToExpiry, + state.lastUpdate + ); } - /// @notice update time between new CD expiries - /// @param newTime number of seconds between expiries - function setTimeBetweenExpiries(uint256 newTime) external onlyRole("CD_Auction_Admin") { - timeBetweenExpiries = newTime; + /// @notice update time between creation and expiry of deposit + /// @param newTime number of seconds + function setTimeToExpiry(uint256 newTime) external onlyRole("CD_Admin") { + state.timeToExpiry = newTime; } } diff --git a/src/policies/CDFacility.sol b/src/policies/CDFacility.sol index bccbfc0c..dc0c3a53 100644 --- a/src/policies/CDFacility.sol +++ b/src/policies/CDFacility.sol @@ -15,12 +15,22 @@ import {FullMath} from "libraries/FullMath.sol"; interface CDRC20 { function mint(address to, uint256 amount) external; + function burn(address from, uint256 amount) external; function convertFor(uint256 amount) external view returns (uint256); + function expiry() external view returns (uint256); } contract CDFacility is Policy, RolesConsumer { using FullMath for uint256; + error Misconfigured(); + + struct CD { + uint256 deposit; + uint256 convertable; + uint256 expiry; + } + // ========== EVENTS ========== // event CreatedCD(address user, uint48 expiry, uint256 deposit, uint256 convert); @@ -28,16 +38,11 @@ contract CDFacility is Policy, RolesConsumer { event ReclaimedCD(address user, uint256 deposit); event SweptYield(address receiver, uint256 amount); - // ========== DATA STRUCTURES ========== // - - struct ConvertibleDebt { - uint256 deposit; - uint256 convert; - uint256 expiry; - } - // ========== STATE VARIABLES ========== // + // Constants + uint256 public constant DECIMALS = 1e18; + // Modules TRSRYv1 public TRSRY; MINTRv1 public MINTR; @@ -45,11 +50,13 @@ contract CDFacility is Policy, RolesConsumer { // Tokens ERC20 public reserve; ERC4626 public sReserve; + CDRC20 public cdUSDS; // State variables - mapping(address => ConvertibleDebt[]) public cdsFor; uint256 public totalDeposits; uint256 public totalShares; + mapping(address => CD[]) public cdInfo; + uint256 public redeemRate; // ========== SETUP ========== // @@ -85,14 +92,16 @@ contract CDFacility is Policy, RolesConsumer { // ========== EMISSIONS MANAGER ========== // /// @notice allow emissions manager to create new convertible debt - /// @param user owner of the convertible debt - /// @param amount amount of reserve tokens deposited - /// @param token CD Token with terms for deposit + /// @param user owner of the convertible debt + /// @param amount amount of reserve tokens deposited + /// @param convertable amount of OHM that can be converted into + /// @param expiry timestamp when conversion expires function addNewCD( address user, uint256 amount, - CDRC20 token - ) external onlyRole("Emissions_Manager") { + uint256 convertable, + uint256 expiry + ) external onlyRole("CD_Auctioneer") { // transfer in debt token reserve.transferFrom(user, address(this), amount); @@ -100,67 +109,116 @@ contract CDFacility is Policy, RolesConsumer { totalShares += sReserve.deposit(amount, address(this)); // add mint approval for conversion - MINTR.increaseMintApproval(address(this), token.convertFor(amount)); + MINTR.increaseMintApproval(address(this), convertable); - // mint CD token - token.mint(user, amount); + // store convertable deposit info and mint cdUSDS + cdInfo[user].push(CD(amount, convertable, expiry)); + cdUSDS.mint(user, amount); } // ========== CD Position Owner ========== // /// @notice allow user to convert their convertible debt before expiration - /// @param ids IDs of convertible debts to convert - /// @return totalDeposit total reserve tokens relinquished - /// @return totalConvert total convert tokens sent + /// @param cds CD indexes to convert + /// @param amounts CD token amounts to convert function convertCD( - CDRC20[] memory tokens, + uint256[] memory cds, uint256[] memory amounts - ) external returns (uint256 totalDeposit, uint256 totalConvert) { - // iterate through list of ids, add to totals, and delete cd entries - for (uint256 i; i < ids.length; ++i) { - ConvertibleDebt memory cd = cdsFor[msg.sender][i]; - if (cd.convert > 0 && cd.expiry <= block.timestamp) { - totalDeposit += cd.deposit; - totalConvert += cd.convert; - delete cdsFor[msg.sender][i]; - } + ) external returns (uint256 converted) { + if (cds.length != amounts.length) revert Misconfigured(); + + uint256 totalDeposit; + + // iterate through and burn CD tokens, adding deposit and conversion amounts to running totals + for (uint256 i; i < cds.length; ++i) { + CD storage cd = cdInfo[msg.sender][i]; + + uint256 amount = amounts[i]; + uint256 converting = ((cd.convertable * amount) / cd.deposit); + + // increment running totals + totalDeposit += amount; + converted += converting; + + // decrement deposit info + cd.convertable -= converting; // reverts on overflow + cd.deposit -= amount; } - // compute shares to send + // compute and account for shares to send to treasury uint256 shares = sReserve.previewWithdraw(totalDeposit); totalShares -= shares; - // mint convert token, and send wrapped debt token to treasury - MINTR.mintOhm(msg.sender, totalConvert); + // burn cdUSDS + cdUSDS.burn(msg.sender, totalDeposit); + + // mint ohm and send underlying debt token to treasury + MINTR.mintOhm(msg.sender, converted); sReserve.transfer(address(TRSRY), shares); - emit ConvertedCD(msg.sender, totalDeposit, totalConvert); + emit ConvertedCD(msg.sender, totalDeposit, converted); } /// @notice allow user to reclaim their convertible debt deposits after expiration - /// @param ids IDs of convertible debts to reclaim - /// @return totalDeposit total reserve tokens relinquished - function reclaimDeposit(uint256[] memory ids) external returns (uint256 totalDeposit) { - // iterate through list of ids, add to total, and delete cd entries - for (uint256 i; i < ids.length; ++i) { - ConvertibleDebt memory cd = cdsFor[msg.sender][i]; - if (cd.expiry > block.timestamp) { - // reduce mint approval - MINTR.decreaseMintApproval(address(this), cd.convert); - - totalDeposit += cd.deposit; - delete cdsFor[msg.sender][i]; + /// @param cds CD indexes to return + /// @param amounts amounts of CD tokens to burn + /// @return returned total reserve tokens returned + function returnDeposit( + uint256[] memory cds, + uint256[] memory amounts + ) external returns (uint256 returned) { + if (cds.length != amounts.length) revert Misconfigured(); + + uint256 unconverted; + + // iterate through and burn CD tokens, adding deposit and conversion amounts to running totals + for (uint256 i; i < cds.length; ++i) { + CD memory cd = cdInfo[msg.sender][cds[i]]; + uint256 amount = amounts[i]; + uint256 convertable = ((cd.convertable * amount) / cd.deposit); + + if (cd.expiry < block.timestamp) { + returned += amount; + unconverted += convertable; + + // decrement deposit info + cd.convertable -= convertable; // reverts on overflow + cd.deposit -= amount; } } + // burn cdUSDS + cdUSDS.burn(msg.sender, returned); + // compute shares to redeem - uint256 shares = sReserve.previewWithdraw(totalDeposit); + uint256 shares = sReserve.previewWithdraw(returned); totalShares -= shares; - // undeploy and return debt token to user + // return debt token to user sReserve.redeem(shares, msg.sender, address(this)); - emit ReclaimedCD(msg.sender, totalDeposit); + // decrease mint approval to reflect tokens that will not convert + MINTR.decreaseMintApproval(address(this), unconverted); + + emit ReclaimedCD(msg.sender, returned); + } + + // ========== cdUSDS ========== // + + /// @notice allow non cd holder to sell cdUSDS for USDS + /// @notice the amount of USDS per cdUSDS is not 1:1 + /// @notice convertible depositors should use returnDeposit() for 1:1 + function redeem(uint256 amount) external returns (uint256 tokensOut) { + // burn cdUSDS + cdUSDS.burn(msg.sender, amount); + + // compute shares to redeem + tokensOut = redeemOutput(amount); + uint256 shares = sReserve.previewWithdraw(tokensOut); + totalShares -= shares; + + // return debt token to user + sReserve.redeem(shares, msg.sender, address(this)); } // ========== YIELD MANAGER ========== // @@ -180,6 +238,15 @@ contract CDFacility is Policy, RolesConsumer { emit SweptYield(msg.sender, yield); } + // ========== GOVERNOR ========== // + + /// @notice allow admin to change redeem rate + /// @dev redeem rate must be lower than or equal to 1:1 + function setRedeemRate(uint256 newRate) external onlyRole("CD_Admin") { + if (newRate > DECIMALS) revert Misconfigured(); + redeemRate = newRate; + } + // ========== VIEW FUNCTIONS ========== // /// @notice get yield accrued on deposited reserve tokens @@ -188,23 +255,10 @@ contract CDFacility is Policy, RolesConsumer { return sReserve.previewRedeem(totalShares) - totalDeposits; } - /// @notice return all existing CD IDs for user - /// @param user to search for - /// @return ids for user - function idsForUser(address user) external view returns (uint256[] memory ids) { - uint256 j; - for (uint256 i; i < cdsFor[user].length; ++i) { - ConvertibleDebt memory cd = cdsFor[user][i]; - if (cd.deposit > 0) ids[j] = i; - ++j; - } - } - - /// @notice query whether a given CD ID is expired - /// @param user who holds the CD - /// @param id of the CD to query - /// @return status whether the CD is expired - function idExpired(address user, uint256 id) external view returns (bool status) { - status = cdsFor[user][id].expiry > block.timestamp; + /// @notice amount of deposit tokens out for amount of cdUSDS redeemed + /// @param amount of cdUSDS in + /// @return output amount of USDS out + function redeemOutput(uint256 amount) public view returns (uint256) { + return (amount * redeemRate) / DECIMALS; } } From 4d55f12b70fbaa596492d5425490981c0bac1aac Mon Sep 17 00:00:00 2001 From: ohmzeus <93612359+chefomi@users.noreply.github.com> Date: Sat, 7 Dec 2024 00:38:52 -0500 Subject: [PATCH 05/64] integrate with emissions manager # Conflicts: # src/policies/EmissionManager.sol --- src/policies/CDAuctioneer.sol | 28 +++++++--- src/policies/CDFacility.sol | 32 +++++++++--- src/policies/EmissionManager.sol | 88 +++++++++++++++++++++++++++----- 3 files changed, 120 insertions(+), 28 deletions(-) diff --git a/src/policies/CDAuctioneer.sol b/src/policies/CDAuctioneer.sol index a8bee333..4eab7700 100644 --- a/src/policies/CDAuctioneer.sol +++ b/src/policies/CDAuctioneer.sol @@ -27,12 +27,17 @@ contract CDAuctioneer is Policy, RolesConsumer { struct State { uint256 target; // number of ohm per day uint256 tickSize; // number of ohm in a tick - uint256 tickStep; // percentage increase (decrease) per tick uint256 minPrice; // minimum tick price + uint256 tickStep; // percentage increase (decrease) per tick uint256 timeToExpiry; // time between creation and expiry of deposit uint256 lastUpdate; // timestamp of last update to current tick } + struct Day { + uint256 deposits; // total deposited for day + uint256 convertable; // total convertable for day + } + struct Tick { uint256 price; uint256 capacity; @@ -51,6 +56,8 @@ contract CDAuctioneer is Policy, RolesConsumer { mapping(uint256 => mapping(uint256 => address)) public cdTokens; // mapping(expiry => price => token) + Day public today; + // ========== SETUP ========== // constructor(Kernel kernel_) Policy(kernel_) {} @@ -86,13 +93,16 @@ contract CDAuctioneer is Policy, RolesConsumer { ? state.tickSize : deposit; if (amount != state.tickSize) currentTick.capacity -= amount; + else currentTick.price *= state.tickStep / decimals; // decrement bid and increment tick price deposit -= amount; - currentTick.price *= state.tickStep / decimals; convertable += convertFor(amount, currentTick.price); } + today.deposits += deposit; + today.convertable += convertable; + // mint amount of CD token cdFacility.addNewCD(msg.sender, deposit, convertable, block.timestamp + state.timeToExpiry); } @@ -139,19 +149,19 @@ contract CDAuctioneer is Policy, RolesConsumer { /// @dev only callable by the auction admin /// @param newTarget new target sale per day /// @param newSize new size per tick - /// @param newStep new percentage change per tick /// @param newMinPrice new minimum tick price function beat( uint256 newTarget, uint256 newSize, - uint256 newStep, uint256 newMinPrice - ) external onlyRole("CD_Auction_Admin") { + ) external onlyRole("CD_Auction_Admin") returns (uint256 remainder) { + remainder = (state.target > today.convertable) ? state.target - today.convertable : 0; + state = State( newTarget, newSize, - newStep, newMinPrice, + state.tickStep, state.timeToExpiry, state.lastUpdate ); @@ -162,4 +172,10 @@ contract CDAuctioneer is Policy, RolesConsumer { function setTimeToExpiry(uint256 newTime) external onlyRole("CD_Admin") { state.timeToExpiry = newTime; } + + /// @notice update change between ticks + /// @param newStep percentage in decimal terms + function setTickStep(uint256 newStep) external onlyRole("CD_Admin") { + state.tickStep = newStep; + } } diff --git a/src/policies/CDFacility.sol b/src/policies/CDFacility.sol index dc0c3a53..46121ffc 100644 --- a/src/policies/CDFacility.sol +++ b/src/policies/CDFacility.sol @@ -18,6 +18,7 @@ interface CDRC20 { function burn(address from, uint256 amount) external; function convertFor(uint256 amount) external view returns (uint256); function expiry() external view returns (uint256); + function totalSupply() external view returns (uint256); } contract CDFacility is Policy, RolesConsumer { @@ -132,6 +133,7 @@ contract CDFacility is Policy, RolesConsumer { // iterate through and burn CD tokens, adding deposit and conversion amounts to running totals for (uint256 i; i < cds.length; ++i) { CD storage cd = cdInfo[msg.sender][i]; + if (cd.expiry < block.timestamp) continue; uint256 amount = amounts[i]; uint256 converting = ((cd.convertable * amount) / cd.deposit); @@ -174,17 +176,17 @@ contract CDFacility is Policy, RolesConsumer { // iterate through and burn CD tokens, adding deposit and conversion amounts to running totals for (uint256 i; i < cds.length; ++i) { CD memory cd = cdInfo[msg.sender][cds[i]]; + if (cd.expiry >= block.timestamp) continue; + uint256 amount = amounts[i]; uint256 convertable = ((cd.convertable * amount) / cd.deposit); - if (cd.expiry < block.timestamp) { - returned += amount; - unconverted += convertable; + returned += amount; + unconverted += convertable; - // decrement deposit info - cd.convertable -= convertable; // reverts on overflow - cd.deposit -= amount; - } + // decrement deposit info + cd.deposit -= amount; + cd.convertable -= convertable; // reverts on overflow } // burn cdUSDS @@ -205,6 +207,19 @@ contract CDFacility is Policy, RolesConsumer { // ========== cdUSDS ========== // + /// @notice allow user to mint cdUSDS + /// @notice redeeming without a CD may be at a discount + /// @param amount of reserve token + /// @return tokensOut cdUSDS out (1:1 with USDS in) + function mint(uint256 amount) external returns (uint256 tokensOut) { + tokensOut = amount; + + reserve.transferFrom(msg.sender, address(this), amount); + totalShares += sReserve.deposit(amount, msg.sender); + + cdUSDS.mint(msg.sender, amount); + } + /// @notice allow non cd holder to sell cdUSDS for USDS /// @notice the amount of USDS per cdUSDS is not 1:1 /// @notice convertible depositors should use returnDeposit() for 1:1 @@ -231,8 +246,9 @@ contract CDFacility is Policy, RolesConsumer { onlyRole("CD_Yield_Manager") returns (uint256 yield, uint256 shares) { - yield = sReserve.previewRedeem(totalShares) - totalDeposits; + yield = sReserve.previewRedeem(totalShares) - cdUSDS.totalSupply(); shares = sReserve.previewWithdraw(yield); + totalShares -= shares; sReserve.transfer(msg.sender, shares); emit SweptYield(msg.sender, yield); diff --git a/src/policies/EmissionManager.sol b/src/policies/EmissionManager.sol index 2afa9493..a8a962af 100644 --- a/src/policies/EmissionManager.sol +++ b/src/policies/EmissionManager.sol @@ -19,6 +19,7 @@ import {MINTRv1} from "modules/MINTR/MINTR.v1.sol"; import {CHREGv1} from "modules/CHREG/CHREG.v1.sol"; import {IEmissionManager} from "policies/interfaces/IEmissionManager.sol"; +import {CDAuctioneer} from "./CDAuctioneer.sol"; interface BurnableERC20 { function burn(uint256 amount) external; @@ -53,8 +54,9 @@ contract EmissionManager is IEmissionManager, Policy, RolesConsumer { ERC4626 public immutable sReserve; // External contracts - IBondSDA public auctioneer; + IBondSDA public bondAuctioneer; address public teller; + CDAuctioneer public cdAuctioneer; // Manager variables uint256 public baseEmissionRate; @@ -64,6 +66,8 @@ contract EmissionManager is IEmissionManager, Policy, RolesConsumer { uint8 public beatCounter; bool public locallyActive; uint256 public activeMarketId; + uint256 public tickSizeScalar; + uint256 public minPriceScalar; uint8 internal _oracleDecimals; uint8 internal immutable _ohmDecimals; @@ -85,7 +89,8 @@ contract EmissionManager is IEmissionManager, Policy, RolesConsumer { address gohm_, address reserve_, address sReserve_, - address auctioneer_, + address bondAuctioneer_, + address cdAuctioneer_, address teller_ ) Policy(kernel_) { // Set immutable variables @@ -93,13 +98,15 @@ contract EmissionManager is IEmissionManager, Policy, RolesConsumer { if (gohm_ == address(0)) revert("gOHM address cannot be 0"); if (reserve_ == address(0)) revert("DAI address cannot be 0"); if (sReserve_ == address(0)) revert("sDAI address cannot be 0"); - if (auctioneer_ == address(0)) revert("Auctioneer address cannot be 0"); + if (bondAuctioneer_ == address(0)) revert("Bond Auctioneer address cannot be 0"); + if (cdAuctioneer_ == address(0)) revert("CD Auctioneer address cannot be 0"); ohm = ERC20(ohm_); gohm = IgOHM(gohm_); reserve = ERC20(reserve_); sReserve = ERC4626(sReserve_); - auctioneer = IBondSDA(auctioneer_); + bondAuctioneer = IBondSDA(bondAuctioneer_); + cdAuctioneer = CDAuctioneer(cdAuctioneer_); teller = teller_; _ohmDecimals = ohm.decimals(); @@ -156,12 +163,18 @@ contract EmissionManager is IEmissionManager, Policy, RolesConsumer { } // It then calculates the amount to sell for the coming day - (, , uint256 sell) = getNextSale(); + (, , uint256 emission) = getNextEmission(); + + uint256 remainder = cdAuctioneer.beat( + emission, + getSizeFor(emission), + getMinPriceFor(PRICE.getCurrentPrice()) + ); // And then opens a market if applicable - if (sell != 0) { - MINTR.increaseMintApproval(address(this), sell); - _createMarket(sell); + if (remainder != 0) { + MINTR.increaseMintApproval(address(this), remainder); + _createMarket(remainder); } } @@ -176,6 +189,8 @@ contract EmissionManager is IEmissionManager, Policy, RolesConsumer { uint256 baseEmissionsRate_, uint256 minimumPremium_, uint256 backing_, + uint256 tickScalar, + uint256 priceScalar, uint48 restartTimeframe_ ) external onlyRole("emissions_admin") { // Cannot initialize if currently active @@ -192,12 +207,18 @@ contract EmissionManager is IEmissionManager, Policy, RolesConsumer { if (minimumPremium_ == 0) revert InvalidParam("minimumPremium"); if (backing_ == 0) revert InvalidParam("backing"); if (restartTimeframe_ == 0) revert InvalidParam("restartTimeframe"); + if (tickScalar == 0 || tickScalar > ONE_HUNDRED_PERCENT) + revert InvalidParam("Tick Size Scalar"); + if (priceScalar == 0 || priceScalar > ONE_HUNDRED_PERCENT) + revert InvalidParam("Tick Size Scalar"); // Assign baseEmissionRate = baseEmissionsRate_; minimumPremium = minimumPremium_; backing = backing_; restartTimeframe = restartTimeframe_; + tickSizeScalar = tickScalar; + minPriceScalar = priceScalar; // Activate locallyActive = true; @@ -258,7 +279,7 @@ contract EmissionManager is IEmissionManager, Policy, RolesConsumer { ); // Create new bond market to buy the reserve with OHM - activeMarketId = auctioneer.createMarket( + activeMarketId = bondAuctioneer.createMarket( abi.encode( IBondSDA.MarketParams({ payoutToken: ohm, @@ -417,22 +438,47 @@ contract EmissionManager is IEmissionManager, Policy, RolesConsumer { } /// @notice allow governance to set the bond contracts used by the emission manager - /// @param auctioneer_ address of the bond auctioneer contract + /// @param bondAuctioneer_ address of the bond auctioneer contract /// @param teller_ address of the bond teller contract function setBondContracts( - address auctioneer_, + address bondAuctioneer_, address teller_ ) external onlyRole("emissions_admin") { // Bond contracts cannot be set to the zero address - if (auctioneer_ == address(0)) revert InvalidParam("auctioneer"); + if (bondAuctioneer_ == address(0)) revert InvalidParam("bondAuctioneer"); if (teller_ == address(0)) revert InvalidParam("teller"); - auctioneer = IBondSDA(auctioneer_); + bondAuctioneer = IBondSDA(bondAuctioneer_); teller = teller_; emit BondContractsSet(auctioneer_, teller_); } + /// @notice allow governance to set the CD contract used by the emission manager + /// @param cdAuctioneer_ address of the cd auctioneer contract + function setCDAuctionContract(address cdAuctioneer_) external onlyRole("emissions_admin") { + // Auction contract cannot be set to the zero address + if (cdAuctioneer_ == address(0)) revert InvalidParam("cdAuctioneer"); + + cdAuctioneer = CDAuctioneer(cdAuctioneer_); + } + + /// @notice allow governance to set the CD tick size scalar + /// @param newScalar as a percentage in 18 decimals + function setTickSizeScalar(uint256 newScalar) external onlyRole("emissions_admin") { + if (newScalar == 0 || newScalar > ONE_HUNDRED_PERCENT) + revert InvalidParam("Tick Size Scalar"); + tickSizeScalar = newScalar; + } + + /// @notice allow governance to set the CD minimum price scalar + /// @param newScalar as a percentage in 18 decimals + function setMinPriceScalar(uint256 newScalar) external onlyRole("emissions_admin") { + if (newScalar == 0 || newScalar > ONE_HUNDRED_PERCENT) + revert InvalidParam("Min Price Scalar"); + minPriceScalar = newScalar; + } + // =========- VIEW FUNCTIONS ========== // /// @notice return reserves, measured as clearinghouse receivables and sReserve balances, in reserve denomination @@ -460,7 +506,7 @@ contract EmissionManager is IEmissionManager, Policy, RolesConsumer { } /// @notice return the next sale amount, premium, emission rate, and emissions based on the current premium - function getNextSale() + function getNextEmission() public view returns (uint256 premium, uint256 emissionRate, uint256 emission) @@ -476,4 +522,18 @@ contract EmissionManager is IEmissionManager, Policy, RolesConsumer { emission = (getSupply() * emissionRate) / 10 ** _ohmDecimals; // OHM Scale * OHM Scale / OHM Scale = OHM Scale } } + + /// @notice get CD auction tick size for a given target + /// @param target size of day's CD auction + /// @return size of tick + function getSizeFor(uint256 target) public view returns (uint256) { + return (target * tickSizeScalar) / ONE_HUNDRED_PERCENT; + } + + /// @notice get CD auction minimum price for given current price + /// @param price of OHM on market according to PRICE module + /// @return minPrice for CD auction + function getMinPriceFor(uint256 price) public view returns (uint256) { + return (price * minPriceScalar) / ONE_HUNDRED_PERCENT; + } } From 61a73777a1bd2b333ca973520a611d3614c5329d Mon Sep 17 00:00:00 2001 From: Jem <0x0xjem@gmail.com> Date: Mon, 9 Dec 2024 12:15:31 +0400 Subject: [PATCH 06/64] Fix compilation errors --- src/policies/EmissionManager.sol | 6 +++--- src/scripts/deploy/DeployV2.sol | 23 ++++++++++++++++++++++- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/src/policies/EmissionManager.sol b/src/policies/EmissionManager.sol index a8a962af..a1a94a77 100644 --- a/src/policies/EmissionManager.sol +++ b/src/policies/EmissionManager.sol @@ -344,8 +344,8 @@ contract EmissionManager is IEmissionManager, Policy, RolesConsumer { shutdownTimestamp = uint48(block.timestamp); // Shutdown the bond market, if it is active - if (auctioneer.isLive(activeMarketId)) { - auctioneer.closeMarket(activeMarketId); + if (bondAuctioneer.isLive(activeMarketId)) { + bondAuctioneer.closeMarket(activeMarketId); } emit Deactivated(); @@ -451,7 +451,7 @@ contract EmissionManager is IEmissionManager, Policy, RolesConsumer { bondAuctioneer = IBondSDA(bondAuctioneer_); teller = teller_; - emit BondContractsSet(auctioneer_, teller_); + emit BondContractsSet(bondAuctioneer_, teller_); } /// @notice allow governance to set the CD contract used by the emission manager diff --git a/src/scripts/deploy/DeployV2.sol b/src/scripts/deploy/DeployV2.sol index 5184fdfe..13283730 100644 --- a/src/scripts/deploy/DeployV2.sol +++ b/src/scripts/deploy/DeployV2.sol @@ -63,6 +63,7 @@ import {Clearinghouse} from "policies/Clearinghouse.sol"; import {YieldRepurchaseFacility} from "policies/YieldRepurchaseFacility.sol"; import {ReserveMigrator} from "policies/ReserveMigrator.sol"; import {EmissionManager} from "policies/EmissionManager.sol"; +import {CDAuctioneer} from "policies/CDAuctioneer.sol"; import {MockPriceFeed} from "src/test/mocks/MockPriceFeed.sol"; import {MockAuraBooster, MockAuraRewardPool, MockAuraMiningLib, MockAuraVirtualRewardPool, MockAuraStashToken} from "src/test/mocks/AuraMocks.sol"; @@ -111,6 +112,7 @@ contract OlympusDeploy is Script { YieldRepurchaseFacility public yieldRepo; ReserveMigrator public reserveMigrator; EmissionManager public emissionManager; + CDAuctioneer public cdAuctioneer; /// Other Olympus contracts OlympusAuthority public burnerReplacementAuthority; @@ -223,6 +225,7 @@ contract OlympusDeploy is Script { selectorMap["YieldRepurchaseFacility"] = this._deployYieldRepurchaseFacility.selector; selectorMap["ReserveMigrator"] = this._deployReserveMigrator.selector; selectorMap["EmissionManager"] = this._deployEmissionManager.selector; + selectorMap["ConvertibleDebtAuctioneer"] = this._deployConvertibleDebtAuctioneer.selector; // Governance selectorMap["Timelock"] = this._deployTimelock.selector; @@ -312,6 +315,7 @@ contract OlympusDeploy is Script { yieldRepo = YieldRepurchaseFacility(envAddress("olympus.policies.YieldRepurchaseFacility")); reserveMigrator = ReserveMigrator(envAddress("olympus.policies.ReserveMigrator")); emissionManager = EmissionManager(envAddress("olympus.policies.EmissionManager")); + cdAuctioneer = CDAuctioneer(envAddress("olympus.policies.ConvertibleDebtAuctioneer")); // Governance timelock = Timelock(payable(envAddress("olympus.governance.Timelock"))); @@ -1203,7 +1207,8 @@ contract OlympusDeploy is Script { console2.log(" gohm", address(gohm)); console2.log(" reserve", address(reserve)); console2.log(" sReserve", address(sReserve)); - console2.log(" auctioneer", address(bondAuctioneer)); + console2.log(" bondAuctioneer", address(bondAuctioneer)); + console2.log(" cdAuctioneer", address(cdAuctioneer)); console2.log(" teller", address(bondFixedTermTeller)); // Deploy EmissionManager @@ -1215,6 +1220,7 @@ contract OlympusDeploy is Script { address(reserve), address(sReserve), address(bondAuctioneer), + address(cdAuctioneer), address(bondFixedTermTeller) ); @@ -1223,6 +1229,21 @@ contract OlympusDeploy is Script { return address(emissionManager); } + function _deployConvertibleDebtAuctioneer(bytes calldata) public returns (address) { + // No additional arguments for ConvertibleDebtAuctioneer + + // Log dependencies + console2.log("ConvertibleDebtAuctioneer parameters:"); + console2.log(" kernel", address(kernel)); + + // Deploy ConvertibleDebtAuctioneer + vm.broadcast(); + cdAuctioneer = new CDAuctioneer(kernel); + console2.log("ConvertibleDebtAuctioneer deployed at:", address(cdAuctioneer)); + + return address(cdAuctioneer); + } + // ========== VERIFICATION ========== // /// @dev Verifies that the environment variable addresses were set correctly following deployment From 9068ba08f3b0f8d6a530aa44d9f65ff49892d7fb Mon Sep 17 00:00:00 2001 From: Jem <0x0xjem@gmail.com> Date: Mon, 9 Dec 2024 12:15:37 +0400 Subject: [PATCH 07/64] chore: linting --- src/policies/CDAuctioneer.sol | 3 +++ src/policies/CDFacility.sol | 4 ++++ 2 files changed, 7 insertions(+) diff --git a/src/policies/CDAuctioneer.sol b/src/policies/CDAuctioneer.sol index 4eab7700..d5dc14c8 100644 --- a/src/policies/CDAuctioneer.sol +++ b/src/policies/CDAuctioneer.sol @@ -14,8 +14,11 @@ import {CDFacility} from "./CDFacility.sol"; interface CDRC20 { function mint(address to, uint256 amount) external; + function burn(address from, uint256 amount) external; + function convertFor(uint256 amount) external view returns (uint256); + function expiry() external view returns (uint256); } diff --git a/src/policies/CDFacility.sol b/src/policies/CDFacility.sol index 46121ffc..1c329702 100644 --- a/src/policies/CDFacility.sol +++ b/src/policies/CDFacility.sol @@ -15,9 +15,13 @@ import {FullMath} from "libraries/FullMath.sol"; interface CDRC20 { function mint(address to, uint256 amount) external; + function burn(address from, uint256 amount) external; + function convertFor(uint256 amount) external view returns (uint256); + function expiry() external view returns (uint256); + function totalSupply() external view returns (uint256); } From e41276b5dc6ce0331a6dc92b304e08dbd1aa4c90 Mon Sep 17 00:00:00 2001 From: Jem <0x0xjem@gmail.com> Date: Mon, 9 Dec 2024 12:36:39 +0400 Subject: [PATCH 08/64] Add custom error to contracts. Add missing CDFacility parameter to CDAuctioneer constructor. --- src/policies/CDAuctioneer.sol | 11 ++++++++++- src/policies/CDFacility.sol | 12 ++++++++++-- src/policies/EmissionManager.sol | 19 +++++++++++++------ src/scripts/deploy/DeployV2.sol | 24 +++++++++++++++++++++++- 4 files changed, 56 insertions(+), 10 deletions(-) diff --git a/src/policies/CDAuctioneer.sol b/src/policies/CDAuctioneer.sol index d5dc14c8..6f247acc 100644 --- a/src/policies/CDAuctioneer.sol +++ b/src/policies/CDAuctioneer.sol @@ -48,6 +48,10 @@ contract CDAuctioneer is Policy, RolesConsumer { // ========== EVENTS ========== // + // ========== ERRORS ========== // + + error CDAuctioneer_InvalidParams(string reason); + // ========== STATE VARIABLES ========== // Tick public currentTick; @@ -63,7 +67,12 @@ contract CDAuctioneer is Policy, RolesConsumer { // ========== SETUP ========== // - constructor(Kernel kernel_) Policy(kernel_) {} + constructor(Kernel kernel_, address cdFacility_) Policy(kernel_) { + if (cdFacility_ == address(0)) + revert CDAuctioneer_InvalidParams("CD Facility address cannot be 0"); + + cdFacility = CDFacility(cdFacility_); + } function configureDependencies() external override returns (Keycode[] memory dependencies) { dependencies = new Keycode[](1); diff --git a/src/policies/CDFacility.sol b/src/policies/CDFacility.sol index 1c329702..eb92f3be 100644 --- a/src/policies/CDFacility.sol +++ b/src/policies/CDFacility.sol @@ -28,8 +28,6 @@ interface CDRC20 { contract CDFacility is Policy, RolesConsumer { using FullMath for uint256; - error Misconfigured(); - struct CD { uint256 deposit; uint256 convertable; @@ -63,9 +61,19 @@ contract CDFacility is Policy, RolesConsumer { mapping(address => CD[]) public cdInfo; uint256 public redeemRate; + // ========== ERRORS ========== // + + error CDFacility_InvalidParams(string reason); + + error Misconfigured(); + // ========== SETUP ========== // constructor(Kernel kernel_, address reserve_, address sReserve_) Policy(kernel_) { + if (reserve_ == address(0)) revert CDFacility_InvalidParams("Reserve address cannot be 0"); + if (sReserve_ == address(0)) + revert CDFacility_InvalidParams("sReserve address cannot be 0"); + reserve = ERC20(reserve_); sReserve = ERC4626(sReserve_); } diff --git a/src/policies/EmissionManager.sol b/src/policies/EmissionManager.sol index a1a94a77..4122783f 100644 --- a/src/policies/EmissionManager.sol +++ b/src/policies/EmissionManager.sol @@ -81,6 +81,10 @@ contract EmissionManager is IEmissionManager, Policy, RolesConsumer { uint256 internal constant ONE_HUNDRED_PERCENT = 1e18; + // ========== ERRORS ========== // + + error EmissionManager_InvalidParams(string reason); + // ========== SETUP ========== // constructor( @@ -94,12 +98,15 @@ contract EmissionManager is IEmissionManager, Policy, RolesConsumer { address teller_ ) Policy(kernel_) { // Set immutable variables - if (ohm_ == address(0)) revert("OHM address cannot be 0"); - if (gohm_ == address(0)) revert("gOHM address cannot be 0"); - if (reserve_ == address(0)) revert("DAI address cannot be 0"); - if (sReserve_ == address(0)) revert("sDAI address cannot be 0"); - if (bondAuctioneer_ == address(0)) revert("Bond Auctioneer address cannot be 0"); - if (cdAuctioneer_ == address(0)) revert("CD Auctioneer address cannot be 0"); + if (ohm_ == address(0)) revert EmissionManager_InvalidParams("OHM address cannot be 0"); + if (gohm_ == address(0)) revert EmissionManager_InvalidParams("gOHM address cannot be 0"); + if (reserve_ == address(0)) revert EmissionManager_InvalidParams("DAI address cannot be 0"); + if (sReserve_ == address(0)) + revert EmissionManager_InvalidParams("sDAI address cannot be 0"); + if (bondAuctioneer_ == address(0)) + revert EmissionManager_InvalidParams("Bond Auctioneer address cannot be 0"); + if (cdAuctioneer_ == address(0)) + revert EmissionManager_InvalidParams("CD Auctioneer address cannot be 0"); ohm = ERC20(ohm_); gohm = IgOHM(gohm_); diff --git a/src/scripts/deploy/DeployV2.sol b/src/scripts/deploy/DeployV2.sol index 13283730..5e0392bd 100644 --- a/src/scripts/deploy/DeployV2.sol +++ b/src/scripts/deploy/DeployV2.sol @@ -64,6 +64,7 @@ import {YieldRepurchaseFacility} from "policies/YieldRepurchaseFacility.sol"; import {ReserveMigrator} from "policies/ReserveMigrator.sol"; import {EmissionManager} from "policies/EmissionManager.sol"; import {CDAuctioneer} from "policies/CDAuctioneer.sol"; +import {CDFacility} from "policies/CDFacility.sol"; import {MockPriceFeed} from "src/test/mocks/MockPriceFeed.sol"; import {MockAuraBooster, MockAuraRewardPool, MockAuraMiningLib, MockAuraVirtualRewardPool, MockAuraStashToken} from "src/test/mocks/AuraMocks.sol"; @@ -113,6 +114,7 @@ contract OlympusDeploy is Script { ReserveMigrator public reserveMigrator; EmissionManager public emissionManager; CDAuctioneer public cdAuctioneer; + CDFacility public cdFacility; /// Other Olympus contracts OlympusAuthority public burnerReplacementAuthority; @@ -226,6 +228,7 @@ contract OlympusDeploy is Script { selectorMap["ReserveMigrator"] = this._deployReserveMigrator.selector; selectorMap["EmissionManager"] = this._deployEmissionManager.selector; selectorMap["ConvertibleDebtAuctioneer"] = this._deployConvertibleDebtAuctioneer.selector; + selectorMap["ConvertibleDebtFacility"] = this._deployConvertibleDebtFacility.selector; // Governance selectorMap["Timelock"] = this._deployTimelock.selector; @@ -316,6 +319,7 @@ contract OlympusDeploy is Script { reserveMigrator = ReserveMigrator(envAddress("olympus.policies.ReserveMigrator")); emissionManager = EmissionManager(envAddress("olympus.policies.EmissionManager")); cdAuctioneer = CDAuctioneer(envAddress("olympus.policies.ConvertibleDebtAuctioneer")); + cdFacility = CDFacility(envAddress("olympus.policies.ConvertibleDebtFacility")); // Governance timelock = Timelock(payable(envAddress("olympus.governance.Timelock"))); @@ -1235,15 +1239,33 @@ contract OlympusDeploy is Script { // Log dependencies console2.log("ConvertibleDebtAuctioneer parameters:"); console2.log(" kernel", address(kernel)); + console2.log(" cdFacility", address(cdFacility)); // Deploy ConvertibleDebtAuctioneer vm.broadcast(); - cdAuctioneer = new CDAuctioneer(kernel); + cdAuctioneer = new CDAuctioneer(kernel, address(cdFacility)); console2.log("ConvertibleDebtAuctioneer deployed at:", address(cdAuctioneer)); return address(cdAuctioneer); } + function _deployConvertibleDebtFacility(bytes calldata) public returns (address) { + // No additional arguments for ConvertibleDebtFacility + + // Log dependencies + console2.log("ConvertibleDebtFacility parameters:"); + console2.log(" kernel", address(kernel)); + console2.log(" reserve", address(reserve)); + console2.log(" sReserve", address(sReserve)); + + // Deploy ConvertibleDebtFacility + vm.broadcast(); + cdFacility = new CDFacility(kernel, address(reserve), address(sReserve)); + console2.log("ConvertibleDebtFacility deployed at:", address(cdFacility)); + + return address(cdFacility); + } + // ========== VERIFICATION ========== // /// @dev Verifies that the environment variable addresses were set correctly following deployment From 74ebfa4661391dee33ca7d3302a23a61171dfc7c Mon Sep 17 00:00:00 2001 From: Jem <0x0xjem@gmail.com> Date: Mon, 9 Dec 2024 13:26:52 +0400 Subject: [PATCH 09/64] Extract CDAuctioneer into interface --- src/interfaces/IConvertibleDebtAuctioneer.sol | 106 ++++++++++++++++++ src/policies/CDAuctioneer.sol | 68 ++++------- src/policies/CDFacility.sol | 5 + src/policies/EmissionManager.sol | 8 +- 4 files changed, 139 insertions(+), 48 deletions(-) create mode 100644 src/interfaces/IConvertibleDebtAuctioneer.sol diff --git a/src/interfaces/IConvertibleDebtAuctioneer.sol b/src/interfaces/IConvertibleDebtAuctioneer.sol new file mode 100644 index 00000000..6bd7dab3 --- /dev/null +++ b/src/interfaces/IConvertibleDebtAuctioneer.sol @@ -0,0 +1,106 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.0; + +interface IConvertibleDebtAuctioneer { + // ========== EVENTS ========== // + + // ========== ERRORS ========== // + + error CDAuctioneer_InvalidParams(string reason); + + // ========== DATA STRUCTURES ========== // + + /// @notice State of the auction + /// + /// @param target number of ohm per day + /// @param tickSize number of ohm in a tick + /// @param minPrice minimum tick price + /// @param tickStep percentage increase (decrease) per tick + /// @param timeToExpiry time between creation and expiry of deposit + /// @param lastUpdate timestamp of last update to current tick + struct State { + uint256 target; + uint256 tickSize; + uint256 minPrice; + uint256 tickStep; + uint256 timeToExpiry; + uint256 lastUpdate; + } + + /// @notice Tracks auction activity for a given day + /// + /// @param deposits total deposited for day + /// @param convertable total convertable for day + struct Day { + uint256 deposits; + uint256 convertable; + } + + /// @notice Information about a tick + /// + /// @param price price of the tick + /// @param capacity capacity of the tick + struct Tick { + uint256 price; + uint256 capacity; + } + + // ========== AUCTION ========== // + + /// @notice Use a deposit to bid for CDs + /// + /// @param deposit amount of reserve tokens + /// @return convertable amount of convertable tokens + function bid(uint256 deposit) external returns (uint256 convertable); + + // ========== VIEW ========== // + + /// @notice Get the current tick + /// + /// @return tick info in Tick struct + function getCurrentTick() external view returns (Tick memory tick); + + /// @notice Get the current state + /// + /// @return state info in State struct + function getState() external view returns (State memory state); + + /// @notice Get the auction activity for the current day + /// + /// @return day info in Day struct + function getDay() external view returns (Day memory day); + + /// @notice Get the amount of convertable tokens for a deposit at a given price + /// + /// @param deposit amount of reserve tokens + /// @param price price of the tick + /// @return convertable amount of convertable tokens + function convertFor(uint256 deposit, uint256 price) external view returns (uint256 convertable); + + // ========== ADMIN ========== // + + /// @notice Update the auction parameters + /// @dev only callable by the auction admin + /// + /// @param newTarget new target sale per day + /// @param newSize new size per tick + /// @param newMinPrice new minimum tick price + /// @return remainder amount of ohm not sold + function beat( + uint256 newTarget, + uint256 newSize, + uint256 newMinPrice + ) external returns (uint256 remainder); + + /// @notice Set the time to expiry + /// @dev only callable by the admin + /// + /// @param newTime new time to expiry + function setTimeToExpiry(uint256 newTime) external; + + /// @notice Set the tick step + /// @dev only callable by the admin + /// + /// @param newStep new tick step + function setTickStep(uint256 newStep) external; +} diff --git a/src/policies/CDAuctioneer.sol b/src/policies/CDAuctioneer.sol index 6f247acc..529c85e1 100644 --- a/src/policies/CDAuctioneer.sol +++ b/src/policies/CDAuctioneer.sol @@ -6,9 +6,10 @@ import "src/Kernel.sol"; import {ReentrancyGuard} from "solmate/utils/ReentrancyGuard.sol"; import {ERC20} from "solmate/tokens/ERC20.sol"; -import {RolesConsumer, ROLESv1} from "modules/ROLES/OlympusRoles.sol"; +import {RolesConsumer, ROLESv1} from "src/modules/ROLES/OlympusRoles.sol"; +import {IConvertibleDebtAuctioneer} from "src/interfaces/IConvertibleDebtAuctioneer.sol"; -import {FullMath} from "libraries/FullMath.sol"; +import {FullMath} from "src/libraries/FullMath.sol"; import {CDFacility} from "./CDFacility.sol"; @@ -22,41 +23,15 @@ interface CDRC20 { function expiry() external view returns (uint256); } -contract CDAuctioneer is Policy, RolesConsumer { +contract CDAuctioneer is IConvertibleDebtAuctioneer, Policy, RolesConsumer { using FullMath for uint256; - // ========== DATA STRUCTURES ========== // - - struct State { - uint256 target; // number of ohm per day - uint256 tickSize; // number of ohm in a tick - uint256 minPrice; // minimum tick price - uint256 tickStep; // percentage increase (decrease) per tick - uint256 timeToExpiry; // time between creation and expiry of deposit - uint256 lastUpdate; // timestamp of last update to current tick - } - - struct Day { - uint256 deposits; // total deposited for day - uint256 convertable; // total convertable for day - } - - struct Tick { - uint256 price; - uint256 capacity; - } - - // ========== EVENTS ========== // - - // ========== ERRORS ========== // - - error CDAuctioneer_InvalidParams(string reason); - // ========== STATE VARIABLES ========== // Tick public currentTick; State public state; + // TODO set decimals, make internal? uint256 public decimals; CDFacility public cdFacility; @@ -90,10 +65,8 @@ contract CDAuctioneer is Policy, RolesConsumer { // ========== AUCTION ========== // - /// @notice use a deposit to bid for CDs - /// @param deposit amount of reserve tokens - /// @return convertable amount of convertable tokens - function bid(uint256 deposit) external returns (uint256 convertable) { + /// @inheritdoc IConvertibleDebtAuctioneer + function bid(uint256 deposit) external override returns (uint256 convertable) { // update state currentTick = getCurrentTick(); state.lastUpdate = block.timestamp; @@ -121,10 +94,8 @@ contract CDAuctioneer is Policy, RolesConsumer { // ========== VIEW FUNCTIONS ========== // - /// @notice get current tick info - /// @dev time passing changes tick info - /// @return tick info in Tick struct - function getCurrentTick() public view returns (Tick memory tick) { + /// @inheritdoc IConvertibleDebtAuctioneer + function getCurrentTick() public view override returns (Tick memory tick) { // find amount of time passed and new capacity to add uint256 timePassed = block.timestamp - state.lastUpdate; uint256 newCapacity = (state.target * timePassed) / 1 days; @@ -149,12 +120,21 @@ contract CDAuctioneer is Policy, RolesConsumer { tick.capacity = newCapacity; } - /// @notice get amount of cdOHM for a deposit at a tick price - /// @return amount convertable - function convertFor(uint256 deposit, uint256 price) public view returns (uint256) { + /// @inheritdoc IConvertibleDebtAuctioneer + function convertFor(uint256 deposit, uint256 price) public view override returns (uint256) { return (deposit * decimals) / price; } + /// @inheritdoc IConvertibleDebtAuctioneer + function getState() external view override returns (State memory) { + return state; + } + + /// @inheritdoc IConvertibleDebtAuctioneer + function getDay() external view override returns (Day memory) { + return today; + } + // ========== ADMIN FUNCTIONS ========== // /// @notice update auction parameters @@ -166,7 +146,7 @@ contract CDAuctioneer is Policy, RolesConsumer { uint256 newTarget, uint256 newSize, uint256 newMinPrice - ) external onlyRole("CD_Auction_Admin") returns (uint256 remainder) { + ) external override onlyRole("CD_Auction_Admin") returns (uint256 remainder) { remainder = (state.target > today.convertable) ? state.target - today.convertable : 0; state = State( @@ -181,13 +161,13 @@ contract CDAuctioneer is Policy, RolesConsumer { /// @notice update time between creation and expiry of deposit /// @param newTime number of seconds - function setTimeToExpiry(uint256 newTime) external onlyRole("CD_Admin") { + function setTimeToExpiry(uint256 newTime) external override onlyRole("CD_Admin") { state.timeToExpiry = newTime; } /// @notice update change between ticks /// @param newStep percentage in decimal terms - function setTickStep(uint256 newStep) external onlyRole("CD_Admin") { + function setTickStep(uint256 newStep) external override onlyRole("CD_Admin") { state.tickStep = newStep; } } diff --git a/src/policies/CDFacility.sol b/src/policies/CDFacility.sol index eb92f3be..420c01b0 100644 --- a/src/policies/CDFacility.sol +++ b/src/policies/CDFacility.sol @@ -13,6 +13,7 @@ import {TRSRYv1} from "modules/TRSRY/TRSRY.v1.sol"; import {FullMath} from "libraries/FullMath.sol"; +// TODO extract into external file interface CDRC20 { function mint(address to, uint256 amount) external; @@ -25,6 +26,8 @@ interface CDRC20 { function totalSupply() external view returns (uint256); } +// TODO extract CDFacility interface + contract CDFacility is Policy, RolesConsumer { using FullMath for uint256; @@ -69,6 +72,8 @@ contract CDFacility is Policy, RolesConsumer { // ========== SETUP ========== // + // TODO add cdUSDS parameter + constructor(Kernel kernel_, address reserve_, address sReserve_) Policy(kernel_) { if (reserve_ == address(0)) revert CDFacility_InvalidParams("Reserve address cannot be 0"); if (sReserve_ == address(0)) diff --git a/src/policies/EmissionManager.sol b/src/policies/EmissionManager.sol index 4122783f..1330f070 100644 --- a/src/policies/EmissionManager.sol +++ b/src/policies/EmissionManager.sol @@ -19,7 +19,7 @@ import {MINTRv1} from "modules/MINTR/MINTR.v1.sol"; import {CHREGv1} from "modules/CHREG/CHREG.v1.sol"; import {IEmissionManager} from "policies/interfaces/IEmissionManager.sol"; -import {CDAuctioneer} from "./CDAuctioneer.sol"; +import {IConvertibleDebtAuctioneer} from "src/interfaces/IConvertibleDebtAuctioneer.sol"; interface BurnableERC20 { function burn(uint256 amount) external; @@ -56,7 +56,7 @@ contract EmissionManager is IEmissionManager, Policy, RolesConsumer { // External contracts IBondSDA public bondAuctioneer; address public teller; - CDAuctioneer public cdAuctioneer; + IConvertibleDebtAuctioneer public cdAuctioneer; // Manager variables uint256 public baseEmissionRate; @@ -113,7 +113,7 @@ contract EmissionManager is IEmissionManager, Policy, RolesConsumer { reserve = ERC20(reserve_); sReserve = ERC4626(sReserve_); bondAuctioneer = IBondSDA(bondAuctioneer_); - cdAuctioneer = CDAuctioneer(cdAuctioneer_); + cdAuctioneer = IConvertibleDebtAuctioneer(cdAuctioneer_); teller = teller_; _ohmDecimals = ohm.decimals(); @@ -467,7 +467,7 @@ contract EmissionManager is IEmissionManager, Policy, RolesConsumer { // Auction contract cannot be set to the zero address if (cdAuctioneer_ == address(0)) revert InvalidParam("cdAuctioneer"); - cdAuctioneer = CDAuctioneer(cdAuctioneer_); + cdAuctioneer = IConvertibleDebtAuctioneer(cdAuctioneer_); } /// @notice allow governance to set the CD tick size scalar From fb68663bcb2bc18bc031898207f5dbf1091d578f Mon Sep 17 00:00:00 2001 From: Jem <0x0xjem@gmail.com> Date: Mon, 9 Dec 2024 13:40:18 +0400 Subject: [PATCH 10/64] Fix compilation errors for EmissionManager test --- src/policies/EmissionManager.sol | 18 ++- .../mocks/MockConvertibleDebtAuctioneer.sol | 49 +++++++ src/test/policies/EmissionManager.t.sol | 138 +++++++++++++----- 3 files changed, 164 insertions(+), 41 deletions(-) create mode 100644 src/test/mocks/MockConvertibleDebtAuctioneer.sol diff --git a/src/policies/EmissionManager.sol b/src/policies/EmissionManager.sol index 1330f070..3b849f8c 100644 --- a/src/policies/EmissionManager.sol +++ b/src/policies/EmissionManager.sol @@ -188,16 +188,19 @@ contract EmissionManager is IEmissionManager, Policy, RolesConsumer { // ========== INITIALIZE ========== // /// @notice allow governance to initialize the emission manager + /// /// @param baseEmissionsRate_ percent of OHM supply to issue per day at the minimum premium, in OHM scale, i.e. 1e9 = 100% /// @param minimumPremium_ minimum premium at which to issue OHM, a percentage where 1e18 is 100% /// @param backing_ backing price of OHM in reserve token, in reserve scale + /// @param tickSizeScalar_ scalar for tick size + /// @param minPriceScalar_ scalar for min price /// @param restartTimeframe_ time in seconds that the manager needs to be restarted after a shutdown, otherwise it must be re-initialized function initialize( uint256 baseEmissionsRate_, uint256 minimumPremium_, uint256 backing_, - uint256 tickScalar, - uint256 priceScalar, + uint256 tickSizeScalar_, + uint256 minPriceScalar_, uint48 restartTimeframe_ ) external onlyRole("emissions_admin") { // Cannot initialize if currently active @@ -214,18 +217,18 @@ contract EmissionManager is IEmissionManager, Policy, RolesConsumer { if (minimumPremium_ == 0) revert InvalidParam("minimumPremium"); if (backing_ == 0) revert InvalidParam("backing"); if (restartTimeframe_ == 0) revert InvalidParam("restartTimeframe"); - if (tickScalar == 0 || tickScalar > ONE_HUNDRED_PERCENT) - revert InvalidParam("Tick Size Scalar"); - if (priceScalar == 0 || priceScalar > ONE_HUNDRED_PERCENT) + if (tickSizeScalar_ == 0 || tickSizeScalar_ > ONE_HUNDRED_PERCENT) revert InvalidParam("Tick Size Scalar"); + if (minPriceScalar_ == 0 || minPriceScalar_ > ONE_HUNDRED_PERCENT) + revert InvalidParam("Min Price Scalar"); // Assign baseEmissionRate = baseEmissionsRate_; minimumPremium = minimumPremium_; backing = backing_; restartTimeframe = restartTimeframe_; - tickSizeScalar = tickScalar; - minPriceScalar = priceScalar; + tickSizeScalar = tickSizeScalar_; + minPriceScalar = minPriceScalar_; // Activate locallyActive = true; @@ -234,6 +237,7 @@ contract EmissionManager is IEmissionManager, Policy, RolesConsumer { emit MinimumPremiumChanged(minimumPremium_); emit BackingChanged(backing_); emit RestartTimeframeChanged(restartTimeframe_); + // TODO emit tickSizeScalar and minPriceScalar } // ========== BOND CALLBACK ========== // diff --git a/src/test/mocks/MockConvertibleDebtAuctioneer.sol b/src/test/mocks/MockConvertibleDebtAuctioneer.sol new file mode 100644 index 00000000..5ff6fd4b --- /dev/null +++ b/src/test/mocks/MockConvertibleDebtAuctioneer.sol @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: Unlicense +pragma solidity 0.8.15; + +import {Kernel, Policy, Keycode, toKeycode, Permissions} from "src/Kernel.sol"; +import {RolesConsumer, ROLESv1} from "src/modules/ROLES/OlympusRoles.sol"; +import {IConvertibleDebtAuctioneer} from "src/interfaces/IConvertibleDebtAuctioneer.sol"; + +contract MockConvertibleDebtAuctioneer is IConvertibleDebtAuctioneer, Policy, RolesConsumer { + constructor(Kernel kernel_) Policy(kernel_) {} + + function configureDependencies() external override returns (Keycode[] memory dependencies) { + dependencies = new Keycode[](1); + dependencies[2] = toKeycode("ROLES"); + + ROLES = ROLESv1(getModuleAddress(dependencies[0])); + } + + function requestPermissions() + external + view + override + returns (Permissions[] memory permissions) + {} + + function bid(uint256 deposit) external override returns (uint256 convertable) { + return deposit; + } + + function getCurrentTick() external view override returns (Tick memory tick) {} + + function getState() external view override returns (State memory state) {} + + function getDay() external view override returns (Day memory day) {} + + function convertFor( + uint256 deposit, + uint256 price + ) external view override returns (uint256 convertable) {} + + function beat( + uint256 newTarget, + uint256 newSize, + uint256 newMinPrice + ) external override returns (uint256 remainder) {} + + function setTimeToExpiry(uint256 newTime) external override {} + + function setTickStep(uint256 newStep) external override {} +} diff --git a/src/test/policies/EmissionManager.t.sol b/src/test/policies/EmissionManager.t.sol index ec4bca17..ca435fe6 100644 --- a/src/test/policies/EmissionManager.t.sol +++ b/src/test/policies/EmissionManager.t.sol @@ -16,6 +16,7 @@ import {MockPrice} from "src/test/mocks/MockPrice.sol"; import {MockOhm} from "src/test/mocks/MockOhm.sol"; import {MockGohm} from "src/test/mocks/MockGohm.sol"; import {MockClearinghouse} from "src/test/mocks/MockClearinghouse.sol"; +import {MockConvertibleDebtAuctioneer} from "src/test/mocks/MockConvertibleDebtAuctioneer.sol"; import {IBondSDA} from "interfaces/IBondSDA.sol"; import {IBondAggregator} from "interfaces/IBondAggregator.sol"; @@ -44,7 +45,7 @@ contract EmissionManagerTest is Test { RolesAuthority internal auth; BondAggregator internal aggregator; BondFixedTermTeller internal teller; - BondFixedTermSDA internal auctioneer; + BondFixedTermSDA internal bondAuctioneer; MockOhm internal ohm; MockGohm internal gohm; MockERC20 internal reserve; @@ -59,6 +60,7 @@ contract EmissionManagerTest is Test { OlympusClearinghouseRegistry internal CHREG; MockClearinghouse internal clearinghouse; + MockConvertibleDebtAuctioneer internal cdAuctioneer; RolesAdmin internal rolesAdmin; EmissionManager internal emissionManager; @@ -69,6 +71,8 @@ contract EmissionManagerTest is Test { uint48 internal restartTimeframe = 1 days; uint256 internal changeBy = 1e5; // 0.01% change per execution uint48 internal changeDuration = 2; // 2 executions + uint256 internal tickSizeScalar = 1e18; // 100% + uint256 internal minPriceScalar = 1e18; // 100% // test cases // @@ -119,7 +123,7 @@ contract EmissionManagerTest is Test { // [X] it returns 0 // [X] when price is greater than backing // [X] it returns the (price - backing) / backing - // [X] getNextSale + // [X] getNextEmission // [X] when the premium is less than the minimum premium // [X] it returns the premium, 0, and 0 // [X] when the premium is greater than or equal to the minimum premium @@ -205,11 +209,11 @@ contract EmissionManagerTest is Test { // [X] when the caller doesn't have the emissions_admin role // [X] it reverts // [X] when the caller has the emissions_admin role - // [X] when the new auctioneer address is the zero address + // [X] when the new bondAuctioneer address is the zero address // [X] it reverts // [X] when the new teller address is the zero address // [X] it reverts - // [X] it sets the auctioneer address + // [X] it sets the bondAuctioneer address // [X] it sets the teller address function setUp() public { @@ -227,11 +231,11 @@ contract EmissionManagerTest is Test { /// Deploy the bond system aggregator = new BondAggregator(guardian, auth); teller = new BondFixedTermTeller(guardian, aggregator, guardian, auth); - auctioneer = new BondFixedTermSDA(teller, aggregator, guardian, auth); + bondAuctioneer = new BondFixedTermSDA(teller, aggregator, guardian, auth); - /// Register auctioneer on the bond system + /// Register bondAuctioneer on the bond system vm.prank(guardian); - aggregator.registerAuctioneer(auctioneer); + aggregator.registerAuctioneer(bondAuctioneer); } { @@ -269,6 +273,9 @@ contract EmissionManagerTest is Test { /// Deploy ROLES administrator rolesAdmin = new RolesAdmin(kernel); + // Deploy the mock CD auctioneer + cdAuctioneer = new MockConvertibleDebtAuctioneer(kernel); + // Deploy the emission manager emissionManager = new EmissionManager( kernel, @@ -276,7 +283,8 @@ contract EmissionManagerTest is Test { address(gohm), address(reserve), address(sReserve), - address(auctioneer), + address(bondAuctioneer), + address(cdAuctioneer), address(teller) ); } @@ -294,6 +302,7 @@ contract EmissionManagerTest is Test { /// Approve policies kernel.executeAction(Actions.ActivatePolicy, address(emissionManager)); kernel.executeAction(Actions.ActivatePolicy, address(rolesAdmin)); + kernel.executeAction(Actions.ActivatePolicy, address(cdAuctioneer)); } { /// Configure access control @@ -332,11 +341,18 @@ contract EmissionManagerTest is Test { // Initialize the emissions manager vm.prank(guardian); - emissionManager.initialize(baseEmissionRate, minimumPremium, backing, restartTimeframe); + emissionManager.initialize( + baseEmissionRate, + minimumPremium, + backing, + tickSizeScalar, + minPriceScalar, + restartTimeframe + ); - // Approve the emission manager to use a bond callback on the auctioneer + // Approve the emission manager to use a bond callback on the bondAuctioneer vm.prank(guardian); - auctioneer.setCallbackAuthStatus(address(emissionManager), true); + bondAuctioneer.setCallbackAuthStatus(address(emissionManager), true); // Total Reserves = $50M + $50 M = $100M // Total Supply = 10,000,000 OHM @@ -579,7 +595,7 @@ contract EmissionManagerTest is Test { // Verify the bond market parameters // Check that the market params are correct { - uint256 marketPrice = auctioneer.marketPrice(nextBondMarketId); + uint256 marketPrice = bondAuctioneer.marketPrice(nextBondMarketId); ( address owner, ERC20 payoutToken, @@ -593,7 +609,7 @@ contract EmissionManagerTest is Test { , , uint256 scale - ) = auctioneer.markets(nextBondMarketId); + ) = bondAuctioneer.markets(nextBondMarketId); assertEq(owner, address(emissionManager), "Owner"); assertEq(address(payoutToken), address(ohm), "Payout token"); @@ -671,7 +687,7 @@ contract EmissionManagerTest is Test { // Verify the bond market parameters // Check that the market params are correct { - uint256 marketPrice = auctioneer.marketPrice(nextBondMarketId); + uint256 marketPrice = bondAuctioneer.marketPrice(nextBondMarketId); ( address owner, ERC20 payoutToken, @@ -685,7 +701,7 @@ contract EmissionManagerTest is Test { , , uint256 scale - ) = auctioneer.markets(nextBondMarketId); + ) = bondAuctioneer.markets(nextBondMarketId); assertEq(owner, address(emissionManager), "Owner"); assertEq(address(payoutToken), address(ohm), "Payout token"); @@ -765,7 +781,7 @@ contract EmissionManagerTest is Test { // Confirm that the capacity of the bond market uses the new base rate assertEq( - auctioneer.currentCapacity(nextBondMarketId), + bondAuctioneer.currentCapacity(nextBondMarketId), expectedCapacity, "Capacity should be updated" ); @@ -827,7 +843,7 @@ contract EmissionManagerTest is Test { // Confirm that the capacity of the bond market uses the new base rate assertEq( - auctioneer.currentCapacity(nextBondMarketId), + bondAuctioneer.currentCapacity(nextBondMarketId), expectedCapacity, "Capacity should be updated" ); @@ -997,7 +1013,7 @@ contract EmissionManagerTest is Test { // Store initial backing value uint256 bidAmount = 1000e18; - uint256 expectedPayout = auctioneer.payoutFor(bidAmount, nextBondMarketId, address(0)); + uint256 expectedPayout = bondAuctioneer.payoutFor(bidAmount, nextBondMarketId, address(0)); uint256 expectedBacking; { uint256 reserves = emissionManager.getReserves(); @@ -1085,7 +1101,7 @@ contract EmissionManagerTest is Test { { // We created a market, confirm it is active uint256 id = emissionManager.activeMarketId(); - assertTrue(auctioneer.isLive(id)); + assertTrue(bondAuctioneer.isLive(id)); // Check that the contract is locally active assertTrue(emissionManager.locallyActive(), "Contract should be locally active"); @@ -1111,7 +1127,7 @@ contract EmissionManagerTest is Test { ); // Check that the market is no longer active - assertFalse(auctioneer.isLive(id)); + assertFalse(bondAuctioneer.isLive(id)); } // restart tests @@ -1193,7 +1209,14 @@ contract EmissionManagerTest is Test { ); vm.expectRevert(err); vm.prank(rando_); - emissionManager.initialize(baseEmissionRate, minimumPremium, backing, restartTimeframe); + emissionManager.initialize( + baseEmissionRate, + minimumPremium, + backing, + tickSizeScalar, + minPriceScalar, + restartTimeframe + ); } function test_initialize_whenAlreadyActive_reverts() public { @@ -1201,7 +1224,14 @@ contract EmissionManagerTest is Test { bytes memory err = abi.encodeWithSignature("AlreadyActive()"); vm.expectRevert(err); vm.prank(guardian); - emissionManager.initialize(baseEmissionRate, minimumPremium, backing, restartTimeframe); + emissionManager.initialize( + baseEmissionRate, + minimumPremium, + backing, + tickSizeScalar, + minPriceScalar, + restartTimeframe + ); } function test_initialize_whenRestartTimeframeNotElapsed_reverts( @@ -1226,7 +1256,14 @@ contract EmissionManagerTest is Test { ); vm.expectRevert(err); vm.prank(guardian); - emissionManager.initialize(baseEmissionRate, minimumPremium, backing, restartTimeframe); + emissionManager.initialize( + baseEmissionRate, + minimumPremium, + backing, + tickSizeScalar, + minPriceScalar, + restartTimeframe + ); } function test_initialize_whenBaseEmissionRateZero_reverts() @@ -1240,7 +1277,14 @@ contract EmissionManagerTest is Test { bytes memory err = abi.encodeWithSignature("InvalidParam(string)", "baseEmissionRate"); vm.expectRevert(err); vm.prank(guardian); - emissionManager.initialize(0, minimumPremium, backing, restartTimeframe); + emissionManager.initialize( + 0, + minimumPremium, + backing, + tickSizeScalar, + minPriceScalar, + restartTimeframe + ); } function test_initialize_whenMinimumPremiumZero_reverts() @@ -1254,7 +1298,14 @@ contract EmissionManagerTest is Test { bytes memory err = abi.encodeWithSignature("InvalidParam(string)", "minimumPremium"); vm.expectRevert(err); vm.prank(guardian); - emissionManager.initialize(baseEmissionRate, 0, backing, restartTimeframe); + emissionManager.initialize( + baseEmissionRate, + 0, + backing, + tickSizeScalar, + minPriceScalar, + restartTimeframe + ); } function test_initialize_whenBackingZero_reverts() @@ -1268,7 +1319,14 @@ contract EmissionManagerTest is Test { bytes memory err = abi.encodeWithSignature("InvalidParam(string)", "backing"); vm.expectRevert(err); vm.prank(guardian); - emissionManager.initialize(baseEmissionRate, minimumPremium, 0, restartTimeframe); + emissionManager.initialize( + baseEmissionRate, + minimumPremium, + 0, + tickSizeScalar, + minPriceScalar, + restartTimeframe + ); } function test_initialize_whenRestartTimeframeZero_reverts() @@ -1282,7 +1340,14 @@ contract EmissionManagerTest is Test { bytes memory err = abi.encodeWithSignature("InvalidParam(string)", "restartTimeframe"); vm.expectRevert(err); vm.prank(guardian); - emissionManager.initialize(baseEmissionRate, minimumPremium, backing, 0); + emissionManager.initialize( + baseEmissionRate, + minimumPremium, + backing, + tickSizeScalar, + minPriceScalar, + 0 + ); } function test_initialize_success() public givenShutdown givenRestartTimeframeElapsed { @@ -1296,6 +1361,8 @@ contract EmissionManagerTest is Test { baseEmissionRate + 1, minimumPremium + 1, backing + 1, + tickSizeScalar, + minPriceScalar, restartTimeframe + 1 ); @@ -1537,7 +1604,7 @@ contract EmissionManagerTest is Test { } function test_setBondContracts_whenBondAuctioneerZero_reverts() public { - // Try to set bond auctioneer to 0, expect revert + // Try to set bondAuctioneer to 0, expect revert bytes memory err = abi.encodeWithSignature("InvalidParam(string)", "auctioneer"); vm.expectRevert(err); vm.prank(guardian); @@ -1559,9 +1626,9 @@ contract EmissionManagerTest is Test { // Confirm new bond contracts assertEq( - address(emissionManager.auctioneer()), + address(emissionManager.bondAuctioneer()), address(1), - "Bond auctioneer should be updated" + "BondAuctioneer should be updated" ); assertEq(emissionManager.teller(), address(1), "Bond teller should be updated"); } @@ -1640,11 +1707,12 @@ contract EmissionManagerTest is Test { ); } - // getNextSale tests + // getNextEmission tests function test_getNextSale_whenPremiumBelowMinimum() public givenPremiumBelowMinimum { // Get the next sale data - (uint256 premium, uint256 emissionRate, uint256 emission) = emissionManager.getNextSale(); + (uint256 premium, uint256 emissionRate, uint256 emission) = emissionManager + .getNextEmission(); // Expect that the premium is as set in the setup // and the other two values are zero @@ -1655,7 +1723,8 @@ contract EmissionManagerTest is Test { function test_getNextSale_whenPremiumEqualToMinimum() public givenPremiumEqualToMinimum { // Get the next sale data - (uint256 premium, uint256 emissionRate, uint256 emission) = emissionManager.getNextSale(); + (uint256 premium, uint256 emissionRate, uint256 emission) = emissionManager + .getNextEmission(); uint256 expectedEmission = 10_000e9; // 10,000 OHM (as described in setup) @@ -1668,7 +1737,8 @@ contract EmissionManagerTest is Test { function test_getNextSale_whenPremiumAboveMinimum() public givenPremiumAboveMinimum { // Get the next sale data - (uint256 premium, uint256 emissionRate, uint256 emission) = emissionManager.getNextSale(); + (uint256 premium, uint256 emissionRate, uint256 emission) = emissionManager + .getNextEmission(); uint256 expectedEmission = 12_000e9; // 12,000 OHM (as described in setup) From 2f2a0de8abc4fa513ef87a98523e9c4cbfe95dae Mon Sep 17 00:00:00 2001 From: Jem <0x0xjem@gmail.com> Date: Mon, 9 Dec 2024 15:49:26 +0400 Subject: [PATCH 11/64] Add cdUSDS parameter to CDFacility. Extract cdUSDS into interface. --- src/interfaces/IConvertibleDebtToken.sol | 18 ++++++++++++ src/policies/CDFacility.sol | 35 ++++++++++++------------ 2 files changed, 35 insertions(+), 18 deletions(-) create mode 100644 src/interfaces/IConvertibleDebtToken.sol diff --git a/src/interfaces/IConvertibleDebtToken.sol b/src/interfaces/IConvertibleDebtToken.sol new file mode 100644 index 00000000..c8abd042 --- /dev/null +++ b/src/interfaces/IConvertibleDebtToken.sol @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import {IERC20} from "openzeppelin-contracts/contracts/interfaces/IERC20.sol"; + +// TODO see if there is a standard interface for this + +interface IConvertibleDebtToken is IERC20 { + function mint(address to, uint256 amount) external; + + function burn(address from, uint256 amount) external; + + function convertFor(uint256 amount) external view returns (uint256); + + function expiry() external view returns (uint256); + + function totalSupply() external view returns (uint256); +} diff --git a/src/policies/CDFacility.sol b/src/policies/CDFacility.sol index 420c01b0..fc00d46a 100644 --- a/src/policies/CDFacility.sol +++ b/src/policies/CDFacility.sol @@ -7,24 +7,13 @@ import {ReentrancyGuard} from "solmate/utils/ReentrancyGuard.sol"; import {ERC20} from "solmate/tokens/ERC20.sol"; import {ERC4626} from "solmate/mixins/ERC4626.sol"; -import {RolesConsumer, ROLESv1} from "modules/ROLES/OlympusRoles.sol"; -import {MINTRv1} from "modules/MINTR/MINTR.v1.sol"; -import {TRSRYv1} from "modules/TRSRY/TRSRY.v1.sol"; +import {RolesConsumer, ROLESv1} from "src/modules/ROLES/OlympusRoles.sol"; +import {MINTRv1} from "src/modules/MINTR/MINTR.v1.sol"; +import {TRSRYv1} from "src/modules/TRSRY/TRSRY.v1.sol"; -import {FullMath} from "libraries/FullMath.sol"; +import {IConvertibleDebtToken} from "src/interfaces/IConvertibleDebtToken.sol"; -// TODO extract into external file -interface CDRC20 { - function mint(address to, uint256 amount) external; - - function burn(address from, uint256 amount) external; - - function convertFor(uint256 amount) external view returns (uint256); - - function expiry() external view returns (uint256); - - function totalSupply() external view returns (uint256); -} +import {FullMath} from "src/libraries/FullMath.sol"; // TODO extract CDFacility interface @@ -56,7 +45,8 @@ contract CDFacility is Policy, RolesConsumer { // Tokens ERC20 public reserve; ERC4626 public sReserve; - CDRC20 public cdUSDS; + // TODO re-think whether this should use a factory pattern instead + IConvertibleDebtToken public cdUSDS; // State variables uint256 public totalDeposits; @@ -72,15 +62,24 @@ contract CDFacility is Policy, RolesConsumer { // ========== SETUP ========== // + // TODO input approved convertible debt tokens in constructor, to allow for migration + // TODO add cdUSDS parameter - constructor(Kernel kernel_, address reserve_, address sReserve_) Policy(kernel_) { + constructor( + Kernel kernel_, + address reserve_, + address sReserve_, + address cdUSDS_ + ) Policy(kernel_) { if (reserve_ == address(0)) revert CDFacility_InvalidParams("Reserve address cannot be 0"); if (sReserve_ == address(0)) revert CDFacility_InvalidParams("sReserve address cannot be 0"); + if (cdUSDS_ == address(0)) revert CDFacility_InvalidParams("cdUSDS address cannot be 0"); reserve = ERC20(reserve_); sReserve = ERC4626(sReserve_); + cdUSDS = IConvertibleDebtToken(cdUSDS_); } function configureDependencies() external override returns (Keycode[] memory dependencies) { From 4797b13a57919c89a4cd456cee5749c239855bd9 Mon Sep 17 00:00:00 2001 From: Jem <0x0xjem@gmail.com> Date: Fri, 13 Dec 2024 11:43:14 +0400 Subject: [PATCH 12/64] Fix naming consistency with debt/deposit. Add missing parameter to deployment script. --- src/policies/CDAuctioneer.sol | 14 ++++---- src/policies/CDFacility.sol | 6 ++-- src/policies/EmissionManager.sol | 8 ++--- .../IConvertibleDepositAuctioneer.sol} | 2 +- .../interfaces/IConvertibleDepositToken.sol} | 2 +- src/scripts/deploy/DeployV2.sol | 35 +++++++++++-------- ...l => MockConvertibleDepositAuctioneer.sol} | 4 +-- src/test/policies/EmissionManager.t.sol | 6 ++-- 8 files changed, 41 insertions(+), 36 deletions(-) rename src/{interfaces/IConvertibleDebtAuctioneer.sol => policies/interfaces/IConvertibleDepositAuctioneer.sol} (98%) rename src/{interfaces/IConvertibleDebtToken.sol => policies/interfaces/IConvertibleDepositToken.sol} (91%) rename src/test/mocks/{MockConvertibleDebtAuctioneer.sol => MockConvertibleDepositAuctioneer.sol} (87%) diff --git a/src/policies/CDAuctioneer.sol b/src/policies/CDAuctioneer.sol index 529c85e1..57e7f164 100644 --- a/src/policies/CDAuctioneer.sol +++ b/src/policies/CDAuctioneer.sol @@ -7,7 +7,7 @@ import {ReentrancyGuard} from "solmate/utils/ReentrancyGuard.sol"; import {ERC20} from "solmate/tokens/ERC20.sol"; import {RolesConsumer, ROLESv1} from "src/modules/ROLES/OlympusRoles.sol"; -import {IConvertibleDebtAuctioneer} from "src/interfaces/IConvertibleDebtAuctioneer.sol"; +import {IConvertibleDepositAuctioneer} from "src/policies/interfaces/IConvertibleDepositAuctioneer.sol"; import {FullMath} from "src/libraries/FullMath.sol"; @@ -23,7 +23,7 @@ interface CDRC20 { function expiry() external view returns (uint256); } -contract CDAuctioneer is IConvertibleDebtAuctioneer, Policy, RolesConsumer { +contract CDAuctioneer is IConvertibleDepositAuctioneer, Policy, RolesConsumer { using FullMath for uint256; // ========== STATE VARIABLES ========== // @@ -65,7 +65,7 @@ contract CDAuctioneer is IConvertibleDebtAuctioneer, Policy, RolesConsumer { // ========== AUCTION ========== // - /// @inheritdoc IConvertibleDebtAuctioneer + /// @inheritdoc IConvertibleDepositAuctioneer function bid(uint256 deposit) external override returns (uint256 convertable) { // update state currentTick = getCurrentTick(); @@ -94,7 +94,7 @@ contract CDAuctioneer is IConvertibleDebtAuctioneer, Policy, RolesConsumer { // ========== VIEW FUNCTIONS ========== // - /// @inheritdoc IConvertibleDebtAuctioneer + /// @inheritdoc IConvertibleDepositAuctioneer function getCurrentTick() public view override returns (Tick memory tick) { // find amount of time passed and new capacity to add uint256 timePassed = block.timestamp - state.lastUpdate; @@ -120,17 +120,17 @@ contract CDAuctioneer is IConvertibleDebtAuctioneer, Policy, RolesConsumer { tick.capacity = newCapacity; } - /// @inheritdoc IConvertibleDebtAuctioneer + /// @inheritdoc IConvertibleDepositAuctioneer function convertFor(uint256 deposit, uint256 price) public view override returns (uint256) { return (deposit * decimals) / price; } - /// @inheritdoc IConvertibleDebtAuctioneer + /// @inheritdoc IConvertibleDepositAuctioneer function getState() external view override returns (State memory) { return state; } - /// @inheritdoc IConvertibleDebtAuctioneer + /// @inheritdoc IConvertibleDepositAuctioneer function getDay() external view override returns (Day memory) { return today; } diff --git a/src/policies/CDFacility.sol b/src/policies/CDFacility.sol index fc00d46a..e04a9daf 100644 --- a/src/policies/CDFacility.sol +++ b/src/policies/CDFacility.sol @@ -11,7 +11,7 @@ import {RolesConsumer, ROLESv1} from "src/modules/ROLES/OlympusRoles.sol"; import {MINTRv1} from "src/modules/MINTR/MINTR.v1.sol"; import {TRSRYv1} from "src/modules/TRSRY/TRSRY.v1.sol"; -import {IConvertibleDebtToken} from "src/interfaces/IConvertibleDebtToken.sol"; +import {IConvertibleDepositToken} from "src/policies/interfaces/IConvertibleDepositToken.sol"; import {FullMath} from "src/libraries/FullMath.sol"; @@ -46,7 +46,7 @@ contract CDFacility is Policy, RolesConsumer { ERC20 public reserve; ERC4626 public sReserve; // TODO re-think whether this should use a factory pattern instead - IConvertibleDebtToken public cdUSDS; + IConvertibleDepositToken public cdUSDS; // State variables uint256 public totalDeposits; @@ -79,7 +79,7 @@ contract CDFacility is Policy, RolesConsumer { reserve = ERC20(reserve_); sReserve = ERC4626(sReserve_); - cdUSDS = IConvertibleDebtToken(cdUSDS_); + cdUSDS = IConvertibleDepositToken(cdUSDS_); } function configureDependencies() external override returns (Keycode[] memory dependencies) { diff --git a/src/policies/EmissionManager.sol b/src/policies/EmissionManager.sol index 3b849f8c..440dfd92 100644 --- a/src/policies/EmissionManager.sol +++ b/src/policies/EmissionManager.sol @@ -19,7 +19,7 @@ import {MINTRv1} from "modules/MINTR/MINTR.v1.sol"; import {CHREGv1} from "modules/CHREG/CHREG.v1.sol"; import {IEmissionManager} from "policies/interfaces/IEmissionManager.sol"; -import {IConvertibleDebtAuctioneer} from "src/interfaces/IConvertibleDebtAuctioneer.sol"; +import {IConvertibleDepositAuctioneer} from "src/policies/interfaces/IConvertibleDepositAuctioneer.sol"; interface BurnableERC20 { function burn(uint256 amount) external; @@ -56,7 +56,7 @@ contract EmissionManager is IEmissionManager, Policy, RolesConsumer { // External contracts IBondSDA public bondAuctioneer; address public teller; - IConvertibleDebtAuctioneer public cdAuctioneer; + IConvertibleDepositAuctioneer public cdAuctioneer; // Manager variables uint256 public baseEmissionRate; @@ -113,7 +113,7 @@ contract EmissionManager is IEmissionManager, Policy, RolesConsumer { reserve = ERC20(reserve_); sReserve = ERC4626(sReserve_); bondAuctioneer = IBondSDA(bondAuctioneer_); - cdAuctioneer = IConvertibleDebtAuctioneer(cdAuctioneer_); + cdAuctioneer = IConvertibleDepositAuctioneer(cdAuctioneer_); teller = teller_; _ohmDecimals = ohm.decimals(); @@ -471,7 +471,7 @@ contract EmissionManager is IEmissionManager, Policy, RolesConsumer { // Auction contract cannot be set to the zero address if (cdAuctioneer_ == address(0)) revert InvalidParam("cdAuctioneer"); - cdAuctioneer = IConvertibleDebtAuctioneer(cdAuctioneer_); + cdAuctioneer = IConvertibleDepositAuctioneer(cdAuctioneer_); } /// @notice allow governance to set the CD tick size scalar diff --git a/src/interfaces/IConvertibleDebtAuctioneer.sol b/src/policies/interfaces/IConvertibleDepositAuctioneer.sol similarity index 98% rename from src/interfaces/IConvertibleDebtAuctioneer.sol rename to src/policies/interfaces/IConvertibleDepositAuctioneer.sol index 6bd7dab3..ed6838fe 100644 --- a/src/interfaces/IConvertibleDebtAuctioneer.sol +++ b/src/policies/interfaces/IConvertibleDepositAuctioneer.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT pragma solidity >=0.8.0; -interface IConvertibleDebtAuctioneer { +interface IConvertibleDepositAuctioneer { // ========== EVENTS ========== // // ========== ERRORS ========== // diff --git a/src/interfaces/IConvertibleDebtToken.sol b/src/policies/interfaces/IConvertibleDepositToken.sol similarity index 91% rename from src/interfaces/IConvertibleDebtToken.sol rename to src/policies/interfaces/IConvertibleDepositToken.sol index c8abd042..ca2482ab 100644 --- a/src/interfaces/IConvertibleDebtToken.sol +++ b/src/policies/interfaces/IConvertibleDepositToken.sol @@ -5,7 +5,7 @@ import {IERC20} from "openzeppelin-contracts/contracts/interfaces/IERC20.sol"; // TODO see if there is a standard interface for this -interface IConvertibleDebtToken is IERC20 { +interface IConvertibleDepositToken is IERC20 { function mint(address to, uint256 amount) external; function burn(address from, uint256 amount) external; diff --git a/src/scripts/deploy/DeployV2.sol b/src/scripts/deploy/DeployV2.sol index 5e0392bd..c5ccef30 100644 --- a/src/scripts/deploy/DeployV2.sol +++ b/src/scripts/deploy/DeployV2.sol @@ -227,8 +227,10 @@ contract OlympusDeploy is Script { selectorMap["YieldRepurchaseFacility"] = this._deployYieldRepurchaseFacility.selector; selectorMap["ReserveMigrator"] = this._deployReserveMigrator.selector; selectorMap["EmissionManager"] = this._deployEmissionManager.selector; - selectorMap["ConvertibleDebtAuctioneer"] = this._deployConvertibleDebtAuctioneer.selector; - selectorMap["ConvertibleDebtFacility"] = this._deployConvertibleDebtFacility.selector; + selectorMap["ConvertibleDepositAuctioneer"] = this + ._deployConvertibleDepositAuctioneer + .selector; + selectorMap["ConvertibleDepositFacility"] = this._deployConvertibleDepositFacility.selector; // Governance selectorMap["Timelock"] = this._deployTimelock.selector; @@ -318,8 +320,8 @@ contract OlympusDeploy is Script { yieldRepo = YieldRepurchaseFacility(envAddress("olympus.policies.YieldRepurchaseFacility")); reserveMigrator = ReserveMigrator(envAddress("olympus.policies.ReserveMigrator")); emissionManager = EmissionManager(envAddress("olympus.policies.EmissionManager")); - cdAuctioneer = CDAuctioneer(envAddress("olympus.policies.ConvertibleDebtAuctioneer")); - cdFacility = CDFacility(envAddress("olympus.policies.ConvertibleDebtFacility")); + cdAuctioneer = CDAuctioneer(envAddress("olympus.policies.ConvertibleDepositAuctioneer")); + cdFacility = CDFacility(envAddress("olympus.policies.ConvertibleDepositFacility")); // Governance timelock = Timelock(payable(envAddress("olympus.governance.Timelock"))); @@ -1233,35 +1235,38 @@ contract OlympusDeploy is Script { return address(emissionManager); } - function _deployConvertibleDebtAuctioneer(bytes calldata) public returns (address) { - // No additional arguments for ConvertibleDebtAuctioneer + function _deployConvertibleDepositAuctioneer(bytes calldata) public returns (address) { + // No additional arguments for ConvertibleDepositAuctioneer // Log dependencies - console2.log("ConvertibleDebtAuctioneer parameters:"); + console2.log("ConvertibleDepositAuctioneer parameters:"); console2.log(" kernel", address(kernel)); console2.log(" cdFacility", address(cdFacility)); - // Deploy ConvertibleDebtAuctioneer + // Deploy ConvertibleDepositAuctioneer vm.broadcast(); cdAuctioneer = new CDAuctioneer(kernel, address(cdFacility)); - console2.log("ConvertibleDebtAuctioneer deployed at:", address(cdAuctioneer)); + console2.log("ConvertibleDepositAuctioneer deployed at:", address(cdAuctioneer)); return address(cdAuctioneer); } - function _deployConvertibleDebtFacility(bytes calldata) public returns (address) { - // No additional arguments for ConvertibleDebtFacility + function _deployConvertibleDepositFacility(bytes calldata) public returns (address) { + // No additional arguments for ConvertibleDepositFacility // Log dependencies - console2.log("ConvertibleDebtFacility parameters:"); + console2.log("ConvertibleDepositFacility parameters:"); console2.log(" kernel", address(kernel)); console2.log(" reserve", address(reserve)); console2.log(" sReserve", address(sReserve)); - // Deploy ConvertibleDebtFacility + // TODO add deployment of cdUSDS + address cdUSDS = address(0); + + // Deploy ConvertibleDepositFacility vm.broadcast(); - cdFacility = new CDFacility(kernel, address(reserve), address(sReserve)); - console2.log("ConvertibleDebtFacility deployed at:", address(cdFacility)); + cdFacility = new CDFacility(kernel, address(reserve), address(sReserve), cdUSDS); + console2.log("ConvertibleDepositFacility deployed at:", address(cdFacility)); return address(cdFacility); } diff --git a/src/test/mocks/MockConvertibleDebtAuctioneer.sol b/src/test/mocks/MockConvertibleDepositAuctioneer.sol similarity index 87% rename from src/test/mocks/MockConvertibleDebtAuctioneer.sol rename to src/test/mocks/MockConvertibleDepositAuctioneer.sol index 5ff6fd4b..c1e154f0 100644 --- a/src/test/mocks/MockConvertibleDebtAuctioneer.sol +++ b/src/test/mocks/MockConvertibleDepositAuctioneer.sol @@ -3,9 +3,9 @@ pragma solidity 0.8.15; import {Kernel, Policy, Keycode, toKeycode, Permissions} from "src/Kernel.sol"; import {RolesConsumer, ROLESv1} from "src/modules/ROLES/OlympusRoles.sol"; -import {IConvertibleDebtAuctioneer} from "src/interfaces/IConvertibleDebtAuctioneer.sol"; +import {IConvertibleDepositAuctioneer} from "src/policies/interfaces/IConvertibleDepositAuctioneer.sol"; -contract MockConvertibleDebtAuctioneer is IConvertibleDebtAuctioneer, Policy, RolesConsumer { +contract MockConvertibleDepositAuctioneer is IConvertibleDepositAuctioneer, Policy, RolesConsumer { constructor(Kernel kernel_) Policy(kernel_) {} function configureDependencies() external override returns (Keycode[] memory dependencies) { diff --git a/src/test/policies/EmissionManager.t.sol b/src/test/policies/EmissionManager.t.sol index ca435fe6..7c5b7548 100644 --- a/src/test/policies/EmissionManager.t.sol +++ b/src/test/policies/EmissionManager.t.sol @@ -16,7 +16,7 @@ import {MockPrice} from "src/test/mocks/MockPrice.sol"; import {MockOhm} from "src/test/mocks/MockOhm.sol"; import {MockGohm} from "src/test/mocks/MockGohm.sol"; import {MockClearinghouse} from "src/test/mocks/MockClearinghouse.sol"; -import {MockConvertibleDebtAuctioneer} from "src/test/mocks/MockConvertibleDebtAuctioneer.sol"; +import {MockConvertibleDepositAuctioneer} from "src/test/mocks/MockConvertibleDepositAuctioneer.sol"; import {IBondSDA} from "interfaces/IBondSDA.sol"; import {IBondAggregator} from "interfaces/IBondAggregator.sol"; @@ -60,7 +60,7 @@ contract EmissionManagerTest is Test { OlympusClearinghouseRegistry internal CHREG; MockClearinghouse internal clearinghouse; - MockConvertibleDebtAuctioneer internal cdAuctioneer; + MockConvertibleDepositAuctioneer internal cdAuctioneer; RolesAdmin internal rolesAdmin; EmissionManager internal emissionManager; @@ -274,7 +274,7 @@ contract EmissionManagerTest is Test { rolesAdmin = new RolesAdmin(kernel); // Deploy the mock CD auctioneer - cdAuctioneer = new MockConvertibleDebtAuctioneer(kernel); + cdAuctioneer = new MockConvertibleDepositAuctioneer(kernel); // Deploy the emission manager emissionManager = new EmissionManager( From a6dacc8efb7d27dfe5592073fa3d63223910956a Mon Sep 17 00:00:00 2001 From: Jem <0x0xjem@gmail.com> Date: Fri, 13 Dec 2024 16:32:36 +0400 Subject: [PATCH 13/64] Create interface for convertible deposit facility --- src/policies/CDAuctioneer.sol | 47 +++--- src/policies/CDFacility.sol | 11 +- .../IConvertibleDepositAuctioneer.sol | 67 ++++---- .../IConvertibleDepositFacility.sol | 143 ++++++++++++++++++ 4 files changed, 202 insertions(+), 66 deletions(-) create mode 100644 src/policies/interfaces/IConvertibleDepositFacility.sol diff --git a/src/policies/CDAuctioneer.sol b/src/policies/CDAuctioneer.sol index 57e7f164..e766305d 100644 --- a/src/policies/CDAuctioneer.sol +++ b/src/policies/CDAuctioneer.sol @@ -13,16 +13,6 @@ import {FullMath} from "src/libraries/FullMath.sol"; import {CDFacility} from "./CDFacility.sol"; -interface CDRC20 { - function mint(address to, uint256 amount) external; - - function burn(address from, uint256 amount) external; - - function convertFor(uint256 amount) external view returns (uint256); - - function expiry() external view returns (uint256); -} - contract CDAuctioneer is IConvertibleDepositAuctioneer, Policy, RolesConsumer { using FullMath for uint256; @@ -46,6 +36,8 @@ contract CDAuctioneer is IConvertibleDepositAuctioneer, Policy, RolesConsumer { if (cdFacility_ == address(0)) revert CDAuctioneer_InvalidParams("CD Facility address cannot be 0"); + // TODO set decimals + cdFacility = CDFacility(cdFacility_); } @@ -66,7 +58,7 @@ contract CDAuctioneer is IConvertibleDepositAuctioneer, Policy, RolesConsumer { // ========== AUCTION ========== // /// @inheritdoc IConvertibleDepositAuctioneer - function bid(uint256 deposit) external override returns (uint256 convertable) { + function bid(uint256 deposit) external override returns (uint256 convertible) { // update state currentTick = getCurrentTick(); state.lastUpdate = block.timestamp; @@ -74,7 +66,7 @@ contract CDAuctioneer is IConvertibleDepositAuctioneer, Policy, RolesConsumer { // iterate until user has no more reserves to bid while (deposit > 0) { // handle spent/capacity for tick - uint256 amount = currentTick.capacity < convertFor(deposit, currentTick.price) + uint256 amount = currentTick.capacity < _convertFor(deposit, currentTick.price) ? state.tickSize : deposit; if (amount != state.tickSize) currentTick.capacity -= amount; @@ -82,14 +74,20 @@ contract CDAuctioneer is IConvertibleDepositAuctioneer, Policy, RolesConsumer { // decrement bid and increment tick price deposit -= amount; - convertable += convertFor(amount, currentTick.price); + convertible += _convertFor(amount, currentTick.price); } today.deposits += deposit; - today.convertable += convertable; + today.convertible += convertible; // mint amount of CD token - cdFacility.addNewCD(msg.sender, deposit, convertable, block.timestamp + state.timeToExpiry); + cdFacility.addNewCD(msg.sender, deposit, convertible, block.timestamp + state.timeToExpiry); + } + + /// @inheritdoc IConvertibleDepositAuctioneer + function previewBid(uint256 deposit) external view override returns (uint256 convertible) { + // TODO + return 0; } // ========== VIEW FUNCTIONS ========== // @@ -120,8 +118,7 @@ contract CDAuctioneer is IConvertibleDepositAuctioneer, Policy, RolesConsumer { tick.capacity = newCapacity; } - /// @inheritdoc IConvertibleDepositAuctioneer - function convertFor(uint256 deposit, uint256 price) public view override returns (uint256) { + function _convertFor(uint256 deposit, uint256 price) internal view returns (uint256) { return (deposit * decimals) / price; } @@ -137,17 +134,13 @@ contract CDAuctioneer is IConvertibleDepositAuctioneer, Policy, RolesConsumer { // ========== ADMIN FUNCTIONS ========== // - /// @notice update auction parameters - /// @dev only callable by the auction admin - /// @param newTarget new target sale per day - /// @param newSize new size per tick - /// @param newMinPrice new minimum tick price - function beat( + /// @inheritdoc IConvertibleDepositAuctioneer + function setAuctionParameters( uint256 newTarget, uint256 newSize, uint256 newMinPrice ) external override onlyRole("CD_Auction_Admin") returns (uint256 remainder) { - remainder = (state.target > today.convertable) ? state.target - today.convertable : 0; + remainder = (state.target > today.convertible) ? state.target - today.convertible : 0; state = State( newTarget, @@ -159,14 +152,12 @@ contract CDAuctioneer is IConvertibleDepositAuctioneer, Policy, RolesConsumer { ); } - /// @notice update time between creation and expiry of deposit - /// @param newTime number of seconds + /// @inheritdoc IConvertibleDepositAuctioneer function setTimeToExpiry(uint256 newTime) external override onlyRole("CD_Admin") { state.timeToExpiry = newTime; } - /// @notice update change between ticks - /// @param newStep percentage in decimal terms + /// @inheritdoc IConvertibleDepositAuctioneer function setTickStep(uint256 newStep) external override onlyRole("CD_Admin") { state.tickStep = newStep; } diff --git a/src/policies/CDFacility.sol b/src/policies/CDFacility.sol index e04a9daf..53f4240b 100644 --- a/src/policies/CDFacility.sol +++ b/src/policies/CDFacility.sol @@ -15,8 +15,6 @@ import {IConvertibleDepositToken} from "src/policies/interfaces/IConvertibleDepo import {FullMath} from "src/libraries/FullMath.sol"; -// TODO extract CDFacility interface - contract CDFacility is Policy, RolesConsumer { using FullMath for uint256; @@ -45,7 +43,6 @@ contract CDFacility is Policy, RolesConsumer { // Tokens ERC20 public reserve; ERC4626 public sReserve; - // TODO re-think whether this should use a factory pattern instead IConvertibleDepositToken public cdUSDS; // State variables @@ -62,10 +59,6 @@ contract CDFacility is Policy, RolesConsumer { // ========== SETUP ========== // - // TODO input approved convertible debt tokens in constructor, to allow for migration - - // TODO add cdUSDS parameter - constructor( Kernel kernel_, address reserve_, @@ -79,6 +72,8 @@ contract CDFacility is Policy, RolesConsumer { reserve = ERC20(reserve_); sReserve = ERC4626(sReserve_); + + // TODO shift to module and dependency injection cdUSDS = IConvertibleDepositToken(cdUSDS_); } @@ -130,6 +125,8 @@ contract CDFacility is Policy, RolesConsumer { // store convertable deposit info and mint cdUSDS cdInfo[user].push(CD(amount, convertable, expiry)); + + // TODO consider if the ERC20 should custody the deposit token cdUSDS.mint(user, amount); } diff --git a/src/policies/interfaces/IConvertibleDepositAuctioneer.sol b/src/policies/interfaces/IConvertibleDepositAuctioneer.sol index ed6838fe..7eaf71c1 100644 --- a/src/policies/interfaces/IConvertibleDepositAuctioneer.sol +++ b/src/policies/interfaces/IConvertibleDepositAuctioneer.sol @@ -4,6 +4,12 @@ pragma solidity >=0.8.0; interface IConvertibleDepositAuctioneer { // ========== EVENTS ========== // + event AuctionParametersUpdated(uint256 newTarget, uint256 newSize, uint256 newMinPrice); + + event TimeToExpiryUpdated(uint256 newTimeToExpiry); + + event TickStepUpdated(uint256 newTickStep); + // ========== ERRORS ========== // error CDAuctioneer_InvalidParams(string reason); @@ -30,10 +36,10 @@ interface IConvertibleDepositAuctioneer { /// @notice Tracks auction activity for a given day /// /// @param deposits total deposited for day - /// @param convertable total convertable for day + /// @param convertible total convertible for day struct Day { uint256 deposits; - uint256 convertable; + uint256 convertible; } /// @notice Information about a tick @@ -47,60 +53,59 @@ interface IConvertibleDepositAuctioneer { // ========== AUCTION ========== // - /// @notice Use a deposit to bid for CDs + /// @notice Deposit reserve tokens to bid for convertible deposit tokens /// - /// @param deposit amount of reserve tokens - /// @return convertable amount of convertable tokens - function bid(uint256 deposit) external returns (uint256 convertable); + /// @param deposit_ Amount of reserve tokens to deposit + /// @return convertible_ Amount of convertible tokens minted + function bid(uint256 deposit_) external returns (uint256 convertible_); - // ========== VIEW ========== // + /// @notice Get the amount of convertible deposit tokens issued for a deposit + /// + /// @param deposit_ Amount of reserve tokens + /// @return convertible_ Amount of convertible tokens + function previewBid(uint256 deposit_) external view returns (uint256 convertible_); - /// @notice Get the current tick + // ========== STATE VARIABLES ========== // + + /// @notice Get the current tick of the auction /// - /// @return tick info in Tick struct + /// @return tick Tick info function getCurrentTick() external view returns (Tick memory tick); - /// @notice Get the current state + /// @notice Get the current state of the auction /// - /// @return state info in State struct + /// @return state State info function getState() external view returns (State memory state); /// @notice Get the auction activity for the current day /// - /// @return day info in Day struct + /// @return day Day info function getDay() external view returns (Day memory day); - /// @notice Get the amount of convertable tokens for a deposit at a given price - /// - /// @param deposit amount of reserve tokens - /// @param price price of the tick - /// @return convertable amount of convertable tokens - function convertFor(uint256 deposit, uint256 price) external view returns (uint256 convertable); - // ========== ADMIN ========== // /// @notice Update the auction parameters /// @dev only callable by the auction admin /// - /// @param newTarget new target sale per day - /// @param newSize new size per tick - /// @param newMinPrice new minimum tick price - /// @return remainder amount of ohm not sold - function beat( - uint256 newTarget, - uint256 newSize, - uint256 newMinPrice + /// @param newTarget_ new target sale per day + /// @param newSize_ new size per tick + /// @param newMinPrice_ new minimum tick price + /// @return remainder amount of ohm not sold + function setAuctionParameters( + uint256 newTarget_, + uint256 newSize_, + uint256 newMinPrice_ ) external returns (uint256 remainder); /// @notice Set the time to expiry /// @dev only callable by the admin /// - /// @param newTime new time to expiry - function setTimeToExpiry(uint256 newTime) external; + /// @param newTime_ new time to expiry + function setTimeToExpiry(uint256 newTime_) external; /// @notice Set the tick step /// @dev only callable by the admin /// - /// @param newStep new tick step - function setTickStep(uint256 newStep) external; + /// @param newStep_ new tick step + function setTickStep(uint256 newStep_) external; } diff --git a/src/policies/interfaces/IConvertibleDepositFacility.sol b/src/policies/interfaces/IConvertibleDepositFacility.sol new file mode 100644 index 00000000..60ead170 --- /dev/null +++ b/src/policies/interfaces/IConvertibleDepositFacility.sol @@ -0,0 +1,143 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import {IERC20} from "openzeppelin-contracts/contracts/interfaces/IERC20.sol"; +import {IERC4626} from "openzeppelin-contracts/contracts/interfaces/IERC4626.sol"; + +/// @title IConvertibleDepositFacility +/// @notice Interface for a contract that can perform functions related to convertible deposit tokens +interface IConvertibleDepositFacility { + // ========== CONVERTIBLE DEPOSIT ACTIONS ========== // + + /// @notice Converts convertible deposit tokens to OHM before expiry + /// @dev The implementing contract is expected to handle the following: + /// - Validating that `account_` has an active convertible deposit position + /// - Validating that `account_` has the required amount of convertible deposit tokens + /// - Validating that all of the positions have not expired + /// - Burning the convertible deposit tokens + /// - Minting OHM to `account_` + /// - Transferring the reserve token to the treasury + /// - Emitting an event + /// + /// @param account_ The address to convert for + /// @param positionIds_ An array of position ids that will be converted + /// @param amounts_ An array of amounts of convertible deposit tokens to convert + /// @return converted The amount of OHM minted to `account_` + function convertFor( + address account_, + uint256[] memory positionIds_, + uint256[] memory amounts_ + ) external returns (uint256 converted); + + /// @notice Reclaims convertible deposit tokens after expiry + /// @dev The implementing contract is expected to handle the following: + /// - Validating that `account_` has an active convertible deposit position + /// - Validating that `account_` has the required amount of convertible deposit tokens + /// - Validating that all of the positions have expired + /// - Burning the convertible deposit tokens + /// - Transferring the reserve token to `account_` + /// - Emitting an event + /// + /// @param account_ The address to reclaim for + /// @param positionIds_ An array of position ids that will be reclaimed + /// @param amounts_ An array of amounts of convertible deposit tokens to reclaim + /// @return reclaimed The amount of reserve token returned to `account_` + function reclaimFor( + address account_, + uint256[] memory positionIds_, + uint256[] memory amounts_ + ) external returns (uint256 reclaimed); + + // TODO decide in the depositFor and withdrawTo functions should be in the ERC20 contract (aka ERC20Wrapper) + + /// @notice Deposits the reserve token in exchange for convertible deposit tokens + /// @dev The implementing contract is expected to handle the following: + /// - Validating that the caller has the required amount of reserve tokens + /// - Minting the convertible deposit tokens to the caller + /// - Emitting an event + /// + /// @param account_ The address to mint to + /// @param amount_ The amount of reserve token to mint + /// @return minted The amount of convertible deposit tokens minted to the caller + function depositFor(address account_, uint256 amount_) external returns (uint256 minted); + + /// @notice Preview the amount of convertible deposit tokens that would be minted for a given amount of reserve token + /// + /// @param account_ The address to mint to + /// @param amount_ The amount of reserve token to mint + /// @return minted The amount of convertible deposit tokens that would be minted + function previewDepositFor( + address account_, + uint256 amount_ + ) external view returns (uint256 minted); + + /// @notice Withdraws the reserve token in exchange for convertible deposit tokens + /// @dev The implementing contract is expected to handle the following: + /// - Validating that the caller has the required amount of convertible deposit tokens + /// - Burning the convertible deposit tokens + /// - Transferring the reserve token to the caller + /// - Emitting an event + /// + /// @param account_ The address to withdraw to + /// @param amount_ The amount of convertible deposit tokens to burn + /// @return withdrawn The amount of reserve tokens returned to the caller + function withdrawTo(address account_, uint256 amount_) external returns (uint256 withdrawn); + + /// @notice Preview the amount of reserve token that would be returned for a given amount of convertible deposit tokens + /// + /// @param account_ The address to withdraw to + /// @param amount_ The amount of convertible deposit tokens to burn + /// @return withdrawn The amount of reserve tokens that would be returned + function previewWithdrawTo( + address account_, + uint256 amount_ + ) external view returns (uint256 withdrawn); + + // ========== YIELD MANAGEMENT ========== // + + /// @notice Claim the yield accrued on the reserve token + /// @dev The implementing contract is expected to handle the following: + /// - Validating that the caller has the correct role + /// - Withdrawing the yield from the sReserve token + /// - Transferring the yield to the caller + /// - Emitting an event + /// + /// @return yieldReserve The amount of reserve token that was swept + /// @return yieldSReserve The amount of sReserve token that was swept + function sweepYield() external returns (uint256 yieldReserve, uint256 yieldSReserve); + + /// @notice Preview the amount of yield that would be swept + /// + /// @return yieldReserve The amount of reserve token that would be swept + /// @return yieldSReserve The amount of sReserve token that would be swept + function previewSweepYield() + external + view + returns (uint256 yieldReserve, uint256 yieldSReserve); + + // ========== ADMIN ========== / + + /// @notice Set the withdraw rate when withdrawing the convertible deposit token, where withdraw rate = reserve token output / convertible deposit token input + /// @dev The implementing contract is expected to handle the following: + /// - Validating that the caller has the correct role + /// - Validating that the new rate is within bounds + /// - Setting the new redeem rate + /// - Emitting an event + /// + /// @param newRate_ The new withdraw rate + function setWithdrawRate(uint256 newRate_) external; + + // ========== STATE VARIABLES ========== // + + /// @notice The reserve token that is exchanged for the convertible deposit token + function reserveToken() external view returns (IERC20); + + /// @notice The sReserve token that the reserve token is deposited into + function sReserveToken() external view returns (IERC4626); + + /// @notice The convertible deposit token that is minted to the user + function convertibleDepositToken() external view returns (IERC20); + + /// @notice The withdraw rate when withdrawing the convertible deposit token + function withdrawRate() external view returns (uint256); +} From 629139902c0ffac03464c9d83b418319903736a0 Mon Sep 17 00:00:00 2001 From: Jem <0x0xjem@gmail.com> Date: Fri, 13 Dec 2024 16:34:20 +0400 Subject: [PATCH 14/64] Fix compile errors --- src/policies/EmissionManager.sol | 2 +- src/test/mocks/MockConvertibleDepositAuctioneer.sol | 7 ++----- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/src/policies/EmissionManager.sol b/src/policies/EmissionManager.sol index 440dfd92..dd9ee62b 100644 --- a/src/policies/EmissionManager.sol +++ b/src/policies/EmissionManager.sol @@ -172,7 +172,7 @@ contract EmissionManager is IEmissionManager, Policy, RolesConsumer { // It then calculates the amount to sell for the coming day (, , uint256 emission) = getNextEmission(); - uint256 remainder = cdAuctioneer.beat( + uint256 remainder = cdAuctioneer.setAuctionParameters( emission, getSizeFor(emission), getMinPriceFor(PRICE.getCurrentPrice()) diff --git a/src/test/mocks/MockConvertibleDepositAuctioneer.sol b/src/test/mocks/MockConvertibleDepositAuctioneer.sol index c1e154f0..1f965632 100644 --- a/src/test/mocks/MockConvertibleDepositAuctioneer.sol +++ b/src/test/mocks/MockConvertibleDepositAuctioneer.sol @@ -32,12 +32,9 @@ contract MockConvertibleDepositAuctioneer is IConvertibleDepositAuctioneer, Poli function getDay() external view override returns (Day memory day) {} - function convertFor( - uint256 deposit, - uint256 price - ) external view override returns (uint256 convertable) {} + function previewBid(uint256 deposit) external view override returns (uint256 convertable) {} - function beat( + function setAuctionParameters( uint256 newTarget, uint256 newSize, uint256 newMinPrice From 55397f881000a6a38342da7847f6069159a1d64d Mon Sep 17 00:00:00 2001 From: Jem <0x0xjem@gmail.com> Date: Fri, 13 Dec 2024 16:38:59 +0400 Subject: [PATCH 15/64] Fix licenses --- src/policies/interfaces/IConvertibleDepositAuctioneer.sol | 2 +- src/policies/interfaces/IConvertibleDepositFacility.sol | 2 +- src/policies/interfaces/IConvertibleDepositToken.sol | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/policies/interfaces/IConvertibleDepositAuctioneer.sol b/src/policies/interfaces/IConvertibleDepositAuctioneer.sol index 7eaf71c1..e42109f6 100644 --- a/src/policies/interfaces/IConvertibleDepositAuctioneer.sol +++ b/src/policies/interfaces/IConvertibleDepositAuctioneer.sol @@ -1,4 +1,4 @@ -// SPDX-License-Identifier: MIT +// SPDX-License-Identifier: AGPL-3.0 pragma solidity >=0.8.0; interface IConvertibleDepositAuctioneer { diff --git a/src/policies/interfaces/IConvertibleDepositFacility.sol b/src/policies/interfaces/IConvertibleDepositFacility.sol index 60ead170..0b1ed28a 100644 --- a/src/policies/interfaces/IConvertibleDepositFacility.sol +++ b/src/policies/interfaces/IConvertibleDepositFacility.sol @@ -1,4 +1,4 @@ -// SPDX-License-Identifier: MIT +// SPDX-License-Identifier: AGPL-3.0 pragma solidity ^0.8.0; import {IERC20} from "openzeppelin-contracts/contracts/interfaces/IERC20.sol"; diff --git a/src/policies/interfaces/IConvertibleDepositToken.sol b/src/policies/interfaces/IConvertibleDepositToken.sol index ca2482ab..93246071 100644 --- a/src/policies/interfaces/IConvertibleDepositToken.sol +++ b/src/policies/interfaces/IConvertibleDepositToken.sol @@ -1,4 +1,4 @@ -// SPDX-License-Identifier: MIT +// SPDX-License-Identifier: AGPL-3.0 pragma solidity ^0.8.0; import {IERC20} from "openzeppelin-contracts/contracts/interfaces/IERC20.sol"; From 7953b936eafe5088b2b3305607b8e587a3250e40 Mon Sep 17 00:00:00 2001 From: Jem <0x0xjem@gmail.com> Date: Fri, 13 Dec 2024 17:19:48 +0400 Subject: [PATCH 16/64] Interface for a proposed CDEPO module --- src/modules/CDEPO/CDEPO.v1.sol | 139 +++++++++++++++++++++++++++++++++ src/policies/CDFacility.sol | 8 ++ 2 files changed, 147 insertions(+) create mode 100644 src/modules/CDEPO/CDEPO.v1.sol diff --git a/src/modules/CDEPO/CDEPO.v1.sol b/src/modules/CDEPO/CDEPO.v1.sol new file mode 100644 index 00000000..999051b6 --- /dev/null +++ b/src/modules/CDEPO/CDEPO.v1.sol @@ -0,0 +1,139 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity 0.8.15; + +import {Kernel, Module} from "src/Kernel.sol"; +import {ERC20} from "solmate/tokens/ERC20.sol"; +import {ERC4626} from "solmate/mixins/ERC4626.sol"; + +/// @title Convertible Deposit Token +abstract contract CDEPOv1 is Module, ERC20 { + // ========== CONSTANTS ========== // + + /// @notice Equivalent to 100% + uint16 public constant ONE_HUNDRED_PERCENT = 100e2; + + // // ========== CONSTRUCTOR ========== // + + // constructor(address kernel_, address erc4626Vault_) Module(Kernel(kernel_)) { + // // Store the vault and asset + // vault = ERC4626(erc4626Vault_); + // asset = ERC20(vault.asset()); + + // // Set the name and symbol + // name = string.concat("cd", asset.symbol()); + // symbol = string.concat("cd", asset.symbol()); + // decimals = asset.decimals(); + + // // Set the initial chain id and domain separator (see solmate/tokens/ERC20.sol) + // INITIAL_CHAIN_ID = block.chainid; + // INITIAL_DOMAIN_SEPARATOR = computeDomainSeparator(); + // } + + // ========== ERC20 OVERRIDES ========== // + + /// @notice Mint tokens to the caller in exchange for the underlying asset + /// @dev The implementing function should perform the following: + /// - Transfers the underlying asset from the caller to the contract + /// - Mints the corresponding amount of convertible deposit tokens to the caller + /// - Deposits the underlying asset into the ERC4626 vault + /// - Emits a `Transfer` event + /// + /// @param amount_ The amount of underlying asset to transfer + function mint(uint256 amount_) external virtual; + + /// @notice Mint tokens to `to_` in exchange for the underlying asset + /// @dev This function behaves the same as `mint`, but allows the caller to + /// specify the address to mint the tokens to and pull the underlying + /// asset from. + /// + /// @param to_ The address to mint the tokens to + /// @param amount_ The amount of underlying asset to transfer + function mintTo(address to_, uint256 amount_) external virtual; + + /// @notice Preview the amount of convertible deposit tokens that would be minted for a given amount of underlying asset + /// @dev The implementing function should perform the following: + /// - Computes the amount of convertible deposit tokens that would be minted for the given amount of underlying asset + /// - Returns the computed amount + /// + /// @param amount_ The amount of underlying asset to transfer + /// @return tokensOut The amount of convertible deposit tokens that would be minted + function previewMint(uint256 amount_) external view virtual returns (uint256 tokensOut); + + /// @notice Burn tokens from the caller and return the underlying asset + /// The amount of underlying asset may not be 1:1 with the amount of + /// convertible deposit tokens, depending on the value of `burnRate` + /// @dev The implementing function should perform the following: + /// - Withdraws the underlying asset from the ERC4626 vault + /// - Transfers the underlying asset to the caller + /// - Burns the corresponding amount of convertible deposit tokens from the caller + /// - Marks the forfeited amount of the underlying asset as yield + /// - Emits a `Transfer` event + /// + /// @param amount_ The amount of convertible deposit tokens to burn + function burn(uint256 amount_) external virtual; + + /// @notice Burn tokens from `from_` and return the underlying asset + /// @dev This function behaves the same as `burn`, but allows the caller to + /// specify the address to burn the tokens from and transfer the underlying + /// asset to. + /// + /// @param from_ The address to burn the tokens from + /// @param amount_ The amount of convertible deposit tokens to burn + function burnFrom(address from_, uint256 amount_) external virtual; + + /// @notice Preview the amount of underlying asset that would be returned for a given amount of convertible deposit tokens + /// @dev The implementing function should perform the following: + /// - Computes the amount of underlying asset that would be returned for the given amount of convertible deposit tokens + /// - Returns the computed amount + /// + /// @param amount_ The amount of convertible deposit tokens to burn + /// @return assetsOut The amount of underlying asset that would be returned + function previewBurn(uint256 amount_) external view virtual returns (uint256 assetsOut); + + // ========== YIELD MANAGER ========== // + + /// @notice Claim the yield accrued on the reserve token + /// @dev The implementing function should perform the following: + /// - Validating that the caller has the correct role + /// - Withdrawing the yield from the sReserve token + /// - Transferring the yield to the caller + /// - Emitting an event + /// + /// @return yieldReserve The amount of reserve token that was swept + /// @return yieldSReserve The amount of sReserve token that was swept + function sweepYield() external virtual returns (uint256 yieldReserve, uint256 yieldSReserve); + + /// @notice Preview the amount of yield that would be swept + /// + /// @return yieldReserve The amount of reserve token that would be swept + /// @return yieldSReserve The amount of sReserve token that would be swept + function previewSweepYield() + external + view + virtual + returns (uint256 yieldReserve, uint256 yieldSReserve); + + // ========== ADMIN ========== // + + /// @notice Set the burn rate of the convertible deposit token + /// @dev The implementing function should perform the following: + /// - Validating that the caller has the correct role + /// - Validating that the new rate is within bounds + /// - Setting the new burn rate + /// - Emitting an event + /// + /// @param newBurnRate_ The new burn rate + function setBurnRate(uint256 newBurnRate_) external virtual; + + // ========== STATE VARIABLES ========== // + + /// @notice The ERC4626 vault that holds the underlying asset + function getVault() external view virtual returns (address); + + /// @notice The underlying asset + function getAsset() external view virtual returns (address); + + /// @notice The burn rate of the convertible deposit token + /// @dev A burn rate of 99e2 (99%) means that for every 100 convertible deposit tokens burned, 99 underlying asset tokens are returned + function burnRate() external view virtual returns (uint16); +} diff --git a/src/policies/CDFacility.sol b/src/policies/CDFacility.sol index 53f4240b..2567a669 100644 --- a/src/policies/CDFacility.sol +++ b/src/policies/CDFacility.sol @@ -126,6 +126,8 @@ contract CDFacility is Policy, RolesConsumer { // store convertable deposit info and mint cdUSDS cdInfo[user].push(CD(amount, convertable, expiry)); + // TODO shift mint/transfer functionality to CDEPO + // TODO consider if the ERC20 should custody the deposit token cdUSDS.mint(user, amount); } @@ -202,6 +204,8 @@ contract CDFacility is Policy, RolesConsumer { cd.convertable -= convertable; // reverts on overflow } + // TODO shift burn and transfer functionality to CDEPO + // burn cdUSDS cdUSDS.burn(msg.sender, returned); @@ -220,6 +224,8 @@ contract CDFacility is Policy, RolesConsumer { // ========== cdUSDS ========== // + // TODO shift these to CDEPO + /// @notice allow user to mint cdUSDS /// @notice redeeming without a CD may be at a discount /// @param amount of reserve token @@ -251,6 +257,8 @@ contract CDFacility is Policy, RolesConsumer { // ========== YIELD MANAGER ========== // + // TODO shift these to CDEPO + /// @notice allow yield manager to sweep yield accrued on reserves /// @return yield yield in reserve token terms /// @return shares yield in sReserve token terms From 43ca59c378551ce04cff3bc05e7a89c3e72c6060 Mon Sep 17 00:00:00 2001 From: Jem <0x0xjem@gmail.com> Date: Fri, 13 Dec 2024 18:49:47 +0400 Subject: [PATCH 17/64] Update description --- src/modules/CDEPO/CDEPO.v1.sol | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/modules/CDEPO/CDEPO.v1.sol b/src/modules/CDEPO/CDEPO.v1.sol index 999051b6..ae981672 100644 --- a/src/modules/CDEPO/CDEPO.v1.sol +++ b/src/modules/CDEPO/CDEPO.v1.sol @@ -5,7 +5,8 @@ import {Kernel, Module} from "src/Kernel.sol"; import {ERC20} from "solmate/tokens/ERC20.sol"; import {ERC4626} from "solmate/mixins/ERC4626.sol"; -/// @title Convertible Deposit Token +/// @title CDEPOv1 +/// @notice This is a base contract for a custodial convertible deposit token. It is designed to be used in conjunction with an ERC4626 vault. abstract contract CDEPOv1 is Module, ERC20 { // ========== CONSTANTS ========== // From c3a7ee87cb4cdb5a816ef6dd607698f0072381c9 Mon Sep 17 00:00:00 2001 From: Jem <0x0xjem@gmail.com> Date: Mon, 16 Dec 2024 12:51:23 +0400 Subject: [PATCH 18/64] Add module for convertible deposit terms --- src/modules/CDEPO/CDEPO.v1.sol | 20 ++++- src/modules/CTERM/CTERM.v1.sol | 150 +++++++++++++++++++++++++++++++++ 2 files changed, 166 insertions(+), 4 deletions(-) create mode 100644 src/modules/CTERM/CTERM.v1.sol diff --git a/src/modules/CDEPO/CDEPO.v1.sol b/src/modules/CDEPO/CDEPO.v1.sol index ae981672..6ff35bbb 100644 --- a/src/modules/CDEPO/CDEPO.v1.sol +++ b/src/modules/CDEPO/CDEPO.v1.sol @@ -13,6 +13,18 @@ abstract contract CDEPOv1 is Module, ERC20 { /// @notice Equivalent to 100% uint16 public constant ONE_HUNDRED_PERCENT = 100e2; + // ========== STATE VARIABLES ========== // + + /// @notice The burn rate of the convertible deposit token + /// @dev A burn rate of 99e2 (99%) means that for every 100 convertible deposit tokens burned, 99 underlying asset tokens are returned + uint16 internal _burnRate; + + /// @notice The total amount of deposits in the contract + uint256 public totalDeposits; + + /// @notice The total amount of vault shares in the contract + uint256 public totalShares; + // // ========== CONSTRUCTOR ========== // // constructor(address kernel_, address erc4626Vault_) Module(Kernel(kernel_)) { @@ -124,15 +136,15 @@ abstract contract CDEPOv1 is Module, ERC20 { /// - Emitting an event /// /// @param newBurnRate_ The new burn rate - function setBurnRate(uint256 newBurnRate_) external virtual; + function setBurnRate(uint16 newBurnRate_) external virtual; // ========== STATE VARIABLES ========== // /// @notice The ERC4626 vault that holds the underlying asset - function getVault() external view virtual returns (address); + function vault() external view virtual returns (ERC4626); - /// @notice The underlying asset - function getAsset() external view virtual returns (address); + /// @notice The underlying ERC20 asset + function asset() external view virtual returns (ERC20); /// @notice The burn rate of the convertible deposit token /// @dev A burn rate of 99e2 (99%) means that for every 100 convertible deposit tokens burned, 99 underlying asset tokens are returned diff --git a/src/modules/CTERM/CTERM.v1.sol b/src/modules/CTERM/CTERM.v1.sol new file mode 100644 index 00000000..96f2981d --- /dev/null +++ b/src/modules/CTERM/CTERM.v1.sol @@ -0,0 +1,150 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +pragma solidity 0.8.15; + +import {Module} from "src/Kernel.sol"; +import {ERC721} from "solmate/tokens/ERC721.sol"; + +/// @title CTERMv1 +/// @notice This defines the interface for the CTERM module. +/// The objective of this module is to track the terms of a convertible deposit. +abstract contract CTERMv1 is Module, ERC721 { + // ========== DATA STRUCTURES ========== // + + /// @notice Data structure for the terms of a convertible deposit + /// + /// @param owner The address of the owner of the term + /// @param remainingDeposit Amount of reserve tokens remaining to be converted + /// @param conversionPrice Price of the reserve token in USD + /// @param expiry Timestamp when the term expires + /// @param wrapped Whether the term is wrapped + struct ConvertibleDepositTerm { + address owner; + uint256 remainingDeposit; + uint256 conversionPrice; + uint48 expiry; + bool wrapped; + } + + // ========== STATE VARIABLES ========== // + + /// @notice The number of terms created + uint256 public termCount; + + /// @notice Mapping of term records to an ID + /// @dev IDs are assigned sequentially starting from 0 + /// Mapping entries should not be deleted, but can be overwritten + mapping(uint256 => ConvertibleDepositTerm) internal _terms; + + /// @notice Mapping of user addresses to their term IDs + mapping(address => uint256[]) internal _userTerms; + + // ========== ERRORS ========== // + + /// @notice Error thrown when an invalid term ID is provided + error CTERM_InvalidTermId(uint256 id_); + + /// @notice Error thrown when a term has already been wrapped + error CTERM_AlreadyWrapped(uint256 termId_); + + /// @notice Error thrown when a term has not been wrapped + error CTERM_NotWrapped(uint256 termId_); + + /// @notice Error thrown when an invalid amount is provided + error CTERM_InvalidAmount(uint256 amount_); + + // ========== WRAPPING ========== // + + /// @notice Wraps a term into an ERC721 token + /// @dev This is useful if the term owner wants a tokenized representation of their term. It is functionally equivalent to the term itself. + /// + /// The implementing function should do the following: + /// - Validate that the caller is the owner of the term + /// - Validate that the term is not already wrapped + /// - Mint an ERC721 token to the term owner + /// + /// @param termId_ The ID of the term to wrap + function wrap(uint256 termId_) external virtual; + + /// @notice Unwraps an ERC721 token into a term + /// @dev This is useful if the term owner wants to convert their token back into the term. + /// + /// The implementing function should do the following: + /// - Validate that the caller is the owner of the term + /// - Validate that the term is already wrapped + /// - Burn the ERC721 token + /// + /// @param termId_ The ID of the term to unwrap + function unwrap(uint256 termId_) external virtual; + + // ========== TERM MANAGEMENT =========== // + + /// @notice Creates a new convertible deposit term + /// @dev The implementing function should do the following: + /// - Validate that the caller is permissioned + /// - Validate that the remaining deposit is greater than 0 + /// - Validate that the conversion price is greater than 0 + /// - Validate that the expiry is in the future + /// - Create the term record + /// - Wrap the term if requested + /// + /// @param owner_ The address of the owner of the term + /// @param remainingDeposit_ The amount of reserve tokens remaining to be converted + /// @param conversionPrice_ The price of the reserve token in USD + /// @param expiry_ The timestamp when the term expires + /// @param wrap_ Whether the term should be wrapped + /// @return termId The ID of the new term + function create( + address owner_, + uint256 remainingDeposit_, + uint256 conversionPrice_, + uint48 expiry_, + bool wrap_ + ) external virtual returns (uint256 termId); + + /// @notice Updates the remaining deposit of a term + /// @dev The implementing function should do the following: + /// - Validate that the caller is permissioned + /// - Validate that the amount is greater than 0 + /// - Update the remaining deposit of the term + /// + /// @param termId_ The ID of the term to update + /// @param amount_ The new amount of the term + function update(uint256 termId_, uint256 amount_) external virtual; + + /// @notice Splits the specified amount of the term into a new term + /// This is useful if the term owner wants to split their term into multiple smaller terms. + /// @dev The implementing function should do the following: + /// - Validate that the caller is the owner of the term + /// - Validate that the amount is greater than 0 + /// - Validate that the amount is less than or equal to the remaining deposit + /// - Validate that the to address is not the zero address + /// - Update the remaining deposit of the original term + /// - Create the new term record + /// - Wrap the new term if requested + /// + /// @param termId_ The ID of the term to split + /// @param amount_ The amount of the term to split + /// @param to_ The address to split the term to + /// @param wrap_ Whether the new term should be wrapped + /// @return newTermId The ID of the new term + function split( + uint256 termId_, + uint256 amount_, + address to_, + bool wrap_ + ) external virtual returns (uint256 newTermId); + + // ========== TERM INFORMATION ========== // + + /// @notice Get the IDs of all terms for a given user + /// + /// @param user The address of the user + /// @return termIds An array of term IDs + function getUserTermIds(address user) external view virtual returns (uint256[] memory termIds); + + /// @notice Get the terms for a given ID + /// + /// @param termId_ The ID of the term + /// @return term The terms for the given ID + function getTerm(uint256 termId_) external view virtual returns (ConvertibleDepositTerm memory); +} From 349c671c4d6a0ca8d089c1181c8b786b35250075 Mon Sep 17 00:00:00 2001 From: Jem <0x0xjem@gmail.com> Date: Mon, 16 Dec 2024 13:17:38 +0400 Subject: [PATCH 19/64] Add CTERM events --- src/modules/CTERM/CTERM.v1.sol | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/src/modules/CTERM/CTERM.v1.sol b/src/modules/CTERM/CTERM.v1.sol index 96f2981d..9d7ab2de 100644 --- a/src/modules/CTERM/CTERM.v1.sol +++ b/src/modules/CTERM/CTERM.v1.sol @@ -25,6 +25,30 @@ abstract contract CTERMv1 is Module, ERC721 { bool wrapped; } + // ========== EVENTS ========== // + + /// @notice Emitted when a term is created + event TermCreated( + uint256 indexed termId, + address indexed owner, + uint256 remainingDeposit, + uint256 conversionPrice, + uint48 expiry, + bool wrapped + ); + + /// @notice Emitted when a term is updated + event TermUpdated(uint256 indexed termId, uint256 remainingDeposit); + + /// @notice Emitted when a term is split + event TermSplit(uint256 indexed termId, uint256 newTermId, uint256 amount, uint256 to); + + /// @notice Emitted when a term is wrapped + event TermWrapped(uint256 indexed termId); + + /// @notice Emitted when a term is unwrapped + event TermUnwrapped(uint256 indexed termId); + // ========== STATE VARIABLES ========== // /// @notice The number of terms created From 6b20f808c23059b81ee44ce7ba9991e30add3451 Mon Sep 17 00:00:00 2001 From: Jem <0x0xjem@gmail.com> Date: Mon, 16 Dec 2024 13:17:50 +0400 Subject: [PATCH 20/64] WIP implementation of CDEPO --- src/modules/CDEPO/CDEPO.v1.sol | 33 ++--- .../CDEPO/OlympusConvertibleDepository.sol | 114 ++++++++++++++++++ 2 files changed, 127 insertions(+), 20 deletions(-) create mode 100644 src/modules/CDEPO/OlympusConvertibleDepository.sol diff --git a/src/modules/CDEPO/CDEPO.v1.sol b/src/modules/CDEPO/CDEPO.v1.sol index 6ff35bbb..de1f181d 100644 --- a/src/modules/CDEPO/CDEPO.v1.sol +++ b/src/modules/CDEPO/CDEPO.v1.sol @@ -8,6 +8,19 @@ import {ERC4626} from "solmate/mixins/ERC4626.sol"; /// @title CDEPOv1 /// @notice This is a base contract for a custodial convertible deposit token. It is designed to be used in conjunction with an ERC4626 vault. abstract contract CDEPOv1 is Module, ERC20 { + // ========== EVENTS ========== // + + /// @notice Emitted when the burn rate is updated + event BurnRateUpdated(uint16 newBurnRate); + + /// @notice Emitted when the yield is swept + event YieldSwept(address receiver, uint256 reserveAmount, uint256 sReserveAmount); + + // ========== ERRORS ========== // + + /// @notice Thrown when the caller provides invalid arguments + error CDEPO_InvalidArgs(string reason); + // ========== CONSTANTS ========== // /// @notice Equivalent to 100% @@ -19,29 +32,9 @@ abstract contract CDEPOv1 is Module, ERC20 { /// @dev A burn rate of 99e2 (99%) means that for every 100 convertible deposit tokens burned, 99 underlying asset tokens are returned uint16 internal _burnRate; - /// @notice The total amount of deposits in the contract - uint256 public totalDeposits; - /// @notice The total amount of vault shares in the contract uint256 public totalShares; - // // ========== CONSTRUCTOR ========== // - - // constructor(address kernel_, address erc4626Vault_) Module(Kernel(kernel_)) { - // // Store the vault and asset - // vault = ERC4626(erc4626Vault_); - // asset = ERC20(vault.asset()); - - // // Set the name and symbol - // name = string.concat("cd", asset.symbol()); - // symbol = string.concat("cd", asset.symbol()); - // decimals = asset.decimals(); - - // // Set the initial chain id and domain separator (see solmate/tokens/ERC20.sol) - // INITIAL_CHAIN_ID = block.chainid; - // INITIAL_DOMAIN_SEPARATOR = computeDomainSeparator(); - // } - // ========== ERC20 OVERRIDES ========== // /// @notice Mint tokens to the caller in exchange for the underlying asset diff --git a/src/modules/CDEPO/OlympusConvertibleDepository.sol b/src/modules/CDEPO/OlympusConvertibleDepository.sol new file mode 100644 index 00000000..7110013e --- /dev/null +++ b/src/modules/CDEPO/OlympusConvertibleDepository.sol @@ -0,0 +1,114 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity 0.8.15; + +import {CDEPOv1} from "./CDEPO.v1.sol"; +import {Kernel, Module} from "src/Kernel.sol"; +import {ERC20} from "solmate/tokens/ERC20.sol"; +import {ERC4626} from "solmate/mixins/ERC4626.sol"; + +contract OlympusConvertibleDepository is CDEPOv1 { + // ========== STATE VARIABLES ========== // + + /// @inheritdoc CDEPOv1 + ERC4626 public immutable override vault; + + /// @inheritdoc CDEPOv1 + ERC20 public immutable override asset; + + /// @inheritdoc CDEPOv1 + uint16 public override burnRate; + + // ========== CONSTRUCTOR ========== // + + constructor( + address kernel_, + address erc4626Vault_ + ) + Module(Kernel(kernel_)) + ERC20( + string.concat("cd", ERC20(ERC4626(erc4626Vault_).asset()).symbol()), + string.concat("cd", ERC20(ERC4626(erc4626Vault_).asset()).symbol()), + ERC4626(erc4626Vault_).decimals() + ) + { + // Store the vault and asset + vault = ERC4626(erc4626Vault_); + asset = ERC20(vault.asset()); + } + + // ========== ERC20 OVERRIDES ========== // + + function mint(uint256 amount_) external virtual override {} + + function mintTo(address to_, uint256 amount_) external virtual override {} + + function previewMint( + uint256 amount_ + ) external view virtual override returns (uint256 tokensOut) {} + + function burn(uint256 amount_) external virtual override {} + + function burnFrom(address from_, uint256 amount_) external virtual override {} + + function previewBurn( + uint256 amount_ + ) external view virtual override returns (uint256 assetsOut) {} + + // ========== YIELD MANAGER ========== // + + /// @inheritdoc CDEPOv1 + function sweepYield() + external + virtual + override + permissioned + returns (uint256 yieldReserve, uint256 yieldSReserve) + { + (yieldReserve, yieldSReserve) = previewSweepYield(); + + // Reduce the shares tracked by the contract + totalShares -= yieldSReserve; + + // Transfer the yield to the permissioned caller + vault.transfer(msg.sender, yieldSReserve); + + // Emit the event + emit YieldSwept(msg.sender, yieldReserve, yieldSReserve); + + return (yieldReserve, yieldSReserve); + } + + /// @inheritdoc CDEPOv1 + function previewSweepYield() + public + view + virtual + override + returns (uint256 yieldReserve, uint256 yieldSReserve) + { + // The yield is the difference between the quantity of underlying assets in the vault and the quantity CD tokens issued + yieldReserve = vault.previewRedeem(totalShares) - totalSupply; + + // The yield in sReserve terms is the quantity of vault shares that would be burnt if yieldReserve was redeemed + yieldSReserve = vault.previewWithdraw(yieldReserve); + + return (yieldReserve, yieldSReserve); + } + + // ========== ADMIN ========== // + + /// @inheritdoc CDEPOv1 + /// @dev This function reverts if: + /// - The caller is not permissioned + /// - The new burn rate is not within bounds + function setBurnRate(uint16 newBurnRate_) external virtual override permissioned { + // Validate that the burn rate is within bounds + if (newBurnRate_ > ONE_HUNDRED_PERCENT) revert CDEPO_InvalidArgs("Greater than 100%"); + + // Update the burn rate + burnRate = newBurnRate_; + + // Emit the event + emit BurnRateUpdated(newBurnRate_); + } +} From 5536d546db158bd27c7543c918ebb3e914bb4446 Mon Sep 17 00:00:00 2001 From: Jem <0x0xjem@gmail.com> Date: Mon, 16 Dec 2024 13:34:21 +0400 Subject: [PATCH 21/64] First pass a CDEPO --- .../CDEPO/OlympusConvertibleDepository.sol | 83 +++++++++++++++++-- 1 file changed, 75 insertions(+), 8 deletions(-) diff --git a/src/modules/CDEPO/OlympusConvertibleDepository.sol b/src/modules/CDEPO/OlympusConvertibleDepository.sol index 7110013e..f5d968e8 100644 --- a/src/modules/CDEPO/OlympusConvertibleDepository.sol +++ b/src/modules/CDEPO/OlympusConvertibleDepository.sol @@ -38,25 +38,92 @@ contract OlympusConvertibleDepository is CDEPOv1 { // ========== ERC20 OVERRIDES ========== // - function mint(uint256 amount_) external virtual override {} + /// @inheritdoc CDEPOv1 + /// @dev This function performs the following: + /// - Calls `mintTo` with the caller as the recipient + function mint(uint256 amount_) external virtual override { + mintTo(msg.sender, amount_); + } - function mintTo(address to_, uint256 amount_) external virtual override {} + /// @inheritdoc CDEPOv1 + /// @dev This function performs the following: + /// - Transfers the underlying asset from `to_` to the contract + /// - Deposits the underlying asset into the ERC4626 vault + /// - Mints the corresponding amount of convertible deposit tokens to `to_` + /// - Emits a `Transfer` event + /// + /// @param to_ The address to mint the tokens to + /// @param amount_ The amount of underlying asset to transfer + function mintTo(address to_, uint256 amount_) public virtual override { + // Transfer the underlying asset to the contract + asset.transferFrom(to_, address(this), amount_); + + // Deposit the underlying asset into the vault and update the total shares + totalShares += vault.deposit(amount_, to_); + + // Mint the CD tokens to the caller + _mint(to_, amount_); + } + /// @inheritdoc CDEPOv1 + /// @dev CD tokens are minted 1:1 with underlying asset, so this function returns the amount of underlying asset function previewMint( uint256 amount_ - ) external view virtual override returns (uint256 tokensOut) {} + ) external view virtual override returns (uint256 tokensOut) { + return amount_; + } - function burn(uint256 amount_) external virtual override {} + /// @inheritdoc CDEPOv1 + /// @dev This function performs the following: + /// - Calls `burnFrom` with the caller as the address to burn the tokens from + function burn(uint256 amount_) external virtual override { + burnFrom(msg.sender, amount_); + } - function burnFrom(address from_, uint256 amount_) external virtual override {} + /// @inheritdoc CDEPOv1 + /// @dev This function performs the following: + /// - Burns the CD tokens from `from_` + /// - Calculates the quantity of underlying asset to withdraw and return + /// - Returns the underlying asset to `from_` + /// - Emits a `Transfer` event + /// + /// @param from_ The address to burn the tokens from + /// @param amount_ The amount of CD tokens to burn + function burnFrom(address from_, uint256 amount_) public virtual override { + // Burn the CD tokens from `from_` + _burn(from_, amount_); + + // Calculate the quantity of underlying asset to withdraw and return + // This will create a difference between the quantity of underlying assets and the vault shares, which will be swept as yield + // TODO make sure there are no shares left over if all CD tokens are burned + uint256 discountedAssetsOut = previewBurn(amount_); + uint256 shares = vault.previewWithdraw(discountedAssetsOut); + totalShares -= shares; + + // Return the underlying asset to `from_` + vault.redeem(shares, from_, address(this)); + } - function previewBurn( - uint256 amount_ - ) external view virtual override returns (uint256 assetsOut) {} + /// @inheritdoc CDEPOv1 + function previewBurn(uint256 amount_) public view virtual override returns (uint256 assetsOut) { + assetsOut = (amount_ * burnRate) / ONE_HUNDRED_PERCENT; + } // ========== YIELD MANAGER ========== // /// @inheritdoc CDEPOv1 + /// @dev This function performs the following: + /// - Validates that the caller has the correct role + /// - Computes the amount of yield that would be swept + /// - Reduces the shares tracked by the contract + /// - Transfers the yield to the caller + /// - Emits an event + /// + /// This function reverts if: + /// - The caller is not permissioned + /// + /// @return yieldReserve The amount of reserve token that was swept + /// @return yieldSReserve The amount of sReserve token that was swept function sweepYield() external virtual From 9fb539ce4bfab6c77606e32b751cb82f7a888056 Mon Sep 17 00:00:00 2001 From: Jem <0x0xjem@gmail.com> Date: Mon, 16 Dec 2024 15:46:03 +0400 Subject: [PATCH 22/64] First pass at implementation of CTERM --- src/modules/CTERM/CTERM.v1.sol | 26 +- .../CTERM/OlympusConvertibleDepositTerms.sol | 262 ++++++++++++++++++ 2 files changed, 279 insertions(+), 9 deletions(-) create mode 100644 src/modules/CTERM/OlympusConvertibleDepositTerms.sol diff --git a/src/modules/CTERM/CTERM.v1.sol b/src/modules/CTERM/CTERM.v1.sol index 9d7ab2de..fba2cd5c 100644 --- a/src/modules/CTERM/CTERM.v1.sol +++ b/src/modules/CTERM/CTERM.v1.sol @@ -12,13 +12,11 @@ abstract contract CTERMv1 is Module, ERC721 { /// @notice Data structure for the terms of a convertible deposit /// - /// @param owner The address of the owner of the term /// @param remainingDeposit Amount of reserve tokens remaining to be converted /// @param conversionPrice Price of the reserve token in USD /// @param expiry Timestamp when the term expires /// @param wrapped Whether the term is wrapped struct ConvertibleDepositTerm { - address owner; uint256 remainingDeposit; uint256 conversionPrice; uint48 expiry; @@ -41,7 +39,13 @@ abstract contract CTERMv1 is Module, ERC721 { event TermUpdated(uint256 indexed termId, uint256 remainingDeposit); /// @notice Emitted when a term is split - event TermSplit(uint256 indexed termId, uint256 newTermId, uint256 amount, uint256 to); + event TermSplit( + uint256 indexed termId, + uint256 newTermId, + uint256 amount, + address to, + bool wrap + ); /// @notice Emitted when a term is wrapped event TermWrapped(uint256 indexed termId); @@ -64,6 +68,9 @@ abstract contract CTERMv1 is Module, ERC721 { // ========== ERRORS ========== // + /// @notice Error thrown when the caller is not the owner of the term + error CTERM_NotOwner(uint256 termId_); + /// @notice Error thrown when an invalid term ID is provided error CTERM_InvalidTermId(uint256 id_); @@ -73,8 +80,8 @@ abstract contract CTERMv1 is Module, ERC721 { /// @notice Error thrown when a term has not been wrapped error CTERM_NotWrapped(uint256 termId_); - /// @notice Error thrown when an invalid amount is provided - error CTERM_InvalidAmount(uint256 amount_); + /// @notice Error thrown when an invalid parameter is provided + error CTERM_InvalidParams(string reason_); // ========== WRAPPING ========== // @@ -105,6 +112,7 @@ abstract contract CTERMv1 is Module, ERC721 { /// @notice Creates a new convertible deposit term /// @dev The implementing function should do the following: /// - Validate that the caller is permissioned + /// - Validate that the owner is not the zero address /// - Validate that the remaining deposit is greater than 0 /// - Validate that the conversion price is greater than 0 /// - Validate that the expiry is in the future @@ -128,7 +136,7 @@ abstract contract CTERMv1 is Module, ERC721 { /// @notice Updates the remaining deposit of a term /// @dev The implementing function should do the following: /// - Validate that the caller is permissioned - /// - Validate that the amount is greater than 0 + /// - Validate that the term ID is valid /// - Update the remaining deposit of the term /// /// @param termId_ The ID of the term to update @@ -141,7 +149,7 @@ abstract contract CTERMv1 is Module, ERC721 { /// - Validate that the caller is the owner of the term /// - Validate that the amount is greater than 0 /// - Validate that the amount is less than or equal to the remaining deposit - /// - Validate that the to address is not the zero address + /// - Validate that `to_` is not the zero address /// - Update the remaining deposit of the original term /// - Create the new term record /// - Wrap the new term if requested @@ -162,9 +170,9 @@ abstract contract CTERMv1 is Module, ERC721 { /// @notice Get the IDs of all terms for a given user /// - /// @param user The address of the user + /// @param user_ The address of the user /// @return termIds An array of term IDs - function getUserTermIds(address user) external view virtual returns (uint256[] memory termIds); + function getUserTermIds(address user_) external view virtual returns (uint256[] memory termIds); /// @notice Get the terms for a given ID /// diff --git a/src/modules/CTERM/OlympusConvertibleDepositTerms.sol b/src/modules/CTERM/OlympusConvertibleDepositTerms.sol new file mode 100644 index 00000000..b0241c0b --- /dev/null +++ b/src/modules/CTERM/OlympusConvertibleDepositTerms.sol @@ -0,0 +1,262 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity 0.8.15; + +import {ERC721} from "solmate/tokens/ERC721.sol"; +import {CTERMv1} from "./CTERM.v1.sol"; +import {Kernel, Module} from "src/Kernel.sol"; + +contract OlympusConvertibleDepositTerms is CTERMv1 { + constructor( + address kernel_ + ) Module(Kernel(kernel_)) ERC721("Olympus Convertible Deposit Terms", "OCDT") {} + + // ========== WRAPPING ========== // + + /// @inheritdoc CTERMv1 + /// @dev This function reverts if: + /// - The term ID is invalid + /// - The caller is not the owner of the term + /// - The term is already wrapped + function wrap( + uint256 termId_ + ) external virtual override onlyValidTerm(termId_) onlyTermOwner(termId_) { + // Does not need to check for invalid term ID because the modifier already ensures that + ConvertibleDepositTerm storage term = _terms[termId_]; + + // Validate that the term is not already wrapped + if (term.wrapped) revert CTERM_AlreadyWrapped(termId_); + + // Mark the term as wrapped + term.wrapped = true; + + // Mint the ERC721 token + _mint(msg.sender, termId_); + + emit TermWrapped(termId_); + } + + /// @inheritdoc CTERMv1 + /// @dev This function reverts if: + /// - The term ID is invalid + /// - The caller is not the owner of the term + /// - The term is not wrapped + function unwrap( + uint256 termId_ + ) external virtual override onlyValidTerm(termId_) onlyTermOwner(termId_) { + // Does not need to check for invalid term ID because the modifier already ensures that + ConvertibleDepositTerm storage term = _terms[termId_]; + + // Validate that the term is wrapped + if (!term.wrapped) revert CTERM_NotWrapped(termId_); + + // Mark the term as unwrapped + term.wrapped = false; + + // Burn the ERC721 token + _burn(termId_); + + emit TermUnwrapped(termId_); + } + + // ========== TERM MANAGEMENT =========== // + + function _create( + address owner_, + uint256 remainingDeposit_, + uint256 conversionPrice_, + uint48 expiry_, + bool wrap_ + ) internal returns (uint256 termId) { + // Create the term record + termId = ++termCount; + _terms[termId] = ConvertibleDepositTerm({ + remainingDeposit: remainingDeposit_, + conversionPrice: conversionPrice_, + expiry: expiry_, + wrapped: wrap_ + }); + + // Update ERC721 storage + _ownerOf[termId] = owner_; + _balanceOf[owner_]++; + + // Add the term ID to the user's list of terms + _userTerms[owner_].push(termId); + + // If specified, wrap the term + if (wrap_) _mint(owner_, termId); + + // Emit the event + emit TermCreated(termId, owner_, remainingDeposit_, conversionPrice_, expiry_, wrap_); + + return termId; + } + + /// @inheritdoc CTERMv1 + /// @dev This function reverts if: + /// - The caller is not permissioned + /// - The owner is the zero address + /// - The remaining deposit is 0 + /// - The conversion price is 0 + /// - The expiry is in the past + function create( + address owner_, + uint256 remainingDeposit_, + uint256 conversionPrice_, + uint48 expiry_, + bool wrap_ + ) external virtual override permissioned returns (uint256 termId) { + // Validate that the owner is not the zero address + if (owner_ == address(0)) revert CTERM_InvalidParams("owner"); + + // Validate that the remaining deposit is greater than 0 + if (remainingDeposit_ == 0) revert CTERM_InvalidParams("deposit"); + + // Validate that the conversion price is greater than 0 + if (conversionPrice_ == 0) revert CTERM_InvalidParams("conversion price"); + + // Validate that the expiry is in the future + if (expiry_ <= block.timestamp) revert CTERM_InvalidParams("expiry"); + + return _create(owner_, remainingDeposit_, conversionPrice_, expiry_, wrap_); + } + + /// @inheritdoc CTERMv1 + /// @dev This function reverts if: + /// - The caller is not permissioned + /// - The term ID is invalid + function update( + uint256 termId_, + uint256 amount_ + ) external virtual override permissioned onlyValidTerm(termId_) { + // Update the remaining deposit of the term + ConvertibleDepositTerm storage term = _terms[termId_]; + term.remainingDeposit = amount_; + + // Emit the event + emit TermUpdated(termId_, amount_); + } + + /// @inheritdoc CTERMv1 + /// @dev This function reverts if: + /// - The caller is not the owner of the term + /// - The amount is 0 + /// - The amount is greater than the remaining deposit + /// - `to_` is the zero address + function split( + uint256 termId_, + uint256 amount_, + address to_, + bool wrap_ + ) + external + virtual + override + onlyValidTerm(termId_) + onlyTermOwner(termId_) + returns (uint256 newTermId) + { + ConvertibleDepositTerm storage term = _terms[termId_]; + + // Validate that the amount is greater than 0 + if (amount_ == 0) revert CTERM_InvalidParams("amount"); + + // Validate that the amount is less than or equal to the remaining deposit + if (amount_ > term.remainingDeposit) revert CTERM_InvalidParams("amount"); + + // Validate that the to address is not the zero address + if (to_ == address(0)) revert CTERM_InvalidParams("to"); + + // Calculate the remaining deposit of the existing term + uint256 remainingDeposit = term.remainingDeposit - amount_; + + // Update the remaining deposit of the existing term + term.remainingDeposit = remainingDeposit; + + // Create the new term + newTermId = _create(to_, amount_, term.conversionPrice, term.expiry, wrap_); + + // Emit the event + emit TermSplit(termId_, newTermId, amount_, to_, wrap_); + + return newTermId; + } + + // ========== ERC721 OVERRIDES ========== // + + /// @inheritdoc ERC721 + function tokenURI(uint256 id_) public view virtual override returns (string memory) { + // TODO implement tokenURI SVG + return ""; + } + + /// @inheritdoc ERC721 + /// @dev This function performs the following: + /// - Updates the owner of the term + /// - Calls `transferFrom` on the parent contract + function transferFrom(address from_, address to_, uint256 tokenId_) public override { + ConvertibleDepositTerm storage term = _terms[tokenId_]; + + // Validate that the term is valid + if (term.conversionPrice == 0) revert CTERM_InvalidTermId(tokenId_); + + // Ownership is validated in `transferFrom` on the parent contract + + // Add to user terms on the destination address + _userTerms[to_].push(tokenId_); + + // Remove from user terms on the source address + bool found = false; + for (uint256 i = 0; i < _userTerms[from_].length; i++) { + if (_userTerms[from_][i] == tokenId_) { + _userTerms[from_][i] = _userTerms[from_][_userTerms[from_].length - 1]; + _userTerms[from_].pop(); + found = true; + break; + } + } + if (!found) revert CTERM_InvalidTermId(tokenId_); + + // Call `transferFrom` on the parent contract + super.transferFrom(from_, to_, tokenId_); + } + + // ========== TERM INFORMATION ========== // + + function _getTerm(uint256 termId_) internal view returns (ConvertibleDepositTerm memory) { + ConvertibleDepositTerm memory term = _terms[termId_]; + // `create()` blocks a 0 conversion price, so this should never happen on a valid term + if (term.conversionPrice == 0) revert CTERM_InvalidTermId(termId_); + + return term; + } + + /// @inheritdoc CTERMv1 + function getUserTermIds( + address user_ + ) external view virtual override returns (uint256[] memory termIds) { + return _userTerms[user_]; + } + + /// @inheritdoc CTERMv1 + /// @dev This function reverts if: + /// - The term ID is invalid + function getTerm( + uint256 termId_ + ) external view virtual override returns (ConvertibleDepositTerm memory) { + return _getTerm(termId_); + } + + // ========== MODIFIERS ========== // + + modifier onlyValidTerm(uint256 termId_) { + if (_getTerm(termId_).conversionPrice == 0) revert CTERM_InvalidTermId(termId_); + _; + } + + modifier onlyTermOwner(uint256 termId_) { + // This validates that the caller is the owner of the term + if (_ownerOf[termId_] != msg.sender) revert CTERM_NotOwner(termId_); + _; + } +} From e3dcecc9065d8c2be2eef09c58da7bee19b54459 Mon Sep 17 00:00:00 2001 From: Jem <0x0xjem@gmail.com> Date: Mon, 16 Dec 2024 17:03:51 +0400 Subject: [PATCH 23/64] Add a permissioned function to CDEPO to redeem CD tokens for assets. Shift CDFacility to use the interface and CDEPO/CTERM. --- src/modules/CDEPO/CDEPO.v1.sol | 15 +- .../CDEPO/OlympusConvertibleDepository.sol | 20 ++ src/modules/CTERM/CTERM.v1.sol | 2 + src/policies/CDFacility.sol | 318 +++++++----------- .../IConvertibleDepositFacility.sol | 173 ++++------ .../interfaces/IConvertibleDepositToken.sol | 18 - 6 files changed, 212 insertions(+), 334 deletions(-) delete mode 100644 src/policies/interfaces/IConvertibleDepositToken.sol diff --git a/src/modules/CDEPO/CDEPO.v1.sol b/src/modules/CDEPO/CDEPO.v1.sol index de1f181d..d369f9ba 100644 --- a/src/modules/CDEPO/CDEPO.v1.sol +++ b/src/modules/CDEPO/CDEPO.v1.sol @@ -48,7 +48,7 @@ abstract contract CDEPOv1 is Module, ERC20 { function mint(uint256 amount_) external virtual; /// @notice Mint tokens to `to_` in exchange for the underlying asset - /// @dev This function behaves the same as `mint`, but allows the caller to + /// This function behaves the same as `mint`, but allows the caller to /// specify the address to mint the tokens to and pull the underlying /// asset from. /// @@ -79,7 +79,7 @@ abstract contract CDEPOv1 is Module, ERC20 { function burn(uint256 amount_) external virtual; /// @notice Burn tokens from `from_` and return the underlying asset - /// @dev This function behaves the same as `burn`, but allows the caller to + /// This function behaves the same as `burn`, but allows the caller to /// specify the address to burn the tokens from and transfer the underlying /// asset to. /// @@ -96,6 +96,17 @@ abstract contract CDEPOv1 is Module, ERC20 { /// @return assetsOut The amount of underlying asset that would be returned function previewBurn(uint256 amount_) external view virtual returns (uint256 assetsOut); + /// @notice Redeem convertible deposit tokens for the underlying asset + /// This differs from the burn function, in that it is an admin-level and permissioned function that does not apply the burn rate. + /// @dev The implementing function should perform the following: + /// - Validates that the caller is permissioned + /// - Transfers the corresponding vault shares to the caller + /// - Burns the corresponding amount of convertible deposit tokens from the caller + /// + /// @param amount_ The amount of convertible deposit tokens to burn + /// @return sharesOut The amount of shares that were transferred to the caller + function redeem(uint256 amount_) external virtual returns (uint256 sharesOut); + // ========== YIELD MANAGER ========== // /// @notice Claim the yield accrued on the reserve token diff --git a/src/modules/CDEPO/OlympusConvertibleDepository.sol b/src/modules/CDEPO/OlympusConvertibleDepository.sol index f5d968e8..a387e5c4 100644 --- a/src/modules/CDEPO/OlympusConvertibleDepository.sol +++ b/src/modules/CDEPO/OlympusConvertibleDepository.sol @@ -109,6 +109,26 @@ contract OlympusConvertibleDepository is CDEPOv1 { assetsOut = (amount_ * burnRate) / ONE_HUNDRED_PERCENT; } + /// @inheritdoc CDEPOv1 + /// @dev This function performs the following: + /// - Validates that the caller is permissioned + /// - Burns the CD tokens from the caller + /// - Calculates the quantity of underlying asset to withdraw and return + /// - Returns the underlying asset to the caller + /// + /// @param amount_ The amount of CD tokens to burn + function redeem(uint256 amount_) external override permissioned returns (uint256 sharesOut) { + // Burn the CD tokens from the caller + _burn(msg.sender, amount_); + + // Calculate the quantity of shares to transfer + sharesOut = vault.previewWithdraw(amount_); + totalShares -= sharesOut; + + // Transfer the shares to the caller + vault.transfer(msg.sender, sharesOut); + } + // ========== YIELD MANAGER ========== // /// @inheritdoc CDEPOv1 diff --git a/src/modules/CTERM/CTERM.v1.sol b/src/modules/CTERM/CTERM.v1.sol index fba2cd5c..0924b7e1 100644 --- a/src/modules/CTERM/CTERM.v1.sol +++ b/src/modules/CTERM/CTERM.v1.sol @@ -8,6 +8,8 @@ import {ERC721} from "solmate/tokens/ERC721.sol"; /// @notice This defines the interface for the CTERM module. /// The objective of this module is to track the terms of a convertible deposit. abstract contract CTERMv1 is Module, ERC721 { + // TODO rename to CDPOS + // ========== DATA STRUCTURES ========== // /// @notice Data structure for the terms of a convertible deposit diff --git a/src/policies/CDFacility.sol b/src/policies/CDFacility.sol index 2567a669..d37ee3c5 100644 --- a/src/policies/CDFacility.sol +++ b/src/policies/CDFacility.sol @@ -7,30 +7,18 @@ import {ReentrancyGuard} from "solmate/utils/ReentrancyGuard.sol"; import {ERC20} from "solmate/tokens/ERC20.sol"; import {ERC4626} from "solmate/mixins/ERC4626.sol"; +import {IConvertibleDepositFacility} from "src/policies/interfaces/IConvertibleDepositFacility.sol"; import {RolesConsumer, ROLESv1} from "src/modules/ROLES/OlympusRoles.sol"; import {MINTRv1} from "src/modules/MINTR/MINTR.v1.sol"; import {TRSRYv1} from "src/modules/TRSRY/TRSRY.v1.sol"; - -import {IConvertibleDepositToken} from "src/policies/interfaces/IConvertibleDepositToken.sol"; +import {CDEPOv1} from "src/modules/CDEPO/CDEPO.v1.sol"; +import {CTERMv1} from "src/modules/CTERM/CTERM.v1.sol"; import {FullMath} from "src/libraries/FullMath.sol"; -contract CDFacility is Policy, RolesConsumer { +contract CDFacility is Policy, RolesConsumer, IConvertibleDepositFacility { using FullMath for uint256; - struct CD { - uint256 deposit; - uint256 convertable; - uint256 expiry; - } - - // ========== EVENTS ========== // - - event CreatedCD(address user, uint48 expiry, uint256 deposit, uint256 convert); - event ConvertedCD(address user, uint256 deposit, uint256 convert); - event ReclaimedCD(address user, uint256 deposit); - event SweptYield(address receiver, uint256 amount); - // ========== STATE VARIABLES ========== // // Constants @@ -39,17 +27,8 @@ contract CDFacility is Policy, RolesConsumer { // Modules TRSRYv1 public TRSRY; MINTRv1 public MINTR; - - // Tokens - ERC20 public reserve; - ERC4626 public sReserve; - IConvertibleDepositToken public cdUSDS; - - // State variables - uint256 public totalDeposits; - uint256 public totalShares; - mapping(address => CD[]) public cdInfo; - uint256 public redeemRate; + CDEPOv1 public CDEPO; + CTERMv1 public CTERM; // ========== ERRORS ========== // @@ -59,33 +38,21 @@ contract CDFacility is Policy, RolesConsumer { // ========== SETUP ========== // - constructor( - Kernel kernel_, - address reserve_, - address sReserve_, - address cdUSDS_ - ) Policy(kernel_) { - if (reserve_ == address(0)) revert CDFacility_InvalidParams("Reserve address cannot be 0"); - if (sReserve_ == address(0)) - revert CDFacility_InvalidParams("sReserve address cannot be 0"); - if (cdUSDS_ == address(0)) revert CDFacility_InvalidParams("cdUSDS address cannot be 0"); - - reserve = ERC20(reserve_); - sReserve = ERC4626(sReserve_); - - // TODO shift to module and dependency injection - cdUSDS = IConvertibleDepositToken(cdUSDS_); - } + constructor(address kernel_) Policy(Kernel(kernel_)) {} function configureDependencies() external override returns (Keycode[] memory dependencies) { - dependencies = new Keycode[](3); + dependencies = new Keycode[](5); dependencies[0] = toKeycode("TRSRY"); dependencies[1] = toKeycode("MINTR"); dependencies[2] = toKeycode("ROLES"); + dependencies[3] = toKeycode("CDEPO"); + dependencies[4] = toKeycode("CTERM"); TRSRY = TRSRYv1(getModuleAddress(dependencies[0])); MINTR = MINTRv1(getModuleAddress(dependencies[1])); ROLES = ROLESv1(getModuleAddress(dependencies[2])); + CDEPO = CDEPOv1(getModuleAddress(dependencies[3])); + CTERM = CTERMv1(getModuleAddress(dependencies[4])); } function requestPermissions() @@ -95,207 +62,148 @@ contract CDFacility is Policy, RolesConsumer { returns (Permissions[] memory permissions) { Keycode mintrKeycode = toKeycode("MINTR"); + Keycode cdepoKeycode = toKeycode("CDEPO"); + Keycode ctermKeycode = toKeycode("CTERM"); - permissions = new Permissions[](2); + permissions = new Permissions[](5); permissions[0] = Permissions(mintrKeycode, MINTR.increaseMintApproval.selector); permissions[1] = Permissions(mintrKeycode, MINTR.mintOhm.selector); + permissions[2] = Permissions(cdepoKeycode, CDEPO.sweepYield.selector); + permissions[3] = Permissions(ctermKeycode, CTERM.create.selector); + permissions[4] = Permissions(ctermKeycode, CTERM.update.selector); } - // ========== EMISSIONS MANAGER ========== // - - /// @notice allow emissions manager to create new convertible debt - /// @param user owner of the convertible debt - /// @param amount amount of reserve tokens deposited - /// @param convertable amount of OHM that can be converted into - /// @param expiry timestamp when conversion expires - function addNewCD( - address user, - uint256 amount, - uint256 convertable, - uint256 expiry - ) external onlyRole("CD_Auctioneer") { - // transfer in debt token - reserve.transferFrom(user, address(this), amount); + // ========== CONVERTIBLE DEPOSIT ACTIONS ========== // - // deploy debt token into vault - totalShares += sReserve.deposit(amount, address(this)); + /// @inheritdoc IConvertibleDepositFacility + function create( + address account_, + uint256 amount_, + uint256 conversionPrice_, + uint48 expiry_, + bool wrap_ + ) external onlyRole("CD_Auctioneer") returns (uint256 termId) { + // Mint the CD token to the account + // This will also transfer the reserve token + CDEPO.mintTo(account_, amount_); - // add mint approval for conversion - MINTR.increaseMintApproval(address(this), convertable); + // Create a new term record in the CTERM module + termId = CTERM.create(account_, amount_, conversionPrice_, expiry_, wrap_); - // store convertable deposit info and mint cdUSDS - cdInfo[user].push(CD(amount, convertable, expiry)); + // Pre-emptively increase the OHM mint approval + MINTR.increaseMintApproval(address(this), amount_); - // TODO shift mint/transfer functionality to CDEPO - - // TODO consider if the ERC20 should custody the deposit token - cdUSDS.mint(user, amount); + // Emit an event + emit CreatedDeposit(account_, termId, amount_); } - // ========== CD Position Owner ========== // - - /// @notice allow user to convert their convertible debt before expiration - /// @param cds CD indexes to convert - /// @param amounts CD token amounts to convert - function convertCD( - uint256[] memory cds, - uint256[] memory amounts - ) external returns (uint256 converted) { - if (cds.length != amounts.length) revert Misconfigured(); - - uint256 totalDeposit; - - // iterate through and burn CD tokens, adding deposit and conversion amounts to running totals - for (uint256 i; i < cds.length; ++i) { - CD storage cd = cdInfo[msg.sender][i]; - if (cd.expiry < block.timestamp) continue; - - uint256 amount = amounts[i]; - uint256 converting = ((cd.convertable * amount) / cd.deposit); - - // increment running totals - totalDeposit += amount; - converted += converting; - - // decrement deposit info - cd.convertable -= converting; // reverts on overflow - cd.deposit -= amount; - } - - // compute and account for shares to send to treasury - uint256 shares = sReserve.previewWithdraw(totalDeposit); - totalShares -= shares; + /// @inheritdoc IConvertibleDepositFacility + function convert( + uint256[] memory positionIds_, + uint256[] memory amounts_ + ) external returns (uint256 totalDeposit, uint256 converted) { + // Make sure the lengths of the arrays are the same + if (positionIds_.length != amounts_.length) + revert CDF_InvalidArgs("array lengths must match"); - // burn cdUSDS - cdUSDS.burn(msg.sender, totalDeposit); + uint256 totalDeposits; - // mint ohm and send underlying debt token to treasury - MINTR.mintOhm(msg.sender, converted); - sReserve.transfer(address(TRSRY), shares); + // Iterate over all positions + for (uint256 i; i < positionIds_.length; ++i) { + uint256 positionId = positionIds_[i]; + uint256 depositAmount = amounts_[i]; - emit ConvertedCD(msg.sender, totalDeposit, converted); - } + // Validate that the caller is the owner of the position + if (CTERM.ownerOf(positionId) != msg.sender) revert CDF_NotOwner(positionId); - /// @notice allow user to reclaim their convertible debt deposits after expiration - /// @param cds CD indexes to return - /// @param amounts amounts of CD tokens to burn - /// @return returned total reserve tokens returned - function returnDeposit( - uint256[] memory cds, - uint256[] memory amounts - ) external returns (uint256 returned) { - if (cds.length != amounts.length) revert Misconfigured(); + // Validate that the position is valid + // This will revert if the position is not valid + CTERMv1.ConvertibleDepositTerm memory term = CTERM.getTerm(positionId); - uint256 unconverted; + // Validate that the term has not expired + if (block.timestamp >= term.expiry) revert CDF_PositionExpired(positionId); - // iterate through and burn CD tokens, adding deposit and conversion amounts to running totals - for (uint256 i; i < cds.length; ++i) { - CD memory cd = cdInfo[msg.sender][cds[i]]; - if (cd.expiry >= block.timestamp) continue; + // Validate that the deposit amount is not greater than the remaining deposit + if (depositAmount > term.remainingDeposit) + revert CDF_InvalidAmount(positionId, depositAmount); - uint256 amount = amounts[i]; - uint256 convertable = ((cd.convertable * amount) / cd.deposit); + uint256 convertedAmount = (depositAmount * term.conversionPrice) / DECIMALS; // TODO check decimals, rounding - returned += amount; - unconverted += convertable; + // Increment running totals + totalDeposits += depositAmount; + converted += convertedAmount; - // decrement deposit info - cd.deposit -= amount; - cd.convertable -= convertable; // reverts on overflow + // Update the position + CTERM.update(positionId, term.remainingDeposit - depositAmount); } - // TODO shift burn and transfer functionality to CDEPO + // Redeem the CD deposits in bulk + uint256 sharesOut = CDEPO.redeem(totalDeposits); - // burn cdUSDS - cdUSDS.burn(msg.sender, returned); + // Transfer the redeemed assets to the TRSRY + CDEPO.vault().transfer(address(TRSRY), sharesOut); - // compute shares to redeem - uint256 shares = sReserve.previewWithdraw(returned); - totalShares -= shares; + // Mint OHM to the owner/caller + MINTR.mintOhm(msg.sender, converted); - // return debt token to user - sReserve.redeem(shares, msg.sender, address(this)); + // Emit event + emit ConvertedDeposit(msg.sender, totalDeposits, converted); - // decrease mint approval to reflect tokens that will not convert - MINTR.decreaseMintApproval(address(this), unconverted); - - emit ReclaimedCD(msg.sender, returned); + return (totalDeposits, converted); } - // ========== cdUSDS ========== // - - // TODO shift these to CDEPO + /// @inheritdoc IConvertibleDepositFacility + function reclaim( + uint256[] memory positionIds_, + uint256[] memory amounts_ + ) external override returns (uint256 reclaimed) { + // Make sure the lengths of the arrays are the same + if (positionIds_.length != amounts_.length) + revert CDF_InvalidArgs("array lengths must match"); - /// @notice allow user to mint cdUSDS - /// @notice redeeming without a CD may be at a discount - /// @param amount of reserve token - /// @return tokensOut cdUSDS out (1:1 with USDS in) - function mint(uint256 amount) external returns (uint256 tokensOut) { - tokensOut = amount; - - reserve.transferFrom(msg.sender, address(this), amount); - totalShares += sReserve.deposit(amount, msg.sender); + uint256 unconverted; - cdUSDS.mint(msg.sender, amount); - } + // Iterate over all positions + for (uint256 i; i < positionIds_.length; ++i) { + uint256 positionId = positionIds_[i]; + uint256 depositAmount = amounts_[i]; - /// @notice allow non cd holder to sell cdUSDS for USDS - /// @notice the amount of USDS per cdUSDS is not 1:1 - /// @notice convertible depositors should use returnDeposit() for 1:1 - function redeem(uint256 amount) external returns (uint256 tokensOut) { - // burn cdUSDS - cdUSDS.burn(msg.sender, amount); + // Validate that the caller is the owner of the position + if (CTERM.ownerOf(positionId) != msg.sender) revert CDF_NotOwner(positionId); - // compute shares to redeem - tokensOut = redeemOutput(amount); - uint256 shares = sReserve.previewWithdraw(tokensOut); - totalShares -= shares; + // Validate that the position is valid + // This will revert if the position is not valid + CTERMv1.ConvertibleDepositTerm memory term = CTERM.getTerm(positionId); - // return debt token to user - sReserve.redeem(shares, msg.sender, address(this)); - } + // Validate that the term has expired + if (block.timestamp < term.expiry) revert CDF_PositionNotExpired(positionId); - // ========== YIELD MANAGER ========== // + // Validate that the deposit amount is not greater than the remaining deposit + if (depositAmount > term.remainingDeposit) + revert CDF_InvalidAmount(positionId, depositAmount); - // TODO shift these to CDEPO + uint256 convertedAmount = (depositAmount * term.conversionPrice) / DECIMALS; // TODO check decimals, rounding - /// @notice allow yield manager to sweep yield accrued on reserves - /// @return yield yield in reserve token terms - /// @return shares yield in sReserve token terms - function sweepYield() - external - onlyRole("CD_Yield_Manager") - returns (uint256 yield, uint256 shares) - { - yield = sReserve.previewRedeem(totalShares) - cdUSDS.totalSupply(); - shares = sReserve.previewWithdraw(yield); - totalShares -= shares; - sReserve.transfer(msg.sender, shares); + // Increment running totals + reclaimed += depositAmount; + unconverted += convertedAmount; - emit SweptYield(msg.sender, yield); - } + // Update the position + CTERM.update(positionId, term.remainingDeposit - depositAmount); + } - // ========== GOVERNOR ========== // + // Redeem the CD deposits in bulk + uint256 sharesOut = CDEPO.redeem(unconverted); - /// @notice allow admin to change redeem rate - /// @dev redeem rate must be lower than or equal to 1:1 - function setRedeemRate(uint256 newRate) external onlyRole("CD_Admin") { - if (newRate > DECIMALS) revert Misconfigured(); - redeemRate = newRate; - } + // Transfer the underlying assets to the caller + CDEPO.vault().redeem(sharesOut, msg.sender, address(this)); - // ========== VIEW FUNCTIONS ========== // + // Decrease the mint approval + MINTR.decreaseMintApproval(address(this), unconverted); - /// @notice get yield accrued on deposited reserve tokens - /// @return yield in reserve token terms - function yieldAccrued() external view returns (uint256) { - return sReserve.previewRedeem(totalShares) - totalDeposits; - } + // Emit event + emit ReclaimedDeposit(msg.sender, reclaimed); - /// @notice amount of deposit tokens out for amount of cdUSDS redeemed - /// @param amount of cdUSDS in - /// @return output amount of USDS out - function redeemOutput(uint256 amount) public view returns (uint256) { - return (amount * redeemRate) / DECIMALS; + return reclaimed; } } diff --git a/src/policies/interfaces/IConvertibleDepositFacility.sol b/src/policies/interfaces/IConvertibleDepositFacility.sol index 0b1ed28a..bcce9003 100644 --- a/src/policies/interfaces/IConvertibleDepositFacility.sol +++ b/src/policies/interfaces/IConvertibleDepositFacility.sol @@ -7,137 +7,92 @@ import {IERC4626} from "openzeppelin-contracts/contracts/interfaces/IERC4626.sol /// @title IConvertibleDepositFacility /// @notice Interface for a contract that can perform functions related to convertible deposit tokens interface IConvertibleDepositFacility { + // ========== EVENTS ========== // + + event CreatedDeposit(address indexed user, uint256 indexed termId, uint256 amount); + event ConvertedDeposit( + address indexed user, + uint256 indexed termId, + uint256 depositAmount, + uint256 convertedAmount + ); + event ReclaimedDeposit( + address indexed user, + uint256 indexed termId, + uint256 depositAmount, + uint256 reclaimedAmount + ); + + // ========== ERRORS ========== // + + error CDF_InvalidArgs(string reason_); + + error CDF_NotOwner(uint256 positionId_); + + error CDF_PositionExpired(uint256 positionId_); + + error CDF_PositionNotExpired(uint256 positionId_); + + error CDF_InvalidAmount(uint256 positionId_, uint256 amount_); + // ========== CONVERTIBLE DEPOSIT ACTIONS ========== // + /// @notice Creates a new convertible deposit position + /// + /// @dev The implementing contract is expected to handle the following: + /// - Validating that the caller has the correct role + /// - Depositing the reserve token into the CDEPO module and minting the convertible deposit token + /// - Creating a new term record in the CTERM module + /// - Pre-emptively increasing the OHM mint approval + /// - Emitting an event + /// + /// @param account_ The address to create the position for + /// @param amount_ The amount of reserve token to deposit + /// @param conversionPrice_ The price of the reserve token in USD + /// @param expiry_ The timestamp when the position expires + /// @param wrap_ Whether the position should be wrapped + /// @return termId The ID of the new term + function create( + address account_, + uint256 amount_, + uint256 conversionPrice_, + uint48 expiry_, + bool wrap_ + ) external returns (uint256 termId); + /// @notice Converts convertible deposit tokens to OHM before expiry /// @dev The implementing contract is expected to handle the following: - /// - Validating that `account_` has an active convertible deposit position - /// - Validating that `account_` has the required amount of convertible deposit tokens + /// - Validating that the caller is the owner of all of the positions + /// - Validating that all of the positions are valid /// - Validating that all of the positions have not expired /// - Burning the convertible deposit tokens /// - Minting OHM to `account_` - /// - Transferring the reserve token to the treasury + /// - Transferring the sReserve token to the treasury /// - Emitting an event /// - /// @param account_ The address to convert for - /// @param positionIds_ An array of position ids that will be converted - /// @param amounts_ An array of amounts of convertible deposit tokens to convert - /// @return converted The amount of OHM minted to `account_` - function convertFor( - address account_, + /// @param positionIds_ An array of position ids that will be converted + /// @param amounts_ An array of amounts of convertible deposit tokens to convert + /// @return totalDeposit The total amount of convertible deposit tokens converted + /// @return converted The amount of OHM minted during conversion + function convert( uint256[] memory positionIds_, uint256[] memory amounts_ - ) external returns (uint256 converted); + ) external returns (uint256 totalDeposit, uint256 converted); /// @notice Reclaims convertible deposit tokens after expiry /// @dev The implementing contract is expected to handle the following: - /// - Validating that `account_` has an active convertible deposit position - /// - Validating that `account_` has the required amount of convertible deposit tokens + /// - Validating that the caller is the owner of all of the positions + /// - Validating that all of the positions are valid /// - Validating that all of the positions have expired /// - Burning the convertible deposit tokens /// - Transferring the reserve token to `account_` /// - Emitting an event /// - /// @param account_ The address to reclaim for /// @param positionIds_ An array of position ids that will be reclaimed /// @param amounts_ An array of amounts of convertible deposit tokens to reclaim - /// @return reclaimed The amount of reserve token returned to `account_` - function reclaimFor( - address account_, + /// @return reclaimed The amount of reserve token returned to the caller + function reclaim( uint256[] memory positionIds_, uint256[] memory amounts_ ) external returns (uint256 reclaimed); - - // TODO decide in the depositFor and withdrawTo functions should be in the ERC20 contract (aka ERC20Wrapper) - - /// @notice Deposits the reserve token in exchange for convertible deposit tokens - /// @dev The implementing contract is expected to handle the following: - /// - Validating that the caller has the required amount of reserve tokens - /// - Minting the convertible deposit tokens to the caller - /// - Emitting an event - /// - /// @param account_ The address to mint to - /// @param amount_ The amount of reserve token to mint - /// @return minted The amount of convertible deposit tokens minted to the caller - function depositFor(address account_, uint256 amount_) external returns (uint256 minted); - - /// @notice Preview the amount of convertible deposit tokens that would be minted for a given amount of reserve token - /// - /// @param account_ The address to mint to - /// @param amount_ The amount of reserve token to mint - /// @return minted The amount of convertible deposit tokens that would be minted - function previewDepositFor( - address account_, - uint256 amount_ - ) external view returns (uint256 minted); - - /// @notice Withdraws the reserve token in exchange for convertible deposit tokens - /// @dev The implementing contract is expected to handle the following: - /// - Validating that the caller has the required amount of convertible deposit tokens - /// - Burning the convertible deposit tokens - /// - Transferring the reserve token to the caller - /// - Emitting an event - /// - /// @param account_ The address to withdraw to - /// @param amount_ The amount of convertible deposit tokens to burn - /// @return withdrawn The amount of reserve tokens returned to the caller - function withdrawTo(address account_, uint256 amount_) external returns (uint256 withdrawn); - - /// @notice Preview the amount of reserve token that would be returned for a given amount of convertible deposit tokens - /// - /// @param account_ The address to withdraw to - /// @param amount_ The amount of convertible deposit tokens to burn - /// @return withdrawn The amount of reserve tokens that would be returned - function previewWithdrawTo( - address account_, - uint256 amount_ - ) external view returns (uint256 withdrawn); - - // ========== YIELD MANAGEMENT ========== // - - /// @notice Claim the yield accrued on the reserve token - /// @dev The implementing contract is expected to handle the following: - /// - Validating that the caller has the correct role - /// - Withdrawing the yield from the sReserve token - /// - Transferring the yield to the caller - /// - Emitting an event - /// - /// @return yieldReserve The amount of reserve token that was swept - /// @return yieldSReserve The amount of sReserve token that was swept - function sweepYield() external returns (uint256 yieldReserve, uint256 yieldSReserve); - - /// @notice Preview the amount of yield that would be swept - /// - /// @return yieldReserve The amount of reserve token that would be swept - /// @return yieldSReserve The amount of sReserve token that would be swept - function previewSweepYield() - external - view - returns (uint256 yieldReserve, uint256 yieldSReserve); - - // ========== ADMIN ========== / - - /// @notice Set the withdraw rate when withdrawing the convertible deposit token, where withdraw rate = reserve token output / convertible deposit token input - /// @dev The implementing contract is expected to handle the following: - /// - Validating that the caller has the correct role - /// - Validating that the new rate is within bounds - /// - Setting the new redeem rate - /// - Emitting an event - /// - /// @param newRate_ The new withdraw rate - function setWithdrawRate(uint256 newRate_) external; - - // ========== STATE VARIABLES ========== // - - /// @notice The reserve token that is exchanged for the convertible deposit token - function reserveToken() external view returns (IERC20); - - /// @notice The sReserve token that the reserve token is deposited into - function sReserveToken() external view returns (IERC4626); - - /// @notice The convertible deposit token that is minted to the user - function convertibleDepositToken() external view returns (IERC20); - - /// @notice The withdraw rate when withdrawing the convertible deposit token - function withdrawRate() external view returns (uint256); } diff --git a/src/policies/interfaces/IConvertibleDepositToken.sol b/src/policies/interfaces/IConvertibleDepositToken.sol deleted file mode 100644 index 93246071..00000000 --- a/src/policies/interfaces/IConvertibleDepositToken.sol +++ /dev/null @@ -1,18 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0 -pragma solidity ^0.8.0; - -import {IERC20} from "openzeppelin-contracts/contracts/interfaces/IERC20.sol"; - -// TODO see if there is a standard interface for this - -interface IConvertibleDepositToken is IERC20 { - function mint(address to, uint256 amount) external; - - function burn(address from, uint256 amount) external; - - function convertFor(uint256 amount) external view returns (uint256); - - function expiry() external view returns (uint256); - - function totalSupply() external view returns (uint256); -} From 42a6d68995a932120f11058089e912e25a83b0b8 Mon Sep 17 00:00:00 2001 From: Jem <0x0xjem@gmail.com> Date: Mon, 16 Dec 2024 17:16:31 +0400 Subject: [PATCH 24/64] Rename CTERM to CDPOS --- src/modules/CDPOS/CDPOS.v1.sol | 184 ++++++++++++ .../OlympusConvertibleDepositPositions.sol | 270 ++++++++++++++++++ src/modules/CTERM/CTERM.v1.sol | 184 ------------ .../CTERM/OlympusConvertibleDepositTerms.sol | 262 ----------------- src/policies/CDFacility.sol | 56 ++-- .../IConvertibleDepositFacility.sol | 14 +- 6 files changed, 485 insertions(+), 485 deletions(-) create mode 100644 src/modules/CDPOS/CDPOS.v1.sol create mode 100644 src/modules/CDPOS/OlympusConvertibleDepositPositions.sol delete mode 100644 src/modules/CTERM/CTERM.v1.sol delete mode 100644 src/modules/CTERM/OlympusConvertibleDepositTerms.sol diff --git a/src/modules/CDPOS/CDPOS.v1.sol b/src/modules/CDPOS/CDPOS.v1.sol new file mode 100644 index 00000000..25673e90 --- /dev/null +++ b/src/modules/CDPOS/CDPOS.v1.sol @@ -0,0 +1,184 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +pragma solidity 0.8.15; + +import {Module} from "src/Kernel.sol"; +import {ERC721} from "solmate/tokens/ERC721.sol"; + +/// @title CDPOSv1 +/// @notice This defines the interface for the CDPOS module. +/// The objective of this module is to track the terms of a convertible deposit. +abstract contract CDPOSv1 is Module, ERC721 { + // ========== DATA STRUCTURES ========== // + + /// @notice Data structure for the terms of a convertible deposit + /// + /// @param remainingDeposit Amount of reserve tokens remaining to be converted + /// @param conversionPrice Price of the reserve token in USD + /// @param expiry Timestamp when the term expires + /// @param wrapped Whether the term is wrapped + struct Position { + uint256 remainingDeposit; + uint256 conversionPrice; + uint48 expiry; + bool wrapped; + } + + // ========== EVENTS ========== // + + /// @notice Emitted when a position is created + event PositionCreated( + uint256 indexed positionId, + address indexed owner, + uint256 remainingDeposit, + uint256 conversionPrice, + uint48 expiry, + bool wrapped + ); + + /// @notice Emitted when a position is updated + event PositionUpdated(uint256 indexed positionId, uint256 remainingDeposit); + + /// @notice Emitted when a position is split + event PositionSplit( + uint256 indexed positionId, + uint256 indexed newPositionId, + uint256 amount, + address to, + bool wrap + ); + + /// @notice Emitted when a position is wrapped + event PositionWrapped(uint256 indexed positionId); + + /// @notice Emitted when a position is unwrapped + event PositionUnwrapped(uint256 indexed positionId); + + // ========== STATE VARIABLES ========== // + + /// @notice The number of positions created + uint256 public positionCount; + + /// @notice Mapping of position records to an ID + /// @dev IDs are assigned sequentially starting from 0 + /// Mapping entries should not be deleted, but can be overwritten + mapping(uint256 => Position) internal _positions; + + /// @notice Mapping of user addresses to their position IDs + mapping(address => uint256[]) internal _userPositions; + + // ========== ERRORS ========== // + + /// @notice Error thrown when the caller is not the owner of the position + error CDPOS_NotOwner(uint256 positionId_); + + /// @notice Error thrown when an invalid position ID is provided + error CDPOS_InvalidPositionId(uint256 id_); + + /// @notice Error thrown when a position has already been wrapped + error CDPOS_AlreadyWrapped(uint256 positionId_); + + /// @notice Error thrown when a position has not been wrapped + error CDPOS_NotWrapped(uint256 positionId_); + + /// @notice Error thrown when an invalid parameter is provided + error CDPOS_InvalidParams(string reason_); + + // ========== WRAPPING ========== // + + /// @notice Wraps a position into an ERC721 token + /// This is useful if the position owner wants a tokenized representation of their position. It is functionally equivalent to the position itself. + /// + /// @dev The implementing function should do the following: + /// - Validate that the caller is the owner of the position + /// - Validate that the position is not already wrapped + /// - Mint an ERC721 token to the position owner + /// + /// @param positionId_ The ID of the position to wrap + function wrap(uint256 positionId_) external virtual; + + /// @notice Unwraps/burns an ERC721 position token + /// This is useful if the position owner wants to convert their token back into the position. + /// + /// @dev The implementing function should do the following: + /// - Validate that the caller is the owner of the position + /// - Validate that the position is already wrapped + /// - Burn the ERC721 token + /// + /// @param positionId_ The ID of the position to unwrap + function unwrap(uint256 positionId_) external virtual; + + // ========== POSITION MANAGEMENT =========== // + + /// @notice Creates a new convertible deposit position + /// @dev The implementing function should do the following: + /// - Validate that the caller is permissioned + /// - Validate that the owner is not the zero address + /// - Validate that the remaining deposit is greater than 0 + /// - Validate that the conversion price is greater than 0 + /// - Validate that the expiry is in the future + /// - Create the position record + /// - Wrap the position if requested + /// + /// @param owner_ The address of the owner of the position + /// @param remainingDeposit_ The amount of reserve tokens remaining to be converted + /// @param conversionPrice_ The price of the reserve token in USD + /// @param expiry_ The timestamp when the position expires + /// @param wrap_ Whether the position should be wrapped + /// @return positionId The ID of the new position + function create( + address owner_, + uint256 remainingDeposit_, + uint256 conversionPrice_, + uint48 expiry_, + bool wrap_ + ) external virtual returns (uint256 positionId); + + /// @notice Updates the remaining deposit of a position + /// @dev The implementing function should do the following: + /// - Validate that the caller is permissioned + /// - Validate that the position ID is valid + /// - Update the remaining deposit of the position + /// + /// @param positionId_ The ID of the position to update + /// @param amount_ The new amount of the position + function update(uint256 positionId_, uint256 amount_) external virtual; + + /// @notice Splits the specified amount of the position into a new position + /// This is useful if the position owner wants to split their position into multiple smaller positions. + /// @dev The implementing function should do the following: + /// - Validate that the caller is the owner of the position + /// - Validate that the amount is greater than 0 + /// - Validate that the amount is less than or equal to the remaining deposit + /// - Validate that `to_` is not the zero address + /// - Update the remaining deposit of the original position + /// - Create the new position record + /// - Wrap the new position if requested + /// + /// @param positionId_ The ID of the position to split + /// @param amount_ The amount of the position to split + /// @param to_ The address to split the position to + /// @param wrap_ Whether the new position should be wrapped + /// @return newPositionId The ID of the new position + function split( + uint256 positionId_, + uint256 amount_, + address to_, + bool wrap_ + ) external virtual returns (uint256 newPositionId); + + // ========== POSITION INFORMATION ========== // + + /// @notice Get the IDs of all positions for a given user + /// + /// @param user_ The address of the user + /// @return positionIds An array of position IDs + function getUserPositionIds( + address user_ + ) external view virtual returns (uint256[] memory positionIds); + + /// @notice Get the positions for a given ID + /// + /// @param positionId_ The ID of the position + /// @return position The positions for the given ID + function getPosition(uint256 positionId_) external view virtual returns (Position memory); +} diff --git a/src/modules/CDPOS/OlympusConvertibleDepositPositions.sol b/src/modules/CDPOS/OlympusConvertibleDepositPositions.sol new file mode 100644 index 00000000..2939431e --- /dev/null +++ b/src/modules/CDPOS/OlympusConvertibleDepositPositions.sol @@ -0,0 +1,270 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity 0.8.15; + +import {ERC721} from "solmate/tokens/ERC721.sol"; +import {CDPOSv1} from "./CDPOS.v1.sol"; +import {Kernel, Module} from "src/Kernel.sol"; + +contract OlympusConvertibleDepositPositions is CDPOSv1 { + constructor( + address kernel_ + ) Module(Kernel(kernel_)) ERC721("Olympus Convertible Deposit Positions", "OCDP") {} + + // ========== WRAPPING ========== // + + /// @inheritdoc CDPOSv1 + /// @dev This function reverts if: + /// - The position ID is invalid + /// - The caller is not the owner of the position + /// - The position is already wrapped + function wrap( + uint256 positionId_ + ) external virtual override onlyValidPosition(positionId_) onlyPositionOwner(positionId_) { + // Does not need to check for invalid position ID because the modifier already ensures that + Position storage position = _positions[positionId_]; + + // Validate that the position is not already wrapped + if (position.wrapped) revert CDPOS_AlreadyWrapped(positionId_); + + // Mark the position as wrapped + position.wrapped = true; + + // Mint the ERC721 token + _mint(msg.sender, positionId_); + + emit PositionWrapped(positionId_); + } + + /// @inheritdoc CDPOSv1 + /// @dev This function reverts if: + /// - The position ID is invalid + /// - The caller is not the owner of the position + /// - The position is not wrapped + function unwrap( + uint256 positionId_ + ) external virtual override onlyValidPosition(positionId_) onlyPositionOwner(positionId_) { + // Does not need to check for invalid position ID because the modifier already ensures that + Position storage position = _positions[positionId_]; + + // Validate that the position is wrapped + if (!position.wrapped) revert CDPOS_NotWrapped(positionId_); + + // Mark the position as unwrapped + position.wrapped = false; + + // Burn the ERC721 token + _burn(positionId_); + + emit PositionUnwrapped(positionId_); + } + + // ========== POSITION MANAGEMENT =========== // + + function _create( + address owner_, + uint256 remainingDeposit_, + uint256 conversionPrice_, + uint48 expiry_, + bool wrap_ + ) internal returns (uint256 positionId) { + // Create the position record + positionId = ++positionCount; + _positions[positionId] = Position({ + remainingDeposit: remainingDeposit_, + conversionPrice: conversionPrice_, + expiry: expiry_, + wrapped: wrap_ + }); + + // Update ERC721 storage + _ownerOf[positionId] = owner_; + _balanceOf[owner_]++; + + // Add the position ID to the user's list of positions + _userPositions[owner_].push(positionId); + + // If specified, wrap the position + if (wrap_) _mint(owner_, positionId); + + // Emit the event + emit PositionCreated( + positionId, + owner_, + remainingDeposit_, + conversionPrice_, + expiry_, + wrap_ + ); + + return positionId; + } + + /// @inheritdoc CDPOSv1 + /// @dev This function reverts if: + /// - The caller is not permissioned + /// - The owner is the zero address + /// - The remaining deposit is 0 + /// - The conversion price is 0 + /// - The expiry is in the past + function create( + address owner_, + uint256 remainingDeposit_, + uint256 conversionPrice_, + uint48 expiry_, + bool wrap_ + ) external virtual override permissioned returns (uint256 positionId) { + // Validate that the owner is not the zero address + if (owner_ == address(0)) revert CDPOS_InvalidParams("owner"); + + // Validate that the remaining deposit is greater than 0 + if (remainingDeposit_ == 0) revert CDPOS_InvalidParams("deposit"); + + // Validate that the conversion price is greater than 0 + if (conversionPrice_ == 0) revert CDPOS_InvalidParams("conversion price"); + + // Validate that the expiry is in the future + if (expiry_ <= block.timestamp) revert CDPOS_InvalidParams("expiry"); + + return _create(owner_, remainingDeposit_, conversionPrice_, expiry_, wrap_); + } + + /// @inheritdoc CDPOSv1 + /// @dev This function reverts if: + /// - The caller is not permissioned + /// - The position ID is invalid + function update( + uint256 positionId_, + uint256 amount_ + ) external virtual override permissioned onlyValidPosition(positionId_) { + // Update the remaining deposit of the position + Position storage position = _positions[positionId_]; + position.remainingDeposit = amount_; + + // Emit the event + emit PositionUpdated(positionId_, amount_); + } + + /// @inheritdoc CDPOSv1 + /// @dev This function reverts if: + /// - The caller is not the owner of the position + /// - The amount is 0 + /// - The amount is greater than the remaining deposit + /// - `to_` is the zero address + function split( + uint256 positionId_, + uint256 amount_, + address to_, + bool wrap_ + ) + external + virtual + override + onlyValidPosition(positionId_) + onlyPositionOwner(positionId_) + returns (uint256 newPositionId) + { + Position storage position = _positions[positionId_]; + + // Validate that the amount is greater than 0 + if (amount_ == 0) revert CDPOS_InvalidParams("amount"); + + // Validate that the amount is less than or equal to the remaining deposit + if (amount_ > position.remainingDeposit) revert CDPOS_InvalidParams("amount"); + + // Validate that the to address is not the zero address + if (to_ == address(0)) revert CDPOS_InvalidParams("to"); + + // Calculate the remaining deposit of the existing position + uint256 remainingDeposit = position.remainingDeposit - amount_; + + // Update the remaining deposit of the existing position + position.remainingDeposit = remainingDeposit; + + // Create the new position + newPositionId = _create(to_, amount_, position.conversionPrice, position.expiry, wrap_); + + // Emit the event + emit PositionSplit(positionId_, newPositionId, amount_, to_, wrap_); + + return newPositionId; + } + + // ========== ERC721 OVERRIDES ========== // + + /// @inheritdoc ERC721 + function tokenURI(uint256 id_) public view virtual override returns (string memory) { + // TODO implement tokenURI SVG + return ""; + } + + /// @inheritdoc ERC721 + /// @dev This function performs the following: + /// - Updates the owner of the position + /// - Calls `transferFrom` on the parent contract + function transferFrom(address from_, address to_, uint256 tokenId_) public override { + Position storage position = _positions[tokenId_]; + + // Validate that the position is valid + if (position.conversionPrice == 0) revert CDPOS_InvalidPositionId(tokenId_); + + // Ownership is validated in `transferFrom` on the parent contract + + // Add to user positions on the destination address + _userPositions[to_].push(tokenId_); + + // Remove from user terms on the source address + bool found = false; + for (uint256 i = 0; i < _userPositions[from_].length; i++) { + if (_userPositions[from_][i] == tokenId_) { + _userPositions[from_][i] = _userPositions[from_][_userPositions[from_].length - 1]; + _userPositions[from_].pop(); + found = true; + break; + } + } + if (!found) revert CDPOS_InvalidPositionId(tokenId_); + + // Call `transferFrom` on the parent contract + super.transferFrom(from_, to_, tokenId_); + } + + // ========== TERM INFORMATION ========== // + + function _getPosition(uint256 positionId_) internal view returns (Position memory) { + Position memory position = _positions[positionId_]; + // `create()` blocks a 0 conversion price, so this should never happen on a valid position + if (position.conversionPrice == 0) revert CDPOS_InvalidPositionId(positionId_); + + return position; + } + + /// @inheritdoc CDPOSv1 + function getUserPositionIds( + address user_ + ) external view virtual override returns (uint256[] memory positionIds) { + return _userPositions[user_]; + } + + /// @inheritdoc CDPOSv1 + /// @dev This function reverts if: + /// - The position ID is invalid + function getPosition( + uint256 positionId_ + ) external view virtual override returns (Position memory) { + return _getPosition(positionId_); + } + + // ========== MODIFIERS ========== // + + modifier onlyValidPosition(uint256 positionId_) { + if (_getPosition(positionId_).conversionPrice == 0) + revert CDPOS_InvalidPositionId(positionId_); + _; + } + + modifier onlyPositionOwner(uint256 positionId_) { + // This validates that the caller is the owner of the position + if (_ownerOf[positionId_] != msg.sender) revert CDPOS_NotOwner(positionId_); + _; + } +} diff --git a/src/modules/CTERM/CTERM.v1.sol b/src/modules/CTERM/CTERM.v1.sol deleted file mode 100644 index 0924b7e1..00000000 --- a/src/modules/CTERM/CTERM.v1.sol +++ /dev/null @@ -1,184 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later -pragma solidity 0.8.15; - -import {Module} from "src/Kernel.sol"; -import {ERC721} from "solmate/tokens/ERC721.sol"; - -/// @title CTERMv1 -/// @notice This defines the interface for the CTERM module. -/// The objective of this module is to track the terms of a convertible deposit. -abstract contract CTERMv1 is Module, ERC721 { - // TODO rename to CDPOS - - // ========== DATA STRUCTURES ========== // - - /// @notice Data structure for the terms of a convertible deposit - /// - /// @param remainingDeposit Amount of reserve tokens remaining to be converted - /// @param conversionPrice Price of the reserve token in USD - /// @param expiry Timestamp when the term expires - /// @param wrapped Whether the term is wrapped - struct ConvertibleDepositTerm { - uint256 remainingDeposit; - uint256 conversionPrice; - uint48 expiry; - bool wrapped; - } - - // ========== EVENTS ========== // - - /// @notice Emitted when a term is created - event TermCreated( - uint256 indexed termId, - address indexed owner, - uint256 remainingDeposit, - uint256 conversionPrice, - uint48 expiry, - bool wrapped - ); - - /// @notice Emitted when a term is updated - event TermUpdated(uint256 indexed termId, uint256 remainingDeposit); - - /// @notice Emitted when a term is split - event TermSplit( - uint256 indexed termId, - uint256 newTermId, - uint256 amount, - address to, - bool wrap - ); - - /// @notice Emitted when a term is wrapped - event TermWrapped(uint256 indexed termId); - - /// @notice Emitted when a term is unwrapped - event TermUnwrapped(uint256 indexed termId); - - // ========== STATE VARIABLES ========== // - - /// @notice The number of terms created - uint256 public termCount; - - /// @notice Mapping of term records to an ID - /// @dev IDs are assigned sequentially starting from 0 - /// Mapping entries should not be deleted, but can be overwritten - mapping(uint256 => ConvertibleDepositTerm) internal _terms; - - /// @notice Mapping of user addresses to their term IDs - mapping(address => uint256[]) internal _userTerms; - - // ========== ERRORS ========== // - - /// @notice Error thrown when the caller is not the owner of the term - error CTERM_NotOwner(uint256 termId_); - - /// @notice Error thrown when an invalid term ID is provided - error CTERM_InvalidTermId(uint256 id_); - - /// @notice Error thrown when a term has already been wrapped - error CTERM_AlreadyWrapped(uint256 termId_); - - /// @notice Error thrown when a term has not been wrapped - error CTERM_NotWrapped(uint256 termId_); - - /// @notice Error thrown when an invalid parameter is provided - error CTERM_InvalidParams(string reason_); - - // ========== WRAPPING ========== // - - /// @notice Wraps a term into an ERC721 token - /// @dev This is useful if the term owner wants a tokenized representation of their term. It is functionally equivalent to the term itself. - /// - /// The implementing function should do the following: - /// - Validate that the caller is the owner of the term - /// - Validate that the term is not already wrapped - /// - Mint an ERC721 token to the term owner - /// - /// @param termId_ The ID of the term to wrap - function wrap(uint256 termId_) external virtual; - - /// @notice Unwraps an ERC721 token into a term - /// @dev This is useful if the term owner wants to convert their token back into the term. - /// - /// The implementing function should do the following: - /// - Validate that the caller is the owner of the term - /// - Validate that the term is already wrapped - /// - Burn the ERC721 token - /// - /// @param termId_ The ID of the term to unwrap - function unwrap(uint256 termId_) external virtual; - - // ========== TERM MANAGEMENT =========== // - - /// @notice Creates a new convertible deposit term - /// @dev The implementing function should do the following: - /// - Validate that the caller is permissioned - /// - Validate that the owner is not the zero address - /// - Validate that the remaining deposit is greater than 0 - /// - Validate that the conversion price is greater than 0 - /// - Validate that the expiry is in the future - /// - Create the term record - /// - Wrap the term if requested - /// - /// @param owner_ The address of the owner of the term - /// @param remainingDeposit_ The amount of reserve tokens remaining to be converted - /// @param conversionPrice_ The price of the reserve token in USD - /// @param expiry_ The timestamp when the term expires - /// @param wrap_ Whether the term should be wrapped - /// @return termId The ID of the new term - function create( - address owner_, - uint256 remainingDeposit_, - uint256 conversionPrice_, - uint48 expiry_, - bool wrap_ - ) external virtual returns (uint256 termId); - - /// @notice Updates the remaining deposit of a term - /// @dev The implementing function should do the following: - /// - Validate that the caller is permissioned - /// - Validate that the term ID is valid - /// - Update the remaining deposit of the term - /// - /// @param termId_ The ID of the term to update - /// @param amount_ The new amount of the term - function update(uint256 termId_, uint256 amount_) external virtual; - - /// @notice Splits the specified amount of the term into a new term - /// This is useful if the term owner wants to split their term into multiple smaller terms. - /// @dev The implementing function should do the following: - /// - Validate that the caller is the owner of the term - /// - Validate that the amount is greater than 0 - /// - Validate that the amount is less than or equal to the remaining deposit - /// - Validate that `to_` is not the zero address - /// - Update the remaining deposit of the original term - /// - Create the new term record - /// - Wrap the new term if requested - /// - /// @param termId_ The ID of the term to split - /// @param amount_ The amount of the term to split - /// @param to_ The address to split the term to - /// @param wrap_ Whether the new term should be wrapped - /// @return newTermId The ID of the new term - function split( - uint256 termId_, - uint256 amount_, - address to_, - bool wrap_ - ) external virtual returns (uint256 newTermId); - - // ========== TERM INFORMATION ========== // - - /// @notice Get the IDs of all terms for a given user - /// - /// @param user_ The address of the user - /// @return termIds An array of term IDs - function getUserTermIds(address user_) external view virtual returns (uint256[] memory termIds); - - /// @notice Get the terms for a given ID - /// - /// @param termId_ The ID of the term - /// @return term The terms for the given ID - function getTerm(uint256 termId_) external view virtual returns (ConvertibleDepositTerm memory); -} diff --git a/src/modules/CTERM/OlympusConvertibleDepositTerms.sol b/src/modules/CTERM/OlympusConvertibleDepositTerms.sol deleted file mode 100644 index b0241c0b..00000000 --- a/src/modules/CTERM/OlympusConvertibleDepositTerms.sol +++ /dev/null @@ -1,262 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-only -pragma solidity 0.8.15; - -import {ERC721} from "solmate/tokens/ERC721.sol"; -import {CTERMv1} from "./CTERM.v1.sol"; -import {Kernel, Module} from "src/Kernel.sol"; - -contract OlympusConvertibleDepositTerms is CTERMv1 { - constructor( - address kernel_ - ) Module(Kernel(kernel_)) ERC721("Olympus Convertible Deposit Terms", "OCDT") {} - - // ========== WRAPPING ========== // - - /// @inheritdoc CTERMv1 - /// @dev This function reverts if: - /// - The term ID is invalid - /// - The caller is not the owner of the term - /// - The term is already wrapped - function wrap( - uint256 termId_ - ) external virtual override onlyValidTerm(termId_) onlyTermOwner(termId_) { - // Does not need to check for invalid term ID because the modifier already ensures that - ConvertibleDepositTerm storage term = _terms[termId_]; - - // Validate that the term is not already wrapped - if (term.wrapped) revert CTERM_AlreadyWrapped(termId_); - - // Mark the term as wrapped - term.wrapped = true; - - // Mint the ERC721 token - _mint(msg.sender, termId_); - - emit TermWrapped(termId_); - } - - /// @inheritdoc CTERMv1 - /// @dev This function reverts if: - /// - The term ID is invalid - /// - The caller is not the owner of the term - /// - The term is not wrapped - function unwrap( - uint256 termId_ - ) external virtual override onlyValidTerm(termId_) onlyTermOwner(termId_) { - // Does not need to check for invalid term ID because the modifier already ensures that - ConvertibleDepositTerm storage term = _terms[termId_]; - - // Validate that the term is wrapped - if (!term.wrapped) revert CTERM_NotWrapped(termId_); - - // Mark the term as unwrapped - term.wrapped = false; - - // Burn the ERC721 token - _burn(termId_); - - emit TermUnwrapped(termId_); - } - - // ========== TERM MANAGEMENT =========== // - - function _create( - address owner_, - uint256 remainingDeposit_, - uint256 conversionPrice_, - uint48 expiry_, - bool wrap_ - ) internal returns (uint256 termId) { - // Create the term record - termId = ++termCount; - _terms[termId] = ConvertibleDepositTerm({ - remainingDeposit: remainingDeposit_, - conversionPrice: conversionPrice_, - expiry: expiry_, - wrapped: wrap_ - }); - - // Update ERC721 storage - _ownerOf[termId] = owner_; - _balanceOf[owner_]++; - - // Add the term ID to the user's list of terms - _userTerms[owner_].push(termId); - - // If specified, wrap the term - if (wrap_) _mint(owner_, termId); - - // Emit the event - emit TermCreated(termId, owner_, remainingDeposit_, conversionPrice_, expiry_, wrap_); - - return termId; - } - - /// @inheritdoc CTERMv1 - /// @dev This function reverts if: - /// - The caller is not permissioned - /// - The owner is the zero address - /// - The remaining deposit is 0 - /// - The conversion price is 0 - /// - The expiry is in the past - function create( - address owner_, - uint256 remainingDeposit_, - uint256 conversionPrice_, - uint48 expiry_, - bool wrap_ - ) external virtual override permissioned returns (uint256 termId) { - // Validate that the owner is not the zero address - if (owner_ == address(0)) revert CTERM_InvalidParams("owner"); - - // Validate that the remaining deposit is greater than 0 - if (remainingDeposit_ == 0) revert CTERM_InvalidParams("deposit"); - - // Validate that the conversion price is greater than 0 - if (conversionPrice_ == 0) revert CTERM_InvalidParams("conversion price"); - - // Validate that the expiry is in the future - if (expiry_ <= block.timestamp) revert CTERM_InvalidParams("expiry"); - - return _create(owner_, remainingDeposit_, conversionPrice_, expiry_, wrap_); - } - - /// @inheritdoc CTERMv1 - /// @dev This function reverts if: - /// - The caller is not permissioned - /// - The term ID is invalid - function update( - uint256 termId_, - uint256 amount_ - ) external virtual override permissioned onlyValidTerm(termId_) { - // Update the remaining deposit of the term - ConvertibleDepositTerm storage term = _terms[termId_]; - term.remainingDeposit = amount_; - - // Emit the event - emit TermUpdated(termId_, amount_); - } - - /// @inheritdoc CTERMv1 - /// @dev This function reverts if: - /// - The caller is not the owner of the term - /// - The amount is 0 - /// - The amount is greater than the remaining deposit - /// - `to_` is the zero address - function split( - uint256 termId_, - uint256 amount_, - address to_, - bool wrap_ - ) - external - virtual - override - onlyValidTerm(termId_) - onlyTermOwner(termId_) - returns (uint256 newTermId) - { - ConvertibleDepositTerm storage term = _terms[termId_]; - - // Validate that the amount is greater than 0 - if (amount_ == 0) revert CTERM_InvalidParams("amount"); - - // Validate that the amount is less than or equal to the remaining deposit - if (amount_ > term.remainingDeposit) revert CTERM_InvalidParams("amount"); - - // Validate that the to address is not the zero address - if (to_ == address(0)) revert CTERM_InvalidParams("to"); - - // Calculate the remaining deposit of the existing term - uint256 remainingDeposit = term.remainingDeposit - amount_; - - // Update the remaining deposit of the existing term - term.remainingDeposit = remainingDeposit; - - // Create the new term - newTermId = _create(to_, amount_, term.conversionPrice, term.expiry, wrap_); - - // Emit the event - emit TermSplit(termId_, newTermId, amount_, to_, wrap_); - - return newTermId; - } - - // ========== ERC721 OVERRIDES ========== // - - /// @inheritdoc ERC721 - function tokenURI(uint256 id_) public view virtual override returns (string memory) { - // TODO implement tokenURI SVG - return ""; - } - - /// @inheritdoc ERC721 - /// @dev This function performs the following: - /// - Updates the owner of the term - /// - Calls `transferFrom` on the parent contract - function transferFrom(address from_, address to_, uint256 tokenId_) public override { - ConvertibleDepositTerm storage term = _terms[tokenId_]; - - // Validate that the term is valid - if (term.conversionPrice == 0) revert CTERM_InvalidTermId(tokenId_); - - // Ownership is validated in `transferFrom` on the parent contract - - // Add to user terms on the destination address - _userTerms[to_].push(tokenId_); - - // Remove from user terms on the source address - bool found = false; - for (uint256 i = 0; i < _userTerms[from_].length; i++) { - if (_userTerms[from_][i] == tokenId_) { - _userTerms[from_][i] = _userTerms[from_][_userTerms[from_].length - 1]; - _userTerms[from_].pop(); - found = true; - break; - } - } - if (!found) revert CTERM_InvalidTermId(tokenId_); - - // Call `transferFrom` on the parent contract - super.transferFrom(from_, to_, tokenId_); - } - - // ========== TERM INFORMATION ========== // - - function _getTerm(uint256 termId_) internal view returns (ConvertibleDepositTerm memory) { - ConvertibleDepositTerm memory term = _terms[termId_]; - // `create()` blocks a 0 conversion price, so this should never happen on a valid term - if (term.conversionPrice == 0) revert CTERM_InvalidTermId(termId_); - - return term; - } - - /// @inheritdoc CTERMv1 - function getUserTermIds( - address user_ - ) external view virtual override returns (uint256[] memory termIds) { - return _userTerms[user_]; - } - - /// @inheritdoc CTERMv1 - /// @dev This function reverts if: - /// - The term ID is invalid - function getTerm( - uint256 termId_ - ) external view virtual override returns (ConvertibleDepositTerm memory) { - return _getTerm(termId_); - } - - // ========== MODIFIERS ========== // - - modifier onlyValidTerm(uint256 termId_) { - if (_getTerm(termId_).conversionPrice == 0) revert CTERM_InvalidTermId(termId_); - _; - } - - modifier onlyTermOwner(uint256 termId_) { - // This validates that the caller is the owner of the term - if (_ownerOf[termId_] != msg.sender) revert CTERM_NotOwner(termId_); - _; - } -} diff --git a/src/policies/CDFacility.sol b/src/policies/CDFacility.sol index d37ee3c5..ab36174a 100644 --- a/src/policies/CDFacility.sol +++ b/src/policies/CDFacility.sol @@ -12,7 +12,7 @@ import {RolesConsumer, ROLESv1} from "src/modules/ROLES/OlympusRoles.sol"; import {MINTRv1} from "src/modules/MINTR/MINTR.v1.sol"; import {TRSRYv1} from "src/modules/TRSRY/TRSRY.v1.sol"; import {CDEPOv1} from "src/modules/CDEPO/CDEPO.v1.sol"; -import {CTERMv1} from "src/modules/CTERM/CTERM.v1.sol"; +import {CDPOSv1} from "src/modules/CDPOS/CDPOS.v1.sol"; import {FullMath} from "src/libraries/FullMath.sol"; @@ -28,7 +28,7 @@ contract CDFacility is Policy, RolesConsumer, IConvertibleDepositFacility { TRSRYv1 public TRSRY; MINTRv1 public MINTR; CDEPOv1 public CDEPO; - CTERMv1 public CTERM; + CDPOSv1 public CDPOS; // ========== ERRORS ========== // @@ -46,13 +46,13 @@ contract CDFacility is Policy, RolesConsumer, IConvertibleDepositFacility { dependencies[1] = toKeycode("MINTR"); dependencies[2] = toKeycode("ROLES"); dependencies[3] = toKeycode("CDEPO"); - dependencies[4] = toKeycode("CTERM"); + dependencies[4] = toKeycode("CDPOS"); TRSRY = TRSRYv1(getModuleAddress(dependencies[0])); MINTR = MINTRv1(getModuleAddress(dependencies[1])); ROLES = ROLESv1(getModuleAddress(dependencies[2])); CDEPO = CDEPOv1(getModuleAddress(dependencies[3])); - CTERM = CTERMv1(getModuleAddress(dependencies[4])); + CDPOS = CDPOSv1(getModuleAddress(dependencies[4])); } function requestPermissions() @@ -63,14 +63,16 @@ contract CDFacility is Policy, RolesConsumer, IConvertibleDepositFacility { { Keycode mintrKeycode = toKeycode("MINTR"); Keycode cdepoKeycode = toKeycode("CDEPO"); - Keycode ctermKeycode = toKeycode("CTERM"); + Keycode cdposKeycode = toKeycode("CDPOS"); - permissions = new Permissions[](5); + permissions = new Permissions[](7); permissions[0] = Permissions(mintrKeycode, MINTR.increaseMintApproval.selector); permissions[1] = Permissions(mintrKeycode, MINTR.mintOhm.selector); - permissions[2] = Permissions(cdepoKeycode, CDEPO.sweepYield.selector); - permissions[3] = Permissions(ctermKeycode, CTERM.create.selector); - permissions[4] = Permissions(ctermKeycode, CTERM.update.selector); + permissions[2] = Permissions(mintrKeycode, MINTR.decreaseMintApproval.selector); + permissions[3] = Permissions(cdepoKeycode, CDEPO.redeem.selector); + permissions[4] = Permissions(cdepoKeycode, CDEPO.sweepYield.selector); + permissions[5] = Permissions(cdposKeycode, CDPOS.create.selector); + permissions[6] = Permissions(cdposKeycode, CDPOS.update.selector); } // ========== CONVERTIBLE DEPOSIT ACTIONS ========== // @@ -82,19 +84,19 @@ contract CDFacility is Policy, RolesConsumer, IConvertibleDepositFacility { uint256 conversionPrice_, uint48 expiry_, bool wrap_ - ) external onlyRole("CD_Auctioneer") returns (uint256 termId) { + ) external onlyRole("CD_Auctioneer") returns (uint256 positionId) { // Mint the CD token to the account // This will also transfer the reserve token CDEPO.mintTo(account_, amount_); - // Create a new term record in the CTERM module - termId = CTERM.create(account_, amount_, conversionPrice_, expiry_, wrap_); + // Create a new term record in the CDPOS module + positionId = CDPOS.create(account_, amount_, conversionPrice_, expiry_, wrap_); // Pre-emptively increase the OHM mint approval MINTR.increaseMintApproval(address(this), amount_); // Emit an event - emit CreatedDeposit(account_, termId, amount_); + emit CreatedDeposit(account_, positionId, amount_); } /// @inheritdoc IConvertibleDepositFacility @@ -114,27 +116,27 @@ contract CDFacility is Policy, RolesConsumer, IConvertibleDepositFacility { uint256 depositAmount = amounts_[i]; // Validate that the caller is the owner of the position - if (CTERM.ownerOf(positionId) != msg.sender) revert CDF_NotOwner(positionId); + if (CDPOS.ownerOf(positionId) != msg.sender) revert CDF_NotOwner(positionId); // Validate that the position is valid // This will revert if the position is not valid - CTERMv1.ConvertibleDepositTerm memory term = CTERM.getTerm(positionId); + CDPOSv1.Position memory position = CDPOS.getPosition(positionId); - // Validate that the term has not expired - if (block.timestamp >= term.expiry) revert CDF_PositionExpired(positionId); + // Validate that the position has not expired + if (block.timestamp >= position.expiry) revert CDF_PositionExpired(positionId); // Validate that the deposit amount is not greater than the remaining deposit - if (depositAmount > term.remainingDeposit) + if (depositAmount > position.remainingDeposit) revert CDF_InvalidAmount(positionId, depositAmount); - uint256 convertedAmount = (depositAmount * term.conversionPrice) / DECIMALS; // TODO check decimals, rounding + uint256 convertedAmount = (depositAmount * position.conversionPrice) / DECIMALS; // TODO check decimals, rounding // Increment running totals totalDeposits += depositAmount; converted += convertedAmount; // Update the position - CTERM.update(positionId, term.remainingDeposit - depositAmount); + CDPOS.update(positionId, position.remainingDeposit - depositAmount); } // Redeem the CD deposits in bulk @@ -169,27 +171,27 @@ contract CDFacility is Policy, RolesConsumer, IConvertibleDepositFacility { uint256 depositAmount = amounts_[i]; // Validate that the caller is the owner of the position - if (CTERM.ownerOf(positionId) != msg.sender) revert CDF_NotOwner(positionId); + if (CDPOS.ownerOf(positionId) != msg.sender) revert CDF_NotOwner(positionId); // Validate that the position is valid // This will revert if the position is not valid - CTERMv1.ConvertibleDepositTerm memory term = CTERM.getTerm(positionId); + CDPOSv1.Position memory position = CDPOS.getPosition(positionId); - // Validate that the term has expired - if (block.timestamp < term.expiry) revert CDF_PositionNotExpired(positionId); + // Validate that the position has expired + if (block.timestamp < position.expiry) revert CDF_PositionNotExpired(positionId); // Validate that the deposit amount is not greater than the remaining deposit - if (depositAmount > term.remainingDeposit) + if (depositAmount > position.remainingDeposit) revert CDF_InvalidAmount(positionId, depositAmount); - uint256 convertedAmount = (depositAmount * term.conversionPrice) / DECIMALS; // TODO check decimals, rounding + uint256 convertedAmount = (depositAmount * position.conversionPrice) / DECIMALS; // TODO check decimals, rounding // Increment running totals reclaimed += depositAmount; unconverted += convertedAmount; // Update the position - CTERM.update(positionId, term.remainingDeposit - depositAmount); + CDPOS.update(positionId, position.remainingDeposit - depositAmount); } // Redeem the CD deposits in bulk diff --git a/src/policies/interfaces/IConvertibleDepositFacility.sol b/src/policies/interfaces/IConvertibleDepositFacility.sol index bcce9003..a7bd9265 100644 --- a/src/policies/interfaces/IConvertibleDepositFacility.sol +++ b/src/policies/interfaces/IConvertibleDepositFacility.sol @@ -10,18 +10,8 @@ interface IConvertibleDepositFacility { // ========== EVENTS ========== // event CreatedDeposit(address indexed user, uint256 indexed termId, uint256 amount); - event ConvertedDeposit( - address indexed user, - uint256 indexed termId, - uint256 depositAmount, - uint256 convertedAmount - ); - event ReclaimedDeposit( - address indexed user, - uint256 indexed termId, - uint256 depositAmount, - uint256 reclaimedAmount - ); + event ConvertedDeposit(address indexed user, uint256 depositAmount, uint256 convertedAmount); + event ReclaimedDeposit(address indexed user, uint256 reclaimedAmount); // ========== ERRORS ========== // From be52b0965eb421feb5ffd59b3697641eaa880990 Mon Sep 17 00:00:00 2001 From: Jem <0x0xjem@gmail.com> Date: Mon, 16 Dec 2024 17:25:37 +0400 Subject: [PATCH 25/64] Shift CDAuctioneer to use new CDFacility interface --- src/policies/CDAuctioneer.sol | 24 +++++++++++++------ src/policies/CDFacility.sol | 4 ++-- .../IConvertibleDepositAuctioneer.sol | 6 ++--- src/scripts/deploy/DeployV2.sol | 7 +----- .../MockConvertibleDepositAuctioneer.sol | 2 +- 5 files changed, 24 insertions(+), 19 deletions(-) diff --git a/src/policies/CDAuctioneer.sol b/src/policies/CDAuctioneer.sol index e766305d..e8f34b10 100644 --- a/src/policies/CDAuctioneer.sol +++ b/src/policies/CDAuctioneer.sol @@ -26,8 +26,6 @@ contract CDAuctioneer is IConvertibleDepositAuctioneer, Policy, RolesConsumer { CDFacility public cdFacility; - mapping(uint256 => mapping(uint256 => address)) public cdTokens; // mapping(expiry => price => token) - Day public today; // ========== SETUP ========== // @@ -77,11 +75,23 @@ contract CDAuctioneer is IConvertibleDepositAuctioneer, Policy, RolesConsumer { convertible += _convertFor(amount, currentTick.price); } + // TODO extract logic to previewBid + // TODO update currentTick based on previewBid output + today.deposits += deposit; today.convertible += convertible; - // mint amount of CD token - cdFacility.addNewCD(msg.sender, deposit, convertible, block.timestamp + state.timeToExpiry); + // TODO calculate average price for total deposit and convertible, check rounding, formula + uint256 conversionPrice = (deposit * decimals) / convertible; + + // Create the CD tokens and position + cdFacility.create( + msg.sender, + deposit, + conversionPrice, + uint48(block.timestamp + state.timeToExpiry), + false + ); } /// @inheritdoc IConvertibleDepositAuctioneer @@ -147,13 +157,13 @@ contract CDAuctioneer is IConvertibleDepositAuctioneer, Policy, RolesConsumer { newSize, newMinPrice, state.tickStep, - state.timeToExpiry, - state.lastUpdate + state.lastUpdate, + state.timeToExpiry ); } /// @inheritdoc IConvertibleDepositAuctioneer - function setTimeToExpiry(uint256 newTime) external override onlyRole("CD_Admin") { + function setTimeToExpiry(uint48 newTime) external override onlyRole("CD_Admin") { state.timeToExpiry = newTime; } diff --git a/src/policies/CDFacility.sol b/src/policies/CDFacility.sol index ab36174a..f7b3da2e 100644 --- a/src/policies/CDFacility.sol +++ b/src/policies/CDFacility.sol @@ -129,7 +129,7 @@ contract CDFacility is Policy, RolesConsumer, IConvertibleDepositFacility { if (depositAmount > position.remainingDeposit) revert CDF_InvalidAmount(positionId, depositAmount); - uint256 convertedAmount = (depositAmount * position.conversionPrice) / DECIMALS; // TODO check decimals, rounding + uint256 convertedAmount = (depositAmount * DECIMALS) / position.conversionPrice; // TODO check decimals, rounding // Increment running totals totalDeposits += depositAmount; @@ -184,7 +184,7 @@ contract CDFacility is Policy, RolesConsumer, IConvertibleDepositFacility { if (depositAmount > position.remainingDeposit) revert CDF_InvalidAmount(positionId, depositAmount); - uint256 convertedAmount = (depositAmount * position.conversionPrice) / DECIMALS; // TODO check decimals, rounding + uint256 convertedAmount = (depositAmount * DECIMALS) / position.conversionPrice; // TODO check decimals, rounding // Increment running totals reclaimed += depositAmount; diff --git a/src/policies/interfaces/IConvertibleDepositAuctioneer.sol b/src/policies/interfaces/IConvertibleDepositAuctioneer.sol index e42109f6..cbf8d67c 100644 --- a/src/policies/interfaces/IConvertibleDepositAuctioneer.sol +++ b/src/policies/interfaces/IConvertibleDepositAuctioneer.sol @@ -22,15 +22,15 @@ interface IConvertibleDepositAuctioneer { /// @param tickSize number of ohm in a tick /// @param minPrice minimum tick price /// @param tickStep percentage increase (decrease) per tick - /// @param timeToExpiry time between creation and expiry of deposit /// @param lastUpdate timestamp of last update to current tick + /// @param timeToExpiry time between creation and expiry of deposit struct State { uint256 target; uint256 tickSize; uint256 minPrice; uint256 tickStep; - uint256 timeToExpiry; uint256 lastUpdate; + uint48 timeToExpiry; } /// @notice Tracks auction activity for a given day @@ -101,7 +101,7 @@ interface IConvertibleDepositAuctioneer { /// @dev only callable by the admin /// /// @param newTime_ new time to expiry - function setTimeToExpiry(uint256 newTime_) external; + function setTimeToExpiry(uint48 newTime_) external; /// @notice Set the tick step /// @dev only callable by the admin diff --git a/src/scripts/deploy/DeployV2.sol b/src/scripts/deploy/DeployV2.sol index c5ccef30..de288622 100644 --- a/src/scripts/deploy/DeployV2.sol +++ b/src/scripts/deploy/DeployV2.sol @@ -1257,15 +1257,10 @@ contract OlympusDeploy is Script { // Log dependencies console2.log("ConvertibleDepositFacility parameters:"); console2.log(" kernel", address(kernel)); - console2.log(" reserve", address(reserve)); - console2.log(" sReserve", address(sReserve)); - - // TODO add deployment of cdUSDS - address cdUSDS = address(0); // Deploy ConvertibleDepositFacility vm.broadcast(); - cdFacility = new CDFacility(kernel, address(reserve), address(sReserve), cdUSDS); + cdFacility = new CDFacility(address(kernel)); console2.log("ConvertibleDepositFacility deployed at:", address(cdFacility)); return address(cdFacility); diff --git a/src/test/mocks/MockConvertibleDepositAuctioneer.sol b/src/test/mocks/MockConvertibleDepositAuctioneer.sol index 1f965632..67f69024 100644 --- a/src/test/mocks/MockConvertibleDepositAuctioneer.sol +++ b/src/test/mocks/MockConvertibleDepositAuctioneer.sol @@ -40,7 +40,7 @@ contract MockConvertibleDepositAuctioneer is IConvertibleDepositAuctioneer, Poli uint256 newMinPrice ) external override returns (uint256 remainder) {} - function setTimeToExpiry(uint256 newTime) external override {} + function setTimeToExpiry(uint48 newTime) external override {} function setTickStep(uint256 newStep) external override {} } From 2c28d33e05a934b6752bd1ecb474458901eb300a Mon Sep 17 00:00:00 2001 From: Jem <0x0xjem@gmail.com> Date: Tue, 17 Dec 2024 16:57:55 +0500 Subject: [PATCH 26/64] Check CD token before converting/reclaiming using a CD position --- src/libraries/Timestamp.sol | 45 +++++++++++++++++++ src/libraries/Uint2Str.sol | 27 +++++++++++ src/modules/CDPOS/CDPOS.v1.sol | 27 ++++++----- .../OlympusConvertibleDepositPositions.sol | 37 +++++++++++++-- src/policies/CDFacility.sol | 17 ++++++- .../IConvertibleDepositFacility.sol | 4 ++ 6 files changed, 143 insertions(+), 14 deletions(-) create mode 100644 src/libraries/Timestamp.sol create mode 100644 src/libraries/Uint2Str.sol diff --git a/src/libraries/Timestamp.sol b/src/libraries/Timestamp.sol new file mode 100644 index 00000000..623c334a --- /dev/null +++ b/src/libraries/Timestamp.sol @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.15; + +import {uint2str} from "./Uint2Str.sol"; + +library Timestamp { + function toPaddedString( + uint48 timestamp + ) internal pure returns (string memory, string memory, string memory) { + // Convert a number of days into a human-readable date, courtesy of BokkyPooBah. + // Source: https://github.com/bokkypoobah/BokkyPooBahsDateTimeLibrary/blob/master/contracts/BokkyPooBahsDateTimeLibrary.sol + + uint256 year; + uint256 month; + uint256 day; + { + int256 __days = int256(int48(timestamp) / 1 days); + + int256 num1 = __days + 68_569 + 2_440_588; // 2440588 = OFFSET19700101 + int256 num2 = (4 * num1) / 146_097; + num1 = num1 - (146_097 * num2 + 3) / 4; + int256 _year = (4000 * (num1 + 1)) / 1_461_001; + num1 = num1 - (1461 * _year) / 4 + 31; + int256 _month = (80 * num1) / 2447; + int256 _day = num1 - (2447 * _month) / 80; + num1 = _month / 11; + _month = _month + 2 - 12 * num1; + _year = 100 * (num2 - 49) + _year + num1; + + year = uint256(_year); + month = uint256(_month); + day = uint256(_day); + } + + string memory yearStr = uint2str(year % 10_000); + string memory monthStr = month < 10 + ? string(abi.encodePacked("0", uint2str(month))) + : uint2str(month); + string memory dayStr = day < 10 + ? string(abi.encodePacked("0", uint2str(day))) + : uint2str(day); + + return (yearStr, monthStr, dayStr); + } +} diff --git a/src/libraries/Uint2Str.sol b/src/libraries/Uint2Str.sol new file mode 100644 index 00000000..a28d399d --- /dev/null +++ b/src/libraries/Uint2Str.sol @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8; + +// Some fancy math to convert a uint into a string, courtesy of Provable Things. +// Updated to work with solc 0.8.0. +// https://github.com/provable-things/ethereum-api/blob/master/provableAPI_0.6.sol +function uint2str(uint256 _i) pure returns (string memory) { + if (_i == 0) { + return "0"; + } + uint256 j = _i; + uint256 len; + while (j != 0) { + len++; + j /= 10; + } + bytes memory bstr = new bytes(len); + uint256 k = len; + while (_i != 0) { + k = k - 1; + uint8 temp = (48 + uint8(_i - (_i / 10) * 10)); + bytes1 b1 = bytes1(temp); + bstr[k] = b1; + _i /= 10; + } + return string(bstr); +} diff --git a/src/modules/CDPOS/CDPOS.v1.sol b/src/modules/CDPOS/CDPOS.v1.sol index 25673e90..3fa1ccdd 100644 --- a/src/modules/CDPOS/CDPOS.v1.sol +++ b/src/modules/CDPOS/CDPOS.v1.sol @@ -12,11 +12,13 @@ abstract contract CDPOSv1 is Module, ERC721 { /// @notice Data structure for the terms of a convertible deposit /// - /// @param remainingDeposit Amount of reserve tokens remaining to be converted - /// @param conversionPrice Price of the reserve token in USD - /// @param expiry Timestamp when the term expires - /// @param wrapped Whether the term is wrapped + /// @param convertibleDepositToken Address of the convertible deposit token + /// @param remainingDeposit Amount of reserve tokens remaining to be converted + /// @param conversionPrice Price of the reserve token in USD + /// @param expiry Timestamp when the term expires + /// @param wrapped Whether the term is wrapped struct Position { + address convertibleDepositToken; uint256 remainingDeposit; uint256 conversionPrice; uint48 expiry; @@ -29,6 +31,7 @@ abstract contract CDPOSv1 is Module, ERC721 { event PositionCreated( uint256 indexed positionId, address indexed owner, + address indexed convertibleDepositToken, uint256 remainingDeposit, uint256 conversionPrice, uint48 expiry, @@ -42,6 +45,7 @@ abstract contract CDPOSv1 is Module, ERC721 { event PositionSplit( uint256 indexed positionId, uint256 indexed newPositionId, + address indexed convertibleDepositToken, uint256 amount, address to, bool wrap @@ -113,20 +117,23 @@ abstract contract CDPOSv1 is Module, ERC721 { /// @dev The implementing function should do the following: /// - Validate that the caller is permissioned /// - Validate that the owner is not the zero address + /// - Validate that the convertible deposit token is not the zero address /// - Validate that the remaining deposit is greater than 0 /// - Validate that the conversion price is greater than 0 /// - Validate that the expiry is in the future /// - Create the position record /// - Wrap the position if requested /// - /// @param owner_ The address of the owner of the position - /// @param remainingDeposit_ The amount of reserve tokens remaining to be converted - /// @param conversionPrice_ The price of the reserve token in USD - /// @param expiry_ The timestamp when the position expires - /// @param wrap_ Whether the position should be wrapped - /// @return positionId The ID of the new position + /// @param owner_ The address of the owner of the position + /// @param convertibleDepositToken_ The address of the convertible deposit token + /// @param remainingDeposit_ The amount of reserve tokens remaining to be converted + /// @param conversionPrice_ The price of the reserve token in USD + /// @param expiry_ The timestamp when the position expires + /// @param wrap_ Whether the position should be wrapped + /// @return positionId The ID of the new position function create( address owner_, + address convertibleDepositToken_, uint256 remainingDeposit_, uint256 conversionPrice_, uint48 expiry_, diff --git a/src/modules/CDPOS/OlympusConvertibleDepositPositions.sol b/src/modules/CDPOS/OlympusConvertibleDepositPositions.sol index 2939431e..be84ad3e 100644 --- a/src/modules/CDPOS/OlympusConvertibleDepositPositions.sol +++ b/src/modules/CDPOS/OlympusConvertibleDepositPositions.sol @@ -62,6 +62,7 @@ contract OlympusConvertibleDepositPositions is CDPOSv1 { function _create( address owner_, + address convertibleDepositToken_, uint256 remainingDeposit_, uint256 conversionPrice_, uint48 expiry_, @@ -70,6 +71,7 @@ contract OlympusConvertibleDepositPositions is CDPOSv1 { // Create the position record positionId = ++positionCount; _positions[positionId] = Position({ + convertibleDepositToken: convertibleDepositToken_, remainingDeposit: remainingDeposit_, conversionPrice: conversionPrice_, expiry: expiry_, @@ -90,6 +92,7 @@ contract OlympusConvertibleDepositPositions is CDPOSv1 { emit PositionCreated( positionId, owner_, + convertibleDepositToken_, remainingDeposit_, conversionPrice_, expiry_, @@ -103,11 +106,13 @@ contract OlympusConvertibleDepositPositions is CDPOSv1 { /// @dev This function reverts if: /// - The caller is not permissioned /// - The owner is the zero address + /// - The convertible deposit token is the zero address /// - The remaining deposit is 0 /// - The conversion price is 0 /// - The expiry is in the past function create( address owner_, + address convertibleDepositToken_, uint256 remainingDeposit_, uint256 conversionPrice_, uint48 expiry_, @@ -116,6 +121,10 @@ contract OlympusConvertibleDepositPositions is CDPOSv1 { // Validate that the owner is not the zero address if (owner_ == address(0)) revert CDPOS_InvalidParams("owner"); + // Validate that the convertible deposit token is not the zero address + if (convertibleDepositToken_ == address(0)) + revert CDPOS_InvalidParams("convertible deposit token"); + // Validate that the remaining deposit is greater than 0 if (remainingDeposit_ == 0) revert CDPOS_InvalidParams("deposit"); @@ -125,7 +134,15 @@ contract OlympusConvertibleDepositPositions is CDPOSv1 { // Validate that the expiry is in the future if (expiry_ <= block.timestamp) revert CDPOS_InvalidParams("expiry"); - return _create(owner_, remainingDeposit_, conversionPrice_, expiry_, wrap_); + return + _create( + owner_, + convertibleDepositToken_, + remainingDeposit_, + conversionPrice_, + expiry_, + wrap_ + ); } /// @inheritdoc CDPOSv1 @@ -181,10 +198,24 @@ contract OlympusConvertibleDepositPositions is CDPOSv1 { position.remainingDeposit = remainingDeposit; // Create the new position - newPositionId = _create(to_, amount_, position.conversionPrice, position.expiry, wrap_); + newPositionId = _create( + to_, + position.convertibleDepositToken, + amount_, + position.conversionPrice, + position.expiry, + wrap_ + ); // Emit the event - emit PositionSplit(positionId_, newPositionId, amount_, to_, wrap_); + emit PositionSplit( + positionId_, + newPositionId, + position.convertibleDepositToken, + amount_, + to_, + wrap_ + ); return newPositionId; } diff --git a/src/policies/CDFacility.sol b/src/policies/CDFacility.sol index f7b3da2e..8a525295 100644 --- a/src/policies/CDFacility.sol +++ b/src/policies/CDFacility.sol @@ -90,7 +90,14 @@ contract CDFacility is Policy, RolesConsumer, IConvertibleDepositFacility { CDEPO.mintTo(account_, amount_); // Create a new term record in the CDPOS module - positionId = CDPOS.create(account_, amount_, conversionPrice_, expiry_, wrap_); + positionId = CDPOS.create( + account_, + address(CDEPO), + amount_, + conversionPrice_, + expiry_, + wrap_ + ); // Pre-emptively increase the OHM mint approval MINTR.increaseMintApproval(address(this), amount_); @@ -122,6 +129,10 @@ contract CDFacility is Policy, RolesConsumer, IConvertibleDepositFacility { // This will revert if the position is not valid CDPOSv1.Position memory position = CDPOS.getPosition(positionId); + // Validate that the position is CDEPO + if (position.convertibleDepositToken != address(CDEPO)) + revert CDF_InvalidToken(positionId, position.convertibleDepositToken); + // Validate that the position has not expired if (block.timestamp >= position.expiry) revert CDF_PositionExpired(positionId); @@ -177,6 +188,10 @@ contract CDFacility is Policy, RolesConsumer, IConvertibleDepositFacility { // This will revert if the position is not valid CDPOSv1.Position memory position = CDPOS.getPosition(positionId); + // Validate that the position is CDEPO + if (position.convertibleDepositToken != address(CDEPO)) + revert CDF_InvalidToken(positionId, position.convertibleDepositToken); + // Validate that the position has expired if (block.timestamp < position.expiry) revert CDF_PositionNotExpired(positionId); diff --git a/src/policies/interfaces/IConvertibleDepositFacility.sol b/src/policies/interfaces/IConvertibleDepositFacility.sol index a7bd9265..01448bf2 100644 --- a/src/policies/interfaces/IConvertibleDepositFacility.sol +++ b/src/policies/interfaces/IConvertibleDepositFacility.sol @@ -25,6 +25,8 @@ interface IConvertibleDepositFacility { error CDF_InvalidAmount(uint256 positionId_, uint256 amount_); + error CDF_InvalidToken(uint256 positionId_, address token_); + // ========== CONVERTIBLE DEPOSIT ACTIONS ========== // /// @notice Creates a new convertible deposit position @@ -53,6 +55,7 @@ interface IConvertibleDepositFacility { /// @notice Converts convertible deposit tokens to OHM before expiry /// @dev The implementing contract is expected to handle the following: /// - Validating that the caller is the owner of all of the positions + /// - Validating that convertible deposit token in the position is CDEPO /// - Validating that all of the positions are valid /// - Validating that all of the positions have not expired /// - Burning the convertible deposit tokens @@ -72,6 +75,7 @@ interface IConvertibleDepositFacility { /// @notice Reclaims convertible deposit tokens after expiry /// @dev The implementing contract is expected to handle the following: /// - Validating that the caller is the owner of all of the positions + /// - Validating that convertible deposit token in the position is CDEPO /// - Validating that all of the positions are valid /// - Validating that all of the positions have expired /// - Burning the convertible deposit tokens From f1dbb752786f2b344d63f82933bb4fa4afd0b893 Mon Sep 17 00:00:00 2001 From: Jem <0x0xjem@gmail.com> Date: Tue, 17 Dec 2024 16:58:08 +0500 Subject: [PATCH 27/64] First pass at SVG for CD positions --- .../OlympusConvertibleDepositPositions.sol | 80 ++++++++++++++++++- 1 file changed, 77 insertions(+), 3 deletions(-) diff --git a/src/modules/CDPOS/OlympusConvertibleDepositPositions.sol b/src/modules/CDPOS/OlympusConvertibleDepositPositions.sol index be84ad3e..01948eb6 100644 --- a/src/modules/CDPOS/OlympusConvertibleDepositPositions.sol +++ b/src/modules/CDPOS/OlympusConvertibleDepositPositions.sol @@ -4,11 +4,14 @@ pragma solidity 0.8.15; import {ERC721} from "solmate/tokens/ERC721.sol"; import {CDPOSv1} from "./CDPOS.v1.sol"; import {Kernel, Module} from "src/Kernel.sol"; +import {Strings} from "@openzeppelin/contracts/utils/Strings.sol"; +import {Base64} from "@openzeppelin/contracts/utils/Base64.sol"; +import {Timestamp} from "src/libraries/Timestamp.sol"; contract OlympusConvertibleDepositPositions is CDPOSv1 { constructor( address kernel_ - ) Module(Kernel(kernel_)) ERC721("Olympus Convertible Deposit Positions", "OCDP") {} + ) Module(Kernel(kernel_)) ERC721("Olympus Convertible Deposit Position", "OCDP") {} // ========== WRAPPING ========== // @@ -222,12 +225,83 @@ contract OlympusConvertibleDepositPositions is CDPOSv1 { // ========== ERC721 OVERRIDES ========== // + function _getTimeString(uint48 time_) internal pure returns (string memory) { + (string memory year, string memory month, string memory day) = Timestamp.toPaddedString( + time_ + ); + + return string.concat(year, "-", month, "-", day); + } + + // solhint-disable quotes + function _render( + uint256 positionId_, + Position memory position_ + ) internal pure returns (string memory) { + // TODO consider adding conversion price and remaining deposit to the SVG. How to display as decimal values? + return + string.concat( + '', + '', + string.concat( + '', + unicode"Ω", + "" + ), + 'Convertible Deposit', + string.concat( + 'Position ID: ', + Strings.toString(positionId_), + "" + ), + string.concat( + 'Expiry: ', + _getTimeString(position_.expiry), + "" + ), + "" + ); + } + + // solhint-enable quotes + /// @inheritdoc ERC721 + // solhint-disable quotes function tokenURI(uint256 id_) public view virtual override returns (string memory) { - // TODO implement tokenURI SVG - return ""; + Position memory position = _getPosition(id_); + + // solhint-disable-next-line quotes + string memory jsonContent = string.concat( + "{", + string.concat('"name": "', name, '",'), + string.concat('"symbol": "', symbol, '",'), + '"attributes": [', + string.concat('{"trait_type": "Position ID", "value": "', Strings.toString(id_), '"},'), + string.concat( + '{"trait_type": "Convertible Deposit Token", "value": "', + Strings.toHexString(position.convertibleDepositToken), + '"},' + ), + string.concat( + '{"trait_type": "Expiry", "display_type": "date", "value": "', + Strings.toString(position.expiry), + '"},' + ), + "]", + string.concat( + '"image": "', + "data:image/svg+xml;base64,", + Base64.encode(bytes(_render(id_, position))), + '"}' + ), + "}" + ); + + return string.concat("data:application/json;base64,", Base64.encode(bytes(jsonContent))); } + // solhint-enable quotes + /// @inheritdoc ERC721 /// @dev This function performs the following: /// - Updates the owner of the position From 6b9888fd6416fd5357593c60523553d427b4f67a Mon Sep 17 00:00:00 2001 From: Jem <0x0xjem@gmail.com> Date: Tue, 17 Dec 2024 17:08:58 +0500 Subject: [PATCH 28/64] Extract previewBid functionality --- src/policies/CDAuctioneer.sol | 58 +++++++++++++++++++++++------------ 1 file changed, 39 insertions(+), 19 deletions(-) diff --git a/src/policies/CDAuctioneer.sol b/src/policies/CDAuctioneer.sol index e8f34b10..c02e6af7 100644 --- a/src/policies/CDAuctioneer.sol +++ b/src/policies/CDAuctioneer.sol @@ -57,30 +57,23 @@ contract CDAuctioneer is IConvertibleDepositAuctioneer, Policy, RolesConsumer { /// @inheritdoc IConvertibleDepositAuctioneer function bid(uint256 deposit) external override returns (uint256 convertible) { - // update state + // Update state currentTick = getCurrentTick(); state.lastUpdate = block.timestamp; - // iterate until user has no more reserves to bid - while (deposit > 0) { - // handle spent/capacity for tick - uint256 amount = currentTick.capacity < _convertFor(deposit, currentTick.price) - ? state.tickSize - : deposit; - if (amount != state.tickSize) currentTick.capacity -= amount; - else currentTick.price *= state.tickStep / decimals; - - // decrement bid and increment tick price - deposit -= amount; - convertible += _convertFor(amount, currentTick.price); - } - - // TODO extract logic to previewBid - // TODO update currentTick based on previewBid output + // Get bid results + uint256 currentTickCapacity; + uint256 currentTickPrice; + (currentTickCapacity, currentTickPrice, convertible) = _previewBid(deposit); + // Update day state today.deposits += deposit; today.convertible += convertible; + // Update current tick + currentTick.capacity = currentTickCapacity; + currentTick.price = currentTickPrice; + // TODO calculate average price for total deposit and convertible, check rounding, formula uint256 conversionPrice = (deposit * decimals) / convertible; @@ -92,12 +85,39 @@ contract CDAuctioneer is IConvertibleDepositAuctioneer, Policy, RolesConsumer { uint48(block.timestamp + state.timeToExpiry), false ); + + return convertible; + } + + function _previewBid( + uint256 deposit_ + ) + internal + view + returns (uint256 currentTickCapacity, uint256 currentTickPrice, uint256 convertible) + { + Tick memory tick = getCurrentTick(); + uint256 remainingDeposit = deposit_; + + while (remainingDeposit > 0) { + uint256 amount = tick.capacity < _convertFor(remainingDeposit, tick.price) + ? state.tickSize + : remainingDeposit; + if (amount != state.tickSize) tick.capacity -= amount; + else tick.price *= state.tickStep / decimals; + + remainingDeposit -= amount; + convertible += _convertFor(amount, tick.price); + } + + return (tick.capacity, tick.price, convertible); } /// @inheritdoc IConvertibleDepositAuctioneer function previewBid(uint256 deposit) external view override returns (uint256 convertible) { - // TODO - return 0; + (, , convertible) = _previewBid(deposit); + + return convertible; } // ========== VIEW FUNCTIONS ========== // From fddaa3b05e4a52ea38fa7a450421c21d27b29107 Mon Sep 17 00:00:00 2001 From: Jem <0x0xjem@gmail.com> Date: Tue, 17 Dec 2024 17:17:15 +0500 Subject: [PATCH 29/64] Docs --- src/policies/CDAuctioneer.sol | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/policies/CDAuctioneer.sol b/src/policies/CDAuctioneer.sol index c02e6af7..8a475aeb 100644 --- a/src/policies/CDAuctioneer.sol +++ b/src/policies/CDAuctioneer.sol @@ -89,6 +89,13 @@ contract CDAuctioneer is IConvertibleDepositAuctioneer, Policy, RolesConsumer { return convertible; } + /// @notice Internal function to preview the number of convertible tokens that can be purchased for a given deposit amount + /// @dev The function also returns the adjusted capacity and price of the current tick + /// + /// @param deposit_ The amount of deposit to be bid + /// @return currentTickCapacity The adjusted capacity of the current tick + /// @return currentTickPrice The adjusted price of the current tick + /// @return convertible The number of convertible tokens that can be purchased function _previewBid( uint256 deposit_ ) From e3ad735966c42da37e8ef787c1a58170cf35cebe Mon Sep 17 00:00:00 2001 From: Jem <0x0xjem@gmail.com> Date: Wed, 18 Dec 2024 12:25:08 +0500 Subject: [PATCH 30/64] Add isExpired getter to CDPOS --- src/modules/CDPOS/CDPOS.v1.sol | 6 ++++++ src/modules/CDPOS/OlympusConvertibleDepositPositions.sol | 9 +++++++++ 2 files changed, 15 insertions(+) diff --git a/src/modules/CDPOS/CDPOS.v1.sol b/src/modules/CDPOS/CDPOS.v1.sol index 3fa1ccdd..c0d4e277 100644 --- a/src/modules/CDPOS/CDPOS.v1.sol +++ b/src/modules/CDPOS/CDPOS.v1.sol @@ -188,4 +188,10 @@ abstract contract CDPOSv1 is Module, ERC721 { /// @param positionId_ The ID of the position /// @return position The positions for the given ID function getPosition(uint256 positionId_) external view virtual returns (Position memory); + + /// @notice Check if a position is expired + /// + /// @param positionId_ The ID of the position + /// @return expired_ Whether the position is expired + function isExpired(uint256 positionId_) external view virtual returns (bool); } diff --git a/src/modules/CDPOS/OlympusConvertibleDepositPositions.sol b/src/modules/CDPOS/OlympusConvertibleDepositPositions.sol index 01948eb6..5ba6fb3a 100644 --- a/src/modules/CDPOS/OlympusConvertibleDepositPositions.sol +++ b/src/modules/CDPOS/OlympusConvertibleDepositPositions.sol @@ -359,6 +359,15 @@ contract OlympusConvertibleDepositPositions is CDPOSv1 { return _getPosition(positionId_); } + /// @inheritdoc CDPOSv1 + /// @dev This function reverts if: + /// - The position ID is invalid + /// + /// @return Returns true if the expiry timestamp is now or in the past + function isExpired(uint256 positionId_) external view virtual override returns (bool) { + return _getPosition(positionId_).expiry <= block.timestamp; + } + // ========== MODIFIERS ========== // modifier onlyValidPosition(uint256 positionId_) { From 77e278771d55b2b4c0a3960e27b2c8b62372611d Mon Sep 17 00:00:00 2001 From: Jem <0x0xjem@gmail.com> Date: Wed, 18 Dec 2024 12:25:38 +0500 Subject: [PATCH 31/64] Adjust CDPOS SVG --- src/modules/CDPOS/OlympusConvertibleDepositPositions.sol | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/modules/CDPOS/OlympusConvertibleDepositPositions.sol b/src/modules/CDPOS/OlympusConvertibleDepositPositions.sol index 5ba6fb3a..975b2f87 100644 --- a/src/modules/CDPOS/OlympusConvertibleDepositPositions.sol +++ b/src/modules/CDPOS/OlympusConvertibleDepositPositions.sol @@ -244,18 +244,18 @@ contract OlympusConvertibleDepositPositions is CDPOSv1 { '', '', string.concat( - '', + '', unicode"Ω", "" ), - 'Convertible Deposit', + 'Convertible Deposit', string.concat( - 'Position ID: ', + 'ID: ', Strings.toString(positionId_), "" ), string.concat( - 'Expiry: ', + 'Expiry: ', _getTimeString(position_.expiry), "" ), From abf0b3c65611658be1de969c9e95a0d91975e512 Mon Sep 17 00:00:00 2001 From: Jem <0x0xjem@gmail.com> Date: Wed, 18 Dec 2024 13:48:21 +0500 Subject: [PATCH 32/64] Wrote helper function to convert a fixed-point integer into a decimal string --- src/libraries/DecimalString.sol | 92 ++++++++++++++++++++++++++++++ src/test/lib/DecimalString.t.sol | 96 ++++++++++++++++++++++++++++++++ 2 files changed, 188 insertions(+) create mode 100644 src/libraries/DecimalString.sol create mode 100644 src/test/lib/DecimalString.t.sol diff --git a/src/libraries/DecimalString.sol b/src/libraries/DecimalString.sol new file mode 100644 index 00000000..40360f39 --- /dev/null +++ b/src/libraries/DecimalString.sol @@ -0,0 +1,92 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8; + +import {uint2str} from "./Uint2Str.sol"; +import {console2} from "forge-std/console2.sol"; + +library DecimalString { + /// @notice Converts a uint256 value to a string with a specified number of decimal places. + /// The value is adjusted by the scale factor and then formatted to the specified number of decimal places. + /// The decimal places are not zero-padded, so the result is not always the same length. + /// @dev This is inspired by code in [FixedStrikeOptionTeller](https://github.com/Bond-Protocol/option-contracts/blob/b8ce2ca2bae3bd06f0e7665c3aa8d827e4d8ca2c/src/fixed-strike/FixedStrikeOptionTeller.sol#L722). + /// + /// @param value_ The uint256 value to convert to a string. + /// @param valueDecimals_ The scale factor of the value. + /// @param decimalPlaces_ The number of decimal places to format the value to. + /// @return result A string representation of the value with the specified number of decimal places. + function toDecimalString( + uint256 value_, + uint8 valueDecimals_, + uint8 decimalPlaces_ + ) internal pure returns (string memory) { + // Handle zero case + if (value_ == 0) return "0"; + + // Convert the entire number to string first + string memory str = uint2str(value_); + bytes memory bStr = bytes(str); + + // If no decimal places requested, just handle the scaling and return + if (decimalPlaces_ == 0) { + if (bStr.length <= valueDecimals_) return "0"; + return uint2str(value_ / (10 ** valueDecimals_)); + } + + // If value is a whole number, return as-is + if (valueDecimals_ == 0) return str; + + // Calculate decimal places to show (limited by request and available decimals) + uint256 maxDecimalPlaces = valueDecimals_ > decimalPlaces_ + ? decimalPlaces_ + : valueDecimals_; + + // Handle numbers smaller than 1 + if (bStr.length <= valueDecimals_) { + bytes memory smallResult = new bytes(2 + maxDecimalPlaces); + smallResult[0] = "0"; + smallResult[1] = "."; + + uint256 leadingZeros = valueDecimals_ - bStr.length; + uint256 zerosToAdd = leadingZeros > maxDecimalPlaces ? maxDecimalPlaces : leadingZeros; + + // Add leading zeros after decimal + for (uint256 i = 0; i < zerosToAdd; i++) { + smallResult[i + 2] = "0"; + } + + // Add available digits + for (uint256 i = 0; i < maxDecimalPlaces - zerosToAdd && i < bStr.length; i++) { + smallResult[i + 2 + zerosToAdd] = bStr[i]; + } + + return string(smallResult); + } + + // Find decimal position and last significant digit + uint256 decimalPosition = bStr.length - valueDecimals_; + uint256 lastNonZeroPos = decimalPosition; + for (uint256 i = 0; i < maxDecimalPlaces && i + decimalPosition < bStr.length; i++) { + if (bStr[decimalPosition + i] != "0") { + lastNonZeroPos = decimalPosition + i + 1; + } + } + + // Create and populate result + bytes memory finalResult = new bytes( + lastNonZeroPos - decimalPosition > 0 ? lastNonZeroPos + 1 : lastNonZeroPos + ); + + for (uint256 i = 0; i < decimalPosition; i++) { + finalResult[i] = bStr[i]; + } + + if (lastNonZeroPos > decimalPosition) { + finalResult[decimalPosition] = "."; + for (uint256 i = 0; i < lastNonZeroPos - decimalPosition; i++) { + finalResult[decimalPosition + 1 + i] = bStr[decimalPosition + i]; + } + } + + return string(finalResult); + } +} diff --git a/src/test/lib/DecimalString.t.sol b/src/test/lib/DecimalString.t.sol new file mode 100644 index 00000000..b7f9636b --- /dev/null +++ b/src/test/lib/DecimalString.t.sol @@ -0,0 +1,96 @@ +// SPDX-License-Identifier: Unlicensed +pragma solidity ^0.8; + +import {Test} from "forge-std/Test.sol"; +import {DecimalString} from "src/libraries/DecimalString.sol"; + +contract DecimalStringTest is Test { + + // when valueDecimals is 0 + // [X] it returns the raw value as a string + // when valueDecimals is 1-3 + // [X] it returns the value with a decimal point and the correct number of digits + // when the decimal value is large + // [X] it returns the value correctly to 3 decimal places + // when the decimal value is small + // [X] it returns the value correctly to 3 decimal places + // when the decimal value is smaller than 3 decimal places + // [X] it returns 0 + + function test_whenValueDecimalsIs0() public { + uint256 value = 123456789; + uint8 valueDecimals = 0; + + assertEq(DecimalString.toDecimalString(value, valueDecimals, 0), "123456789", "decimal places is 0"); + assertEq(DecimalString.toDecimalString(value, valueDecimals, 1), "123456789", "decimal places is 1"); + assertEq(DecimalString.toDecimalString(value, valueDecimals, 2), "123456789", "decimal places is 2"); + assertEq(DecimalString.toDecimalString(value, valueDecimals, 3), "123456789", "decimal places is 3"); + assertEq(DecimalString.toDecimalString(value, valueDecimals, 18), "123456789", "decimal places is 18"); + } + + function test_whenValueDecimalsIs1() public { + uint256 value = 123456789; + uint8 valueDecimals = 1; + + assertEq(DecimalString.toDecimalString(value, valueDecimals, 0), "12345678", "decimal places is 0"); + assertEq(DecimalString.toDecimalString(value, valueDecimals, 1), "12345678.9", "decimal places is 1"); + assertEq(DecimalString.toDecimalString(value, valueDecimals, 2), "12345678.9", "decimal places is 2"); + assertEq(DecimalString.toDecimalString(value, valueDecimals, 3), "12345678.9", "decimal places is 3"); + assertEq(DecimalString.toDecimalString(value, valueDecimals, 18), "12345678.9", "decimal places is 18"); + } + + function test_whenValueDecimalsIs2() public { + uint256 value = 123456789; + uint8 valueDecimals = 2; + + assertEq(DecimalString.toDecimalString(value, valueDecimals, 0), "1234567", "decimal places is 0" ); + assertEq(DecimalString.toDecimalString(value, valueDecimals, 1), "1234567.8", "decimal places is 1"); + assertEq(DecimalString.toDecimalString(value, valueDecimals, 2), "1234567.89", "decimal places is 2"); + assertEq(DecimalString.toDecimalString(value, valueDecimals, 3), "1234567.89", "decimal places is 3"); + assertEq(DecimalString.toDecimalString(value, valueDecimals, 18), "1234567.89", "decimal places is 18"); + } + + function test_whenValueDecimalsIs3() public { + uint256 value = 123456789; + uint8 valueDecimals = 3; + + assertEq(DecimalString.toDecimalString(value, valueDecimals, 0), "123456", "decimal places is 0"); + assertEq(DecimalString.toDecimalString(value, valueDecimals, 1), "123456.7", "decimal places is 1"); + assertEq(DecimalString.toDecimalString(value, valueDecimals, 2), "123456.78", "decimal places is 2"); + assertEq(DecimalString.toDecimalString(value, valueDecimals, 3), "123456.789", "decimal places is 3"); + assertEq(DecimalString.toDecimalString(value, valueDecimals, 18), "123456.789", "decimal places is 18"); + } + + function test_whenValueDecimalValueIsLessThanOne() public { + uint256 value = 1234; + uint8 valueDecimals = 4; + + assertEq(DecimalString.toDecimalString(value, valueDecimals, 0), "0", "decimal places is 0"); + assertEq(DecimalString.toDecimalString(value, valueDecimals, 1), "0.1", "decimal places is 1"); + assertEq(DecimalString.toDecimalString(value, valueDecimals, 2), "0.12", "decimal places is 2"); + assertEq(DecimalString.toDecimalString(value, valueDecimals, 3), "0.123", "decimal places is 3"); + assertEq(DecimalString.toDecimalString(value, valueDecimals, 18), "0.1234", "decimal places is 18"); + } + + function test_whenValueDecimalValueIsGreaterThanOne() public { + uint256 value = 1234567890000000000; + uint8 valueDecimals = 18; + + assertEq(DecimalString.toDecimalString(value, valueDecimals, 0), "1", "decimal places is 0"); + assertEq(DecimalString.toDecimalString(value, valueDecimals, 1), "1.2", "decimal places is 1"); + assertEq(DecimalString.toDecimalString(value, valueDecimals, 2), "1.23", "decimal places is 2"); + assertEq(DecimalString.toDecimalString(value, valueDecimals, 3), "1.234", "decimal places is 3"); + assertEq(DecimalString.toDecimalString(value, valueDecimals, 18), "1.23456789", "decimal places is 18"); + } + + function test_whenValueDecimalValueIsLarge() public { + uint256 value = 1234567890000000000000; + uint8 valueDecimals = 18; + + assertEq(DecimalString.toDecimalString(value, valueDecimals, 0), "1234", "decimal places is 0"); + assertEq(DecimalString.toDecimalString(value, valueDecimals, 1), "1234.5", "decimal places is 1"); + assertEq(DecimalString.toDecimalString(value, valueDecimals, 2), "1234.56", "decimal places is 2"); + assertEq(DecimalString.toDecimalString(value, valueDecimals, 3), "1234.567", "decimal places is 3"); + assertEq(DecimalString.toDecimalString(value, valueDecimals, 18), "1234.56789", "decimal places is 18"); + } +} From 7ba544a5380f42b781abb8d3455fba97983f7bbb Mon Sep 17 00:00:00 2001 From: Jem <0x0xjem@gmail.com> Date: Wed, 18 Dec 2024 13:58:02 +0500 Subject: [PATCH 33/64] Add conversion price and remaining deposit to the SVG and traits --- .../OlympusConvertibleDepositPositions.sol | 57 +++++++++++++++++-- 1 file changed, 51 insertions(+), 6 deletions(-) diff --git a/src/modules/CDPOS/OlympusConvertibleDepositPositions.sol b/src/modules/CDPOS/OlympusConvertibleDepositPositions.sol index 975b2f87..e174826b 100644 --- a/src/modules/CDPOS/OlympusConvertibleDepositPositions.sol +++ b/src/modules/CDPOS/OlympusConvertibleDepositPositions.sol @@ -2,13 +2,17 @@ pragma solidity 0.8.15; import {ERC721} from "solmate/tokens/ERC721.sol"; +import {ERC20} from "solmate/tokens/ERC20.sol"; import {CDPOSv1} from "./CDPOS.v1.sol"; import {Kernel, Module} from "src/Kernel.sol"; import {Strings} from "@openzeppelin/contracts/utils/Strings.sol"; import {Base64} from "@openzeppelin/contracts/utils/Base64.sol"; import {Timestamp} from "src/libraries/Timestamp.sol"; +import {DecimalString} from "src/libraries/DecimalString.sol"; contract OlympusConvertibleDepositPositions is CDPOSv1 { + uint8 internal _displayDecimals = 2; + constructor( address kernel_ ) Module(Kernel(kernel_)) ERC721("Olympus Convertible Deposit Position", "OCDP") {} @@ -237,28 +241,48 @@ contract OlympusConvertibleDepositPositions is CDPOSv1 { function _render( uint256 positionId_, Position memory position_ - ) internal pure returns (string memory) { - // TODO consider adding conversion price and remaining deposit to the SVG. How to display as decimal values? + ) internal view returns (string memory) { + // Get the decimals of the deposit token + uint8 depositDecimals = ERC20(position_.convertibleDepositToken).decimals(); + return string.concat( '', '', string.concat( - '', + '', unicode"Ω", "" ), - 'Convertible Deposit', + 'Convertible Deposit', string.concat( - 'ID: ', + 'ID: ', Strings.toString(positionId_), "" ), string.concat( - 'Expiry: ', + 'Expiry: ', _getTimeString(position_.expiry), "" ), + string.concat( + 'Remaining: ', + DecimalString.toDecimalString( + position_.remainingDeposit, + depositDecimals, + _displayDecimals + ), + "" + ), + string.concat( + 'Conversion: ', + DecimalString.toDecimalString( + position_.conversionPrice, + depositDecimals, + _displayDecimals + ), + "" + ), // TODO check decimals of conversion price. This probably isn't correct. "" ); } @@ -270,6 +294,9 @@ contract OlympusConvertibleDepositPositions is CDPOSv1 { function tokenURI(uint256 id_) public view virtual override returns (string memory) { Position memory position = _getPosition(id_); + // Get the decimals of the deposit token + uint8 depositDecimals = ERC20(position.convertibleDepositToken).decimals(); + // solhint-disable-next-line quotes string memory jsonContent = string.concat( "{", @@ -287,6 +314,24 @@ contract OlympusConvertibleDepositPositions is CDPOSv1 { Strings.toString(position.expiry), '"},' ), + string.concat( + '{"trait_type": "Remaining Deposit", "value": "', + DecimalString.toDecimalString( + position.remainingDeposit, + depositDecimals, + _displayDecimals + ), + '"},' + ), + string.concat( + '{"trait_type": "Conversion Price", "value": "', + DecimalString.toDecimalString( + position.conversionPrice, + depositDecimals, + _displayDecimals + ), + '"},' + ), "]", string.concat( '"image": "', From 724b2b59126edf54ad2fa7125a0747e0e2ff9871 Mon Sep 17 00:00:00 2001 From: Jem <0x0xjem@gmail.com> Date: Wed, 18 Dec 2024 14:00:20 +0500 Subject: [PATCH 34/64] Add setter --- src/modules/CDPOS/OlympusConvertibleDepositPositions.sol | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/modules/CDPOS/OlympusConvertibleDepositPositions.sol b/src/modules/CDPOS/OlympusConvertibleDepositPositions.sol index e174826b..2fabec7d 100644 --- a/src/modules/CDPOS/OlympusConvertibleDepositPositions.sol +++ b/src/modules/CDPOS/OlympusConvertibleDepositPositions.sol @@ -413,6 +413,14 @@ contract OlympusConvertibleDepositPositions is CDPOSv1 { return _getPosition(positionId_).expiry <= block.timestamp; } + // ========== ADMIN FUNCTIONS ========== // + + /// @notice Set the number of decimal places to display when rendering values as decimal strings. + /// @dev This affects the display of the remaining deposit and conversion price in the SVG and JSON metadata. + function setDisplayDecimals(uint8 decimals_) external permissioned { + _displayDecimals = decimals_; + } + // ========== MODIFIERS ========== // modifier onlyValidPosition(uint256 positionId_) { From 51648f0cf1e81a9596f70ee9f5fd632d8ff807d3 Mon Sep 17 00:00:00 2001 From: Jem <0x0xjem@gmail.com> Date: Wed, 18 Dec 2024 14:06:43 +0500 Subject: [PATCH 35/64] Docs --- src/modules/CDPOS/OlympusConvertibleDepositPositions.sol | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/modules/CDPOS/OlympusConvertibleDepositPositions.sol b/src/modules/CDPOS/OlympusConvertibleDepositPositions.sol index 2fabec7d..388d14bb 100644 --- a/src/modules/CDPOS/OlympusConvertibleDepositPositions.sol +++ b/src/modules/CDPOS/OlympusConvertibleDepositPositions.sol @@ -11,6 +11,9 @@ import {Timestamp} from "src/libraries/Timestamp.sol"; import {DecimalString} from "src/libraries/DecimalString.sol"; contract OlympusConvertibleDepositPositions is CDPOSv1 { + /// @notice The number of decimal places to display when rendering values as decimal strings. + /// @dev This affects the display of the remaining deposit and conversion price in the SVG and JSON metadata. + /// It can be adjusted using the `setDisplayDecimals` function, which is permissioned. uint8 internal _displayDecimals = 2; constructor( From 1719af0b94e77379559451d3a8f290e10ca1353f Mon Sep 17 00:00:00 2001 From: Jem <0x0xjem@gmail.com> Date: Wed, 18 Dec 2024 17:01:38 +0500 Subject: [PATCH 36/64] Add previewConvert to CDPOS --- src/modules/CDPOS/CDPOS.v1.sol | 10 +++++++++ .../OlympusConvertibleDepositPositions.sol | 21 +++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/src/modules/CDPOS/CDPOS.v1.sol b/src/modules/CDPOS/CDPOS.v1.sol index c0d4e277..cc70f977 100644 --- a/src/modules/CDPOS/CDPOS.v1.sol +++ b/src/modules/CDPOS/CDPOS.v1.sol @@ -194,4 +194,14 @@ abstract contract CDPOSv1 is Module, ERC721 { /// @param positionId_ The ID of the position /// @return expired_ Whether the position is expired function isExpired(uint256 positionId_) external view virtual returns (bool); + + /// @notice Preview the amount of OHM that would be received for a given amount of convertible deposit tokens + /// + /// @param positionId_ The ID of the position + /// @param amount_ The amount of convertible deposit tokens to convert + /// @return ohmOut The amount of OHM that would be received + function previewConvert( + uint256 positionId_, + uint256 amount_ + ) external view virtual returns (uint256 ohmOut); } diff --git a/src/modules/CDPOS/OlympusConvertibleDepositPositions.sol b/src/modules/CDPOS/OlympusConvertibleDepositPositions.sol index 388d14bb..f99eefcd 100644 --- a/src/modules/CDPOS/OlympusConvertibleDepositPositions.sol +++ b/src/modules/CDPOS/OlympusConvertibleDepositPositions.sol @@ -11,11 +11,17 @@ import {Timestamp} from "src/libraries/Timestamp.sol"; import {DecimalString} from "src/libraries/DecimalString.sol"; contract OlympusConvertibleDepositPositions is CDPOSv1 { + // ========== STATE VARIABLES ========== // + + uint256 public constant DECIMALS = 1e18; + /// @notice The number of decimal places to display when rendering values as decimal strings. /// @dev This affects the display of the remaining deposit and conversion price in the SVG and JSON metadata. /// It can be adjusted using the `setDisplayDecimals` function, which is permissioned. uint8 internal _displayDecimals = 2; + // ========== CONSTRUCTOR ========== // + constructor( address kernel_ ) Module(Kernel(kernel_)) ERC721("Olympus Convertible Deposit Position", "OCDP") {} @@ -416,6 +422,21 @@ contract OlympusConvertibleDepositPositions is CDPOSv1 { return _getPosition(positionId_).expiry <= block.timestamp; } + function _previewConvert( + uint256 amount_, + uint256 conversionPrice_ + ) internal pure returns (uint256) { + return (amount_ * DECIMALS) / conversionPrice_; // TODO check decimals, rounding + } + + /// @inheritdoc CDPOSv1 + function previewConvert( + uint256 positionId_, + uint256 amount_ + ) public view virtual override onlyValidPosition(positionId_) returns (uint256) { + return _previewConvert(amount_, _getPosition(positionId_).conversionPrice); + } + // ========== ADMIN FUNCTIONS ========== // /// @notice Set the number of decimal places to display when rendering values as decimal strings. From 4be3322135c840a3a5cd1e36aa7973de83f91461 Mon Sep 17 00:00:00 2001 From: Jem <0x0xjem@gmail.com> Date: Wed, 18 Dec 2024 17:01:47 +0500 Subject: [PATCH 37/64] Add stubs for CDPOS tests --- src/test/modules/CDPOS/CDPOSTest.sol | 42 +++++++++++++++++++ src/test/modules/CDPOS/create.t.sol | 38 +++++++++++++++++ src/test/modules/CDPOS/previewConvert.t.sol | 12 ++++++ .../modules/CDPOS/setDisplayDecimals.t.sol | 10 +++++ src/test/modules/CDPOS/split.t.sol | 24 +++++++++++ src/test/modules/CDPOS/tokenURI.t.sol | 18 ++++++++ src/test/modules/CDPOS/transferFrom.t.sol | 16 +++++++ src/test/modules/CDPOS/unwrap.t.sol | 21 ++++++++++ src/test/modules/CDPOS/update.t.sol | 17 ++++++++ src/test/modules/CDPOS/wrap.t.sol | 21 ++++++++++ 10 files changed, 219 insertions(+) create mode 100644 src/test/modules/CDPOS/CDPOSTest.sol create mode 100644 src/test/modules/CDPOS/create.t.sol create mode 100644 src/test/modules/CDPOS/previewConvert.t.sol create mode 100644 src/test/modules/CDPOS/setDisplayDecimals.t.sol create mode 100644 src/test/modules/CDPOS/split.t.sol create mode 100644 src/test/modules/CDPOS/tokenURI.t.sol create mode 100644 src/test/modules/CDPOS/transferFrom.t.sol create mode 100644 src/test/modules/CDPOS/unwrap.t.sol create mode 100644 src/test/modules/CDPOS/update.t.sol create mode 100644 src/test/modules/CDPOS/wrap.t.sol diff --git a/src/test/modules/CDPOS/CDPOSTest.sol b/src/test/modules/CDPOS/CDPOSTest.sol new file mode 100644 index 00000000..f4339b41 --- /dev/null +++ b/src/test/modules/CDPOS/CDPOSTest.sol @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: Unlicensed +pragma solidity 0.8.15; + +import {Test} from "forge-std/Test.sol"; +import {IERC721Receiver} from "@openzeppelin/contracts/token/ERC721/IERC721Receiver.sol"; +import {ModuleTestFixtureGenerator} from "src/test/lib/ModuleTestFixtureGenerator.sol"; + +import {Kernel, Actions} from "src/Kernel.sol"; +import {OlympusConvertibleDepositPositions} from "src/modules/CDPOS/OlympusConvertibleDepositPositions.sol"; + +abstract contract CDPOSTest is Test, IERC721Receiver { + using ModuleTestFixtureGenerator for OlympusConvertibleDepositPositions; + + Kernel public kernel; + OlympusConvertibleDepositPositions public CDPOS; + address public godmode; + + uint256[] public positions; + + function setUp() public { + kernel = new Kernel(); + CDPOS = OlympusConvertibleDepositPositions(address(kernel)); + + // Generate fixtures + godmode = CDPOS.generateGodmodeFixture(type(OlympusConvertibleDepositPositions).name); + + // Install modules and policies on Kernel + kernel.executeAction(Actions.InstallModule, address(CDPOS)); + kernel.executeAction(Actions.ActivatePolicy, godmode); + } + + function onERC721Received( + address, + address, + uint256 tokenId, + bytes calldata + ) external override returns (bytes4) { + positions.push(tokenId); + + return this.onERC721Received.selector; + } +} diff --git a/src/test/modules/CDPOS/create.t.sol b/src/test/modules/CDPOS/create.t.sol new file mode 100644 index 00000000..3d97e66c --- /dev/null +++ b/src/test/modules/CDPOS/create.t.sol @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: Unlicensed +pragma solidity 0.8.15; + +import {CDPOSTest} from "./CDPOSTest.sol"; + +contract CreateCDPOSTest is CDPOSTest { + // when the caller is not a permissioned address + // [ ] it reverts + // when the owner is the zero address + // [ ] it reverts + // when the convertible deposit token is the zero address + // [ ] it reverts + // when the remaining deposit is 0 + // [ ] it reverts + // when the conversion price is 0 + // [ ] it reverts + // when the expiry is in the past or now + // [ ] it reverts + // when multiple positions are created + // [ ] the position IDs are sequential + // [ ] the position IDs are unique + // [ ] the owner's list of positions is updated + // [ ] the owner's balance is increased + // when the expiry is in the future + // [ ] it sets the expiry + // when the conversion would result in an overflow + // [ ] it reverts + // when the conversion would result in an underflow + // [ ] it reverts + // when the wrap flag is true + // [ ] it mints the ERC721 token + // [ ] it marks the position as wrapped + // [ ] it emits a PositionCreated event + // [ ] the position is marked as unwrapped + // [ ] the position is listed as owned by the owner + // [ ] the owner's list of positions is updated + // [ ] the balance of the owner is increased +} diff --git a/src/test/modules/CDPOS/previewConvert.t.sol b/src/test/modules/CDPOS/previewConvert.t.sol new file mode 100644 index 00000000..c545f805 --- /dev/null +++ b/src/test/modules/CDPOS/previewConvert.t.sol @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: Unlicensed +pragma solidity 0.8.15; + +import {CDPOSTest} from "./CDPOSTest.sol"; + +contract PreviewConvertTest is CDPOSTest { + // when the position does not exist + // [ ] it reverts + // when the position is expired + // [ ] it returns 0 + // [ ] it returns the correct value +} diff --git a/src/test/modules/CDPOS/setDisplayDecimals.t.sol b/src/test/modules/CDPOS/setDisplayDecimals.t.sol new file mode 100644 index 00000000..6d157bfe --- /dev/null +++ b/src/test/modules/CDPOS/setDisplayDecimals.t.sol @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: Unlicensed +pragma solidity 0.8.15; + +import {CDPOSTest} from "./CDPOSTest.sol"; + +contract SetDisplayDecimalsTest is CDPOSTest { + // when the caller is not a permissioned address + // [ ] it reverts + // [ ] it sets the display decimals +} diff --git a/src/test/modules/CDPOS/split.t.sol b/src/test/modules/CDPOS/split.t.sol new file mode 100644 index 00000000..7d548e76 --- /dev/null +++ b/src/test/modules/CDPOS/split.t.sol @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: Unlicensed +pragma solidity 0.8.15; + +import {CDPOSTest} from "./CDPOSTest.sol"; + +contract SplitCDPOSTest is CDPOSTest { + // when the position does not exist + // [ ] it reverts + // when the caller is not the owner of the position + // [ ] it reverts + // when the caller is a permissioned address + // [ ] it reverts + // when the amount is 0 + // [ ] it reverts + // when the amount is greater than the remaining deposit + // [ ] it reverts + // when the to_ address is the zero address + // [ ] it reverts + // when wrap is true + // [ ] it wraps the new position + // [ ] it creates a new position with the new amount, new owner and the same expiry + // [ ] it updates the remaining deposit of the original position + // [ ] it emits a PositionSplit event +} diff --git a/src/test/modules/CDPOS/tokenURI.t.sol b/src/test/modules/CDPOS/tokenURI.t.sol new file mode 100644 index 00000000..c93de06c --- /dev/null +++ b/src/test/modules/CDPOS/tokenURI.t.sol @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: Unlicensed +pragma solidity 0.8.15; + +import {CDPOSTest} from "./CDPOSTest.sol"; + +contract TokenURITest is CDPOSTest { + // when the position does not exist + // [ ] it reverts + // [ ] the value is Base64 encoded + // [ ] the name value is the name of the contract + // [ ] the symbol value is the symbol of the contract + // [ ] the position ID attribute is the position ID + // [ ] the convertible deposit token attribute is the convertible deposit token address + // [ ] the expiry attribute is the expiry in YYYY-MM-DD format + // [ ] the remaining deposit attribute is the remaining deposit to 2 decimal places + // [ ] the conversion price attribute is the conversion price to 2 decimal places + // [ ] the image value is set +} diff --git a/src/test/modules/CDPOS/transferFrom.t.sol b/src/test/modules/CDPOS/transferFrom.t.sol new file mode 100644 index 00000000..3e5ab12b --- /dev/null +++ b/src/test/modules/CDPOS/transferFrom.t.sol @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: Unlicensed +pragma solidity 0.8.15; + +import {CDPOSTest} from "./CDPOSTest.sol"; + +contract TransferFromTest is CDPOSTest { + // when the position does not exist + // [ ] it reverts + // when the caller is not the owner of the position + // [ ] it reverts + // when the caller is a permissioned address + // [ ] it reverts + // [ ] it transfers the ownership of the position to the to_ address + // [ ] it adds the position to the to_ address's list of positions + // [ ] it removes the position from the from_ address's list of positions +} diff --git a/src/test/modules/CDPOS/unwrap.t.sol b/src/test/modules/CDPOS/unwrap.t.sol new file mode 100644 index 00000000..892c6bff --- /dev/null +++ b/src/test/modules/CDPOS/unwrap.t.sol @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: Unlicensed +pragma solidity 0.8.15; + +import {CDPOSTest} from "./CDPOSTest.sol"; + +contract UnwrapCDPOSTest is CDPOSTest { + // when the position does not exist + // [ ] it reverts + // when the caller is not the owner of the position + // [ ] it reverts + // when the caller is a permissioned address + // [ ] it reverts + // when the position is not wrapped + // [ ] it reverts + // [ ] it burns the ERC721 token + // [ ] it emits a PositionUnwrapped event + // [ ] the position is marked as unwrapped + // [ ] the balance of the owner is decreased + // [ ] the position is listed as owned by the owner + // [ ] the owner's list of positions is updated +} diff --git a/src/test/modules/CDPOS/update.t.sol b/src/test/modules/CDPOS/update.t.sol new file mode 100644 index 00000000..646c8963 --- /dev/null +++ b/src/test/modules/CDPOS/update.t.sol @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: Unlicensed +pragma solidity 0.8.15; + +import {CDPOSTest} from "./CDPOSTest.sol"; + +contract UpdateCDPOSTest is CDPOSTest { + // when the position does not exist + // [ ] it reverts + // when the caller is not the owner of the position + // [ ] it reverts + // when the caller is a permissioned address + // [ ] it reverts + // when the amount is 0 + // [ ] it sets the remaining deposit to 0 + // [ ] it updates the remaining deposit + // [ ] it emits a PositionUpdated event +} diff --git a/src/test/modules/CDPOS/wrap.t.sol b/src/test/modules/CDPOS/wrap.t.sol new file mode 100644 index 00000000..f206e3b2 --- /dev/null +++ b/src/test/modules/CDPOS/wrap.t.sol @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: Unlicensed +pragma solidity 0.8.15; + +import {CDPOSTest} from "./CDPOSTest.sol"; + +contract WrapCDPOSTest is CDPOSTest { + // when the position does not exist + // [ ] it reverts + // when the caller is not the owner of the position + // [ ] it reverts + // when the caller is a permissioned address + // [ ] it reverts + // when the position is already wrapped + // [ ] it reverts + // [ ] it mints the ERC721 token + // [ ] it emits a PositionWrapped event + // [ ] the position is marked as wrapped + // [ ] the balance of the owner is increased + // [ ] the position is listed as owned by the owner + // [ ] the owner's list of positions is updated +} From 497ed1ca9a62b59e85cbf0b2717cfedb40a3d1f9 Mon Sep 17 00:00:00 2001 From: Jem <0x0xjem@gmail.com> Date: Wed, 18 Dec 2024 18:12:37 +0500 Subject: [PATCH 38/64] Add owner back to position struct --- src/modules/CDPOS/CDPOS.v1.sol | 2 ++ .../OlympusConvertibleDepositPositions.sol | 23 +++++++++++++++---- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/src/modules/CDPOS/CDPOS.v1.sol b/src/modules/CDPOS/CDPOS.v1.sol index cc70f977..14cf4131 100644 --- a/src/modules/CDPOS/CDPOS.v1.sol +++ b/src/modules/CDPOS/CDPOS.v1.sol @@ -12,12 +12,14 @@ abstract contract CDPOSv1 is Module, ERC721 { /// @notice Data structure for the terms of a convertible deposit /// + /// @param owner Address of the owner of the position /// @param convertibleDepositToken Address of the convertible deposit token /// @param remainingDeposit Amount of reserve tokens remaining to be converted /// @param conversionPrice Price of the reserve token in USD /// @param expiry Timestamp when the term expires /// @param wrapped Whether the term is wrapped struct Position { + address owner; address convertibleDepositToken; uint256 remainingDeposit; uint256 conversionPrice; diff --git a/src/modules/CDPOS/OlympusConvertibleDepositPositions.sol b/src/modules/CDPOS/OlympusConvertibleDepositPositions.sol index f99eefcd..8257aea1 100644 --- a/src/modules/CDPOS/OlympusConvertibleDepositPositions.sol +++ b/src/modules/CDPOS/OlympusConvertibleDepositPositions.sol @@ -4,7 +4,7 @@ pragma solidity 0.8.15; import {ERC721} from "solmate/tokens/ERC721.sol"; import {ERC20} from "solmate/tokens/ERC20.sol"; import {CDPOSv1} from "./CDPOS.v1.sol"; -import {Kernel, Module} from "src/Kernel.sol"; +import {Kernel, Module, Keycode, toKeycode} from "src/Kernel.sol"; import {Strings} from "@openzeppelin/contracts/utils/Strings.sol"; import {Base64} from "@openzeppelin/contracts/utils/Base64.sol"; import {Timestamp} from "src/libraries/Timestamp.sol"; @@ -26,6 +26,19 @@ contract OlympusConvertibleDepositPositions is CDPOSv1 { address kernel_ ) Module(Kernel(kernel_)) ERC721("Olympus Convertible Deposit Position", "OCDP") {} + // ========== MODULE FUNCTIONS ========== // + + /// @inheritdoc Module + function KEYCODE() public pure override returns (Keycode) { + return toKeycode("CDPOS"); + } + + /// @inheritdoc Module + function VERSION() public pure override returns (uint8 major, uint8 minor) { + major = 1; + minor = 0; + } + // ========== WRAPPING ========== // /// @inheritdoc CDPOSv1 @@ -85,8 +98,9 @@ contract OlympusConvertibleDepositPositions is CDPOSv1 { bool wrap_ ) internal returns (uint256 positionId) { // Create the position record - positionId = ++positionCount; + positionId = positionCount++; _positions[positionId] = Position({ + owner: owner_, convertibleDepositToken: convertibleDepositToken_, remainingDeposit: remainingDeposit_, conversionPrice: conversionPrice_, @@ -95,8 +109,9 @@ contract OlympusConvertibleDepositPositions is CDPOSv1 { }); // Update ERC721 storage - _ownerOf[positionId] = owner_; - _balanceOf[owner_]++; + // TODO remove this, only when wrapped + // _ownerOf[positionId] = owner_; + // _balanceOf[owner_]++; // Add the position ID to the user's list of positions _userPositions[owner_].push(positionId); From f293e5e41de385c774f673259f09f40a18c2d652 Mon Sep 17 00:00:00 2001 From: Jem <0x0xjem@gmail.com> Date: Wed, 18 Dec 2024 18:12:55 +0500 Subject: [PATCH 39/64] Implement tests for CDPOS.create() --- src/test/modules/CDPOS/CDPOSTest.sol | 56 +++- src/test/modules/CDPOS/create.t.sol | 374 +++++++++++++++++++++++++-- 2 files changed, 411 insertions(+), 19 deletions(-) diff --git a/src/test/modules/CDPOS/CDPOSTest.sol b/src/test/modules/CDPOS/CDPOSTest.sol index f4339b41..727f783a 100644 --- a/src/test/modules/CDPOS/CDPOSTest.sol +++ b/src/test/modules/CDPOS/CDPOSTest.sol @@ -4,6 +4,7 @@ pragma solidity 0.8.15; import {Test} from "forge-std/Test.sol"; import {IERC721Receiver} from "@openzeppelin/contracts/token/ERC721/IERC721Receiver.sol"; import {ModuleTestFixtureGenerator} from "src/test/lib/ModuleTestFixtureGenerator.sol"; +import {MockERC20} from "forge-std/mocks/MockERC20.sol"; import {Kernel, Actions} from "src/Kernel.sol"; import {OlympusConvertibleDepositPositions} from "src/modules/CDPOS/OlympusConvertibleDepositPositions.sol"; @@ -11,15 +12,28 @@ import {OlympusConvertibleDepositPositions} from "src/modules/CDPOS/OlympusConve abstract contract CDPOSTest is Test, IERC721Receiver { using ModuleTestFixtureGenerator for OlympusConvertibleDepositPositions; + uint256 public constant REMAINING_DEPOSIT = 25e18; + uint256 public constant CONVERSION_PRICE = 2e18; + uint48 public constant EXPIRY_DELAY = 1 days; + uint48 public constant INITIAL_BLOCK = 100000000; + Kernel public kernel; OlympusConvertibleDepositPositions public CDPOS; address public godmode; + address public convertibleDepositToken; uint256[] public positions; function setUp() public { + vm.warp(INITIAL_BLOCK); + kernel = new Kernel(); - CDPOS = OlympusConvertibleDepositPositions(address(kernel)); + CDPOS = new OlympusConvertibleDepositPositions(address(kernel)); + + // Set up the convertible deposit token + MockERC20 mockERC20 = new MockERC20(); + mockERC20.initialize("Convertible Deposit Token", "CDT", 18); + convertibleDepositToken = address(mockERC20); // Generate fixtures godmode = CDPOS.generateGodmodeFixture(type(OlympusConvertibleDepositPositions).name); @@ -39,4 +53,44 @@ abstract contract CDPOSTest is Test, IERC721Receiver { return this.onERC721Received.selector; } + + // ========== MODIFIERS ========== // + + modifier givenConvertibleDepositTokenDecimals(uint8 decimals_) { + // Create a new token with the given decimals + MockERC20 mockERC20 = new MockERC20(); + mockERC20.initialize("Convertible Deposit Token", "CDT", decimals_); + convertibleDepositToken = address(mockERC20); + _; + } + + function _createPosition( + address owner_, + uint256 remainingDeposit_, + uint256 conversionPrice_, + uint48 expiry_, + bool wrap_ + ) internal { + vm.prank(godmode); + CDPOS.create( + owner_, + convertibleDepositToken, + remainingDeposit_, + conversionPrice_, + expiry_, + wrap_ + ); + } + + modifier givenPositionCreated( + address owner_, + uint256 remainingDeposit_, + uint256 conversionPrice_, + uint48 expiry_, + bool wrap_ + ) { + // Create a new position + _createPosition(owner_, remainingDeposit_, conversionPrice_, expiry_, wrap_); + _; + } } diff --git a/src/test/modules/CDPOS/create.t.sol b/src/test/modules/CDPOS/create.t.sol index 3d97e66c..6dfeacdd 100644 --- a/src/test/modules/CDPOS/create.t.sol +++ b/src/test/modules/CDPOS/create.t.sol @@ -3,36 +3,374 @@ pragma solidity 0.8.15; import {CDPOSTest} from "./CDPOSTest.sol"; +import {CDPOSv1} from "src/modules/CDPOS/CDPOS.v1.sol"; +import {Module} from "src/Kernel.sol"; + contract CreateCDPOSTest is CDPOSTest { + event PositionCreated( + uint256 indexed positionId, + address indexed owner, + address indexed convertibleDepositToken, + uint256 remainingDeposit, + uint256 conversionPrice, + uint48 expiry, + bool wrapped + ); + // when the caller is not a permissioned address - // [ ] it reverts + // [X] it reverts // when the owner is the zero address - // [ ] it reverts + // [X] it reverts // when the convertible deposit token is the zero address - // [ ] it reverts + // [X] it reverts // when the remaining deposit is 0 - // [ ] it reverts + // [X] it reverts // when the conversion price is 0 - // [ ] it reverts + // [X] it reverts // when the expiry is in the past or now - // [ ] it reverts + // [X] it reverts // when multiple positions are created - // [ ] the position IDs are sequential - // [ ] the position IDs are unique - // [ ] the owner's list of positions is updated - // [ ] the owner's balance is increased + // [X] the position IDs are sequential + // [X] the position IDs are unique + // [X] the owner's list of positions is updated // when the expiry is in the future - // [ ] it sets the expiry + // [X] it sets the expiry // when the conversion would result in an overflow // [ ] it reverts // when the conversion would result in an underflow // [ ] it reverts // when the wrap flag is true - // [ ] it mints the ERC721 token - // [ ] it marks the position as wrapped - // [ ] it emits a PositionCreated event - // [ ] the position is marked as unwrapped - // [ ] the position is listed as owned by the owner - // [ ] the owner's list of positions is updated - // [ ] the balance of the owner is increased + // [X] it mints the ERC721 token + // [X] it marks the position as wrapped + // [X] the position is listed as owned by the owner + // [X] the ERC721 position is listed as owned by the owner + // [X] the ERC721 balance of the owner is increased + // [X] it emits a PositionCreated event + // [X] the position is marked as unwrapped + // [X] the position is listed as owned by the owner + // [X] the owner's list of positions is updated + // [X] the ERC721 position is not listed as owned by the owner + // [X] the ERC721 balance of the owner is not increased + + function test_callerNotPermissioned_reverts() public { + vm.expectRevert( + abi.encodeWithSelector(Module.Module_PolicyNotPermitted.selector, address(this)) + ); + + vm.prank(address(this)); + CDPOS.create( + address(this), + convertibleDepositToken, + REMAINING_DEPOSIT, + CONVERSION_PRICE, + EXPIRY_DELAY, + false + ); + } + + function test_ownerIsZeroAddress_reverts() public { + // Expect revert + vm.expectRevert(abi.encodeWithSelector(CDPOSv1.CDPOS_InvalidParams.selector, "owner")); + + // Call function + _createPosition(address(0), REMAINING_DEPOSIT, CONVERSION_PRICE, EXPIRY_DELAY, false); + } + + function test_convertibleDepositTokenIsZeroAddress_reverts() public { + // Expect revert + vm.expectRevert( + abi.encodeWithSelector( + CDPOSv1.CDPOS_InvalidParams.selector, + "convertible deposit token" + ) + ); + + // Call function + vm.prank(godmode); + CDPOS.create( + address(this), + address(0), + REMAINING_DEPOSIT, + CONVERSION_PRICE, + EXPIRY_DELAY, + false + ); + } + + function test_remainingDepositIsZero_reverts() public { + // Expect revert + vm.expectRevert(abi.encodeWithSelector(CDPOSv1.CDPOS_InvalidParams.selector, "deposit")); + + // Call function + _createPosition(address(this), 0, CONVERSION_PRICE, EXPIRY_DELAY, false); + } + + function test_conversionPriceIsZero_reverts() public { + // Expect revert + vm.expectRevert( + abi.encodeWithSelector(CDPOSv1.CDPOS_InvalidParams.selector, "conversion price") + ); + + // Call function + _createPosition(address(this), REMAINING_DEPOSIT, 0, EXPIRY_DELAY, false); + } + + function test_expiryIsInPastOrNow_reverts(uint48 expiry_) public { + uint48 expiry = uint48(bound(expiry_, 0, block.timestamp)); + + // Expect revert + vm.expectRevert(abi.encodeWithSelector(CDPOSv1.CDPOS_InvalidParams.selector, "expiry")); + + // Call function + _createPosition(address(this), REMAINING_DEPOSIT, CONVERSION_PRICE, expiry, false); + } + + function test_singlePosition() public { + // Expect event + vm.expectEmit(true, true, true, true); + emit PositionCreated( + 0, + address(this), + convertibleDepositToken, + REMAINING_DEPOSIT, + CONVERSION_PRICE, + uint48(block.timestamp + EXPIRY_DELAY), + false + ); + + // Call function + _createPosition( + address(this), + REMAINING_DEPOSIT, + CONVERSION_PRICE, + uint48(block.timestamp + EXPIRY_DELAY), + false + ); + + // Assert that this contract did not receive the position ERC721 + assertEq(positions.length, 0, "positions.length"); + + // Assert that the ERC721 balances were not updated + vm.expectRevert("NOT_MINTED"); + CDPOS.ownerOf(0); + assertEq(CDPOS.balanceOf(address(this)), 0, "balanceOf(address(this))"); + + // Assert that the position is correct + CDPOSv1.Position memory position = CDPOS.getPosition(0); + assertEq(position.owner, address(this), "position.owner"); + assertEq( + position.convertibleDepositToken, + convertibleDepositToken, + "position.convertibleDepositToken" + ); + assertEq(position.remainingDeposit, REMAINING_DEPOSIT, "position.remainingDeposit"); + assertEq(position.conversionPrice, CONVERSION_PRICE, "position.conversionPrice"); + assertEq(position.expiry, block.timestamp + EXPIRY_DELAY, "position.expiry"); + assertEq(position.wrapped, false, "position.wrapped"); + + // Assert that the owner's list of positions is updated + uint256[] memory ownerPositions = CDPOS.getUserPositionIds(address(this)); + assertEq(ownerPositions.length, 1, "ownerPositions.length"); + assertEq(ownerPositions[0], 0, "ownerPositions[0]"); + } + + function test_singlePosition_whenWrapped() public { + // Expect event + vm.expectEmit(true, true, true, true); + emit PositionCreated( + 0, + address(this), + convertibleDepositToken, + REMAINING_DEPOSIT, + CONVERSION_PRICE, + uint48(block.timestamp + EXPIRY_DELAY), + true + ); + + // Call function + _createPosition( + address(this), + REMAINING_DEPOSIT, + CONVERSION_PRICE, + uint48(block.timestamp + EXPIRY_DELAY), + true + ); + + // Assert that this contract received the position ERC721 + assertEq(positions.length, 1, "positions.length"); + assertEq(positions[0], 0, "positions[0]"); + + // Assert that the ERC721 balances were updated + assertEq(CDPOS.ownerOf(0), address(this), "ownerOf(0)"); + assertEq(CDPOS.balanceOf(address(this)), 1, "balanceOf(address(this))"); + + // Assert that the position is correct + CDPOSv1.Position memory position = CDPOS.getPosition(0); + assertEq(position.owner, address(this), "position.owner"); + assertEq( + position.convertibleDepositToken, + convertibleDepositToken, + "position.convertibleDepositToken" + ); + assertEq(position.remainingDeposit, REMAINING_DEPOSIT, "position.remainingDeposit"); + assertEq(position.conversionPrice, CONVERSION_PRICE, "position.conversionPrice"); + assertEq(position.expiry, block.timestamp + EXPIRY_DELAY, "position.expiry"); + assertEq(position.wrapped, true, "position.wrapped"); + + // Assert that the owner's list of positions is updated + uint256[] memory ownerPositions = CDPOS.getUserPositionIds(address(this)); + assertEq(ownerPositions.length, 1, "ownerPositions.length"); + assertEq(ownerPositions[0], 0, "ownerPositions[0]"); + } + + function test_multiplePositions_singleOwner() public { + // Create 10 positions + for (uint256 i = 0; i < 10; i++) { + _createPosition( + address(this), + REMAINING_DEPOSIT, + CONVERSION_PRICE, + uint48(block.timestamp + EXPIRY_DELAY), + false + ); + } + + // Assert that the position count is correct + assertEq(CDPOS.positionCount(), 10, "positionCount"); + + // Assert that the owner has sequential position IDs + for (uint256 i = 0; i < 10; i++) { + CDPOSv1.Position memory position = CDPOS.getPosition(i); + assertEq(position.owner, address(this), "position.owner"); + + // Assert that the ERC721 position is not updated + vm.expectRevert("NOT_MINTED"); + CDPOS.ownerOf(i); + } + + // Assert that the ERC721 balance of the owner is not updated + assertEq(CDPOS.balanceOf(address(this)), 0, "balanceOf(address(this))"); + + // Assert that the owner's positions list is correct + uint256[] memory ownerPositions = CDPOS.getUserPositionIds(address(this)); + assertEq(ownerPositions.length, 10, "ownerPositions.length"); + for (uint256 i = 0; i < 10; i++) { + assertEq(ownerPositions[i], i, "ownerPositions[i]"); + } + } + + function test_multiplePositions_multipleOwners() public { + address owner1 = address(0x1); + address owner2 = address(0x2); + + // Create 5 positions for owner1 + for (uint256 i = 0; i < 5; i++) { + _createPosition( + owner1, + REMAINING_DEPOSIT, + CONVERSION_PRICE, + uint48(block.timestamp + EXPIRY_DELAY), + false + ); + } + + // Create 5 positions for owner2 + for (uint256 i = 0; i < 5; i++) { + _createPosition( + owner2, + REMAINING_DEPOSIT, + CONVERSION_PRICE, + uint48(block.timestamp + EXPIRY_DELAY), + false + ); + } + + // Assert that the position count is correct + assertEq(CDPOS.positionCount(), 10, "positionCount"); + + // Assert that the owner1's positions are correct + for (uint256 i = 0; i < 5; i++) { + CDPOSv1.Position memory position = CDPOS.getPosition(i); + assertEq(position.owner, owner1, "position.owner"); + } + + // Assert that the owner2's positions are correct + for (uint256 i = 5; i < 10; i++) { + CDPOSv1.Position memory position = CDPOS.getPosition(i); + assertEq(position.owner, owner2, "position.owner"); + } + + // Assert that the ERC721 balances of the owners are correct + assertEq(CDPOS.balanceOf(owner1), 0, "balanceOf(owner1)"); + assertEq(CDPOS.balanceOf(owner2), 0, "balanceOf(owner2)"); + + // Assert that the owner1's positions list is correct + uint256[] memory owner1Positions = CDPOS.getUserPositionIds(owner1); + assertEq(owner1Positions.length, 5, "owner1Positions.length"); + for (uint256 i = 0; i < 5; i++) { + assertEq(owner1Positions[i], i, "owner1Positions[i]"); + } + + // Assert that the owner2's positions list is correct + uint256[] memory owner2Positions = CDPOS.getUserPositionIds(owner2); + assertEq(owner2Positions.length, 5, "owner2Positions.length"); + for (uint256 i = 0; i < 5; i++) { + assertEq(owner2Positions[i], i + 5, "owner2Positions[i]"); + } + } + + function test_expiryInFuture(uint48 expiry_) public { + uint48 expiry = uint48(bound(expiry_, block.timestamp + 1, type(uint48).max)); + + // Expect event + vm.expectEmit(true, true, true, true); + emit PositionCreated( + 0, + address(this), + convertibleDepositToken, + REMAINING_DEPOSIT, + CONVERSION_PRICE, + expiry, + false + ); + + // Call function + _createPosition(address(this), REMAINING_DEPOSIT, CONVERSION_PRICE, expiry, false); + + // Assert that the position is correct + CDPOSv1.Position memory position = CDPOS.getPosition(0); + assertEq(position.expiry, expiry, "position.expiry"); + } + + function test_conversionOverflow_reverts() public { + // Expect revert + vm.expectRevert( + abi.encodeWithSelector(CDPOSv1.CDPOS_InvalidParams.selector, "conversion overflow") + ); + + // Call function + _createPosition( + address(this), + type(uint256).max, + CONVERSION_PRICE, + uint48(block.timestamp + EXPIRY_DELAY), + false + ); + } + + function test_conversionUnderflow_reverts() public { + // Expect revert + vm.expectRevert( + abi.encodeWithSelector(CDPOSv1.CDPOS_InvalidParams.selector, "conversion underflow") + ); + + // Call function + _createPosition( + address(this), + REMAINING_DEPOSIT, + type(uint256).max, + uint48(block.timestamp + EXPIRY_DELAY), + false + ); + } } From 6571d51b8cff8ae52c12d85c4515fb102b99b464 Mon Sep 17 00:00:00 2001 From: Jem <0x0xjem@gmail.com> Date: Thu, 19 Dec 2024 12:20:08 +0500 Subject: [PATCH 40/64] CDPOS tests for create/update/split --- .../OlympusConvertibleDepositPositions.sol | 2 +- src/test/modules/CDPOS/CDPOSTest.sol | 69 +++++ src/test/modules/CDPOS/create.t.sol | 41 +-- src/test/modules/CDPOS/split.t.sol | 259 +++++++++++++++++- src/test/modules/CDPOS/update.t.sol | 86 +++++- 5 files changed, 410 insertions(+), 47 deletions(-) diff --git a/src/modules/CDPOS/OlympusConvertibleDepositPositions.sol b/src/modules/CDPOS/OlympusConvertibleDepositPositions.sol index 8257aea1..a76e7cba 100644 --- a/src/modules/CDPOS/OlympusConvertibleDepositPositions.sol +++ b/src/modules/CDPOS/OlympusConvertibleDepositPositions.sol @@ -470,7 +470,7 @@ contract OlympusConvertibleDepositPositions is CDPOSv1 { modifier onlyPositionOwner(uint256 positionId_) { // This validates that the caller is the owner of the position - if (_ownerOf[positionId_] != msg.sender) revert CDPOS_NotOwner(positionId_); + if (_getPosition(positionId_).owner != msg.sender) revert CDPOS_NotOwner(positionId_); _; } } diff --git a/src/test/modules/CDPOS/CDPOSTest.sol b/src/test/modules/CDPOS/CDPOSTest.sol index 727f783a..73088851 100644 --- a/src/test/modules/CDPOS/CDPOSTest.sol +++ b/src/test/modules/CDPOS/CDPOSTest.sol @@ -8,6 +8,7 @@ import {MockERC20} from "forge-std/mocks/MockERC20.sol"; import {Kernel, Actions} from "src/Kernel.sol"; import {OlympusConvertibleDepositPositions} from "src/modules/CDPOS/OlympusConvertibleDepositPositions.sol"; +import {CDPOSv1} from "src/modules/CDPOS/CDPOS.v1.sol"; abstract contract CDPOSTest is Test, IERC721Receiver { using ModuleTestFixtureGenerator for OlympusConvertibleDepositPositions; @@ -16,6 +17,7 @@ abstract contract CDPOSTest is Test, IERC721Receiver { uint256 public constant CONVERSION_PRICE = 2e18; uint48 public constant EXPIRY_DELAY = 1 days; uint48 public constant INITIAL_BLOCK = 100000000; + uint48 public constant EXPIRY = uint48(INITIAL_BLOCK + EXPIRY_DELAY); Kernel public kernel; OlympusConvertibleDepositPositions public CDPOS; @@ -54,6 +56,57 @@ abstract contract CDPOSTest is Test, IERC721Receiver { return this.onERC721Received.selector; } + // ========== ASSERTIONS ========== // + + function _assertPosition( + uint256 positionId_, + address owner_, + uint256 remainingDeposit_, + uint256 conversionPrice_, + uint48 expiry_, + bool wrap_ + ) internal { + CDPOSv1.Position memory position = CDPOS.getPosition(positionId_); + assertEq(position.owner, owner_, "position.owner"); + assertEq( + position.convertibleDepositToken, + convertibleDepositToken, + "position.convertibleDepositToken" + ); + assertEq(position.remainingDeposit, remainingDeposit_, "position.remainingDeposit"); + assertEq(position.conversionPrice, conversionPrice_, "position.conversionPrice"); + assertEq(position.expiry, expiry_, "position.expiry"); + assertEq(position.wrapped, wrap_, "position.wrapped"); + } + + function _assertUserPosition(address owner_, uint256 positionId_, uint256 total_) internal { + uint256[] memory userPositions = CDPOS.getUserPositionIds(owner_); + assertEq(userPositions.length, total_, "userPositions.length"); + + // Iterate over the positions and assert that the positionId_ is in the array + bool found = false; + for (uint256 i = 0; i < userPositions.length; i++) { + if (userPositions[i] == positionId_) { + found = true; + break; + } + } + assertTrue(found, "positionId_ not found in getUserPositionIds"); + } + + function _assertERC721Owner(uint256 positionId_, address owner_, bool minted_) internal { + if (minted_) { + assertEq(CDPOS.ownerOf(positionId_), owner_, "ownerOf"); + } else { + vm.expectRevert("NOT_MINTED"); + CDPOS.ownerOf(positionId_); + } + } + + function _assertERC721Balance(address owner_, uint256 balance_) internal { + assertEq(CDPOS.balanceOf(owner_), balance_, "balanceOf"); + } + // ========== MODIFIERS ========== // modifier givenConvertibleDepositTokenDecimals(uint8 decimals_) { @@ -93,4 +146,20 @@ abstract contract CDPOSTest is Test, IERC721Receiver { _createPosition(owner_, remainingDeposit_, conversionPrice_, expiry_, wrap_); _; } + + function _updatePosition(uint256 positionId_, uint256 remainingDeposit_) internal { + vm.prank(godmode); + CDPOS.update(positionId_, remainingDeposit_); + } + + function _splitPosition( + address owner_, + uint256 positionId_, + uint256 amount_, + address to_, + bool wrap_ + ) internal { + vm.prank(owner_); + CDPOS.split(positionId_, amount_, to_, wrap_); + } } diff --git a/src/test/modules/CDPOS/create.t.sol b/src/test/modules/CDPOS/create.t.sol index 6dfeacdd..4280ff11 100644 --- a/src/test/modules/CDPOS/create.t.sol +++ b/src/test/modules/CDPOS/create.t.sol @@ -36,9 +36,9 @@ contract CreateCDPOSTest is CDPOSTest { // when the expiry is in the future // [X] it sets the expiry // when the conversion would result in an overflow - // [ ] it reverts + // [X] it reverts // when the conversion would result in an underflow - // [ ] it reverts + // [X] it reverts // when the wrap flag is true // [X] it mints the ERC721 token // [X] it marks the position as wrapped @@ -151,9 +151,8 @@ contract CreateCDPOSTest is CDPOSTest { assertEq(positions.length, 0, "positions.length"); // Assert that the ERC721 balances were not updated - vm.expectRevert("NOT_MINTED"); - CDPOS.ownerOf(0); - assertEq(CDPOS.balanceOf(address(this)), 0, "balanceOf(address(this))"); + _assertERC721Balance(address(this), 0); + _assertERC721Owner(0, address(this), false); // Assert that the position is correct CDPOSv1.Position memory position = CDPOS.getPosition(0); @@ -201,26 +200,14 @@ contract CreateCDPOSTest is CDPOSTest { assertEq(positions[0], 0, "positions[0]"); // Assert that the ERC721 balances were updated - assertEq(CDPOS.ownerOf(0), address(this), "ownerOf(0)"); - assertEq(CDPOS.balanceOf(address(this)), 1, "balanceOf(address(this))"); + _assertERC721Balance(address(this), 1); + _assertERC721Owner(0, address(this), true); // Assert that the position is correct - CDPOSv1.Position memory position = CDPOS.getPosition(0); - assertEq(position.owner, address(this), "position.owner"); - assertEq( - position.convertibleDepositToken, - convertibleDepositToken, - "position.convertibleDepositToken" - ); - assertEq(position.remainingDeposit, REMAINING_DEPOSIT, "position.remainingDeposit"); - assertEq(position.conversionPrice, CONVERSION_PRICE, "position.conversionPrice"); - assertEq(position.expiry, block.timestamp + EXPIRY_DELAY, "position.expiry"); - assertEq(position.wrapped, true, "position.wrapped"); + _assertPosition(0, address(this), REMAINING_DEPOSIT, CONVERSION_PRICE, EXPIRY, true); // Assert that the owner's list of positions is updated - uint256[] memory ownerPositions = CDPOS.getUserPositionIds(address(this)); - assertEq(ownerPositions.length, 1, "ownerPositions.length"); - assertEq(ownerPositions[0], 0, "ownerPositions[0]"); + _assertUserPosition(address(this), 0, 1); } function test_multiplePositions_singleOwner() public { @@ -244,12 +231,11 @@ contract CreateCDPOSTest is CDPOSTest { assertEq(position.owner, address(this), "position.owner"); // Assert that the ERC721 position is not updated - vm.expectRevert("NOT_MINTED"); - CDPOS.ownerOf(i); + _assertERC721Owner(i, address(this), false); } // Assert that the ERC721 balance of the owner is not updated - assertEq(CDPOS.balanceOf(address(this)), 0, "balanceOf(address(this))"); + _assertERC721Balance(address(this), 0); // Assert that the owner's positions list is correct uint256[] memory ownerPositions = CDPOS.getUserPositionIds(address(this)); @@ -301,8 +287,8 @@ contract CreateCDPOSTest is CDPOSTest { } // Assert that the ERC721 balances of the owners are correct - assertEq(CDPOS.balanceOf(owner1), 0, "balanceOf(owner1)"); - assertEq(CDPOS.balanceOf(owner2), 0, "balanceOf(owner2)"); + _assertERC721Balance(owner1, 0); + _assertERC721Balance(owner2, 0); // Assert that the owner1's positions list is correct uint256[] memory owner1Positions = CDPOS.getUserPositionIds(owner1); @@ -338,8 +324,7 @@ contract CreateCDPOSTest is CDPOSTest { _createPosition(address(this), REMAINING_DEPOSIT, CONVERSION_PRICE, expiry, false); // Assert that the position is correct - CDPOSv1.Position memory position = CDPOS.getPosition(0); - assertEq(position.expiry, expiry, "position.expiry"); + _assertPosition(0, address(this), REMAINING_DEPOSIT, CONVERSION_PRICE, expiry, false); } function test_conversionOverflow_reverts() public { diff --git a/src/test/modules/CDPOS/split.t.sol b/src/test/modules/CDPOS/split.t.sol index 7d548e76..81470d52 100644 --- a/src/test/modules/CDPOS/split.t.sol +++ b/src/test/modules/CDPOS/split.t.sol @@ -2,23 +2,262 @@ pragma solidity 0.8.15; import {CDPOSTest} from "./CDPOSTest.sol"; +import {CDPOSv1} from "src/modules/CDPOS/CDPOS.v1.sol"; +import {Module} from "src/Kernel.sol"; contract SplitCDPOSTest is CDPOSTest { + event PositionSplit( + uint256 indexed positionId, + uint256 indexed newPositionId, + address indexed convertibleDepositToken, + uint256 amount, + address to, + bool wrap + ); + // when the position does not exist - // [ ] it reverts + // [X] it reverts // when the caller is not the owner of the position - // [ ] it reverts + // [X] it reverts // when the caller is a permissioned address - // [ ] it reverts + // [X] it reverts // when the amount is 0 - // [ ] it reverts + // [X] it reverts // when the amount is greater than the remaining deposit - // [ ] it reverts + // [X] it reverts // when the to_ address is the zero address - // [ ] it reverts + // [X] it reverts // when wrap is true - // [ ] it wraps the new position - // [ ] it creates a new position with the new amount, new owner and the same expiry - // [ ] it updates the remaining deposit of the original position - // [ ] it emits a PositionSplit event + // [X] it wraps the new position + // given the existing position is wrapped + // [X] the new position is unwrapped + // when the to_ address is the same as the owner + // [X] it creates the new position + // [X] it creates a new position with the new amount, new owner and the same expiry + // [X] it updates the remaining deposit of the original position + // [X] it emits a PositionSplit event + + function test_invalidPositionId_reverts() public { + // Expect revert + vm.expectRevert(abi.encodeWithSelector(CDPOSv1.CDPOS_InvalidPositionId.selector, 0)); + + // Call function + _splitPosition(address(this), 0, 1e18, address(0x1), false); + } + + function test_callerIsNotOwner_reverts() + public + givenPositionCreated(address(this), REMAINING_DEPOSIT, CONVERSION_PRICE, EXPIRY, false) + { + // Expect revert + vm.expectRevert(abi.encodeWithSelector(CDPOSv1.CDPOS_NotOwner.selector, 0)); + + // Call function + _splitPosition(address(0x1), 0, REMAINING_DEPOSIT, address(0x1), false); + } + + function test_callerIsPermissioned_reverts() + public + givenPositionCreated(address(this), REMAINING_DEPOSIT, CONVERSION_PRICE, EXPIRY, false) + { + // Expect revert + vm.expectRevert(abi.encodeWithSelector(CDPOSv1.CDPOS_NotOwner.selector, 0)); + + // Call function + _splitPosition(godmode, 0, REMAINING_DEPOSIT, address(0x1), false); + } + + function test_amountIsZero_reverts() + public + givenPositionCreated(address(this), REMAINING_DEPOSIT, CONVERSION_PRICE, EXPIRY, false) + { + // Expect revert + vm.expectRevert(abi.encodeWithSelector(CDPOSv1.CDPOS_InvalidParams.selector, "amount")); + + // Call function + _splitPosition(address(this), 0, 0, address(0x1), false); + } + + function test_amountIsGreaterThanRemainingDeposit_reverts( + uint256 amount_ + ) + public + givenPositionCreated(address(this), REMAINING_DEPOSIT, CONVERSION_PRICE, EXPIRY, false) + { + uint256 amount = bound(amount_, REMAINING_DEPOSIT + 1, REMAINING_DEPOSIT + 2e18); + + // Expect revert + vm.expectRevert(abi.encodeWithSelector(CDPOSv1.CDPOS_InvalidParams.selector, "amount")); + + // Call function + _splitPosition(address(this), 0, amount, address(0x1), false); + } + + function test_recipientIsZeroAddress_reverts() + public + givenPositionCreated(address(this), REMAINING_DEPOSIT, CONVERSION_PRICE, EXPIRY, false) + { + // Expect revert + vm.expectRevert(abi.encodeWithSelector(CDPOSv1.CDPOS_InvalidParams.selector, "to")); + + // Call function + _splitPosition(address(this), 0, REMAINING_DEPOSIT, address(0), false); + } + + function test_success( + uint256 amount_ + ) + public + givenPositionCreated(address(this), REMAINING_DEPOSIT, CONVERSION_PRICE, EXPIRY, false) + { + uint256 amount = bound(amount_, 1, REMAINING_DEPOSIT); + + // Expect event + vm.expectEmit(true, true, true, true); + emit PositionSplit(0, 1, convertibleDepositToken, amount, address(0x1), false); + + // Call function + _splitPosition(address(this), 0, amount, address(0x1), false); + + // Assert old position + _assertPosition( + 0, + address(this), + REMAINING_DEPOSIT - amount, + CONVERSION_PRICE, + EXPIRY, + false + ); + + // Assert new position + _assertPosition(1, address(0x1), amount, CONVERSION_PRICE, EXPIRY, false); + + // ERC721 balances are not updated + _assertERC721Balance(address(this), 0); + _assertERC721Owner(0, address(this), false); + _assertERC721Balance(address(0x1), 0); + _assertERC721Owner(1, address(0x1), false); + + // Assert the ownership is updated + _assertUserPosition(address(this), 0, 1); + _assertUserPosition(address(0x1), 1, 1); + } + + function test_sameRecipient( + uint256 amount_ + ) + public + givenPositionCreated(address(this), REMAINING_DEPOSIT, CONVERSION_PRICE, EXPIRY, false) + { + uint256 amount = bound(amount_, 1, REMAINING_DEPOSIT); + + // Expect event + vm.expectEmit(true, true, true, true); + emit PositionSplit(0, 1, convertibleDepositToken, amount, address(this), false); + + // Call function + _splitPosition(address(this), 0, amount, address(this), false); + + // Assert old position + _assertPosition( + 0, + address(this), + REMAINING_DEPOSIT - amount, + CONVERSION_PRICE, + EXPIRY, + false + ); + + // Assert new position + _assertPosition(1, address(this), amount, CONVERSION_PRICE, EXPIRY, false); + + // ERC721 balances are not updated + _assertERC721Balance(address(this), 0); + _assertERC721Owner(0, address(this), false); + _assertERC721Owner(1, address(this), false); + + // Assert the ownership is updated + _assertUserPosition(address(this), 0, 2); + _assertUserPosition(address(this), 1, 2); + } + + function test_oldPositionIsWrapped( + uint256 amount_ + ) + public + givenPositionCreated(address(this), REMAINING_DEPOSIT, CONVERSION_PRICE, EXPIRY, true) + { + uint256 amount = bound(amount_, 1, REMAINING_DEPOSIT); + + // Expect event + vm.expectEmit(true, true, true, true); + emit PositionSplit(0, 1, convertibleDepositToken, amount, address(0x1), false); + + // Call function + _splitPosition(address(this), 0, amount, address(0x1), false); + + // Assert old position + _assertPosition( + 0, + address(this), + REMAINING_DEPOSIT - amount, + CONVERSION_PRICE, + EXPIRY, + true + ); + + // Assert new position + _assertPosition(1, address(0x1), amount, CONVERSION_PRICE, EXPIRY, false); + + // ERC721 balances are not updated + _assertERC721Balance(address(this), 1); + _assertERC721Owner(0, address(this), true); + _assertERC721Balance(address(0x1), 0); + _assertERC721Owner(1, address(0x1), false); + + // Assert the ownership is updated + _assertUserPosition(address(this), 0, 1); + _assertUserPosition(address(0x1), 1, 1); + } + + function test_newPositionIsWrapped( + uint256 amount_ + ) + public + givenPositionCreated(address(this), REMAINING_DEPOSIT, CONVERSION_PRICE, EXPIRY, false) + { + uint256 amount = bound(amount_, 1, REMAINING_DEPOSIT); + + // Expect event + vm.expectEmit(true, true, true, true); + emit PositionSplit(0, 1, convertibleDepositToken, amount, address(0x1), true); + + // Call function + _splitPosition(address(this), 0, amount, address(0x1), true); + + // Assert old position + _assertPosition( + 0, + address(this), + REMAINING_DEPOSIT - amount, + CONVERSION_PRICE, + EXPIRY, + false + ); + + // Assert new position + _assertPosition(1, address(0x1), amount, CONVERSION_PRICE, EXPIRY, true); + + // ERC721 balances for the old position are not updated + _assertERC721Balance(address(this), 0); + _assertERC721Owner(0, address(this), false); + + // ERC721 balances for the new position are updated + _assertERC721Balance(address(0x1), 1); + _assertERC721Owner(1, address(0x1), true); + + // Assert the ownership is updated + _assertUserPosition(address(this), 0, 1); + _assertUserPosition(address(0x1), 1, 1); + } } diff --git a/src/test/modules/CDPOS/update.t.sol b/src/test/modules/CDPOS/update.t.sol index 646c8963..616bf30d 100644 --- a/src/test/modules/CDPOS/update.t.sol +++ b/src/test/modules/CDPOS/update.t.sol @@ -3,15 +3,85 @@ pragma solidity 0.8.15; import {CDPOSTest} from "./CDPOSTest.sol"; +import {CDPOSv1} from "src/modules/CDPOS/CDPOS.v1.sol"; +import {Module} from "src/Kernel.sol"; + contract UpdateCDPOSTest is CDPOSTest { + event PositionUpdated(uint256 indexed positionId, uint256 remainingDeposit); + // when the position does not exist - // [ ] it reverts - // when the caller is not the owner of the position - // [ ] it reverts - // when the caller is a permissioned address - // [ ] it reverts + // [X] it reverts + // when the caller is not a permissioned address + // [X] it reverts + // when the caller is the owner of the position + // [X] it reverts // when the amount is 0 - // [ ] it sets the remaining deposit to 0 - // [ ] it updates the remaining deposit - // [ ] it emits a PositionUpdated event + // [X] it sets the remaining deposit to 0 + // [X] it updates the remaining deposit + // [X] it emits a PositionUpdated event + + function test_invalidPosition_reverts() public { + // Expect revert + vm.expectRevert(abi.encodeWithSelector(CDPOSv1.CDPOS_InvalidPositionId.selector, 0)); + + // Call function + _updatePosition(0, 1e18); + } + + function test_callerNotPermissioned_reverts() + public + givenPositionCreated(address(this), REMAINING_DEPOSIT, CONVERSION_PRICE, EXPIRY, false) + { + address owner1 = address(0x1); + + // Expect revert + vm.expectRevert(abi.encodeWithSelector(Module.Module_PolicyNotPermitted.selector, owner1)); + + // Call function + vm.prank(owner1); + CDPOS.update(0, 1e18); + } + + function test_callerIsOwner_reverts() + public + givenPositionCreated(address(this), REMAINING_DEPOSIT, CONVERSION_PRICE, EXPIRY, false) + { + // Expect revert + vm.expectRevert( + abi.encodeWithSelector(Module.Module_PolicyNotPermitted.selector, address(this)) + ); + + // Call function + CDPOS.update(0, 1e18); + } + + function test_amountIsZero() + public + givenPositionCreated(address(this), REMAINING_DEPOSIT, CONVERSION_PRICE, EXPIRY, false) + { + // Call function + _updatePosition(0, 0); + + // Assert + assertEq(CDPOS.getPosition(0).remainingDeposit, 0); + } + + function test_updatesRemainingDeposit( + uint256 remainingDeposit_ + ) + public + givenPositionCreated(address(this), REMAINING_DEPOSIT, CONVERSION_PRICE, EXPIRY, false) + { + uint256 remainingDeposit = bound(remainingDeposit_, 0, REMAINING_DEPOSIT); + + // Expect event + vm.expectEmit(true, true, true, true); + emit PositionUpdated(0, remainingDeposit); + + // Call function + _updatePosition(0, remainingDeposit); + + // Assert + _assertPosition(0, address(this), remainingDeposit, CONVERSION_PRICE, EXPIRY, false); + } } From 6c4fcacbdb73367427e2277847749fd1163837ae Mon Sep 17 00:00:00 2001 From: Jem <0x0xjem@gmail.com> Date: Thu, 19 Dec 2024 12:41:13 +0500 Subject: [PATCH 41/64] CDPOS tests for wrap/unwrap --- src/test/modules/CDPOS/CDPOSTest.sol | 43 +++++++++ src/test/modules/CDPOS/create.t.sol | 21 +---- src/test/modules/CDPOS/unwrap.t.sol | 135 +++++++++++++++++++++++++-- src/test/modules/CDPOS/wrap.t.sol | 122 ++++++++++++++++++++++-- 4 files changed, 284 insertions(+), 37 deletions(-) diff --git a/src/test/modules/CDPOS/CDPOSTest.sol b/src/test/modules/CDPOS/CDPOSTest.sol index 73088851..d8d60c31 100644 --- a/src/test/modules/CDPOS/CDPOSTest.sol +++ b/src/test/modules/CDPOS/CDPOSTest.sol @@ -107,6 +107,29 @@ abstract contract CDPOSTest is Test, IERC721Receiver { assertEq(CDPOS.balanceOf(owner_), balance_, "balanceOf"); } + function _assertERC721PositionReceived( + uint256 positionId_, + uint256 total_, + bool received_ + ) internal { + assertEq(positions.length, total_, "positions.length"); + + // Iterate over the positions and assert that the positionId_ is in the array + bool found = false; + for (uint256 i = 0; i < positions.length; i++) { + if (positions[i] == positionId_) { + found = true; + break; + } + } + + if (received_) { + assertTrue(found, "positionId_ not found in positions"); + } else { + assertFalse(found, "positionId_ found in positions"); + } + } + // ========== MODIFIERS ========== // modifier givenConvertibleDepositTokenDecimals(uint8 decimals_) { @@ -162,4 +185,24 @@ abstract contract CDPOSTest is Test, IERC721Receiver { vm.prank(owner_); CDPOS.split(positionId_, amount_, to_, wrap_); } + + function _wrapPosition(address owner_, uint256 positionId_) internal { + vm.prank(owner_); + CDPOS.wrap(positionId_); + } + + modifier givenPositionWrapped(address owner_, uint256 positionId_) { + _wrapPosition(owner_, positionId_); + _; + } + + function _unwrapPosition(address owner_, uint256 positionId_) internal { + vm.prank(owner_); + CDPOS.unwrap(positionId_); + } + + modifier givenPositionUnwrapped(address owner_, uint256 positionId_) { + _unwrapPosition(owner_, positionId_); + _; + } } diff --git a/src/test/modules/CDPOS/create.t.sol b/src/test/modules/CDPOS/create.t.sol index 4280ff11..0f6a329a 100644 --- a/src/test/modules/CDPOS/create.t.sol +++ b/src/test/modules/CDPOS/create.t.sol @@ -148,29 +148,17 @@ contract CreateCDPOSTest is CDPOSTest { ); // Assert that this contract did not receive the position ERC721 - assertEq(positions.length, 0, "positions.length"); + _assertERC721PositionReceived(0, 0, false); // Assert that the ERC721 balances were not updated _assertERC721Balance(address(this), 0); _assertERC721Owner(0, address(this), false); // Assert that the position is correct - CDPOSv1.Position memory position = CDPOS.getPosition(0); - assertEq(position.owner, address(this), "position.owner"); - assertEq( - position.convertibleDepositToken, - convertibleDepositToken, - "position.convertibleDepositToken" - ); - assertEq(position.remainingDeposit, REMAINING_DEPOSIT, "position.remainingDeposit"); - assertEq(position.conversionPrice, CONVERSION_PRICE, "position.conversionPrice"); - assertEq(position.expiry, block.timestamp + EXPIRY_DELAY, "position.expiry"); - assertEq(position.wrapped, false, "position.wrapped"); + _assertPosition(0, address(this), REMAINING_DEPOSIT, CONVERSION_PRICE, EXPIRY, false); // Assert that the owner's list of positions is updated - uint256[] memory ownerPositions = CDPOS.getUserPositionIds(address(this)); - assertEq(ownerPositions.length, 1, "ownerPositions.length"); - assertEq(ownerPositions[0], 0, "ownerPositions[0]"); + _assertUserPosition(address(this), 0, 1); } function test_singlePosition_whenWrapped() public { @@ -196,8 +184,7 @@ contract CreateCDPOSTest is CDPOSTest { ); // Assert that this contract received the position ERC721 - assertEq(positions.length, 1, "positions.length"); - assertEq(positions[0], 0, "positions[0]"); + _assertERC721PositionReceived(0, 1, true); // Assert that the ERC721 balances were updated _assertERC721Balance(address(this), 1); diff --git a/src/test/modules/CDPOS/unwrap.t.sol b/src/test/modules/CDPOS/unwrap.t.sol index 892c6bff..03c02552 100644 --- a/src/test/modules/CDPOS/unwrap.t.sol +++ b/src/test/modules/CDPOS/unwrap.t.sol @@ -3,19 +3,134 @@ pragma solidity 0.8.15; import {CDPOSTest} from "./CDPOSTest.sol"; +import {CDPOSv1} from "src/modules/CDPOS/CDPOS.v1.sol"; + contract UnwrapCDPOSTest is CDPOSTest { + event PositionUnwrapped(uint256 indexed positionId); + // when the position does not exist - // [ ] it reverts + // [X] it reverts // when the caller is not the owner of the position - // [ ] it reverts + // [X] it reverts // when the caller is a permissioned address - // [ ] it reverts + // [X] it reverts // when the position is not wrapped - // [ ] it reverts - // [ ] it burns the ERC721 token - // [ ] it emits a PositionUnwrapped event - // [ ] the position is marked as unwrapped - // [ ] the balance of the owner is decreased - // [ ] the position is listed as owned by the owner - // [ ] the owner's list of positions is updated + // [X] it reverts + // when the owner has multiple positions + // [X] the balance of the owner is decreased + // [X] the position is listed as not owned by the owner + // [X] the owner's list of positions is updated + // [X] it burns the ERC721 token + // [X] it emits a PositionUnwrapped event + // [X] the position is marked as unwrapped + // [X] the balance of the owner is decreased + // [X] the position is listed as owned by the owner + // [X] the owner's list of positions is updated + + function test_invalidPositionId_reverts() public { + // Expect revert + vm.expectRevert(abi.encodeWithSelector(CDPOSv1.CDPOS_InvalidPositionId.selector, 0)); + + // Call function + _unwrapPosition(godmode, 0); + } + + function test_callerIsNotOwner_reverts() + public + givenPositionCreated(address(this), REMAINING_DEPOSIT, CONVERSION_PRICE, EXPIRY, false) + { + // Expect revert + vm.expectRevert(abi.encodeWithSelector(CDPOSv1.CDPOS_NotOwner.selector, 0)); + + // Call function + _unwrapPosition(address(0x1), 0); + } + + function test_callerIsPermissionedAddress_reverts() + public + givenPositionCreated(address(this), REMAINING_DEPOSIT, CONVERSION_PRICE, EXPIRY, false) + { + // Expect revert + vm.expectRevert(abi.encodeWithSelector(CDPOSv1.CDPOS_NotOwner.selector, 0)); + + // Call function + _unwrapPosition(address(0x1), 0); + } + + function test_positionIsNotWrapped_reverts() + public + givenPositionCreated(address(this), REMAINING_DEPOSIT, CONVERSION_PRICE, EXPIRY, false) + { + // Expect revert + vm.expectRevert(abi.encodeWithSelector(CDPOSv1.CDPOS_NotWrapped.selector, 0)); + + // Call function + _unwrapPosition(address(this), 0); + } + + function test_success() + public + givenPositionCreated(address(this), REMAINING_DEPOSIT, CONVERSION_PRICE, EXPIRY, true) + { + // Expect event + vm.expectEmit(true, true, true, true); + emit PositionUnwrapped(0); + + // Call function + _unwrapPosition(address(this), 0); + + // Assert position is unwrapped + _assertPosition(0, address(this), REMAINING_DEPOSIT, CONVERSION_PRICE, EXPIRY, false); + + // Assert ERC721 balances are updated + _assertERC721Balance(address(this), 0); + _assertERC721Owner(0, address(this), false); + + // Assert owner's list of positions is updated + _assertUserPosition(address(this), 0, 1); + } + + function test_multiplePositions_unwrapFirst() + public + givenPositionCreated(address(this), REMAINING_DEPOSIT, CONVERSION_PRICE, EXPIRY, true) + givenPositionCreated(address(this), REMAINING_DEPOSIT, CONVERSION_PRICE, EXPIRY, true) + { + // Call function + _unwrapPosition(address(this), 0); + + // Assert position is unwrapped + _assertPosition(0, address(this), REMAINING_DEPOSIT, CONVERSION_PRICE, EXPIRY, false); + _assertPosition(1, address(this), REMAINING_DEPOSIT, CONVERSION_PRICE, EXPIRY, true); + + // Assert ERC721 balances are updated + _assertERC721Balance(address(this), 1); + _assertERC721Owner(0, address(this), false); + _assertERC721Owner(1, address(this), true); + + // Assert owner's list of positions is updated + _assertUserPosition(address(this), 0, 2); + _assertUserPosition(address(this), 1, 2); + } + + function test_multiplePositions_unwrapSecond() + public + givenPositionCreated(address(this), REMAINING_DEPOSIT, CONVERSION_PRICE, EXPIRY, true) + givenPositionCreated(address(this), REMAINING_DEPOSIT, CONVERSION_PRICE, EXPIRY, true) + { + // Call function + _unwrapPosition(address(this), 1); + + // Assert position is unwrapped + _assertPosition(0, address(this), REMAINING_DEPOSIT, CONVERSION_PRICE, EXPIRY, true); + _assertPosition(1, address(this), REMAINING_DEPOSIT, CONVERSION_PRICE, EXPIRY, false); + + // Assert ERC721 balances are updated + _assertERC721Balance(address(this), 1); + _assertERC721Owner(0, address(this), true); + _assertERC721Owner(1, address(this), false); + + // Assert owner's list of positions is updated + _assertUserPosition(address(this), 0, 2); + _assertUserPosition(address(this), 1, 2); + } } diff --git a/src/test/modules/CDPOS/wrap.t.sol b/src/test/modules/CDPOS/wrap.t.sol index f206e3b2..19b0aa76 100644 --- a/src/test/modules/CDPOS/wrap.t.sol +++ b/src/test/modules/CDPOS/wrap.t.sol @@ -3,19 +3,121 @@ pragma solidity 0.8.15; import {CDPOSTest} from "./CDPOSTest.sol"; +import {CDPOSv1} from "src/modules/CDPOS/CDPOS.v1.sol"; + contract WrapCDPOSTest is CDPOSTest { + event PositionWrapped(uint256 indexed positionId); + // when the position does not exist - // [ ] it reverts + // [X] it reverts // when the caller is not the owner of the position - // [ ] it reverts + // [X] it reverts // when the caller is a permissioned address - // [ ] it reverts + // [X] it reverts // when the position is already wrapped - // [ ] it reverts - // [ ] it mints the ERC721 token - // [ ] it emits a PositionWrapped event - // [ ] the position is marked as wrapped - // [ ] the balance of the owner is increased - // [ ] the position is listed as owned by the owner - // [ ] the owner's list of positions is updated + // [X] it reverts + // when the owner has an existing wrapped position + // [X] the balance of the owner is increased + // [X] the position is listed as owned by the owner + // [X] the owner's list of positions is updated + // [X] it mints the ERC721 token + // [X] it emits a PositionWrapped event + // [X] the position is marked as wrapped + // [X] the balance of the owner is increased + // [X] the position is listed as owned by the owner + // [X] the owner's list of positions is updated + + function test_invalidPositionId_reverts() public { + // Expect revert + vm.expectRevert(abi.encodeWithSelector(CDPOSv1.CDPOS_InvalidPositionId.selector, 0)); + + // Call function + _wrapPosition(address(this), 0); + } + + function test_callerIsNotOwner_reverts() + public + givenPositionCreated(address(this), REMAINING_DEPOSIT, CONVERSION_PRICE, EXPIRY, false) + { + // Expect revert + vm.expectRevert(abi.encodeWithSelector(CDPOSv1.CDPOS_NotOwner.selector, 0)); + + // Call function + _wrapPosition(address(0x1), 0); + } + + function test_callerIsPermissionedAddress_reverts() + public + givenPositionCreated(address(this), REMAINING_DEPOSIT, CONVERSION_PRICE, EXPIRY, false) + { + // Expect revert + vm.expectRevert(abi.encodeWithSelector(CDPOSv1.CDPOS_NotOwner.selector, 0)); + + // Call function + _wrapPosition(godmode, 0); + } + + function test_positionIsAlreadyWrapped_reverts() + public + givenPositionCreated(address(this), REMAINING_DEPOSIT, CONVERSION_PRICE, EXPIRY, true) + { + // Expect revert + vm.expectRevert(abi.encodeWithSelector(CDPOSv1.CDPOS_AlreadyWrapped.selector, 0)); + + // Call function + _wrapPosition(address(this), 0); + } + + function test_success() + public + givenPositionCreated(address(this), REMAINING_DEPOSIT, CONVERSION_PRICE, EXPIRY, false) + { + // Expect event + vm.expectEmit(true, true, true, true); + emit PositionWrapped(0); + + // Call function + _wrapPosition(address(this), 0); + + // Assert position is updated + _assertPosition(0, address(this), REMAINING_DEPOSIT, CONVERSION_PRICE, EXPIRY, true); + + // Assert ERC721 token is minted + _assertERC721PositionReceived(0, 1, true); + + // Assert ERC721 balances are updated + _assertERC721Balance(address(this), 1); + _assertERC721Owner(0, address(this), true); + + // Assert owner's list of positions is updated + _assertUserPosition(address(this), 1, 1); + } + + function test_multiplePositions() + public + givenPositionCreated(address(this), REMAINING_DEPOSIT, CONVERSION_PRICE, EXPIRY, true) + givenPositionCreated(address(this), REMAINING_DEPOSIT, CONVERSION_PRICE, EXPIRY, false) + { + // Expect event + vm.expectEmit(true, true, true, true); + emit PositionWrapped(1); + + // Call function + _wrapPosition(address(this), 1); + + // Assert position is updated + _assertPosition(1, address(this), REMAINING_DEPOSIT, CONVERSION_PRICE, EXPIRY, true); + + // Assert ERC721 token is minted + _assertERC721PositionReceived(1, 2, true); + + // Assert ERC721 balances are updated + _assertERC721Balance(address(this), 2); + _assertERC721Owner(0, address(this), true); + _assertERC721Owner(1, address(this), true); + + // Assert owner's list of positions is updated + _assertUserPosition(address(this), 1, 2); + _assertUserPosition(address(this), 2, 2); + } } From 95ad4be37c95e3a5835c86eb3507d79d5b5c4364 Mon Sep 17 00:00:00 2001 From: Jem <0x0xjem@gmail.com> Date: Thu, 19 Dec 2024 13:25:24 +0500 Subject: [PATCH 42/64] Clarify decimal scale in docs, tests for previewConvert --- .../OlympusConvertibleDepositPositions.sol | 15 +- src/policies/CDAuctioneer.sol | 33 ++-- .../IConvertibleDepositAuctioneer.sol | 10 +- .../IConvertibleDepositFacility.sol | 2 +- src/test/modules/CDPOS/CDPOSTest.sol | 3 +- src/test/modules/CDPOS/previewConvert.t.sol | 184 +++++++++++++++++- .../modules/CDPOS/setDisplayDecimals.t.sol | 2 +- src/test/modules/CDPOS/tokenURI.t.sol | 2 +- src/test/modules/CDPOS/transferFrom.t.sol | 2 +- 9 files changed, 223 insertions(+), 30 deletions(-) diff --git a/src/modules/CDPOS/OlympusConvertibleDepositPositions.sol b/src/modules/CDPOS/OlympusConvertibleDepositPositions.sol index a76e7cba..8fa96e36 100644 --- a/src/modules/CDPOS/OlympusConvertibleDepositPositions.sol +++ b/src/modules/CDPOS/OlympusConvertibleDepositPositions.sol @@ -441,7 +441,10 @@ contract OlympusConvertibleDepositPositions is CDPOSv1 { uint256 amount_, uint256 conversionPrice_ ) internal pure returns (uint256) { - return (amount_ * DECIMALS) / conversionPrice_; // TODO check decimals, rounding + // amount_ and conversionPrice_ are in the same decimals and cancel each other out + // The output needs to be in OHM, so we multiply by 1e9 + // This also deliberately rounds down + return (amount_ * 1e9) / conversionPrice_; } /// @inheritdoc CDPOSv1 @@ -449,7 +452,15 @@ contract OlympusConvertibleDepositPositions is CDPOSv1 { uint256 positionId_, uint256 amount_ ) public view virtual override onlyValidPosition(positionId_) returns (uint256) { - return _previewConvert(amount_, _getPosition(positionId_).conversionPrice); + Position memory position = _getPosition(positionId_); + + // If expired, conversion output is 0 + if (position.expiry <= block.timestamp) return 0; + + // If the amount is greater than the remaining deposit, revert + if (amount_ > position.remainingDeposit) revert CDPOS_InvalidParams("amount"); + + return _previewConvert(amount_, position.conversionPrice); } // ========== ADMIN FUNCTIONS ========== // diff --git a/src/policies/CDAuctioneer.sol b/src/policies/CDAuctioneer.sol index 8a475aeb..e4a6540c 100644 --- a/src/policies/CDAuctioneer.sol +++ b/src/policies/CDAuctioneer.sol @@ -23,6 +23,7 @@ contract CDAuctioneer is IConvertibleDepositAuctioneer, Policy, RolesConsumer { // TODO set decimals, make internal? uint256 public decimals; + uint8 internal constant _ohmDecimals = 9; CDFacility public cdFacility; @@ -56,7 +57,7 @@ contract CDAuctioneer is IConvertibleDepositAuctioneer, Policy, RolesConsumer { // ========== AUCTION ========== // /// @inheritdoc IConvertibleDepositAuctioneer - function bid(uint256 deposit) external override returns (uint256 convertible) { + function bid(uint256 deposit) external override returns (uint256 ohmOut) { // Update state currentTick = getCurrentTick(); state.lastUpdate = block.timestamp; @@ -64,18 +65,20 @@ contract CDAuctioneer is IConvertibleDepositAuctioneer, Policy, RolesConsumer { // Get bid results uint256 currentTickCapacity; uint256 currentTickPrice; - (currentTickCapacity, currentTickPrice, convertible) = _previewBid(deposit); + (currentTickCapacity, currentTickPrice, ohmOut) = _previewBid(deposit); // Update day state today.deposits += deposit; - today.convertible += convertible; + today.convertible += ohmOut; // Update current tick currentTick.capacity = currentTickCapacity; currentTick.price = currentTickPrice; - // TODO calculate average price for total deposit and convertible, check rounding, formula - uint256 conversionPrice = (deposit * decimals) / convertible; + // Calculate average price based on the total deposit and ohmOut + // This is the number of deposit tokens per OHM token + // TODO check rounding + uint256 conversionPrice = (deposit * _ohmDecimals) / ohmOut; // Create the CD tokens and position cdFacility.create( @@ -86,22 +89,24 @@ contract CDAuctioneer is IConvertibleDepositAuctioneer, Policy, RolesConsumer { false ); - return convertible; + // TODO add position id to return value + + return ohmOut; } - /// @notice Internal function to preview the number of convertible tokens that can be purchased for a given deposit amount + /// @notice Internal function to preview the quantity of OHM tokens that can be purchased for a given deposit amount /// @dev The function also returns the adjusted capacity and price of the current tick /// /// @param deposit_ The amount of deposit to be bid /// @return currentTickCapacity The adjusted capacity of the current tick /// @return currentTickPrice The adjusted price of the current tick - /// @return convertible The number of convertible tokens that can be purchased + /// @return ohmOut The quantity of OHM tokens that can be purchased function _previewBid( uint256 deposit_ ) internal view - returns (uint256 currentTickCapacity, uint256 currentTickPrice, uint256 convertible) + returns (uint256 currentTickCapacity, uint256 currentTickPrice, uint256 ohmOut) { Tick memory tick = getCurrentTick(); uint256 remainingDeposit = deposit_; @@ -114,17 +119,17 @@ contract CDAuctioneer is IConvertibleDepositAuctioneer, Policy, RolesConsumer { else tick.price *= state.tickStep / decimals; remainingDeposit -= amount; - convertible += _convertFor(amount, tick.price); + ohmOut += _convertFor(amount, tick.price); } - return (tick.capacity, tick.price, convertible); + return (tick.capacity, tick.price, ohmOut); } /// @inheritdoc IConvertibleDepositAuctioneer - function previewBid(uint256 deposit) external view override returns (uint256 convertible) { - (, , convertible) = _previewBid(deposit); + function previewBid(uint256 deposit) external view override returns (uint256 ohmOut) { + (, , ohmOut) = _previewBid(deposit); - return convertible; + return ohmOut; } // ========== VIEW FUNCTIONS ========== // diff --git a/src/policies/interfaces/IConvertibleDepositAuctioneer.sol b/src/policies/interfaces/IConvertibleDepositAuctioneer.sol index cbf8d67c..aadf0945 100644 --- a/src/policies/interfaces/IConvertibleDepositAuctioneer.sol +++ b/src/policies/interfaces/IConvertibleDepositAuctioneer.sol @@ -56,14 +56,14 @@ interface IConvertibleDepositAuctioneer { /// @notice Deposit reserve tokens to bid for convertible deposit tokens /// /// @param deposit_ Amount of reserve tokens to deposit - /// @return convertible_ Amount of convertible tokens minted - function bid(uint256 deposit_) external returns (uint256 convertible_); + /// @return ohmOut Amount of OHM tokens that the deposit can be converted to + function bid(uint256 deposit_) external returns (uint256 ohmOut); - /// @notice Get the amount of convertible deposit tokens issued for a deposit + /// @notice Get the amount of OHM tokens issued for a deposit /// /// @param deposit_ Amount of reserve tokens - /// @return convertible_ Amount of convertible tokens - function previewBid(uint256 deposit_) external view returns (uint256 convertible_); + /// @return ohmOut Amount of OHM tokens + function previewBid(uint256 deposit_) external view returns (uint256 ohmOut); // ========== STATE VARIABLES ========== // diff --git a/src/policies/interfaces/IConvertibleDepositFacility.sol b/src/policies/interfaces/IConvertibleDepositFacility.sol index 01448bf2..686d12ac 100644 --- a/src/policies/interfaces/IConvertibleDepositFacility.sol +++ b/src/policies/interfaces/IConvertibleDepositFacility.sol @@ -40,7 +40,7 @@ interface IConvertibleDepositFacility { /// /// @param account_ The address to create the position for /// @param amount_ The amount of reserve token to deposit - /// @param conversionPrice_ The price of the reserve token in USD + /// @param conversionPrice_ The amount of convertible deposit tokens per OHM token /// @param expiry_ The timestamp when the position expires /// @param wrap_ Whether the position should be wrapped /// @return termId The ID of the new term diff --git a/src/test/modules/CDPOS/CDPOSTest.sol b/src/test/modules/CDPOS/CDPOSTest.sol index d8d60c31..9ca715b9 100644 --- a/src/test/modules/CDPOS/CDPOSTest.sol +++ b/src/test/modules/CDPOS/CDPOSTest.sol @@ -23,6 +23,7 @@ abstract contract CDPOSTest is Test, IERC721Receiver { OlympusConvertibleDepositPositions public CDPOS; address public godmode; address public convertibleDepositToken; + uint8 public convertibleDepositTokenDecimals = 18; uint256[] public positions; @@ -34,7 +35,7 @@ abstract contract CDPOSTest is Test, IERC721Receiver { // Set up the convertible deposit token MockERC20 mockERC20 = new MockERC20(); - mockERC20.initialize("Convertible Deposit Token", "CDT", 18); + mockERC20.initialize("Convertible Deposit Token", "CDT", convertibleDepositTokenDecimals); convertibleDepositToken = address(mockERC20); // Generate fixtures diff --git a/src/test/modules/CDPOS/previewConvert.t.sol b/src/test/modules/CDPOS/previewConvert.t.sol index c545f805..76802351 100644 --- a/src/test/modules/CDPOS/previewConvert.t.sol +++ b/src/test/modules/CDPOS/previewConvert.t.sol @@ -3,10 +3,186 @@ pragma solidity 0.8.15; import {CDPOSTest} from "./CDPOSTest.sol"; -contract PreviewConvertTest is CDPOSTest { +import {CDPOSv1} from "src/modules/CDPOS/CDPOS.v1.sol"; + +contract PreviewConvertCDPOSTest is CDPOSTest { // when the position does not exist - // [ ] it reverts + // [X] it reverts // when the position is expired - // [ ] it returns 0 - // [ ] it returns the correct value + // [X] it returns 0 + // when the amount is greater than the position's balance + // [X] it reverts + // when the convertible deposit token has different decimals + // [X] it returns the correct value + // when the convertible deposit token has 9 decimals + // [X] it returns the correct value + // when the amount is very small + // [X] it returns the correct value + // when the amount is very large + // [X] it returns the correct value + // when the conversion price is very small + // [X] it returns the correct value + // when the conversion price is very large + // [X] it returns the correct value + // [X] it returns the correct value + + function test_invalidPositionId_reverts() public { + // Expect revert + vm.expectRevert(abi.encodeWithSelector(CDPOSv1.CDPOS_InvalidPositionId.selector, 0)); + + // Call function + CDPOS.previewConvert(0, 0); + } + + function test_positionExpired( + uint48 expiry_ + ) + public + givenPositionCreated(address(this), REMAINING_DEPOSIT, CONVERSION_PRICE, EXPIRY, false) + { + uint48 expiry = uint48(bound(expiry_, EXPIRY, type(uint48).max)); + + // Warp to expiry and beyond + vm.warp(expiry); + + // Call function + uint256 ohmOut = CDPOS.previewConvert(0, REMAINING_DEPOSIT); + + // Assert + assertEq(ohmOut, 0); + } + + function test_amountGreaterThanRemainingDeposit_reverts( + uint256 amount_ + ) + public + givenPositionCreated(address(this), REMAINING_DEPOSIT, CONVERSION_PRICE, EXPIRY, false) + { + uint256 amount = bound(amount_, REMAINING_DEPOSIT + 1, type(uint256).max); + + // Expect revert + vm.expectRevert(abi.encodeWithSelector(CDPOSv1.CDPOS_InvalidParams.selector, "amount")); + + // Call function + CDPOS.previewConvert(0, amount); + } + + function test_success() + public + givenPositionCreated(address(this), REMAINING_DEPOSIT, CONVERSION_PRICE, EXPIRY, false) + { + // Call function + uint256 ohmOut = CDPOS.previewConvert(0, REMAINING_DEPOSIT); + + // Calculate expected ohmOut + uint256 expectedOhmOut = (REMAINING_DEPOSIT * 1e9) / CONVERSION_PRICE; + + // Assert + assertEq(ohmOut, expectedOhmOut, "ohmOut"); + } + + function test_convertibleDepositTokenDecimalsLower() + public + givenConvertibleDepositTokenDecimals(17) + givenPositionCreated(address(this), 10e17, 2e17, EXPIRY, false) + { + // Call function + uint256 ohmOut = CDPOS.previewConvert(0, 10e17); + + // Calculate expected ohmOut + uint256 expectedOhmOut = (10e17 * 1e9) / 2e17; + + // Assert + assertEq(ohmOut, expectedOhmOut, "ohmOut"); + } + + function test_convertibleDepositTokenDecimalsHigher() + public + givenConvertibleDepositTokenDecimals(19) + givenPositionCreated(address(this), 10e19, 2e19, EXPIRY, false) + { + // Call function + uint256 ohmOut = CDPOS.previewConvert(0, 10e19); + + // Calculate expected ohmOut + uint256 expectedOhmOut = (10e19 * 1e9) / 2e19; + + // Assert + assertEq(ohmOut, expectedOhmOut, "ohmOut"); + } + + function test_convertibleDepositTokenDecimalsSame() + public + givenConvertibleDepositTokenDecimals(9) + givenPositionCreated(address(this), 10e9, 2e9, EXPIRY, false) + { + // Call function + uint256 ohmOut = CDPOS.previewConvert(0, 10e9); + + // Calculate expected ohmOut + uint256 expectedOhmOut = (10e9 * 1e9) / 2e9; + + // Assert + assertEq(ohmOut, expectedOhmOut, "ohmOut"); + } + + function test_conversionPriceVerySmall() + public + givenPositionCreated(address(this), REMAINING_DEPOSIT, 1, EXPIRY, false) + { + // Call function + uint256 ohmOut = CDPOS.previewConvert(0, REMAINING_DEPOSIT); + + // Calculate expected ohmOut + // uint256 expectedOhmOut = (REMAINING_DEPOSIT * 1e9) / 1; + uint256 expectedOhmOut = 25e27; + + // Assert + assertEq(ohmOut, expectedOhmOut, "ohmOut"); + } + + function test_conversionPriceVeryLarge() + public + givenPositionCreated(address(this), REMAINING_DEPOSIT, 1e36, EXPIRY, false) + { + // Call function + uint256 ohmOut = CDPOS.previewConvert(0, REMAINING_DEPOSIT); + + // Calculate expected ohmOut + // uint256 expectedOhmOut = (REMAINING_DEPOSIT * 1e9) / 1e36; + uint256 expectedOhmOut = 0; + + // Assert + assertEq(ohmOut, expectedOhmOut, "ohmOut"); + } + + function test_amountVerySmall() + public + givenPositionCreated(address(this), REMAINING_DEPOSIT, CONVERSION_PRICE, EXPIRY, false) + { + // Call function + uint256 ohmOut = CDPOS.previewConvert(0, 1); + + // Calculate expected ohmOut + // uint256 expectedOhmOut = (1 * 1e9) / CONVERSION_PRICE; + uint256 expectedOhmOut = 0; + + // Assert + assertEq(ohmOut, expectedOhmOut, "ohmOut"); + } + + function test_amountVeryLarge() + public + givenPositionCreated(address(this), 1000e18, CONVERSION_PRICE, EXPIRY, false) + { + // Call function + uint256 ohmOut = CDPOS.previewConvert(0, 1000e18); + + // Calculate expected ohmOut + // uint256 expectedOhmOut = (1000e18 * 1e9) / CONVERSION_PRICE; + uint256 expectedOhmOut = 5e11; + + // Assert + assertEq(ohmOut, expectedOhmOut, "ohmOut"); + } } diff --git a/src/test/modules/CDPOS/setDisplayDecimals.t.sol b/src/test/modules/CDPOS/setDisplayDecimals.t.sol index 6d157bfe..f58d81cf 100644 --- a/src/test/modules/CDPOS/setDisplayDecimals.t.sol +++ b/src/test/modules/CDPOS/setDisplayDecimals.t.sol @@ -3,7 +3,7 @@ pragma solidity 0.8.15; import {CDPOSTest} from "./CDPOSTest.sol"; -contract SetDisplayDecimalsTest is CDPOSTest { +contract SetDisplayDecimalsCDPOSTest is CDPOSTest { // when the caller is not a permissioned address // [ ] it reverts // [ ] it sets the display decimals diff --git a/src/test/modules/CDPOS/tokenURI.t.sol b/src/test/modules/CDPOS/tokenURI.t.sol index c93de06c..35548887 100644 --- a/src/test/modules/CDPOS/tokenURI.t.sol +++ b/src/test/modules/CDPOS/tokenURI.t.sol @@ -3,7 +3,7 @@ pragma solidity 0.8.15; import {CDPOSTest} from "./CDPOSTest.sol"; -contract TokenURITest is CDPOSTest { +contract TokenURICDPOSTest is CDPOSTest { // when the position does not exist // [ ] it reverts // [ ] the value is Base64 encoded diff --git a/src/test/modules/CDPOS/transferFrom.t.sol b/src/test/modules/CDPOS/transferFrom.t.sol index 3e5ab12b..b32db80f 100644 --- a/src/test/modules/CDPOS/transferFrom.t.sol +++ b/src/test/modules/CDPOS/transferFrom.t.sol @@ -3,7 +3,7 @@ pragma solidity 0.8.15; import {CDPOSTest} from "./CDPOSTest.sol"; -contract TransferFromTest is CDPOSTest { +contract TransferFromCDPOSTest is CDPOSTest { // when the position does not exist // [ ] it reverts // when the caller is not the owner of the position From 0520eba8bc5bb579d8c89e248808a990eedb20aa Mon Sep 17 00:00:00 2001 From: Jem <0x0xjem@gmail.com> Date: Thu, 19 Dec 2024 13:41:36 +0500 Subject: [PATCH 43/64] Tests for setDisplayDecimals and transferFrom --- .../OlympusConvertibleDepositPositions.sol | 12 +-- .../modules/CDPOS/setDisplayDecimals.t.sol | 21 +++++ src/test/modules/CDPOS/transferFrom.t.sol | 84 +++++++++++++++++-- 3 files changed, 105 insertions(+), 12 deletions(-) diff --git a/src/modules/CDPOS/OlympusConvertibleDepositPositions.sol b/src/modules/CDPOS/OlympusConvertibleDepositPositions.sol index 8fa96e36..c4ad9636 100644 --- a/src/modules/CDPOS/OlympusConvertibleDepositPositions.sol +++ b/src/modules/CDPOS/OlympusConvertibleDepositPositions.sol @@ -18,7 +18,7 @@ contract OlympusConvertibleDepositPositions is CDPOSv1 { /// @notice The number of decimal places to display when rendering values as decimal strings. /// @dev This affects the display of the remaining deposit and conversion price in the SVG and JSON metadata. /// It can be adjusted using the `setDisplayDecimals` function, which is permissioned. - uint8 internal _displayDecimals = 2; + uint8 public displayDecimals = 2; // ========== CONSTRUCTOR ========== // @@ -294,7 +294,7 @@ contract OlympusConvertibleDepositPositions is CDPOSv1 { DecimalString.toDecimalString( position_.remainingDeposit, depositDecimals, - _displayDecimals + displayDecimals ), "" ), @@ -303,7 +303,7 @@ contract OlympusConvertibleDepositPositions is CDPOSv1 { DecimalString.toDecimalString( position_.conversionPrice, depositDecimals, - _displayDecimals + displayDecimals ), "" ), // TODO check decimals of conversion price. This probably isn't correct. @@ -343,7 +343,7 @@ contract OlympusConvertibleDepositPositions is CDPOSv1 { DecimalString.toDecimalString( position.remainingDeposit, depositDecimals, - _displayDecimals + displayDecimals ), '"},' ), @@ -352,7 +352,7 @@ contract OlympusConvertibleDepositPositions is CDPOSv1 { DecimalString.toDecimalString( position.conversionPrice, depositDecimals, - _displayDecimals + displayDecimals ), '"},' ), @@ -468,7 +468,7 @@ contract OlympusConvertibleDepositPositions is CDPOSv1 { /// @notice Set the number of decimal places to display when rendering values as decimal strings. /// @dev This affects the display of the remaining deposit and conversion price in the SVG and JSON metadata. function setDisplayDecimals(uint8 decimals_) external permissioned { - _displayDecimals = decimals_; + displayDecimals = decimals_; } // ========== MODIFIERS ========== // diff --git a/src/test/modules/CDPOS/setDisplayDecimals.t.sol b/src/test/modules/CDPOS/setDisplayDecimals.t.sol index f58d81cf..53618512 100644 --- a/src/test/modules/CDPOS/setDisplayDecimals.t.sol +++ b/src/test/modules/CDPOS/setDisplayDecimals.t.sol @@ -3,8 +3,29 @@ pragma solidity 0.8.15; import {CDPOSTest} from "./CDPOSTest.sol"; +import {Module} from "src/Kernel.sol"; + contract SetDisplayDecimalsCDPOSTest is CDPOSTest { // when the caller is not a permissioned address // [ ] it reverts // [ ] it sets the display decimals + + function test_notPermissioned_reverts() public { + // Expect revert + vm.expectRevert( + abi.encodeWithSelector(Module.Module_PolicyNotPermitted.selector, address(this)) + ); + + // Call function + CDPOS.setDisplayDecimals(2); + } + + function test_setDisplayDecimals() public { + // Call function + vm.prank(godmode); + CDPOS.setDisplayDecimals(4); + + // Assert + assertEq(CDPOS.displayDecimals(), 4, "displayDecimals"); + } } diff --git a/src/test/modules/CDPOS/transferFrom.t.sol b/src/test/modules/CDPOS/transferFrom.t.sol index b32db80f..a8b5897a 100644 --- a/src/test/modules/CDPOS/transferFrom.t.sol +++ b/src/test/modules/CDPOS/transferFrom.t.sol @@ -3,14 +3,86 @@ pragma solidity 0.8.15; import {CDPOSTest} from "./CDPOSTest.sol"; +import {CDPOSv1} from "src/modules/CDPOS/CDPOS.v1.sol"; + contract TransferFromCDPOSTest is CDPOSTest { // when the position does not exist - // [ ] it reverts + // [X] it reverts + // when the ERC721 has not been minted + // [X] it reverts // when the caller is not the owner of the position - // [ ] it reverts + // [X] it reverts // when the caller is a permissioned address - // [ ] it reverts - // [ ] it transfers the ownership of the position to the to_ address - // [ ] it adds the position to the to_ address's list of positions - // [ ] it removes the position from the from_ address's list of positions + // [X] it reverts + // [X] it transfers the ownership of the position to the to_ address + // [X] it adds the position to the to_ address's list of positions + // [X] it removes the position from the from_ address's list of positions + + function test_invalidPositionId_reverts() public { + // Expect revert + vm.expectRevert(abi.encodeWithSelector(CDPOSv1.CDPOS_InvalidPositionId.selector, 0)); + + // Call function + CDPOS.transferFrom(address(this), address(1), 0); + } + + function test_callerIsNotOwner_reverts() + public + givenPositionCreated(address(this), REMAINING_DEPOSIT, CONVERSION_PRICE, EXPIRY, true) + { + // Expect revert + vm.expectRevert("NOT_AUTHORIZED"); + + // Call function + vm.prank(address(0x1)); + CDPOS.transferFrom(address(this), address(0x1), 0); + } + + function test_callerIsPermissioned_reverts() + public + givenPositionCreated(address(this), REMAINING_DEPOSIT, CONVERSION_PRICE, EXPIRY, true) + { + // Expect revert + vm.expectRevert("NOT_AUTHORIZED"); + + // Call function + vm.prank(godmode); + CDPOS.transferFrom(address(this), address(0x1), 0); + } + + function test_notMinted_reverts() + public + givenPositionCreated(address(this), REMAINING_DEPOSIT, CONVERSION_PRICE, EXPIRY, false) + { + // Expect revert + vm.expectRevert("WRONG_FROM"); + + // Call function + vm.prank(address(this)); + CDPOS.transferFrom(address(this), address(0x1), 0); + } + + function test_success() + public + givenPositionCreated(address(this), REMAINING_DEPOSIT, CONVERSION_PRICE, EXPIRY, true) + { + // Call function + CDPOS.transferFrom(address(this), address(0x1), 0); + + // ERC721 balance updated + _assertERC721Balance(address(this), 0); + _assertERC721Balance(address(0x1), 1); + _assertERC721Owner(0, address(0x1), true); + + // Position record updated + _assertPosition(0, address(0x1), REMAINING_DEPOSIT, CONVERSION_PRICE, EXPIRY, true); + + // Position ownership updated + assertEq( + CDPOS.getUserPositionIds(address(this)).length, + 0, + "getUserPositionIds should return 0 length" + ); + _assertUserPosition(address(0x1), 0, 1); + } } From d8d89bf1fd1882817a9e45e55b4aa9ba3d4a9035 Mon Sep 17 00:00:00 2001 From: Jem <0x0xjem@gmail.com> Date: Thu, 19 Dec 2024 13:45:51 +0500 Subject: [PATCH 44/64] Handling of transferFrom in CDPOS --- .../CDPOS/OlympusConvertibleDepositPositions.sol | 11 ++++++++++- src/test/modules/CDPOS/transferFrom.t.sol | 7 ++++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/modules/CDPOS/OlympusConvertibleDepositPositions.sol b/src/modules/CDPOS/OlympusConvertibleDepositPositions.sol index c4ad9636..10225ea5 100644 --- a/src/modules/CDPOS/OlympusConvertibleDepositPositions.sol +++ b/src/modules/CDPOS/OlympusConvertibleDepositPositions.sol @@ -381,7 +381,16 @@ contract OlympusConvertibleDepositPositions is CDPOSv1 { // Validate that the position is valid if (position.conversionPrice == 0) revert CDPOS_InvalidPositionId(tokenId_); - // Ownership is validated in `transferFrom` on the parent contract + // Validate that the position is wrapped/minted + if (!position.wrapped) revert CDPOS_NotWrapped(tokenId_); + + // Additional validation performed in super.transferForm(): + // - Approvals + // - Ownership + // - Destination address + + // Update the position record + position.owner = to_; // Add to user positions on the destination address _userPositions[to_].push(tokenId_); diff --git a/src/test/modules/CDPOS/transferFrom.t.sol b/src/test/modules/CDPOS/transferFrom.t.sol index a8b5897a..f9686c27 100644 --- a/src/test/modules/CDPOS/transferFrom.t.sol +++ b/src/test/modules/CDPOS/transferFrom.t.sol @@ -55,7 +55,12 @@ contract TransferFromCDPOSTest is CDPOSTest { givenPositionCreated(address(this), REMAINING_DEPOSIT, CONVERSION_PRICE, EXPIRY, false) { // Expect revert - vm.expectRevert("WRONG_FROM"); + vm.expectRevert( + abi.encodeWithSelector( + CDPOSv1.CDPOS_NotWrapped.selector, + 0 + ) + ); // Call function vm.prank(address(this)); From 27a36959bfa8db04a3bee988e9030b0159de1127 Mon Sep 17 00:00:00 2001 From: Jem <0x0xjem@gmail.com> Date: Thu, 19 Dec 2024 14:06:36 +0500 Subject: [PATCH 45/64] Fix handling of mint checks --- .../OlympusConvertibleDepositPositions.sol | 9 ++------- src/test/modules/CDPOS/CDPOSTest.sol | 7 +++++++ src/test/modules/CDPOS/create.t.sol | 20 +++++++++++++++++-- src/test/modules/CDPOS/transferFrom.t.sol | 7 +------ src/test/modules/CDPOS/wrap.t.sol | 4 ++-- 5 files changed, 30 insertions(+), 17 deletions(-) diff --git a/src/modules/CDPOS/OlympusConvertibleDepositPositions.sol b/src/modules/CDPOS/OlympusConvertibleDepositPositions.sol index 10225ea5..b782a341 100644 --- a/src/modules/CDPOS/OlympusConvertibleDepositPositions.sol +++ b/src/modules/CDPOS/OlympusConvertibleDepositPositions.sol @@ -59,7 +59,7 @@ contract OlympusConvertibleDepositPositions is CDPOSv1 { position.wrapped = true; // Mint the ERC721 token - _mint(msg.sender, positionId_); + _safeMint(msg.sender, positionId_); emit PositionWrapped(positionId_); } @@ -108,16 +108,11 @@ contract OlympusConvertibleDepositPositions is CDPOSv1 { wrapped: wrap_ }); - // Update ERC721 storage - // TODO remove this, only when wrapped - // _ownerOf[positionId] = owner_; - // _balanceOf[owner_]++; - // Add the position ID to the user's list of positions _userPositions[owner_].push(positionId); // If specified, wrap the position - if (wrap_) _mint(owner_, positionId); + if (wrap_) _safeMint(owner_, positionId); // Emit the event emit PositionCreated( diff --git a/src/test/modules/CDPOS/CDPOSTest.sol b/src/test/modules/CDPOS/CDPOSTest.sol index 9ca715b9..eb99d2fe 100644 --- a/src/test/modules/CDPOS/CDPOSTest.sol +++ b/src/test/modules/CDPOS/CDPOSTest.sol @@ -5,6 +5,8 @@ import {Test} from "forge-std/Test.sol"; import {IERC721Receiver} from "@openzeppelin/contracts/token/ERC721/IERC721Receiver.sol"; import {ModuleTestFixtureGenerator} from "src/test/lib/ModuleTestFixtureGenerator.sol"; import {MockERC20} from "forge-std/mocks/MockERC20.sol"; +import {ERC721ReceiverMock} from "@openzeppelin/contracts/mocks/ERC721ReceiverMock.sol"; +import {IERC721Receiver} from "@openzeppelin/contracts/interfaces/IERC721Receiver.sol"; import {Kernel, Actions} from "src/Kernel.sol"; import {OlympusConvertibleDepositPositions} from "src/modules/CDPOS/OlympusConvertibleDepositPositions.sol"; @@ -21,6 +23,7 @@ abstract contract CDPOSTest is Test, IERC721Receiver { Kernel public kernel; OlympusConvertibleDepositPositions public CDPOS; + ERC721ReceiverMock public mockERC721Receiver; address public godmode; address public convertibleDepositToken; uint8 public convertibleDepositTokenDecimals = 18; @@ -32,6 +35,10 @@ abstract contract CDPOSTest is Test, IERC721Receiver { kernel = new Kernel(); CDPOS = new OlympusConvertibleDepositPositions(address(kernel)); + mockERC721Receiver = new ERC721ReceiverMock( + IERC721Receiver.onERC721Received.selector, + ERC721ReceiverMock.Error.None + ); // Set up the convertible deposit token MockERC20 mockERC20 = new MockERC20(); diff --git a/src/test/modules/CDPOS/create.t.sol b/src/test/modules/CDPOS/create.t.sol index 0f6a329a..dd9a85f0 100644 --- a/src/test/modules/CDPOS/create.t.sol +++ b/src/test/modules/CDPOS/create.t.sol @@ -40,6 +40,8 @@ contract CreateCDPOSTest is CDPOSTest { // when the conversion would result in an underflow // [X] it reverts // when the wrap flag is true + // when the receiver cannot receive ERC721 tokens + // [X] it reverts // [X] it mints the ERC721 token // [X] it marks the position as wrapped // [X] the position is listed as owned by the owner @@ -161,6 +163,20 @@ contract CreateCDPOSTest is CDPOSTest { _assertUserPosition(address(this), 0, 1); } + function test_singlePosition_whenWrapped_unsafeRecipient_reverts() public { + // Expect revert + vm.expectRevert(); + + // Call function + _createPosition( + address(convertibleDepositToken), // Needs to be a contract + REMAINING_DEPOSIT, + CONVERSION_PRICE, + uint48(block.timestamp + EXPIRY_DELAY), + true + ); + } + function test_singlePosition_whenWrapped() public { // Expect event vm.expectEmit(true, true, true, true); @@ -233,8 +249,8 @@ contract CreateCDPOSTest is CDPOSTest { } function test_multiplePositions_multipleOwners() public { - address owner1 = address(0x1); - address owner2 = address(0x2); + address owner1 = address(this); + address owner2 = address(mockERC721Receiver); // Create 5 positions for owner1 for (uint256 i = 0; i < 5; i++) { diff --git a/src/test/modules/CDPOS/transferFrom.t.sol b/src/test/modules/CDPOS/transferFrom.t.sol index f9686c27..6f9d919a 100644 --- a/src/test/modules/CDPOS/transferFrom.t.sol +++ b/src/test/modules/CDPOS/transferFrom.t.sol @@ -55,12 +55,7 @@ contract TransferFromCDPOSTest is CDPOSTest { givenPositionCreated(address(this), REMAINING_DEPOSIT, CONVERSION_PRICE, EXPIRY, false) { // Expect revert - vm.expectRevert( - abi.encodeWithSelector( - CDPOSv1.CDPOS_NotWrapped.selector, - 0 - ) - ); + vm.expectRevert(abi.encodeWithSelector(CDPOSv1.CDPOS_NotWrapped.selector, 0)); // Call function vm.prank(address(this)); diff --git a/src/test/modules/CDPOS/wrap.t.sol b/src/test/modules/CDPOS/wrap.t.sol index 19b0aa76..2a8a48f2 100644 --- a/src/test/modules/CDPOS/wrap.t.sol +++ b/src/test/modules/CDPOS/wrap.t.sol @@ -90,7 +90,7 @@ contract WrapCDPOSTest is CDPOSTest { _assertERC721Owner(0, address(this), true); // Assert owner's list of positions is updated - _assertUserPosition(address(this), 1, 1); + _assertUserPosition(address(this), 0, 1); } function test_multiplePositions() @@ -117,7 +117,7 @@ contract WrapCDPOSTest is CDPOSTest { _assertERC721Owner(1, address(this), true); // Assert owner's list of positions is updated + _assertUserPosition(address(this), 0, 2); _assertUserPosition(address(this), 1, 2); - _assertUserPosition(address(this), 2, 2); } } From 875cdd95caa6576d638b40fcb7cc277874f42a3b Mon Sep 17 00:00:00 2001 From: Jem <0x0xjem@gmail.com> Date: Thu, 19 Dec 2024 14:38:43 +0500 Subject: [PATCH 46/64] Remove redundant tests --- src/test/modules/CDPOS/create.t.sol | 36 ----------------------------- 1 file changed, 36 deletions(-) diff --git a/src/test/modules/CDPOS/create.t.sol b/src/test/modules/CDPOS/create.t.sol index dd9a85f0..bf7c99ad 100644 --- a/src/test/modules/CDPOS/create.t.sol +++ b/src/test/modules/CDPOS/create.t.sol @@ -35,10 +35,6 @@ contract CreateCDPOSTest is CDPOSTest { // [X] the owner's list of positions is updated // when the expiry is in the future // [X] it sets the expiry - // when the conversion would result in an overflow - // [X] it reverts - // when the conversion would result in an underflow - // [X] it reverts // when the wrap flag is true // when the receiver cannot receive ERC721 tokens // [X] it reverts @@ -329,36 +325,4 @@ contract CreateCDPOSTest is CDPOSTest { // Assert that the position is correct _assertPosition(0, address(this), REMAINING_DEPOSIT, CONVERSION_PRICE, expiry, false); } - - function test_conversionOverflow_reverts() public { - // Expect revert - vm.expectRevert( - abi.encodeWithSelector(CDPOSv1.CDPOS_InvalidParams.selector, "conversion overflow") - ); - - // Call function - _createPosition( - address(this), - type(uint256).max, - CONVERSION_PRICE, - uint48(block.timestamp + EXPIRY_DELAY), - false - ); - } - - function test_conversionUnderflow_reverts() public { - // Expect revert - vm.expectRevert( - abi.encodeWithSelector(CDPOSv1.CDPOS_InvalidParams.selector, "conversion underflow") - ); - - // Call function - _createPosition( - address(this), - REMAINING_DEPOSIT, - type(uint256).max, - uint48(block.timestamp + EXPIRY_DELAY), - false - ); - } } From dbd3f6e9592697a5d886089ba9f2a397afb93e74 Mon Sep 17 00:00:00 2001 From: Jem <0x0xjem@gmail.com> Date: Thu, 19 Dec 2024 15:17:11 +0500 Subject: [PATCH 47/64] CDEPO: rename burn() to reclaim() and adjust logic around spending tokens --- src/modules/CDEPO/CDEPO.v1.sol | 20 +++++++------- .../CDEPO/OlympusConvertibleDepository.sol | 26 ++++++++++--------- 2 files changed, 25 insertions(+), 21 deletions(-) diff --git a/src/modules/CDEPO/CDEPO.v1.sol b/src/modules/CDEPO/CDEPO.v1.sol index d369f9ba..58acaf25 100644 --- a/src/modules/CDEPO/CDEPO.v1.sol +++ b/src/modules/CDEPO/CDEPO.v1.sol @@ -65,7 +65,9 @@ abstract contract CDEPOv1 is Module, ERC20 { /// @return tokensOut The amount of convertible deposit tokens that would be minted function previewMint(uint256 amount_) external view virtual returns (uint256 tokensOut); - /// @notice Burn tokens from the caller and return the underlying asset + // TODO review docs + + /// @notice Burn tokens from the caller and reclaim the underlying asset /// The amount of underlying asset may not be 1:1 with the amount of /// convertible deposit tokens, depending on the value of `burnRate` /// @dev The implementing function should perform the following: @@ -76,25 +78,25 @@ abstract contract CDEPOv1 is Module, ERC20 { /// - Emits a `Transfer` event /// /// @param amount_ The amount of convertible deposit tokens to burn - function burn(uint256 amount_) external virtual; + function reclaim(uint256 amount_) external virtual; - /// @notice Burn tokens from `from_` and return the underlying asset - /// This function behaves the same as `burn`, but allows the caller to + /// @notice Burn tokens from `from_` and reclaim the underlying asset + /// This function behaves the same as `reclaim`, but allows the caller to /// specify the address to burn the tokens from and transfer the underlying /// asset to. /// - /// @param from_ The address to burn the tokens from + /// @param to_ The address to reclaim the underlying asset to /// @param amount_ The amount of convertible deposit tokens to burn - function burnFrom(address from_, uint256 amount_) external virtual; + function reclaimTo(address to_, uint256 amount_) external virtual; - /// @notice Preview the amount of underlying asset that would be returned for a given amount of convertible deposit tokens + /// @notice Preview the amount of underlying asset that would be reclaimed for a given amount of convertible deposit tokens /// @dev The implementing function should perform the following: /// - Computes the amount of underlying asset that would be returned for the given amount of convertible deposit tokens /// - Returns the computed amount /// /// @param amount_ The amount of convertible deposit tokens to burn - /// @return assetsOut The amount of underlying asset that would be returned - function previewBurn(uint256 amount_) external view virtual returns (uint256 assetsOut); + /// @return assetsOut The amount of underlying asset that would be reclaimed + function previewReclaim(uint256 amount_) external view virtual returns (uint256 assetsOut); /// @notice Redeem convertible deposit tokens for the underlying asset /// This differs from the burn function, in that it is an admin-level and permissioned function that does not apply the burn rate. diff --git a/src/modules/CDEPO/OlympusConvertibleDepository.sol b/src/modules/CDEPO/OlympusConvertibleDepository.sol index a387e5c4..5a45635f 100644 --- a/src/modules/CDEPO/OlympusConvertibleDepository.sol +++ b/src/modules/CDEPO/OlympusConvertibleDepository.sol @@ -75,37 +75,39 @@ contract OlympusConvertibleDepository is CDEPOv1 { /// @inheritdoc CDEPOv1 /// @dev This function performs the following: - /// - Calls `burnFrom` with the caller as the address to burn the tokens from - function burn(uint256 amount_) external virtual override { - burnFrom(msg.sender, amount_); + /// - Calls `reclaimTo` with the caller as the address to reclaim the tokens to + function reclaim(uint256 amount_) external virtual override { + reclaimTo(msg.sender, amount_); } /// @inheritdoc CDEPOv1 /// @dev This function performs the following: - /// - Burns the CD tokens from `from_` + /// - Burns the CD tokens from the caller /// - Calculates the quantity of underlying asset to withdraw and return - /// - Returns the underlying asset to `from_` + /// - Returns the underlying asset to `to_` /// - Emits a `Transfer` event /// - /// @param from_ The address to burn the tokens from + /// @param to_ The address to reclaim the tokens to /// @param amount_ The amount of CD tokens to burn - function burnFrom(address from_, uint256 amount_) public virtual override { + function reclaimTo(address to_, uint256 amount_) public virtual override { // Burn the CD tokens from `from_` - _burn(from_, amount_); + _burn(msg.sender, amount_); // Calculate the quantity of underlying asset to withdraw and return // This will create a difference between the quantity of underlying assets and the vault shares, which will be swept as yield // TODO make sure there are no shares left over if all CD tokens are burned - uint256 discountedAssetsOut = previewBurn(amount_); + uint256 discountedAssetsOut = previewReclaim(amount_); uint256 shares = vault.previewWithdraw(discountedAssetsOut); totalShares -= shares; - // Return the underlying asset to `from_` - vault.redeem(shares, from_, address(this)); + // Return the underlying asset to `to_` + vault.redeem(shares, to_, address(this)); } /// @inheritdoc CDEPOv1 - function previewBurn(uint256 amount_) public view virtual override returns (uint256 assetsOut) { + function previewReclaim( + uint256 amount_ + ) public view virtual override returns (uint256 assetsOut) { assetsOut = (amount_ * burnRate) / ONE_HUNDRED_PERCENT; } From fcd4e3d364dbd467984f959dbb1b1da1a44033c6 Mon Sep 17 00:00:00 2001 From: Jem <0x0xjem@gmail.com> Date: Thu, 19 Dec 2024 15:17:17 +0500 Subject: [PATCH 48/64] CDEPO: test stubs --- src/test/modules/CDEPO/CDEPOTest.sol | 56 +++++++++++++++++++ src/test/modules/CDEPO/mint.t.sol | 23 ++++++++ src/test/modules/CDEPO/mintTo.t.sol | 23 ++++++++ src/test/modules/CDEPO/previewMint.t.sol | 13 +++++ src/test/modules/CDEPO/previewReclaim.t.sol | 13 +++++ .../modules/CDEPO/previewSweepYield.t.sol | 13 +++++ src/test/modules/CDEPO/reclaim.t.sol | 19 +++++++ src/test/modules/CDEPO/reclaimTo.t.sol | 23 ++++++++ src/test/modules/CDEPO/redeem.t.sol | 18 ++++++ src/test/modules/CDEPO/setBurnRate.t.sol | 16 ++++++ src/test/modules/CDEPO/sweepYield.t.sol | 23 ++++++++ 11 files changed, 240 insertions(+) create mode 100644 src/test/modules/CDEPO/CDEPOTest.sol create mode 100644 src/test/modules/CDEPO/mint.t.sol create mode 100644 src/test/modules/CDEPO/mintTo.t.sol create mode 100644 src/test/modules/CDEPO/previewMint.t.sol create mode 100644 src/test/modules/CDEPO/previewReclaim.t.sol create mode 100644 src/test/modules/CDEPO/previewSweepYield.t.sol create mode 100644 src/test/modules/CDEPO/reclaim.t.sol create mode 100644 src/test/modules/CDEPO/reclaimTo.t.sol create mode 100644 src/test/modules/CDEPO/redeem.t.sol create mode 100644 src/test/modules/CDEPO/setBurnRate.t.sol create mode 100644 src/test/modules/CDEPO/sweepYield.t.sol diff --git a/src/test/modules/CDEPO/CDEPOTest.sol b/src/test/modules/CDEPO/CDEPOTest.sol new file mode 100644 index 00000000..161c9669 --- /dev/null +++ b/src/test/modules/CDEPO/CDEPOTest.sol @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: Unlicensed +pragma solidity 0.8.15; + +import {Test} from "forge-std/Test.sol"; +import {ModuleTestFixtureGenerator} from "src/test/lib/ModuleTestFixtureGenerator.sol"; +import {MockERC20} from "solmate/test/utils/mocks/MockERC20.sol"; +import {MockERC4626} from "solmate/test/utils/mocks/MockERC4626.sol"; + +import {Kernel, Actions} from "src/Kernel.sol"; +import {CDEPOv1} from "src/modules/CDEPO/CDEPO.v1.sol"; +import {OlympusConvertibleDepository} from "src/modules/CDEPO/OlympusConvertibleDepository.sol"; + +abstract contract CDEPOTest is Test { + using ModuleTestFixtureGenerator for OlympusConvertibleDepository; + + Kernel public kernel; + OlympusConvertibleDepository public CDEPO; + MockERC20 public reserveToken; + MockERC4626 public vault; + address public godmode; + + uint48 public constant INITIAL_BLOCK = 100000000; + + function setUp() public { + vm.warp(INITIAL_BLOCK); + + reserveToken = new MockERC20("Reserve Token", "RST", 18); + vault = new MockERC4626(reserveToken, "sReserve Token", "sRST"); + + // Mint reserve tokens to the vault without depositing, so that the conversion is not 1 + reserveToken.mint(address(vault), 10e18); + + kernel = new Kernel(); + CDEPO = new OlympusConvertibleDepository(address(kernel), address(vault)); + + // Generate fixtures + godmode = CDEPO.generateGodmodeFixture(type(OlympusConvertibleDepository).name); + + // Install modules and policies on Kernel + kernel.executeAction(Actions.InstallModule, address(CDEPO)); + kernel.executeAction(Actions.ActivatePolicy, godmode); + } + + // ========== ASSERTIONS ========== // + + // ========== MODIFIERS ========== // + + function _mintReserveToken(address to_, uint256 amount_) internal { + reserveToken.mint(to_, amount_); + } + + modifier givenAddressHasReserveToken(address to_, uint256 amount_) { + _mintReserveToken(to_, amount_); + _; + } +} diff --git a/src/test/modules/CDEPO/mint.t.sol b/src/test/modules/CDEPO/mint.t.sol new file mode 100644 index 00000000..97fe6307 --- /dev/null +++ b/src/test/modules/CDEPO/mint.t.sol @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: Unlicensed +pragma solidity 0.8.15; + +import {Test} from "forge-std/Test.sol"; + +import {CDEPOTest} from "./CDEPOTest.sol"; + +contract MintCDEPOTest is CDEPOTest { + // when the recipient is the zero address + // [ ] it reverts + // when the amount is zero + // [ ] it reverts + // when the caller has not approved CDEPO to spend reserve tokens + // [ ] it reverts + // when the caller has approved CDEPO to spend reserve tokens + // when the caller has an insufficient balance of reserve tokens + // [ ] it reverts + // when the caller has a sufficient balance of reserve tokens + // [ ] it transfers the reserve tokens to CDEPO + // [ ] it mints an equal amount of convertible deposit tokens to the caller + // [ ] it deposits the reserve tokens into the vault + // [ ] it emits a `Transfer` event +} diff --git a/src/test/modules/CDEPO/mintTo.t.sol b/src/test/modules/CDEPO/mintTo.t.sol new file mode 100644 index 00000000..635d7ffb --- /dev/null +++ b/src/test/modules/CDEPO/mintTo.t.sol @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: Unlicensed +pragma solidity 0.8.15; + +import {Test} from "forge-std/Test.sol"; + +import {CDEPOTest} from "./CDEPOTest.sol"; + +contract MintToCDEPOTest is CDEPOTest { + // when the recipient is the zero address + // [ ] it reverts + // when the amount is zero + // [ ] it reverts + // when the caller has not approved CDEPO to spend reserve tokens + // [ ] it reverts + // when the caller has approved CDEPO to spend reserve tokens + // when the caller has an insufficient balance of reserve tokens + // [ ] it reverts + // when the caller has a sufficient balance of reserve tokens + // [ ] it transfers the reserve tokens to CDEPO + // [ ] it mints an equal amount of convertible deposit tokens to the `to_` address + // [ ] it deposits the reserve tokens into the vault + // [ ] it emits a `Transfer` event +} diff --git a/src/test/modules/CDEPO/previewMint.t.sol b/src/test/modules/CDEPO/previewMint.t.sol new file mode 100644 index 00000000..2a39a13a --- /dev/null +++ b/src/test/modules/CDEPO/previewMint.t.sol @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: Unlicensed +pragma solidity 0.8.15; + +import {Test} from "forge-std/Test.sol"; + +import {CDEPOTest} from "./CDEPOTest.sol"; + +contract PreviewMintCDEPOTest is CDEPOTest { + // when the amount is zero + // [ ] it reverts + // when the amount is greater than zero + // [ ] it returns the same amount +} diff --git a/src/test/modules/CDEPO/previewReclaim.t.sol b/src/test/modules/CDEPO/previewReclaim.t.sol new file mode 100644 index 00000000..b04f1e8f --- /dev/null +++ b/src/test/modules/CDEPO/previewReclaim.t.sol @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: Unlicensed +pragma solidity 0.8.15; + +import {Test} from "forge-std/Test.sol"; + +import {CDEPOTest} from "./CDEPOTest.sol"; + +contract PreviewReclaimCDEPOTest is CDEPOTest { + // when the amount is zero + // [ ] it reverts + // when the amount is greater than zero + // [ ] it returns the amount after applying the burn rate +} diff --git a/src/test/modules/CDEPO/previewSweepYield.t.sol b/src/test/modules/CDEPO/previewSweepYield.t.sol new file mode 100644 index 00000000..cfe0f2eb --- /dev/null +++ b/src/test/modules/CDEPO/previewSweepYield.t.sol @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: Unlicensed +pragma solidity 0.8.15; + +import {Test} from "forge-std/Test.sol"; + +import {CDEPOTest} from "./CDEPOTest.sol"; + +contract PreviewSweepYieldCDEPOTest is CDEPOTest { + // when there are no deposits + // [ ] it returns zero + // when there are deposits + // [ ] it returns the difference between the total deposits and the total assets in the vault +} diff --git a/src/test/modules/CDEPO/reclaim.t.sol b/src/test/modules/CDEPO/reclaim.t.sol new file mode 100644 index 00000000..388834b6 --- /dev/null +++ b/src/test/modules/CDEPO/reclaim.t.sol @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: Unlicensed +pragma solidity 0.8.15; + +import {Test} from "forge-std/Test.sol"; + +import {CDEPOTest} from "./CDEPOTest.sol"; + +contract ReclaimCDEPOTest is CDEPOTest { + // when the amount is zero + // [ ] it reverts + // when the amount is greater than the caller's balance + // [ ] it reverts + // when the amount is greater than zero + // [ ] it burns the corresponding amount of convertible deposit tokens + // [ ] it withdraws the underlying asset from the vault + // [ ] it transfers the underlying asset to the caller after applying the burn rate + // [ ] it updates the total deposits + // [ ] it marks the forfeited amount of the underlying asset as yield +} diff --git a/src/test/modules/CDEPO/reclaimTo.t.sol b/src/test/modules/CDEPO/reclaimTo.t.sol new file mode 100644 index 00000000..4db23679 --- /dev/null +++ b/src/test/modules/CDEPO/reclaimTo.t.sol @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: Unlicensed +pragma solidity 0.8.15; + +import {Test} from "forge-std/Test.sol"; + +import {CDEPOTest} from "./CDEPOTest.sol"; + +contract ReclaimToCDEPOTest is CDEPOTest { + // when the amount is zero + // [ ] it reverts + // when the caller has not approved CDEPO to spend convertible deposit tokens + // [ ] it reverts + // when the caller has approved CDEPO to spend convertible deposit tokens + // when the caller has an insufficient balance of convertible deposit tokens + // [ ] it reverts + // when the caller has a sufficient balance of convertible deposit tokens + // [ ] it burns the corresponding amount of convertible deposit tokens + // [ ] it withdraws the underlying asset from the vault + // [ ] it transfers the underlying asset to the `to_` address after applying the burn rate + // [ ] it marks the forfeited amount of the underlying asset as yield + // [ ] it updates the total deposits + // [ ] it emits a `Transfer` event +} diff --git a/src/test/modules/CDEPO/redeem.t.sol b/src/test/modules/CDEPO/redeem.t.sol new file mode 100644 index 00000000..4789240b --- /dev/null +++ b/src/test/modules/CDEPO/redeem.t.sol @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: Unlicensed +pragma solidity 0.8.15; + +import {Test} from "forge-std/Test.sol"; + +import {CDEPOTest} from "./CDEPOTest.sol"; + +contract RedeemCDEPOTest is CDEPOTest { + // when the amount is zero + // [ ] it reverts + // when the caller is not permissioned + // [ ] it reverts + // when the caller is permissioned + // [ ] it burns the corresponding amount of convertible deposit tokens + // [ ] it withdraws the underlying asset from the vault + // [ ] it transfers the underlying asset to the caller + // [ ] it emits a `Transfer` event +} diff --git a/src/test/modules/CDEPO/setBurnRate.t.sol b/src/test/modules/CDEPO/setBurnRate.t.sol new file mode 100644 index 00000000..6d28834c --- /dev/null +++ b/src/test/modules/CDEPO/setBurnRate.t.sol @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: Unlicensed +pragma solidity 0.8.15; + +import {Test} from "forge-std/Test.sol"; + +import {CDEPOTest} from "./CDEPOTest.sol"; + +contract SetBurnRateCDEPOTest is CDEPOTest { + // when the caller is not permissioned + // [ ] it reverts + // when the new burn rate is greater than the maximum burn rate + // [ ] it reverts + // when the new burn rate is within bounds + // [ ] it sets the new burn rate + // [ ] it emits an event +} diff --git a/src/test/modules/CDEPO/sweepYield.t.sol b/src/test/modules/CDEPO/sweepYield.t.sol new file mode 100644 index 00000000..942a02fe --- /dev/null +++ b/src/test/modules/CDEPO/sweepYield.t.sol @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: Unlicensed +pragma solidity 0.8.15; + +import {Test} from "forge-std/Test.sol"; + +import {CDEPOTest} from "./CDEPOTest.sol"; + +contract SweepYieldCDEPOTest is CDEPOTest { + // when the caller is not permissioned + // [ ] it reverts + // when there are no deposits + // [ ] it does not transfer any yield + // [ ] it returns zero + // [ ] it does not emit any events + // when there are deposits + // when it is called again without any additional yield + // [ ] it returns zero + // when deposit tokens have been reclaimed + // [ ] the yield includes the forfeited amount + // [ ] it withdraws the underlying asset from the vault + // [ ] it transfers the underlying asset to the caller + // [ ] it emits a `YieldSwept` event +} From 908a641f8ec1edfa33e58f1fa9929b643d7849cf Mon Sep 17 00:00:00 2001 From: Jem <0x0xjem@gmail.com> Date: Thu, 19 Dec 2024 15:52:05 +0500 Subject: [PATCH 49/64] CDEPO: tests for mint/mintTo/previewMint, rename burn rate to reclaim rate --- src/modules/CDEPO/CDEPO.v1.sol | 24 +++---- .../CDEPO/OlympusConvertibleDepository.sol | 33 ++++++--- src/test/modules/CDEPO/CDEPOTest.sol | 72 +++++++++++++++++++ src/test/modules/CDEPO/constructor.t.sol | 17 +++++ src/test/modules/CDEPO/mint.t.sol | 65 ++++++++++++++--- src/test/modules/CDEPO/mintTo.t.sol | 65 ++++++++++++++--- src/test/modules/CDEPO/previewMint.t.sol | 25 ++++++- src/test/modules/CDEPO/setBurnRate.t.sol | 8 +-- 8 files changed, 259 insertions(+), 50 deletions(-) create mode 100644 src/test/modules/CDEPO/constructor.t.sol diff --git a/src/modules/CDEPO/CDEPO.v1.sol b/src/modules/CDEPO/CDEPO.v1.sol index 58acaf25..ccb705b3 100644 --- a/src/modules/CDEPO/CDEPO.v1.sol +++ b/src/modules/CDEPO/CDEPO.v1.sol @@ -10,8 +10,8 @@ import {ERC4626} from "solmate/mixins/ERC4626.sol"; abstract contract CDEPOv1 is Module, ERC20 { // ========== EVENTS ========== // - /// @notice Emitted when the burn rate is updated - event BurnRateUpdated(uint16 newBurnRate); + /// @notice Emitted when the reclaim rate is updated + event ReclaimRateUpdated(uint16 newReclaimRate); /// @notice Emitted when the yield is swept event YieldSwept(address receiver, uint256 reserveAmount, uint256 sReserveAmount); @@ -28,9 +28,9 @@ abstract contract CDEPOv1 is Module, ERC20 { // ========== STATE VARIABLES ========== // - /// @notice The burn rate of the convertible deposit token - /// @dev A burn rate of 99e2 (99%) means that for every 100 convertible deposit tokens burned, 99 underlying asset tokens are returned - uint16 internal _burnRate; + /// @notice The reclaim rate of the convertible deposit token + /// @dev A reclaim rate of 99e2 (99%) means that for every 100 convertible deposit tokens burned, 99 underlying asset tokens are returned + uint16 internal _reclaimRate; /// @notice The total amount of vault shares in the contract uint256 public totalShares; @@ -134,15 +134,15 @@ abstract contract CDEPOv1 is Module, ERC20 { // ========== ADMIN ========== // - /// @notice Set the burn rate of the convertible deposit token + /// @notice Set the reclaim rate of the convertible deposit token /// @dev The implementing function should perform the following: /// - Validating that the caller has the correct role /// - Validating that the new rate is within bounds - /// - Setting the new burn rate + /// - Setting the new reclaim rate /// - Emitting an event /// - /// @param newBurnRate_ The new burn rate - function setBurnRate(uint16 newBurnRate_) external virtual; + /// @param newReclaimRate_ The new reclaim rate + function setReclaimRate(uint16 newReclaimRate_) external virtual; // ========== STATE VARIABLES ========== // @@ -152,7 +152,7 @@ abstract contract CDEPOv1 is Module, ERC20 { /// @notice The underlying ERC20 asset function asset() external view virtual returns (ERC20); - /// @notice The burn rate of the convertible deposit token - /// @dev A burn rate of 99e2 (99%) means that for every 100 convertible deposit tokens burned, 99 underlying asset tokens are returned - function burnRate() external view virtual returns (uint16); + /// @notice The reclaim rate of the convertible deposit token + /// @dev A reclaim rate of 99e2 (99%) means that for every 100 convertible deposit tokens burned, 99 underlying asset tokens are returned + function reclaimRate() external view virtual returns (uint16); } diff --git a/src/modules/CDEPO/OlympusConvertibleDepository.sol b/src/modules/CDEPO/OlympusConvertibleDepository.sol index 5a45635f..9659b4ff 100644 --- a/src/modules/CDEPO/OlympusConvertibleDepository.sol +++ b/src/modules/CDEPO/OlympusConvertibleDepository.sol @@ -2,7 +2,7 @@ pragma solidity 0.8.15; import {CDEPOv1} from "./CDEPO.v1.sol"; -import {Kernel, Module} from "src/Kernel.sol"; +import {Kernel, Module, Keycode, toKeycode} from "src/Kernel.sol"; import {ERC20} from "solmate/tokens/ERC20.sol"; import {ERC4626} from "solmate/mixins/ERC4626.sol"; @@ -16,7 +16,7 @@ contract OlympusConvertibleDepository is CDEPOv1 { ERC20 public immutable override asset; /// @inheritdoc CDEPOv1 - uint16 public override burnRate; + uint16 public override reclaimRate; // ========== CONSTRUCTOR ========== // @@ -36,6 +36,19 @@ contract OlympusConvertibleDepository is CDEPOv1 { asset = ERC20(vault.asset()); } + // ========== MODULE FUNCTIONS ========== // + + /// @inheritdoc Module + function KEYCODE() public pure override returns (Keycode) { + return toKeycode("CDEPO"); + } + + /// @inheritdoc Module + function VERSION() public pure override returns (uint8 major, uint8 minor) { + major = 1; + minor = 0; + } + // ========== ERC20 OVERRIDES ========== // /// @inheritdoc CDEPOv1 @@ -108,7 +121,7 @@ contract OlympusConvertibleDepository is CDEPOv1 { function previewReclaim( uint256 amount_ ) public view virtual override returns (uint256 assetsOut) { - assetsOut = (amount_ * burnRate) / ONE_HUNDRED_PERCENT; + assetsOut = (amount_ * reclaimRate) / ONE_HUNDRED_PERCENT; } /// @inheritdoc CDEPOv1 @@ -189,15 +202,15 @@ contract OlympusConvertibleDepository is CDEPOv1 { /// @inheritdoc CDEPOv1 /// @dev This function reverts if: /// - The caller is not permissioned - /// - The new burn rate is not within bounds - function setBurnRate(uint16 newBurnRate_) external virtual override permissioned { - // Validate that the burn rate is within bounds - if (newBurnRate_ > ONE_HUNDRED_PERCENT) revert CDEPO_InvalidArgs("Greater than 100%"); + /// - The new reclaim rate is not within bounds + function setReclaimRate(uint16 newReclaimRate_) external virtual override permissioned { + // Validate that the reclaim rate is within bounds + if (newReclaimRate_ > ONE_HUNDRED_PERCENT) revert CDEPO_InvalidArgs("Greater than 100%"); - // Update the burn rate - burnRate = newBurnRate_; + // Update the reclaim rate + reclaimRate = newReclaimRate_; // Emit the event - emit BurnRateUpdated(newBurnRate_); + emit ReclaimRateUpdated(newReclaimRate_); } } diff --git a/src/test/modules/CDEPO/CDEPOTest.sol b/src/test/modules/CDEPO/CDEPOTest.sol index 161c9669..3ce8f390 100644 --- a/src/test/modules/CDEPO/CDEPOTest.sol +++ b/src/test/modules/CDEPO/CDEPOTest.sol @@ -18,6 +18,8 @@ abstract contract CDEPOTest is Test { MockERC20 public reserveToken; MockERC4626 public vault; address public godmode; + address public recipient = address(0x1); + address public recipientTwo = address(0x2); uint48 public constant INITIAL_BLOCK = 100000000; @@ -43,6 +45,43 @@ abstract contract CDEPOTest is Test { // ========== ASSERTIONS ========== // + function _assertReserveTokenBalance( + uint256 recipientAmount_, + uint256 recipientTwoAmount_ + ) public { + assertEq( + reserveToken.balanceOf(recipient), + recipientAmount_, + "recipient: reserve token balance" + ); + assertEq( + reserveToken.balanceOf(recipientTwo), + recipientTwoAmount_, + "recipientTwo: reserve token balance" + ); + + assertEq( + reserveToken.totalSupply(), + reserveToken.balanceOf(address(CDEPO.vault())) + recipientAmount_ + recipientTwoAmount_, + "total supply" + ); + } + + function _assertCDEPOBalance(uint256 recipientAmount_, uint256 recipientTwoAmount_) public { + assertEq(CDEPO.balanceOf(recipient), recipientAmount_, "recipient: CDEPO balance"); + assertEq(CDEPO.balanceOf(recipientTwo), recipientTwoAmount_, "recipientTwo: CDEPO balance"); + + assertEq(CDEPO.totalSupply(), recipientAmount_ + recipientTwoAmount_, "total supply"); + } + + function _assertVaultBalance(uint256 recipientAmount_, uint256 recipientTwoAmount_) public { + assertEq( + vault.totalAssets(), + recipientAmount_ + recipientTwoAmount_, + "vault: total assets" + ); + } + // ========== MODIFIERS ========== // function _mintReserveToken(address to_, uint256 amount_) internal { @@ -53,4 +92,37 @@ abstract contract CDEPOTest is Test { _mintReserveToken(to_, amount_); _; } + + function _approveReserveTokenSpending( + address owner_, + address spender_, + uint256 amount_ + ) internal { + vm.prank(owner_); + reserveToken.approve(spender_, amount_); + } + + modifier givenReserveTokenSpendingIsApproved( + address owner_, + address spender_, + uint256 amount_ + ) { + _approveReserveTokenSpending(owner_, spender_, amount_); + _; + } + + function _mint(uint256 amount_) internal { + vm.prank(recipient); + CDEPO.mint(amount_); + } + + function _mintTo(address owner_, address to_, uint256 amount_) internal { + vm.prank(owner_); + CDEPO.mintTo(to_, amount_); + } + + modifier givenRecipientHasCDEPO(uint256 amount_) { + _mint(amount_); + _; + } } diff --git a/src/test/modules/CDEPO/constructor.t.sol b/src/test/modules/CDEPO/constructor.t.sol new file mode 100644 index 00000000..bc0ac6b4 --- /dev/null +++ b/src/test/modules/CDEPO/constructor.t.sol @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: Unlicensed +pragma solidity 0.8.15; + +import {Test} from "forge-std/Test.sol"; +import {CDEPOTest} from "./CDEPOTest.sol"; + +contract ConstructorTest is CDEPOTest { + // when the kernel address is zero + // [ ] it reverts + // when the vault address is zero + // [ ] it reverts + // [ ] the name is set to "cd" + the asset symbol + // [ ] the symbol is set to "cd" + the asset symbol + // [ ] the decimals are set to the asset decimals + // [ ] the asset is recorded + // [ ] the vault is recorded +} diff --git a/src/test/modules/CDEPO/mint.t.sol b/src/test/modules/CDEPO/mint.t.sol index 97fe6307..e23e4e56 100644 --- a/src/test/modules/CDEPO/mint.t.sol +++ b/src/test/modules/CDEPO/mint.t.sol @@ -1,23 +1,66 @@ // SPDX-License-Identifier: Unlicensed pragma solidity 0.8.15; -import {Test} from "forge-std/Test.sol"; - +import {Test, stdError} from "forge-std/Test.sol"; import {CDEPOTest} from "./CDEPOTest.sol"; +import {CDEPOv1} from "src/modules/CDEPO/CDEPO.v1.sol"; + contract MintCDEPOTest is CDEPOTest { - // when the recipient is the zero address - // [ ] it reverts // when the amount is zero - // [ ] it reverts + // [X] it reverts // when the caller has not approved CDEPO to spend reserve tokens - // [ ] it reverts + // [X] it reverts // when the caller has approved CDEPO to spend reserve tokens // when the caller has an insufficient balance of reserve tokens - // [ ] it reverts + // [X] it reverts // when the caller has a sufficient balance of reserve tokens - // [ ] it transfers the reserve tokens to CDEPO - // [ ] it mints an equal amount of convertible deposit tokens to the caller - // [ ] it deposits the reserve tokens into the vault - // [ ] it emits a `Transfer` event + // [X] it transfers the reserve tokens to CDEPO + // [X] it mints an equal amount of convertible deposit tokens to the caller + // [X] it deposits the reserve tokens into the vault + + function test_zeroAmount_reverts() public { + // Expect revert + vm.expectRevert(abi.encodeWithSelector(CDEPOv1.CDEPO_InvalidArgs.selector, "amount")); + + // Call function + _mint(0); + } + + function test_spendingNotApproved_reverts() + public + givenAddressHasReserveToken(recipient, 10e18) + { + // Expect revert + vm.expectRevert(stdError.arithmeticError); + + // Call function + _mint(10e18); + } + + function test_insufficientBalance_reverts() + public + givenAddressHasReserveToken(recipient, 5e18) + givenReserveTokenSpendingIsApproved(address(recipient), address(CDEPO), 10e18) + { + // Expect revert + vm.expectRevert(stdError.arithmeticError); + + // Call function + _mint(10e18); + } + + function test_success() + public + givenAddressHasReserveToken(recipient, 10e18) + givenReserveTokenSpendingIsApproved(address(recipient), address(CDEPO), 10e18) + { + // Call function + _mint(10e18); + + // Assert balances + _assertReserveTokenBalance(0, 0); + _assertCDEPOBalance(10e18, 0); + _assertVaultBalance(10e18, 0); + } } diff --git a/src/test/modules/CDEPO/mintTo.t.sol b/src/test/modules/CDEPO/mintTo.t.sol index 635d7ffb..6dc46db6 100644 --- a/src/test/modules/CDEPO/mintTo.t.sol +++ b/src/test/modules/CDEPO/mintTo.t.sol @@ -1,23 +1,68 @@ // SPDX-License-Identifier: Unlicensed pragma solidity 0.8.15; -import {Test} from "forge-std/Test.sol"; - +import {Test, stdError} from "forge-std/Test.sol"; import {CDEPOTest} from "./CDEPOTest.sol"; +import {CDEPOv1} from "src/modules/CDEPO/CDEPO.v1.sol"; + contract MintToCDEPOTest is CDEPOTest { // when the recipient is the zero address - // [ ] it reverts + // [X] it reverts // when the amount is zero - // [ ] it reverts + // [X] it reverts // when the caller has not approved CDEPO to spend reserve tokens - // [ ] it reverts + // [X] it reverts // when the caller has approved CDEPO to spend reserve tokens // when the caller has an insufficient balance of reserve tokens - // [ ] it reverts + // [X] it reverts // when the caller has a sufficient balance of reserve tokens - // [ ] it transfers the reserve tokens to CDEPO - // [ ] it mints an equal amount of convertible deposit tokens to the `to_` address - // [ ] it deposits the reserve tokens into the vault - // [ ] it emits a `Transfer` event + // [X] it transfers the reserve tokens to CDEPO + // [X] it mints an equal amount of convertible deposit tokens to the `to_` address + // [X] it deposits the reserve tokens into the vault + + function test_zeroAmount_reverts() public { + // Expect revert + vm.expectRevert(abi.encodeWithSelector(CDEPOv1.CDEPO_InvalidArgs.selector, "amount")); + + // Call function + _mintTo(recipient, recipientTwo, 0); + } + + function test_spendingNotApproved_reverts() + public + givenAddressHasReserveToken(recipient, 10e18) + { + // Expect revert + vm.expectRevert(stdError.arithmeticError); + + // Call function + _mintTo(recipient, recipientTwo, 10e18); + } + + function test_insufficientBalance_reverts() + public + givenAddressHasReserveToken(recipient, 5e18) + givenReserveTokenSpendingIsApproved(recipient, address(CDEPO), 10e18) + { + // Expect revert + vm.expectRevert(stdError.arithmeticError); + + // Call function + _mintTo(recipient, recipientTwo, 10e18); + } + + function test_success() + public + givenAddressHasReserveToken(recipient, 10e18) + givenReserveTokenSpendingIsApproved(recipient, address(CDEPO), 10e18) + { + // Call function + _mintTo(recipient, recipientTwo, 10e18); + + // Assert balances + _assertReserveTokenBalance(0, 0); + _assertCDEPOBalance(0, 10e18); + _assertVaultBalance(0, 10e18); + } } diff --git a/src/test/modules/CDEPO/previewMint.t.sol b/src/test/modules/CDEPO/previewMint.t.sol index 2a39a13a..dc808c5a 100644 --- a/src/test/modules/CDEPO/previewMint.t.sol +++ b/src/test/modules/CDEPO/previewMint.t.sol @@ -2,12 +2,31 @@ pragma solidity 0.8.15; import {Test} from "forge-std/Test.sol"; - import {CDEPOTest} from "./CDEPOTest.sol"; +import {CDEPOv1} from "src/modules/CDEPO/CDEPO.v1.sol"; + contract PreviewMintCDEPOTest is CDEPOTest { // when the amount is zero - // [ ] it reverts + // [X] it reverts // when the amount is greater than zero - // [ ] it returns the same amount + // [X] it returns the same amount + + function test_zeroAmount_reverts() public { + // Expect revert + vm.expectRevert(abi.encodeWithSelector(CDEPOv1.CDEPO_InvalidArgs.selector, "amount")); + + // Call function + CDEPO.previewMint(0); + } + + function test_success(uint256 amount_) public { + uint256 amount = bound(amount_, 1, type(uint256).max); + + // Call function + uint256 amountOut = CDEPO.previewMint(amount); + + // Assert + assertEq(amountOut, amount, "amountOut"); + } } diff --git a/src/test/modules/CDEPO/setBurnRate.t.sol b/src/test/modules/CDEPO/setBurnRate.t.sol index 6d28834c..03145a59 100644 --- a/src/test/modules/CDEPO/setBurnRate.t.sol +++ b/src/test/modules/CDEPO/setBurnRate.t.sol @@ -5,12 +5,12 @@ import {Test} from "forge-std/Test.sol"; import {CDEPOTest} from "./CDEPOTest.sol"; -contract SetBurnRateCDEPOTest is CDEPOTest { +contract SetReclaimRateCDEPOTest is CDEPOTest { // when the caller is not permissioned // [ ] it reverts - // when the new burn rate is greater than the maximum burn rate + // when the new reclaim rate is greater than the maximum reclaim rate // [ ] it reverts - // when the new burn rate is within bounds - // [ ] it sets the new burn rate + // when the new reclaim rate is within bounds + // [ ] it sets the new reclaim rate // [ ] it emits an event } From fb9bf7eab00c1b402ec4f3eaff3cc8c271b0b8e2 Mon Sep 17 00:00:00 2001 From: Jem <0x0xjem@gmail.com> Date: Thu, 19 Dec 2024 15:57:30 +0500 Subject: [PATCH 50/64] CDEPO: setReclaimRate tests --- src/test/modules/CDEPO/setBurnRate.t.sol | 16 ------ src/test/modules/CDEPO/setReclaimRate.t.sol | 56 +++++++++++++++++++++ 2 files changed, 56 insertions(+), 16 deletions(-) delete mode 100644 src/test/modules/CDEPO/setBurnRate.t.sol create mode 100644 src/test/modules/CDEPO/setReclaimRate.t.sol diff --git a/src/test/modules/CDEPO/setBurnRate.t.sol b/src/test/modules/CDEPO/setBurnRate.t.sol deleted file mode 100644 index 03145a59..00000000 --- a/src/test/modules/CDEPO/setBurnRate.t.sol +++ /dev/null @@ -1,16 +0,0 @@ -// SPDX-License-Identifier: Unlicensed -pragma solidity 0.8.15; - -import {Test} from "forge-std/Test.sol"; - -import {CDEPOTest} from "./CDEPOTest.sol"; - -contract SetReclaimRateCDEPOTest is CDEPOTest { - // when the caller is not permissioned - // [ ] it reverts - // when the new reclaim rate is greater than the maximum reclaim rate - // [ ] it reverts - // when the new reclaim rate is within bounds - // [ ] it sets the new reclaim rate - // [ ] it emits an event -} diff --git a/src/test/modules/CDEPO/setReclaimRate.t.sol b/src/test/modules/CDEPO/setReclaimRate.t.sol new file mode 100644 index 00000000..5f6f13a9 --- /dev/null +++ b/src/test/modules/CDEPO/setReclaimRate.t.sol @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: Unlicensed +pragma solidity 0.8.15; + +import {Test} from "forge-std/Test.sol"; +import {CDEPOTest} from "./CDEPOTest.sol"; + +import {Module} from "src/Kernel.sol"; +import {CDEPOv1} from "src/modules/CDEPO/CDEPO.v1.sol"; + +contract SetReclaimRateCDEPOTest is CDEPOTest { + event ReclaimRateUpdated(uint16 newReclaimRate); + + // when the caller is not permissioned + // [X] it reverts + // when the new reclaim rate is greater than the maximum reclaim rate + // [X] it reverts + // when the new reclaim rate is within bounds + // [X] it sets the new reclaim rate + // [X] it emits an event + + function test_callerNotPermissioned_reverts() public { + // Expect revert + vm.expectRevert( + abi.encodeWithSelector(Module.Module_PolicyNotPermitted.selector, address(this)) + ); + + // Call function + CDEPO.setReclaimRate(100e2); + } + + function test_aboveMax_reverts() public { + // Expect revert + vm.expectRevert( + abi.encodeWithSelector(CDEPOv1.CDEPO_InvalidArgs.selector, "Greater than 100%") + ); + + // Call function + vm.prank(godmode); + CDEPO.setReclaimRate(100e2 + 1); + } + + function test_success(uint16 newReclaimRate_) public { + uint16 reclaimRate = uint16(bound(newReclaimRate_, 0, 100e2)); + + // Expect event + vm.expectEmit(true, true, true, true); + emit ReclaimRateUpdated(reclaimRate); + + // Call function + vm.prank(godmode); + CDEPO.setReclaimRate(reclaimRate); + + // Assert + assertEq(CDEPO.reclaimRate(), reclaimRate, "reclaimRate"); + } +} From 56cabb2d76bef020b43fd90f16870e9a56a12c7d Mon Sep 17 00:00:00 2001 From: Jem <0x0xjem@gmail.com> Date: Thu, 19 Dec 2024 16:06:32 +0500 Subject: [PATCH 51/64] CDEPO: mint test fixes --- .../CDEPO/OlympusConvertibleDepository.sol | 15 +++++++++++++-- src/test/modules/CDEPO/CDEPOTest.sol | 5 +++-- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/src/modules/CDEPO/OlympusConvertibleDepository.sol b/src/modules/CDEPO/OlympusConvertibleDepository.sol index 9659b4ff..a1bb45d0 100644 --- a/src/modules/CDEPO/OlympusConvertibleDepository.sol +++ b/src/modules/CDEPO/OlympusConvertibleDepository.sol @@ -60,18 +60,25 @@ contract OlympusConvertibleDepository is CDEPOv1 { /// @inheritdoc CDEPOv1 /// @dev This function performs the following: - /// - Transfers the underlying asset from `to_` to the contract + /// - Transfers the underlying asset from the caller to the contract /// - Deposits the underlying asset into the ERC4626 vault /// - Mints the corresponding amount of convertible deposit tokens to `to_` /// - Emits a `Transfer` event /// + /// This function reverts if: + /// - The amount is zero + /// /// @param to_ The address to mint the tokens to /// @param amount_ The amount of underlying asset to transfer function mintTo(address to_, uint256 amount_) public virtual override { + // Validate that the amount is greater than zero + if (amount_ == 0) revert CDEPO_InvalidArgs("amount"); + // Transfer the underlying asset to the contract - asset.transferFrom(to_, address(this), amount_); + asset.transferFrom(msg.sender, address(this), amount_); // Deposit the underlying asset into the vault and update the total shares + asset.approve(address(vault), amount_); totalShares += vault.deposit(amount_, to_); // Mint the CD tokens to the caller @@ -83,6 +90,10 @@ contract OlympusConvertibleDepository is CDEPOv1 { function previewMint( uint256 amount_ ) external view virtual override returns (uint256 tokensOut) { + // Validate that the amount is greater than zero + if (amount_ == 0) revert CDEPO_InvalidArgs("amount"); + + // Return the same amount of CD tokens return amount_; } diff --git a/src/test/modules/CDEPO/CDEPOTest.sol b/src/test/modules/CDEPO/CDEPOTest.sol index 3ce8f390..95db0999 100644 --- a/src/test/modules/CDEPO/CDEPOTest.sol +++ b/src/test/modules/CDEPO/CDEPOTest.sol @@ -20,6 +20,7 @@ abstract contract CDEPOTest is Test { address public godmode; address public recipient = address(0x1); address public recipientTwo = address(0x2); + uint256 public constant INITIAL_VAULT_BALANCE = 10e18; uint48 public constant INITIAL_BLOCK = 100000000; @@ -30,7 +31,7 @@ abstract contract CDEPOTest is Test { vault = new MockERC4626(reserveToken, "sReserve Token", "sRST"); // Mint reserve tokens to the vault without depositing, so that the conversion is not 1 - reserveToken.mint(address(vault), 10e18); + reserveToken.mint(address(vault), INITIAL_VAULT_BALANCE); kernel = new Kernel(); CDEPO = new OlympusConvertibleDepository(address(kernel), address(vault)); @@ -77,7 +78,7 @@ abstract contract CDEPOTest is Test { function _assertVaultBalance(uint256 recipientAmount_, uint256 recipientTwoAmount_) public { assertEq( vault.totalAssets(), - recipientAmount_ + recipientTwoAmount_, + recipientAmount_ + recipientTwoAmount_ + INITIAL_VAULT_BALANCE, "vault: total assets" ); } From 85e73e93c84a7f8f4054a282963b5e1601701a91 Mon Sep 17 00:00:00 2001 From: Jem <0x0xjem@gmail.com> Date: Thu, 19 Dec 2024 18:05:57 +0500 Subject: [PATCH 52/64] CDEPO: more tests --- .../CDEPO/OlympusConvertibleDepository.sol | 17 +++- src/test/modules/CDEPO/CDEPOTest.sol | 53 ++++++++++- src/test/modules/CDEPO/mint.t.sol | 2 +- src/test/modules/CDEPO/mintTo.t.sol | 2 +- src/test/modules/CDEPO/reclaim.t.sol | 69 +++++++++++++-- src/test/modules/CDEPO/reclaimTo.t.sol | 87 ++++++++++++++++--- 6 files changed, 200 insertions(+), 30 deletions(-) diff --git a/src/modules/CDEPO/OlympusConvertibleDepository.sol b/src/modules/CDEPO/OlympusConvertibleDepository.sol index a1bb45d0..c00344cf 100644 --- a/src/modules/CDEPO/OlympusConvertibleDepository.sol +++ b/src/modules/CDEPO/OlympusConvertibleDepository.sol @@ -5,6 +5,7 @@ import {CDEPOv1} from "./CDEPO.v1.sol"; import {Kernel, Module, Keycode, toKeycode} from "src/Kernel.sol"; import {ERC20} from "solmate/tokens/ERC20.sol"; import {ERC4626} from "solmate/mixins/ERC4626.sol"; +import {FullMath} from "src/libraries/FullMath.sol"; contract OlympusConvertibleDepository is CDEPOv1 { // ========== STATE VARIABLES ========== // @@ -79,7 +80,7 @@ contract OlympusConvertibleDepository is CDEPOv1 { // Deposit the underlying asset into the vault and update the total shares asset.approve(address(vault), amount_); - totalShares += vault.deposit(amount_, to_); + totalShares += vault.deposit(amount_, address(this)); // Mint the CD tokens to the caller _mint(to_, amount_); @@ -109,17 +110,25 @@ contract OlympusConvertibleDepository is CDEPOv1 { /// - Burns the CD tokens from the caller /// - Calculates the quantity of underlying asset to withdraw and return /// - Returns the underlying asset to `to_` - /// - Emits a `Transfer` event + /// + /// This function reverts if: + /// - The amount is zero + /// - The caller has not approved spending of the CD tokens /// /// @param to_ The address to reclaim the tokens to /// @param amount_ The amount of CD tokens to burn function reclaimTo(address to_, uint256 amount_) public virtual override { + // Validate that the amount is greater than zero + if (amount_ == 0) revert CDEPO_InvalidArgs("amount"); + + // Ensure that the caller has approved spending of the CD tokens + if (allowance[msg.sender][address(this)] < amount_) revert CDEPO_InvalidArgs("allowance"); + // Burn the CD tokens from `from_` _burn(msg.sender, amount_); // Calculate the quantity of underlying asset to withdraw and return // This will create a difference between the quantity of underlying assets and the vault shares, which will be swept as yield - // TODO make sure there are no shares left over if all CD tokens are burned uint256 discountedAssetsOut = previewReclaim(amount_); uint256 shares = vault.previewWithdraw(discountedAssetsOut); totalShares -= shares; @@ -132,7 +141,7 @@ contract OlympusConvertibleDepository is CDEPOv1 { function previewReclaim( uint256 amount_ ) public view virtual override returns (uint256 assetsOut) { - assetsOut = (amount_ * reclaimRate) / ONE_HUNDRED_PERCENT; + assetsOut = FullMath.mulDiv(amount_, reclaimRate, ONE_HUNDRED_PERCENT); } /// @inheritdoc CDEPOv1 diff --git a/src/test/modules/CDEPO/CDEPOTest.sol b/src/test/modules/CDEPO/CDEPOTest.sol index 95db0999..2a21f184 100644 --- a/src/test/modules/CDEPO/CDEPOTest.sol +++ b/src/test/modules/CDEPO/CDEPOTest.sol @@ -21,6 +21,7 @@ abstract contract CDEPOTest is Test { address public recipient = address(0x1); address public recipientTwo = address(0x2); uint256 public constant INITIAL_VAULT_BALANCE = 10e18; + uint16 public reclaimRate = 99e2; uint48 public constant INITIAL_BLOCK = 100000000; @@ -42,6 +43,10 @@ abstract contract CDEPOTest is Test { // Install modules and policies on Kernel kernel.executeAction(Actions.InstallModule, address(CDEPO)); kernel.executeAction(Actions.ActivatePolicy, godmode); + + // Set reclaim rate + vm.prank(godmode); + CDEPO.setReclaimRate(reclaimRate); } // ========== ASSERTIONS ========== // @@ -49,7 +54,7 @@ abstract contract CDEPOTest is Test { function _assertReserveTokenBalance( uint256 recipientAmount_, uint256 recipientTwoAmount_ - ) public { + ) internal { assertEq( reserveToken.balanceOf(recipient), recipientAmount_, @@ -68,19 +73,37 @@ abstract contract CDEPOTest is Test { ); } - function _assertCDEPOBalance(uint256 recipientAmount_, uint256 recipientTwoAmount_) public { + function _assertCDEPOBalance(uint256 recipientAmount_, uint256 recipientTwoAmount_) internal { assertEq(CDEPO.balanceOf(recipient), recipientAmount_, "recipient: CDEPO balance"); assertEq(CDEPO.balanceOf(recipientTwo), recipientTwoAmount_, "recipientTwo: CDEPO balance"); assertEq(CDEPO.totalSupply(), recipientAmount_ + recipientTwoAmount_, "total supply"); } - function _assertVaultBalance(uint256 recipientAmount_, uint256 recipientTwoAmount_) public { + function _assertVaultBalance( + uint256 recipientAmount_, + uint256 recipientTwoAmount_, + uint256 forfeitedAmount_ + ) internal { assertEq( vault.totalAssets(), - recipientAmount_ + recipientTwoAmount_ + INITIAL_VAULT_BALANCE, + recipientAmount_ + recipientTwoAmount_ + INITIAL_VAULT_BALANCE + forfeitedAmount_, "vault: total assets" ); + + assertGt(vault.balanceOf(address(CDEPO)), 0, "CDEPO: vault balance > 0"); + assertEq(vault.balanceOf(recipient), 0, "recipient: vault balance = 0"); + assertEq(vault.balanceOf(recipientTwo), 0, "recipientTwo: vault balance = 0"); + } + + function _assertTotalShares(uint256 withdrawnAmount_) internal { + // Calculate the amount of reserve tokens that remain in the vault + uint256 vaultLockedReserveTokens = reserveToken.totalSupply() - withdrawnAmount_; + + // Convert to shares + uint256 expectedShares = vault.previewWithdraw(vaultLockedReserveTokens); + + assertEq(CDEPO.totalShares(), expectedShares, "total shares"); } // ========== MODIFIERS ========== // @@ -126,4 +149,26 @@ abstract contract CDEPOTest is Test { _mint(amount_); _; } + + function _approveCDEPOSpending(address owner_, address spender_, uint256 amount_) internal { + vm.prank(owner_); + CDEPO.approve(spender_, amount_); + } + + modifier givenCDEPOSpendingIsApproved( + address owner_, + address spender_, + uint256 amount_ + ) { + _approveCDEPOSpending(owner_, spender_, amount_); + _; + } + + modifier givenReclaimRateIsSet(uint16 reclaimRate_) { + vm.prank(godmode); + CDEPO.setReclaimRate(reclaimRate_); + + reclaimRate = reclaimRate_; + _; + } } diff --git a/src/test/modules/CDEPO/mint.t.sol b/src/test/modules/CDEPO/mint.t.sol index e23e4e56..238408e2 100644 --- a/src/test/modules/CDEPO/mint.t.sol +++ b/src/test/modules/CDEPO/mint.t.sol @@ -61,6 +61,6 @@ contract MintCDEPOTest is CDEPOTest { // Assert balances _assertReserveTokenBalance(0, 0); _assertCDEPOBalance(10e18, 0); - _assertVaultBalance(10e18, 0); + _assertVaultBalance(10e18, 0, 0); } } diff --git a/src/test/modules/CDEPO/mintTo.t.sol b/src/test/modules/CDEPO/mintTo.t.sol index 6dc46db6..e1647c62 100644 --- a/src/test/modules/CDEPO/mintTo.t.sol +++ b/src/test/modules/CDEPO/mintTo.t.sol @@ -63,6 +63,6 @@ contract MintToCDEPOTest is CDEPOTest { // Assert balances _assertReserveTokenBalance(0, 0); _assertCDEPOBalance(0, 10e18); - _assertVaultBalance(0, 10e18); + _assertVaultBalance(0, 10e18, 0); } } diff --git a/src/test/modules/CDEPO/reclaim.t.sol b/src/test/modules/CDEPO/reclaim.t.sol index 388834b6..ddda34a2 100644 --- a/src/test/modules/CDEPO/reclaim.t.sol +++ b/src/test/modules/CDEPO/reclaim.t.sol @@ -1,19 +1,70 @@ // SPDX-License-Identifier: Unlicensed pragma solidity 0.8.15; -import {Test} from "forge-std/Test.sol"; - +import {Test, stdError} from "forge-std/Test.sol"; import {CDEPOTest} from "./CDEPOTest.sol"; +import {FullMath} from "src/libraries/FullMath.sol"; +import {console2} from "forge-std/console2.sol"; + +import {CDEPOv1} from "src/modules/CDEPO/CDEPO.v1.sol"; contract ReclaimCDEPOTest is CDEPOTest { // when the amount is zero - // [ ] it reverts + // [X] it reverts // when the amount is greater than the caller's balance - // [ ] it reverts + // [X] it reverts // when the amount is greater than zero - // [ ] it burns the corresponding amount of convertible deposit tokens - // [ ] it withdraws the underlying asset from the vault - // [ ] it transfers the underlying asset to the caller after applying the burn rate - // [ ] it updates the total deposits - // [ ] it marks the forfeited amount of the underlying asset as yield + // [ X] it burns the corresponding amount of convertible deposit tokens + // [X] it withdraws the underlying asset from the vault + // [X] it transfers the underlying asset to the caller after applying the burn rate + // [X] it updates the total deposits + // [X] it marks the forfeited amount of the underlying asset as yield + + function test_amountIsZero_reverts() public { + // Expect revert + vm.expectRevert(abi.encodeWithSelector(CDEPOv1.CDEPO_InvalidArgs.selector, "amount")); + + // Call function + vm.prank(recipient); + CDEPO.reclaim(0); + } + + function test_insufficientBalance_reverts() + public + givenAddressHasReserveToken(recipient, 5e18) + givenReserveTokenSpendingIsApproved(recipient, address(CDEPO), 5e18) + givenRecipientHasCDEPO(5e18) + givenCDEPOSpendingIsApproved(recipient, address(CDEPO), 10e18) + { + // Expect revert + vm.expectRevert(stdError.arithmeticError); + + // Call function + vm.prank(recipient); + CDEPO.reclaim(10e18); + } + + function test_success() + public + givenAddressHasReserveToken(recipient, 10e18) + givenReserveTokenSpendingIsApproved(recipient, address(CDEPO), 10e18) + givenRecipientHasCDEPO(10e18) + givenCDEPOSpendingIsApproved(recipient, address(CDEPO), 10e18) + { + uint256 expectedReserveTokenAmount = FullMath.mulDiv(10e18, reclaimRate, 100e2); + assertEq(expectedReserveTokenAmount, 99e17, "expectedReserveTokenAmount"); + uint256 forfeitedAmount = 10e18 - expectedReserveTokenAmount; + + // Call function + vm.prank(recipient); + CDEPO.reclaim(10e18); + + // Assert balances + _assertReserveTokenBalance(expectedReserveTokenAmount, 0); + _assertCDEPOBalance(0, 0); + _assertVaultBalance(0, 0, forfeitedAmount); + + // Assert deposits + _assertTotalShares(expectedReserveTokenAmount); + } } diff --git a/src/test/modules/CDEPO/reclaimTo.t.sol b/src/test/modules/CDEPO/reclaimTo.t.sol index 4db23679..4a8f4674 100644 --- a/src/test/modules/CDEPO/reclaimTo.t.sol +++ b/src/test/modules/CDEPO/reclaimTo.t.sol @@ -1,23 +1,88 @@ // SPDX-License-Identifier: Unlicensed pragma solidity 0.8.15; -import {Test} from "forge-std/Test.sol"; - +import {Test, stdError} from "forge-std/Test.sol"; import {CDEPOTest} from "./CDEPOTest.sol"; +import {FullMath} from "src/libraries/FullMath.sol"; + +import {CDEPOv1} from "src/modules/CDEPO/CDEPO.v1.sol"; + +import {console2} from "forge-std/console2.sol"; contract ReclaimToCDEPOTest is CDEPOTest { // when the amount is zero - // [ ] it reverts + // [X] it reverts // when the caller has not approved CDEPO to spend convertible deposit tokens - // [ ] it reverts + // [X] it reverts // when the caller has approved CDEPO to spend convertible deposit tokens // when the caller has an insufficient balance of convertible deposit tokens - // [ ] it reverts + // [X] it reverts // when the caller has a sufficient balance of convertible deposit tokens - // [ ] it burns the corresponding amount of convertible deposit tokens - // [ ] it withdraws the underlying asset from the vault - // [ ] it transfers the underlying asset to the `to_` address after applying the burn rate - // [ ] it marks the forfeited amount of the underlying asset as yield - // [ ] it updates the total deposits - // [ ] it emits a `Transfer` event + // [X] it burns the corresponding amount of convertible deposit tokens + // [X] it withdraws the underlying asset from the vault + // [X] it transfers the underlying asset to the `to_` address after applying the reclaim rate + // [X] it marks the forfeited amount of the underlying asset as yield + // [X] it updates the total deposits + + function test_amountIsZero_reverts() public { + // Expect revert + vm.expectRevert(abi.encodeWithSelector(CDEPOv1.CDEPO_InvalidArgs.selector, "amount")); + + // Call function + vm.prank(recipient); + CDEPO.reclaimTo(recipientTwo, 0); + } + + function test_spendingNotApproved_reverts() + public + givenAddressHasReserveToken(recipient, 10e18) + givenReserveTokenSpendingIsApproved(recipient, address(CDEPO), 10e18) + givenRecipientHasCDEPO(10e18) + { + // Expect revert + vm.expectRevert(stdError.arithmeticError); + + // Call function + vm.prank(recipient); + CDEPO.reclaimTo(recipientTwo, 10e18); + } + + function test_insufficientBalance_reverts() + public + givenAddressHasReserveToken(recipient, 5e18) + givenReserveTokenSpendingIsApproved(recipient, address(CDEPO), 5e18) + givenRecipientHasCDEPO(5e18) + givenCDEPOSpendingIsApproved(recipient, address(CDEPO), 10e18) + { + // Expect revert + vm.expectRevert(abi.encodeWithSelector(CDEPOv1.CDEPO_InvalidArgs.selector, "allowance")); + + // Call function + vm.prank(recipient); + CDEPO.reclaimTo(recipientTwo, 10e18); + } + + function test_success() + public + givenAddressHasReserveToken(recipient, 10e18) + givenReserveTokenSpendingIsApproved(recipient, address(CDEPO), 10e18) + givenRecipientHasCDEPO(10e18) + givenCDEPOSpendingIsApproved(recipient, address(CDEPO), 10e18) + { + uint256 expectedReserveTokenAmount = FullMath.mulDiv(10e18, reclaimRate, 100e2); + assertEq(expectedReserveTokenAmount, 99e17, "expectedReserveTokenAmount"); + uint256 forfeitedAmount = 10e18 - expectedReserveTokenAmount; + + // Call function + vm.prank(recipient); + CDEPO.reclaimTo(recipientTwo, 10e18); + + // Assert balances + _assertReserveTokenBalance(0, expectedReserveTokenAmount); + _assertCDEPOBalance(0, 0); + _assertVaultBalance(0, 0, forfeitedAmount); + + // Assert deposits + _assertTotalShares(expectedReserveTokenAmount); + } } From e73bf8ef4c282448582a79d486d0c15e9c45171d Mon Sep 17 00:00:00 2001 From: Jem <0x0xjem@gmail.com> Date: Fri, 20 Dec 2024 11:52:50 +0500 Subject: [PATCH 53/64] CDEPO: more tests, use safeTransfer/safeApprove, clarify allowance --- .../CDEPO/OlympusConvertibleDepository.sol | 25 +++++++---- src/test/modules/CDEPO/CDEPOTest.sol | 14 ------- src/test/modules/CDEPO/constructor.t.sol | 42 +++++++++++++++---- src/test/modules/CDEPO/mint.t.sol | 6 +-- src/test/modules/CDEPO/mintTo.t.sol | 6 +-- src/test/modules/CDEPO/previewReclaim.t.sol | 29 +++++++++++-- src/test/modules/CDEPO/reclaim.t.sol | 2 - src/test/modules/CDEPO/reclaimTo.t.sol | 35 ++++------------ 8 files changed, 92 insertions(+), 67 deletions(-) diff --git a/src/modules/CDEPO/OlympusConvertibleDepository.sol b/src/modules/CDEPO/OlympusConvertibleDepository.sol index c00344cf..509eedb6 100644 --- a/src/modules/CDEPO/OlympusConvertibleDepository.sol +++ b/src/modules/CDEPO/OlympusConvertibleDepository.sol @@ -6,8 +6,12 @@ import {Kernel, Module, Keycode, toKeycode} from "src/Kernel.sol"; import {ERC20} from "solmate/tokens/ERC20.sol"; import {ERC4626} from "solmate/mixins/ERC4626.sol"; import {FullMath} from "src/libraries/FullMath.sol"; +import {SafeTransferLib} from "solmate/utils/SafeTransferLib.sol"; contract OlympusConvertibleDepository is CDEPOv1 { + using SafeTransferLib for ERC20; + using SafeTransferLib for ERC4626; + // ========== STATE VARIABLES ========== // /// @inheritdoc CDEPOv1 @@ -68,6 +72,7 @@ contract OlympusConvertibleDepository is CDEPOv1 { /// /// This function reverts if: /// - The amount is zero + /// - The caller has not approved this contract to spend `asset` /// /// @param to_ The address to mint the tokens to /// @param amount_ The amount of underlying asset to transfer @@ -76,10 +81,10 @@ contract OlympusConvertibleDepository is CDEPOv1 { if (amount_ == 0) revert CDEPO_InvalidArgs("amount"); // Transfer the underlying asset to the contract - asset.transferFrom(msg.sender, address(this), amount_); + asset.safeTransferFrom(msg.sender, address(this), amount_); // Deposit the underlying asset into the vault and update the total shares - asset.approve(address(vault), amount_); + asset.safeApprove(address(vault), amount_); totalShares += vault.deposit(amount_, address(this)); // Mint the CD tokens to the caller @@ -113,7 +118,6 @@ contract OlympusConvertibleDepository is CDEPOv1 { /// /// This function reverts if: /// - The amount is zero - /// - The caller has not approved spending of the CD tokens /// /// @param to_ The address to reclaim the tokens to /// @param amount_ The amount of CD tokens to burn @@ -121,10 +125,11 @@ contract OlympusConvertibleDepository is CDEPOv1 { // Validate that the amount is greater than zero if (amount_ == 0) revert CDEPO_InvalidArgs("amount"); - // Ensure that the caller has approved spending of the CD tokens - if (allowance[msg.sender][address(this)] < amount_) revert CDEPO_InvalidArgs("allowance"); - // Burn the CD tokens from `from_` + // This uses the standard ERC20 implementation from solmate + // It will revert if the caller does not have enough CD tokens + // Allowance is not checked, because the CD tokens belonging to the caller + // will be burned, and this function cannot be called on behalf of another address _burn(msg.sender, amount_); // Calculate the quantity of underlying asset to withdraw and return @@ -138,9 +143,13 @@ contract OlympusConvertibleDepository is CDEPOv1 { } /// @inheritdoc CDEPOv1 + /// @dev This function reverts if: + /// - The amount is zero function previewReclaim( uint256 amount_ ) public view virtual override returns (uint256 assetsOut) { + if (amount_ == 0) revert CDEPO_InvalidArgs("amount"); + assetsOut = FullMath.mulDiv(amount_, reclaimRate, ONE_HUNDRED_PERCENT); } @@ -161,7 +170,7 @@ contract OlympusConvertibleDepository is CDEPOv1 { totalShares -= sharesOut; // Transfer the shares to the caller - vault.transfer(msg.sender, sharesOut); + vault.safeTransfer(msg.sender, sharesOut); } // ========== YIELD MANAGER ========== // @@ -192,7 +201,7 @@ contract OlympusConvertibleDepository is CDEPOv1 { totalShares -= yieldSReserve; // Transfer the yield to the permissioned caller - vault.transfer(msg.sender, yieldSReserve); + vault.safeTransfer(msg.sender, yieldSReserve); // Emit the event emit YieldSwept(msg.sender, yieldReserve, yieldSReserve); diff --git a/src/test/modules/CDEPO/CDEPOTest.sol b/src/test/modules/CDEPO/CDEPOTest.sol index 2a21f184..4a25b3d8 100644 --- a/src/test/modules/CDEPO/CDEPOTest.sol +++ b/src/test/modules/CDEPO/CDEPOTest.sol @@ -150,20 +150,6 @@ abstract contract CDEPOTest is Test { _; } - function _approveCDEPOSpending(address owner_, address spender_, uint256 amount_) internal { - vm.prank(owner_); - CDEPO.approve(spender_, amount_); - } - - modifier givenCDEPOSpendingIsApproved( - address owner_, - address spender_, - uint256 amount_ - ) { - _approveCDEPOSpending(owner_, spender_, amount_); - _; - } - modifier givenReclaimRateIsSet(uint16 reclaimRate_) { vm.prank(godmode); CDEPO.setReclaimRate(reclaimRate_); diff --git a/src/test/modules/CDEPO/constructor.t.sol b/src/test/modules/CDEPO/constructor.t.sol index bc0ac6b4..20874bd5 100644 --- a/src/test/modules/CDEPO/constructor.t.sol +++ b/src/test/modules/CDEPO/constructor.t.sol @@ -4,14 +4,42 @@ pragma solidity 0.8.15; import {Test} from "forge-std/Test.sol"; import {CDEPOTest} from "./CDEPOTest.sol"; +import {OlympusConvertibleDepository} from "src/modules/CDEPO/OlympusConvertibleDepository.sol"; +import {CDEPOv1} from "src/modules/CDEPO/CDEPO.v1.sol"; + contract ConstructorTest is CDEPOTest { // when the kernel address is zero - // [ ] it reverts + // [X] it reverts // when the vault address is zero - // [ ] it reverts - // [ ] the name is set to "cd" + the asset symbol - // [ ] the symbol is set to "cd" + the asset symbol - // [ ] the decimals are set to the asset decimals - // [ ] the asset is recorded - // [ ] the vault is recorded + // [X] it reverts + // [X] the name is set to "cd" + the asset symbol + // [X] the symbol is set to "cd" + the asset symbol + // [X] the decimals are set to the asset decimals + // [X] the asset is recorded + // [X] the vault is recorded + + function test_kernel_zeroAddress_reverts() public { + // Expect revert + vm.expectRevert(abi.encodeWithSelector(CDEPOv1.CDEPO_InvalidArgs.selector, "kernel")); + + // Call function + new OlympusConvertibleDepository(address(0), address(vault)); + } + + function test_vault_zeroAddress_reverts() public { + // Expect revert + vm.expectRevert(abi.encodeWithSelector(CDEPOv1.CDEPO_InvalidArgs.selector, "vault")); + + // Call function + new OlympusConvertibleDepository(address(kernel), address(0)); + } + + function test_stateVariables() public { + assertEq(address(CDEPO.kernel()), address(kernel), "kernel"); + assertEq(CDEPO.name(), "cdRST", "name"); + assertEq(CDEPO.symbol(), "cdRST", "symbol"); + assertEq(CDEPO.decimals(), 18, "decimals"); + assertEq(address(CDEPO.asset()), address(reserveToken), "asset"); + assertEq(address(CDEPO.vault()), address(vault), "vault"); + } } diff --git a/src/test/modules/CDEPO/mint.t.sol b/src/test/modules/CDEPO/mint.t.sol index 238408e2..43c87482 100644 --- a/src/test/modules/CDEPO/mint.t.sol +++ b/src/test/modules/CDEPO/mint.t.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: Unlicensed pragma solidity 0.8.15; -import {Test, stdError} from "forge-std/Test.sol"; +import {Test} from "forge-std/Test.sol"; import {CDEPOTest} from "./CDEPOTest.sol"; import {CDEPOv1} from "src/modules/CDEPO/CDEPO.v1.sol"; @@ -32,7 +32,7 @@ contract MintCDEPOTest is CDEPOTest { givenAddressHasReserveToken(recipient, 10e18) { // Expect revert - vm.expectRevert(stdError.arithmeticError); + vm.expectRevert("TRANSFER_FROM_FAILED"); // Call function _mint(10e18); @@ -44,7 +44,7 @@ contract MintCDEPOTest is CDEPOTest { givenReserveTokenSpendingIsApproved(address(recipient), address(CDEPO), 10e18) { // Expect revert - vm.expectRevert(stdError.arithmeticError); + vm.expectRevert("TRANSFER_FROM_FAILED"); // Call function _mint(10e18); diff --git a/src/test/modules/CDEPO/mintTo.t.sol b/src/test/modules/CDEPO/mintTo.t.sol index e1647c62..204d833a 100644 --- a/src/test/modules/CDEPO/mintTo.t.sol +++ b/src/test/modules/CDEPO/mintTo.t.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: Unlicensed pragma solidity 0.8.15; -import {Test, stdError} from "forge-std/Test.sol"; +import {Test} from "forge-std/Test.sol"; import {CDEPOTest} from "./CDEPOTest.sol"; import {CDEPOv1} from "src/modules/CDEPO/CDEPO.v1.sol"; @@ -34,7 +34,7 @@ contract MintToCDEPOTest is CDEPOTest { givenAddressHasReserveToken(recipient, 10e18) { // Expect revert - vm.expectRevert(stdError.arithmeticError); + vm.expectRevert("TRANSFER_FROM_FAILED"); // Call function _mintTo(recipient, recipientTwo, 10e18); @@ -46,7 +46,7 @@ contract MintToCDEPOTest is CDEPOTest { givenReserveTokenSpendingIsApproved(recipient, address(CDEPO), 10e18) { // Expect revert - vm.expectRevert(stdError.arithmeticError); + vm.expectRevert("TRANSFER_FROM_FAILED"); // Call function _mintTo(recipient, recipientTwo, 10e18); diff --git a/src/test/modules/CDEPO/previewReclaim.t.sol b/src/test/modules/CDEPO/previewReclaim.t.sol index b04f1e8f..2fa1d36a 100644 --- a/src/test/modules/CDEPO/previewReclaim.t.sol +++ b/src/test/modules/CDEPO/previewReclaim.t.sol @@ -2,12 +2,35 @@ pragma solidity 0.8.15; import {Test} from "forge-std/Test.sol"; - import {CDEPOTest} from "./CDEPOTest.sol"; +import {FullMath} from "src/libraries/FullMath.sol"; + +import {CDEPOv1} from "src/modules/CDEPO/CDEPO.v1.sol"; contract PreviewReclaimCDEPOTest is CDEPOTest { // when the amount is zero - // [ ] it reverts + // [X] it reverts // when the amount is greater than zero - // [ ] it returns the amount after applying the burn rate + // [X] it returns the amount after applying the burn rate + + function test_amountIsZero_reverts() public { + // Expect revert + vm.expectRevert(abi.encodeWithSelector(CDEPOv1.CDEPO_InvalidArgs.selector, "amount")); + + // Call function + CDEPO.previewReclaim(0); + } + + function test_amountGreaterThanZero(uint256 amount_) public { + uint256 amount = bound(amount_, 1, type(uint256).max); + + // Call function + uint256 reclaimAmount = CDEPO.previewReclaim(amount); + + // Calculate the expected reclaim amount + uint256 expectedReclaimAmount = FullMath.mulDiv(amount, reclaimRate, 100e2); + + // Assert + assertEq(reclaimAmount, expectedReclaimAmount, "reclaimAmount"); + } } diff --git a/src/test/modules/CDEPO/reclaim.t.sol b/src/test/modules/CDEPO/reclaim.t.sol index ddda34a2..58846f35 100644 --- a/src/test/modules/CDEPO/reclaim.t.sol +++ b/src/test/modules/CDEPO/reclaim.t.sol @@ -34,7 +34,6 @@ contract ReclaimCDEPOTest is CDEPOTest { givenAddressHasReserveToken(recipient, 5e18) givenReserveTokenSpendingIsApproved(recipient, address(CDEPO), 5e18) givenRecipientHasCDEPO(5e18) - givenCDEPOSpendingIsApproved(recipient, address(CDEPO), 10e18) { // Expect revert vm.expectRevert(stdError.arithmeticError); @@ -49,7 +48,6 @@ contract ReclaimCDEPOTest is CDEPOTest { givenAddressHasReserveToken(recipient, 10e18) givenReserveTokenSpendingIsApproved(recipient, address(CDEPO), 10e18) givenRecipientHasCDEPO(10e18) - givenCDEPOSpendingIsApproved(recipient, address(CDEPO), 10e18) { uint256 expectedReserveTokenAmount = FullMath.mulDiv(10e18, reclaimRate, 100e2); assertEq(expectedReserveTokenAmount, 99e17, "expectedReserveTokenAmount"); diff --git a/src/test/modules/CDEPO/reclaimTo.t.sol b/src/test/modules/CDEPO/reclaimTo.t.sol index 4a8f4674..1a6c7b7c 100644 --- a/src/test/modules/CDEPO/reclaimTo.t.sol +++ b/src/test/modules/CDEPO/reclaimTo.t.sol @@ -12,17 +12,14 @@ import {console2} from "forge-std/console2.sol"; contract ReclaimToCDEPOTest is CDEPOTest { // when the amount is zero // [X] it reverts - // when the caller has not approved CDEPO to spend convertible deposit tokens + // when the caller has an insufficient balance of convertible deposit tokens // [X] it reverts - // when the caller has approved CDEPO to spend convertible deposit tokens - // when the caller has an insufficient balance of convertible deposit tokens - // [X] it reverts - // when the caller has a sufficient balance of convertible deposit tokens - // [X] it burns the corresponding amount of convertible deposit tokens - // [X] it withdraws the underlying asset from the vault - // [X] it transfers the underlying asset to the `to_` address after applying the reclaim rate - // [X] it marks the forfeited amount of the underlying asset as yield - // [X] it updates the total deposits + // when the caller has a sufficient balance of convertible deposit tokens + // [X] it burns the corresponding amount of convertible deposit tokens + // [X] it withdraws the underlying asset from the vault + // [X] it transfers the underlying asset to the `to_` address after applying the reclaim rate + // [X] it marks the forfeited amount of the underlying asset as yield + // [X] it updates the total deposits function test_amountIsZero_reverts() public { // Expect revert @@ -33,29 +30,14 @@ contract ReclaimToCDEPOTest is CDEPOTest { CDEPO.reclaimTo(recipientTwo, 0); } - function test_spendingNotApproved_reverts() - public - givenAddressHasReserveToken(recipient, 10e18) - givenReserveTokenSpendingIsApproved(recipient, address(CDEPO), 10e18) - givenRecipientHasCDEPO(10e18) - { - // Expect revert - vm.expectRevert(stdError.arithmeticError); - - // Call function - vm.prank(recipient); - CDEPO.reclaimTo(recipientTwo, 10e18); - } - function test_insufficientBalance_reverts() public givenAddressHasReserveToken(recipient, 5e18) givenReserveTokenSpendingIsApproved(recipient, address(CDEPO), 5e18) givenRecipientHasCDEPO(5e18) - givenCDEPOSpendingIsApproved(recipient, address(CDEPO), 10e18) { // Expect revert - vm.expectRevert(abi.encodeWithSelector(CDEPOv1.CDEPO_InvalidArgs.selector, "allowance")); + vm.expectRevert(stdError.arithmeticError); // Call function vm.prank(recipient); @@ -67,7 +49,6 @@ contract ReclaimToCDEPOTest is CDEPOTest { givenAddressHasReserveToken(recipient, 10e18) givenReserveTokenSpendingIsApproved(recipient, address(CDEPO), 10e18) givenRecipientHasCDEPO(10e18) - givenCDEPOSpendingIsApproved(recipient, address(CDEPO), 10e18) { uint256 expectedReserveTokenAmount = FullMath.mulDiv(10e18, reclaimRate, 100e2); assertEq(expectedReserveTokenAmount, 99e17, "expectedReserveTokenAmount"); From d9b3679682d668890e5699072ed3c06553748017 Mon Sep 17 00:00:00 2001 From: Jem <0x0xjem@gmail.com> Date: Fri, 20 Dec 2024 15:21:43 +0500 Subject: [PATCH 54/64] CDEPO: tests and impl fixes for rounding, redeem tests --- .../CDEPO/OlympusConvertibleDepository.sol | 41 +++++-- src/test/modules/CDEPO/CDEPOTest.sol | 13 +- src/test/modules/CDEPO/reclaim.t.sol | 49 +++++++- src/test/modules/CDEPO/reclaimTo.t.sol | 45 +++++++ src/test/modules/CDEPO/redeem.t.sol | 111 ++++++++++++++++-- 5 files changed, 237 insertions(+), 22 deletions(-) diff --git a/src/modules/CDEPO/OlympusConvertibleDepository.sol b/src/modules/CDEPO/OlympusConvertibleDepository.sol index 509eedb6..7916d28e 100644 --- a/src/modules/CDEPO/OlympusConvertibleDepository.sol +++ b/src/modules/CDEPO/OlympusConvertibleDepository.sol @@ -118,6 +118,7 @@ contract OlympusConvertibleDepository is CDEPOv1 { /// /// This function reverts if: /// - The amount is zero + /// - The quantity of vault shares for the amount is zero /// /// @param to_ The address to reclaim the tokens to /// @param amount_ The amount of CD tokens to burn @@ -125,6 +126,16 @@ contract OlympusConvertibleDepository is CDEPOv1 { // Validate that the amount is greater than zero if (amount_ == 0) revert CDEPO_InvalidArgs("amount"); + // Calculate the quantity of underlying asset to withdraw and return + // This will create a difference between the quantity of underlying assets and the vault shares, which will be swept as yield + uint256 discountedAssetsOut = previewReclaim(amount_); + uint256 sharesOut = vault.previewWithdraw(discountedAssetsOut); + totalShares -= sharesOut; + + // We want to avoid situations where the amount is low enough to be < 1 share, as that would enable users to manipulate the accounting with many small calls + // Although the ERC4626 vault will typically round up the number of shares withdrawn, if `discountedAssetsOut` is low enough, it will round down to 0 and `sharesOut` will be 0 + if (sharesOut == 0) revert CDEPO_InvalidArgs("shares"); + // Burn the CD tokens from `from_` // This uses the standard ERC20 implementation from solmate // It will revert if the caller does not have enough CD tokens @@ -132,14 +143,8 @@ contract OlympusConvertibleDepository is CDEPOv1 { // will be burned, and this function cannot be called on behalf of another address _burn(msg.sender, amount_); - // Calculate the quantity of underlying asset to withdraw and return - // This will create a difference between the quantity of underlying assets and the vault shares, which will be swept as yield - uint256 discountedAssetsOut = previewReclaim(amount_); - uint256 shares = vault.previewWithdraw(discountedAssetsOut); - totalShares -= shares; - // Return the underlying asset to `to_` - vault.redeem(shares, to_, address(this)); + vault.withdraw(discountedAssetsOut, to_, address(this)); } /// @inheritdoc CDEPOv1 @@ -150,6 +155,8 @@ contract OlympusConvertibleDepository is CDEPOv1 { ) public view virtual override returns (uint256 assetsOut) { if (amount_ == 0) revert CDEPO_InvalidArgs("amount"); + // This is rounded down to keep assets in the vault, otherwise the contract may end up + // in a state where there are not enough of the assets in the vault to redeem/reclaim assetsOut = FullMath.mulDiv(amount_, reclaimRate, ONE_HUNDRED_PERCENT); } @@ -160,17 +167,29 @@ contract OlympusConvertibleDepository is CDEPOv1 { /// - Calculates the quantity of underlying asset to withdraw and return /// - Returns the underlying asset to the caller /// + /// This function reverts if: + /// - The amount is zero + /// - The quantity of vault shares for the amount is zero + /// /// @param amount_ The amount of CD tokens to burn function redeem(uint256 amount_) external override permissioned returns (uint256 sharesOut) { - // Burn the CD tokens from the caller - _burn(msg.sender, amount_); + // Validate that the amount is greater than zero + if (amount_ == 0) revert CDEPO_InvalidArgs("amount"); // Calculate the quantity of shares to transfer sharesOut = vault.previewWithdraw(amount_); totalShares -= sharesOut; - // Transfer the shares to the caller - vault.safeTransfer(msg.sender, sharesOut); + // We want to avoid situations where the amount is low enough to be < 1 share, as that would enable users to manipulate the accounting with many small calls + // This is unlikely to happen, as the vault will typically round up the number of shares withdrawn + // However a different ERC4626 vault implementation may trigger the condition + if (sharesOut == 0) revert CDEPO_InvalidArgs("shares"); + + // Burn the CD tokens from the caller + _burn(msg.sender, amount_); + + // Transfer the assets to the caller + vault.withdraw(amount_, msg.sender, address(this)); } // ========== YIELD MANAGER ========== // diff --git a/src/test/modules/CDEPO/CDEPOTest.sol b/src/test/modules/CDEPO/CDEPOTest.sol index 4a25b3d8..dd0c8bd8 100644 --- a/src/test/modules/CDEPO/CDEPOTest.sol +++ b/src/test/modules/CDEPO/CDEPOTest.sol @@ -69,7 +69,7 @@ abstract contract CDEPOTest is Test { assertEq( reserveToken.totalSupply(), reserveToken.balanceOf(address(CDEPO.vault())) + recipientAmount_ + recipientTwoAmount_, - "total supply" + "reserve token balance: total supply" ); } @@ -77,7 +77,11 @@ abstract contract CDEPOTest is Test { assertEq(CDEPO.balanceOf(recipient), recipientAmount_, "recipient: CDEPO balance"); assertEq(CDEPO.balanceOf(recipientTwo), recipientTwoAmount_, "recipientTwo: CDEPO balance"); - assertEq(CDEPO.totalSupply(), recipientAmount_ + recipientTwoAmount_, "total supply"); + assertEq( + CDEPO.totalSupply(), + recipientAmount_ + recipientTwoAmount_, + "CDEPO balance: total supply" + ); } function _assertVaultBalance( @@ -150,6 +154,11 @@ abstract contract CDEPOTest is Test { _; } + modifier givenAddressHasCDEPO(address to_, uint256 amount_) { + _mintTo(to_, to_, amount_); + _; + } + modifier givenReclaimRateIsSet(uint16 reclaimRate_) { vm.prank(godmode); CDEPO.setReclaimRate(reclaimRate_); diff --git a/src/test/modules/CDEPO/reclaim.t.sol b/src/test/modules/CDEPO/reclaim.t.sol index 58846f35..2d0e466c 100644 --- a/src/test/modules/CDEPO/reclaim.t.sol +++ b/src/test/modules/CDEPO/reclaim.t.sol @@ -11,10 +11,14 @@ import {CDEPOv1} from "src/modules/CDEPO/CDEPO.v1.sol"; contract ReclaimCDEPOTest is CDEPOTest { // when the amount is zero // [X] it reverts + // when the discounted amount is zero + // [X] it reverts + // when the shares for the discounted amount is zero + // [X] it reverts // when the amount is greater than the caller's balance // [X] it reverts // when the amount is greater than zero - // [ X] it burns the corresponding amount of convertible deposit tokens + // [X] it burns the corresponding amount of convertible deposit tokens // [X] it withdraws the underlying asset from the vault // [X] it transfers the underlying asset to the caller after applying the burn rate // [X] it updates the total deposits @@ -29,6 +33,23 @@ contract ReclaimCDEPOTest is CDEPOTest { CDEPO.reclaim(0); } + function test_discountedAmountIsZero_reverts() + public + givenAddressHasReserveToken(recipient, 10e18) + givenReserveTokenSpendingIsApproved(recipient, address(CDEPO), 10e18) + givenRecipientHasCDEPO(10e18) + { + // This amount would result in 0 shares being withdrawn, and should revert + uint256 amount = 1; + + // Expect revert + vm.expectRevert(abi.encodeWithSelector(CDEPOv1.CDEPO_InvalidArgs.selector, "shares")); + + // Call function + vm.prank(recipient); + CDEPO.reclaim(amount); + } + function test_insufficientBalance_reverts() public givenAddressHasReserveToken(recipient, 5e18) @@ -65,4 +86,30 @@ contract ReclaimCDEPOTest is CDEPOTest { // Assert deposits _assertTotalShares(expectedReserveTokenAmount); } + + function test_success_fuzz( + uint256 amount_ + ) + public + givenAddressHasReserveToken(recipient, 10e18) + givenReserveTokenSpendingIsApproved(recipient, address(CDEPO), 10e18) + givenRecipientHasCDEPO(10e18) + { + uint256 amount = bound(amount_, 2, 10e18); + + uint256 expectedReserveTokenAmount = FullMath.mulDiv(amount, reclaimRate, 100e2); + uint256 forfeitedAmount = amount - expectedReserveTokenAmount; + + // Call function + vm.prank(recipient); + CDEPO.reclaim(amount); + + // Assert balances + _assertReserveTokenBalance(expectedReserveTokenAmount, 0); + _assertCDEPOBalance(10e18 - amount, 0); + _assertVaultBalance(10e18 - amount, 0, forfeitedAmount); + + // Assert deposits + _assertTotalShares(expectedReserveTokenAmount); + } } diff --git a/src/test/modules/CDEPO/reclaimTo.t.sol b/src/test/modules/CDEPO/reclaimTo.t.sol index 1a6c7b7c..3014a681 100644 --- a/src/test/modules/CDEPO/reclaimTo.t.sol +++ b/src/test/modules/CDEPO/reclaimTo.t.sol @@ -12,6 +12,8 @@ import {console2} from "forge-std/console2.sol"; contract ReclaimToCDEPOTest is CDEPOTest { // when the amount is zero // [X] it reverts + // when the discounted amount is zero + // [X] it reverts // when the caller has an insufficient balance of convertible deposit tokens // [X] it reverts // when the caller has a sufficient balance of convertible deposit tokens @@ -30,6 +32,23 @@ contract ReclaimToCDEPOTest is CDEPOTest { CDEPO.reclaimTo(recipientTwo, 0); } + function test_discountedAmountIsZero_reverts() + public + givenAddressHasReserveToken(recipient, 10e18) + givenReserveTokenSpendingIsApproved(recipient, address(CDEPO), 10e18) + givenRecipientHasCDEPO(10e18) + { + // This amount would result in 0 shares being withdrawn, and should revert + uint256 amount = 1; + + // Expect revert + vm.expectRevert(abi.encodeWithSelector(CDEPOv1.CDEPO_InvalidArgs.selector, "shares")); + + // Call function + vm.prank(recipient); + CDEPO.reclaimTo(recipientTwo, amount); + } + function test_insufficientBalance_reverts() public givenAddressHasReserveToken(recipient, 5e18) @@ -66,4 +85,30 @@ contract ReclaimToCDEPOTest is CDEPOTest { // Assert deposits _assertTotalShares(expectedReserveTokenAmount); } + + function test_success_fuzz( + uint256 amount_ + ) + public + givenAddressHasReserveToken(recipient, 10e18) + givenReserveTokenSpendingIsApproved(recipient, address(CDEPO), 10e18) + givenRecipientHasCDEPO(10e18) + { + uint256 amount = bound(amount_, 2, 10e18); + + uint256 expectedReserveTokenAmount = FullMath.mulDiv(amount, reclaimRate, 100e2); + uint256 forfeitedAmount = amount - expectedReserveTokenAmount; + + // Call function + vm.prank(recipient); + CDEPO.reclaimTo(recipientTwo, amount); + + // Assert balances + _assertReserveTokenBalance(0, expectedReserveTokenAmount); + _assertCDEPOBalance(10e18 - amount, 0); + _assertVaultBalance(10e18 - amount, 0, forfeitedAmount); + + // Assert deposits + _assertTotalShares(expectedReserveTokenAmount); + } } diff --git a/src/test/modules/CDEPO/redeem.t.sol b/src/test/modules/CDEPO/redeem.t.sol index 4789240b..df3c36f0 100644 --- a/src/test/modules/CDEPO/redeem.t.sol +++ b/src/test/modules/CDEPO/redeem.t.sol @@ -1,18 +1,113 @@ // SPDX-License-Identifier: Unlicensed pragma solidity 0.8.15; -import {Test} from "forge-std/Test.sol"; - +import {Test, stdError} from "forge-std/Test.sol"; import {CDEPOTest} from "./CDEPOTest.sol"; +import {CDEPOv1} from "src/modules/CDEPO/CDEPO.v1.sol"; +import {Module} from "src/Kernel.sol"; + +import {console2} from "forge-std/console2.sol"; + contract RedeemCDEPOTest is CDEPOTest { // when the amount is zero - // [ ] it reverts + // [X] it reverts + // when the shares for the amount is zero + // [X] it reverts + // when the amount is greater than the caller's balance + // [X] it reverts // when the caller is not permissioned - // [ ] it reverts + // [X] it reverts // when the caller is permissioned - // [ ] it burns the corresponding amount of convertible deposit tokens - // [ ] it withdraws the underlying asset from the vault - // [ ] it transfers the underlying asset to the caller - // [ ] it emits a `Transfer` event + // [X] it burns the corresponding amount of convertible deposit tokens + // [X] it withdraws the underlying asset from the vault + // [X] it transfers the underlying asset to the caller and does not apply the reclaim rate + + function test_amountIsZero_reverts() public { + // Expect revert + vm.expectRevert(abi.encodeWithSelector(CDEPOv1.CDEPO_InvalidArgs.selector, "amount")); + + // Call function + vm.prank(godmode); + CDEPO.redeem(0); + } + + // Cannot test this, as the vault will round up the number of shares withdrawn + // A different ERC4626 vault implementation may trigger the condition though + // function test_sharesForAmountIsZero_reverts() + // public + // givenAddressHasReserveToken(godmode, 10e18) + // givenReserveTokenSpendingIsApproved(godmode, address(CDEPO), 10e18) + // givenAddressHasCDEPO(godmode, 10e18) + // { + // // Deposit more reserve tokens into the vault to that the shares returned is 0 + // reserveToken.mint(address(vault), 100e18); + + // // This amount would result in 0 shares being withdrawn, and should revert + // uint256 amount = 1; + + // // Expect revert + // vm.expectRevert(abi.encodeWithSelector(CDEPOv1.CDEPO_InvalidArgs.selector, "shares")); + + // // Call function + // vm.prank(godmode); + // CDEPO.redeem(amount); + // } + + function test_amountIsGreaterThanBalance_reverts() + public + givenAddressHasReserveToken(godmode, 10e18) + givenReserveTokenSpendingIsApproved(godmode, address(CDEPO), 10e18) + givenAddressHasCDEPO(godmode, 10e18) + { + // Expect revert + vm.expectRevert(stdError.arithmeticError); + + // Call function + vm.prank(godmode); + CDEPO.redeem(10e18 + 1); + } + + function test_callerIsNotPermissioned_reverts() public { + // Expect revert + vm.expectRevert( + abi.encodeWithSelector(Module.Module_PolicyNotPermitted.selector, recipient) + ); + + // Call function + vm.prank(recipient); + CDEPO.redeem(10e18); + } + + function test_success( + uint256 amount_ + ) + public + givenAddressHasReserveToken(godmode, 10e18) + givenReserveTokenSpendingIsApproved(godmode, address(CDEPO), 10e18) + givenAddressHasCDEPO(godmode, 10e18) + { + uint256 amount = bound(amount_, 1, 10e18); + + // Call function + vm.prank(godmode); + CDEPO.redeem(amount); + + // Assert CD token balance + assertEq(CDEPO.balanceOf(godmode), 10e18 - amount, "CD token balance"); + assertEq(CDEPO.totalSupply(), 10e18 - amount, "CD token total supply"); + + // Assert reserve token balance + // No reclaim rate is applied + assertEq(reserveToken.balanceOf(godmode), amount, "godmode reserve token balance"); + assertEq(reserveToken.balanceOf(address(CDEPO)), 0, "CDEPO reserve token balance"); + assertEq( + reserveToken.balanceOf(address(vault)), + reserveToken.totalSupply() - amount, + "vault reserve token balance" + ); + + // Assert total shares tracked + _assertTotalShares(amount); + } } From 4bbde8a4fe0b21f57047d97523c1c8c7c369d10b Mon Sep 17 00:00:00 2001 From: Jem <0x0xjem@gmail.com> Date: Fri, 20 Dec 2024 15:21:58 +0500 Subject: [PATCH 55/64] CDEPO: sweepYield tests --- src/modules/CDEPO/CDEPO.v1.sol | 5 +- .../CDEPO/OlympusConvertibleDepository.sol | 26 +- .../modules/CDEPO/previewSweepYield.t.sol | 87 ++++++- src/test/modules/CDEPO/sweepYield.t.sol | 224 +++++++++++++++++- 4 files changed, 315 insertions(+), 27 deletions(-) diff --git a/src/modules/CDEPO/CDEPO.v1.sol b/src/modules/CDEPO/CDEPO.v1.sol index ccb705b3..c9535460 100644 --- a/src/modules/CDEPO/CDEPO.v1.sol +++ b/src/modules/CDEPO/CDEPO.v1.sol @@ -118,9 +118,12 @@ abstract contract CDEPOv1 is Module, ERC20 { /// - Transferring the yield to the caller /// - Emitting an event /// + /// @param to_ The address to sweep the yield to /// @return yieldReserve The amount of reserve token that was swept /// @return yieldSReserve The amount of sReserve token that was swept - function sweepYield() external virtual returns (uint256 yieldReserve, uint256 yieldSReserve); + function sweepYield( + address to_ + ) external virtual returns (uint256 yieldReserve, uint256 yieldSReserve); /// @notice Preview the amount of yield that would be swept /// diff --git a/src/modules/CDEPO/OlympusConvertibleDepository.sol b/src/modules/CDEPO/OlympusConvertibleDepository.sol index 7916d28e..dbba254c 100644 --- a/src/modules/CDEPO/OlympusConvertibleDepository.sol +++ b/src/modules/CDEPO/OlympusConvertibleDepository.sol @@ -204,26 +204,26 @@ contract OlympusConvertibleDepository is CDEPOv1 { /// /// This function reverts if: /// - The caller is not permissioned - /// - /// @return yieldReserve The amount of reserve token that was swept - /// @return yieldSReserve The amount of sReserve token that was swept - function sweepYield() - external - virtual - override - permissioned - returns (uint256 yieldReserve, uint256 yieldSReserve) - { + /// - The recipient_ address is the zero address + function sweepYield( + address recipient_ + ) external virtual override permissioned returns (uint256 yieldReserve, uint256 yieldSReserve) { + // Validate that the recipient_ address is not the zero address + if (recipient_ == address(0)) revert CDEPO_InvalidArgs("recipient"); + (yieldReserve, yieldSReserve) = previewSweepYield(); + // Skip if there is no yield to sweep + if (yieldSReserve == 0) return (0, 0); + // Reduce the shares tracked by the contract totalShares -= yieldSReserve; - // Transfer the yield to the permissioned caller - vault.safeTransfer(msg.sender, yieldSReserve); + // Transfer the yield to the recipient + vault.safeTransfer(recipient_, yieldSReserve); // Emit the event - emit YieldSwept(msg.sender, yieldReserve, yieldSReserve); + emit YieldSwept(recipient_, yieldReserve, yieldSReserve); return (yieldReserve, yieldSReserve); } diff --git a/src/test/modules/CDEPO/previewSweepYield.t.sol b/src/test/modules/CDEPO/previewSweepYield.t.sol index cfe0f2eb..22220cdd 100644 --- a/src/test/modules/CDEPO/previewSweepYield.t.sol +++ b/src/test/modules/CDEPO/previewSweepYield.t.sol @@ -2,12 +2,93 @@ pragma solidity 0.8.15; import {Test} from "forge-std/Test.sol"; - import {CDEPOTest} from "./CDEPOTest.sol"; +import {FullMath} from "src/libraries/FullMath.sol"; contract PreviewSweepYieldCDEPOTest is CDEPOTest { // when there are no deposits - // [ ] it returns zero + // [X] it returns zero // when there are deposits - // [ ] it returns the difference between the total deposits and the total assets in the vault + // when there have been reclaimed deposits + // [X] the forfeited amount is included in the yield + // [X] it returns the difference between the total deposits and the total assets in the vault + + function test_noDeposits() public { + (uint256 yieldReserve, uint256 yieldSReserve) = CDEPO.previewSweepYield(); + + // Assert values + assertEq(yieldReserve, 0, "yieldReserve"); + assertEq(yieldSReserve, 0, "yieldSReserve"); + } + + function test_withDeposits() + public + givenAddressHasReserveToken(recipient, 10e18) + givenReserveTokenSpendingIsApproved(recipient, address(CDEPO), 10e18) + givenAddressHasCDEPO(recipient, 10e18) + { + // Call function + (uint256 yieldReserve, uint256 yieldSReserve) = CDEPO.previewSweepYield(); + + // Assert values + assertEq(yieldReserve, INITIAL_VAULT_BALANCE, "yieldReserve"); + assertEq(yieldSReserve, vault.previewWithdraw(INITIAL_VAULT_BALANCE), "yieldSReserve"); + } + + function test_withReclaimedDeposits() + public + givenAddressHasReserveToken(recipient, 10e18) + givenReserveTokenSpendingIsApproved(recipient, address(CDEPO), 10e18) + givenAddressHasCDEPO(recipient, 10e18) + { + // Recipient has reclaimed all of their deposit, leaving behind a forfeited amount + // The forfeited amount is included in the yield + vm.prank(recipient); + CDEPO.reclaim(10e18); + + uint256 reclaimedAmount = CDEPO.previewReclaim(10e18); + uint256 forfeitedAmount = 10e18 - reclaimedAmount; + + // Call function + (uint256 yieldReserve, uint256 yieldSReserve) = CDEPO.previewSweepYield(); + + // Assert values + assertEq(yieldReserve, INITIAL_VAULT_BALANCE + forfeitedAmount, "yieldReserve"); + assertEq( + yieldSReserve, + vault.previewWithdraw(INITIAL_VAULT_BALANCE + forfeitedAmount), + "yieldSReserve" + ); + } + + function test_withReclaimedDeposits_fuzz( + uint256 amount_ + ) + public + givenAddressHasReserveToken(recipient, 10e18) + givenReserveTokenSpendingIsApproved(recipient, address(CDEPO), 10e18) + givenAddressHasCDEPO(recipient, 10e18) + { + // Start from 2 as it will revert due to 0 shares if amount is 1 + uint256 amount = bound(amount_, 2, 10e18); + + // Recipient has reclaimed their deposit, leaving behind a forfeited amount + // The forfeited amount is included in the yield + vm.prank(recipient); + CDEPO.reclaim(amount); + + uint256 reclaimedAmount = CDEPO.previewReclaim(amount); + uint256 forfeitedAmount = amount - reclaimedAmount; + + // Call function + (uint256 yieldReserve, uint256 yieldSReserve) = CDEPO.previewSweepYield(); + + // Assert values + assertEq(yieldReserve, INITIAL_VAULT_BALANCE + forfeitedAmount, "yieldReserve"); + assertEq( + yieldSReserve, + vault.previewWithdraw(INITIAL_VAULT_BALANCE + forfeitedAmount), + "yieldSReserve" + ); + } } diff --git a/src/test/modules/CDEPO/sweepYield.t.sol b/src/test/modules/CDEPO/sweepYield.t.sol index 942a02fe..790dd7b5 100644 --- a/src/test/modules/CDEPO/sweepYield.t.sol +++ b/src/test/modules/CDEPO/sweepYield.t.sol @@ -2,22 +2,226 @@ pragma solidity 0.8.15; import {Test} from "forge-std/Test.sol"; - import {CDEPOTest} from "./CDEPOTest.sol"; +import {Module} from "src/Kernel.sol"; +import {CDEPOv1} from "src/modules/CDEPO/CDEPO.v1.sol"; + contract SweepYieldCDEPOTest is CDEPOTest { + event YieldSwept(address receiver, uint256 reserveAmount, uint256 sReserveAmount); + // when the caller is not permissioned - // [ ] it reverts + // [X] it reverts + // when the recipient_ address is the zero address + // [X] it reverts // when there are no deposits - // [ ] it does not transfer any yield - // [ ] it returns zero - // [ ] it does not emit any events + // [X] it does not transfer any yield + // [X] it returns zero + // [X] it does not emit any events // when there are deposits // when it is called again without any additional yield - // [ ] it returns zero + // [X] it returns zero // when deposit tokens have been reclaimed - // [ ] the yield includes the forfeited amount - // [ ] it withdraws the underlying asset from the vault - // [ ] it transfers the underlying asset to the caller - // [ ] it emits a `YieldSwept` event + // [X] the yield includes the forfeited amount + // [X] it withdraws the underlying asset from the vault + // [X] it transfers the underlying asset to the recipient_ address + // [X] it emits a `YieldSwept` event + + function test_callerNotPermissioned_reverts() public { + // Expect revert + vm.expectRevert( + abi.encodeWithSelector(Module.Module_PolicyNotPermitted.selector, recipient) + ); + + // Call function + vm.prank(recipient); + CDEPO.sweepYield(recipient); + } + + function test_recipientZeroAddress_reverts() public { + // Expect revert + vm.expectRevert(abi.encodeWithSelector(CDEPOv1.CDEPO_InvalidArgs.selector, "recipient")); + + // Call function + vm.prank(godmode); + CDEPO.sweepYield(address(0)); + } + + function test_noDeposits() public { + // Call function + vm.prank(godmode); + (uint256 yieldReserve, uint256 yieldSReserve) = CDEPO.sweepYield(recipient); + + // Assert values + assertEq(yieldReserve, 0, "yieldReserve"); + assertEq(yieldSReserve, 0, "yieldSReserve"); + } + + function test_withDeposits() + public + givenAddressHasReserveToken(recipient, 10e18) + givenReserveTokenSpendingIsApproved(recipient, address(CDEPO), 10e18) + givenAddressHasCDEPO(recipient, 10e18) + { + address yieldRecipient = address(0xB); + + uint256 expectedSReserveYield = vault.previewWithdraw(INITIAL_VAULT_BALANCE); + uint256 sReserveBalanceBefore = vault.balanceOf(address(CDEPO)); + + // Emit event + vm.expectEmit(true, true, true, true); + emit YieldSwept(yieldRecipient, INITIAL_VAULT_BALANCE, expectedSReserveYield); + + // Call function + vm.prank(godmode); + (uint256 yieldReserve, uint256 yieldSReserve) = CDEPO.sweepYield(yieldRecipient); + + // Assert values + assertEq(yieldReserve, INITIAL_VAULT_BALANCE, "yieldReserve"); + assertEq(yieldSReserve, expectedSReserveYield, "yieldSReserve"); + + // Assert balances + assertEq(reserveToken.balanceOf(recipient), 0, "reserveToken.balanceOf(recipient)"); + assertEq(vault.balanceOf(recipient), 0, "vault.balanceOf(recipient)"); + assertEq( + reserveToken.balanceOf(yieldRecipient), + 0, + "reserveToken.balanceOf(yieldRecipient)" + ); + assertEq( + vault.balanceOf(yieldRecipient), + expectedSReserveYield, + "vault.balanceOf(yieldRecipient)" + ); + assertEq(reserveToken.balanceOf(godmode), 0, "reserveToken.balanceOf(godmode)"); + assertEq(vault.balanceOf(godmode), 0, "vault.balanceOf(godmode)"); + assertEq( + reserveToken.balanceOf(address(CDEPO)), + 0, + "reserveToken.balanceOf(address(CDEPO))" + ); + assertEq( + vault.balanceOf(address(CDEPO)), + sReserveBalanceBefore - expectedSReserveYield, + "vault.balanceOf(address(CDEPO))" + ); + } + + function test_sweepYieldAgain() + public + givenAddressHasReserveToken(recipient, 10e18) + givenReserveTokenSpendingIsApproved(recipient, address(CDEPO), 10e18) + givenAddressHasCDEPO(recipient, 10e18) + { + address yieldRecipient = address(0xB); + + uint256 expectedSReserveYield = vault.previewWithdraw(INITIAL_VAULT_BALANCE); + uint256 sReserveBalanceBefore = vault.balanceOf(address(CDEPO)); + + // Call function + vm.prank(godmode); + CDEPO.sweepYield(yieldRecipient); + + // Call function again + vm.prank(godmode); + (uint256 yieldReserve2, uint256 yieldSReserve2) = CDEPO.sweepYield(yieldRecipient); + + // Assert values + assertEq(yieldReserve2, 0, "yieldReserve2"); + assertEq(yieldSReserve2, 0, "yieldSReserve2"); + + // Assert balances + assertEq(reserveToken.balanceOf(recipient), 0, "reserveToken.balanceOf(recipient)"); + assertEq(vault.balanceOf(recipient), 0, "vault.balanceOf(recipient)"); + assertEq( + reserveToken.balanceOf(yieldRecipient), + 0, + "reserveToken.balanceOf(yieldRecipient)" + ); + assertEq( + vault.balanceOf(yieldRecipient), + expectedSReserveYield, + "vault.balanceOf(yieldRecipient)" + ); + assertEq(reserveToken.balanceOf(godmode), 0, "reserveToken.balanceOf(godmode)"); + assertEq(vault.balanceOf(godmode), 0, "vault.balanceOf(godmode)"); + assertEq( + reserveToken.balanceOf(address(CDEPO)), + 0, + "reserveToken.balanceOf(address(CDEPO))" + ); + assertEq( + vault.balanceOf(address(CDEPO)), + sReserveBalanceBefore - expectedSReserveYield, + "vault.balanceOf(address(CDEPO))" + ); + } + + function test_withReclaimedDeposits() + public + givenAddressHasReserveToken(recipient, 10e18) + givenReserveTokenSpendingIsApproved(recipient, address(CDEPO), 10e18) + givenAddressHasCDEPO(recipient, 10e18) + { + // Recipient has reclaimed all of their deposit, leaving behind a forfeited amount + // The forfeited amount is included in the yield + vm.prank(recipient); + CDEPO.reclaim(10e18); + + uint256 reclaimedAmount = CDEPO.previewReclaim(10e18); + uint256 forfeitedAmount = 10e18 - reclaimedAmount; + + address yieldRecipient = address(0xB); + + uint256 expectedSReserveYield = vault.previewWithdraw( + INITIAL_VAULT_BALANCE + forfeitedAmount + ); + uint256 sReserveBalanceBefore = vault.balanceOf(address(CDEPO)); + + // Emit event + vm.expectEmit(true, true, true, true); + emit YieldSwept( + yieldRecipient, + INITIAL_VAULT_BALANCE + forfeitedAmount, + expectedSReserveYield + ); + + // Call function + vm.prank(godmode); + (uint256 yieldReserve, uint256 yieldSReserve) = CDEPO.sweepYield(yieldRecipient); + + // Assert values + assertEq(yieldReserve, INITIAL_VAULT_BALANCE + forfeitedAmount, "yieldReserve"); + assertEq(yieldSReserve, expectedSReserveYield, "yieldSReserve"); + + // Assert balances + assertEq( + reserveToken.balanceOf(recipient), + reclaimedAmount, + "reserveToken.balanceOf(recipient)" + ); + assertEq(vault.balanceOf(recipient), 0, "vault.balanceOf(recipient)"); + assertEq( + reserveToken.balanceOf(yieldRecipient), + 0, + "reserveToken.balanceOf(yieldRecipient)" + ); + assertEq( + vault.balanceOf(yieldRecipient), + expectedSReserveYield, + "vault.balanceOf(yieldRecipient)" + ); + assertEq(reserveToken.balanceOf(godmode), 0, "reserveToken.balanceOf(godmode)"); + assertEq(vault.balanceOf(godmode), 0, "vault.balanceOf(godmode)"); + assertEq( + reserveToken.balanceOf(address(CDEPO)), + 0, + "reserveToken.balanceOf(address(CDEPO))" + ); + assertEq( + vault.balanceOf(address(CDEPO)), + sReserveBalanceBefore - expectedSReserveYield, + "vault.balanceOf(address(CDEPO))" + ); + } } From e98f50246e7737b0c68355d55139e20a802bafc5 Mon Sep 17 00:00:00 2001 From: Jem <0x0xjem@gmail.com> Date: Fri, 20 Dec 2024 16:06:30 +0500 Subject: [PATCH 56/64] CDFacility: test stubs --- src/policies/CDFacility.sol | 7 ++ .../ConvertibleDepositFacilityTest.sol | 100 ++++++++++++++++++ .../ConvertibleDepositFacility/convert.t.sol | 25 +++++ .../ConvertibleDepositFacility/create.t.sol | 17 +++ .../ConvertibleDepositFacility/reclaim.t.sol | 25 +++++ 5 files changed, 174 insertions(+) create mode 100644 src/test/policies/ConvertibleDepositFacility/ConvertibleDepositFacilityTest.sol create mode 100644 src/test/policies/ConvertibleDepositFacility/convert.t.sol create mode 100644 src/test/policies/ConvertibleDepositFacility/create.t.sol create mode 100644 src/test/policies/ConvertibleDepositFacility/reclaim.t.sol diff --git a/src/policies/CDFacility.sol b/src/policies/CDFacility.sol index 8a525295..f1a11743 100644 --- a/src/policies/CDFacility.sol +++ b/src/policies/CDFacility.sol @@ -107,6 +107,13 @@ contract CDFacility is Policy, RolesConsumer, IConvertibleDepositFacility { } /// @inheritdoc IConvertibleDepositFacility + /// @dev This function reverts if: + /// - The length of the positionIds_ array does not match the length of the amounts_ array + /// - The caller is not the owner of all of the positions + /// - The position is not valid + /// - The position is not CDEPO + /// - The position has expired + /// - The deposit amount is greater than the remaining deposit function convert( uint256[] memory positionIds_, uint256[] memory amounts_ diff --git a/src/test/policies/ConvertibleDepositFacility/ConvertibleDepositFacilityTest.sol b/src/test/policies/ConvertibleDepositFacility/ConvertibleDepositFacilityTest.sol new file mode 100644 index 00000000..3a54409c --- /dev/null +++ b/src/test/policies/ConvertibleDepositFacility/ConvertibleDepositFacilityTest.sol @@ -0,0 +1,100 @@ +// SPDX-License-Identifier: Unlicensed +pragma solidity 0.8.15; + +import {Test} from "forge-std/Test.sol"; +import {MockERC20} from "solmate/test/utils/mocks/MockERC20.sol"; +import {MockERC4626} from "solmate/test/utils/mocks/MockERC4626.sol"; + +import {Kernel, Actions} from "src/Kernel.sol"; +import {CDFacility} from "src/policies/CDFacility.sol"; +import {OlympusTreasury} from "src/modules/TRSRY/OlympusTreasury.sol"; +import {OlympusMinter} from "src/modules/MINTR/OlympusMinter.sol"; +import {OlympusRoles} from "src/modules/ROLES/OlympusRoles.sol"; +import {OlympusConvertibleDepository} from "src/modules/CDEPO/OlympusConvertibleDepository.sol"; +import {OlympusConvertibleDepositPositions} from "src/modules/CDPOS/OlympusConvertibleDepositPositions.sol"; +import {RolesAdmin} from "src/policies/RolesAdmin.sol"; + +contract ConvertibleDepositFacilityTest is Test { + Kernel public kernel; + CDFacility public facility; + OlympusTreasury public treasury; + OlympusMinter public minter; + OlympusRoles public roles; + OlympusConvertibleDepository public convertibleDepository; + OlympusConvertibleDepositPositions public convertibleDepositPositions; + RolesAdmin public rolesAdmin; + + MockERC20 public ohm; + MockERC20 public reserveToken; + MockERC4626 public vault; + + address public recipient = address(0x1); + address public auctioneer = address(0x2); + + uint48 public constant INITIAL_BLOCK = 1_000_000; + uint256 public constant CONVERSION_PRICE = 2e18; + uint48 public constant EXPIRY = INITIAL_BLOCK + 1 days; + + function setUp() public { + vm.warp(INITIAL_BLOCK); + + ohm = new MockERC20("Olympus", "OHM", 9); + reserveToken = new MockERC20("Reserve Token", "RES", 18); + vault = new MockERC4626(reserveToken, "Vault", "VAULT"); + + // Instantiate bophades + kernel = new Kernel(); + treasury = new OlympusTreasury(kernel); + minter = new OlympusMinter(kernel, address(ohm)); + roles = new OlympusRoles(kernel); + convertibleDepository = new OlympusConvertibleDepository(address(kernel), address(vault)); + convertibleDepositPositions = new OlympusConvertibleDepositPositions(address(kernel)); + facility = new CDFacility(address(kernel)); + rolesAdmin = new RolesAdmin(kernel); + + // Install modules + kernel.executeAction(Actions.InstallModule, address(treasury)); + kernel.executeAction(Actions.InstallModule, address(minter)); + kernel.executeAction(Actions.InstallModule, address(roles)); + kernel.executeAction(Actions.InstallModule, address(convertibleDepository)); + kernel.executeAction(Actions.InstallModule, address(convertibleDepositPositions)); + kernel.executeAction(Actions.ActivatePolicy, address(facility)); + kernel.executeAction(Actions.ActivatePolicy, address(rolesAdmin)); + + // Grant roles + rolesAdmin.grantRole(bytes32("CD_Auctioneer"), auctioneer); + } + + // ========== MODIFIERS ========== // + + modifier givenAddressHasReserveToken(address to_, uint256 amount_) { + reserveToken.mint(to_, amount_); + _; + } + + modifier givenReserveTokenSpendingIsApproved( + address owner_, + address spender_, + uint256 amount_ + ) { + vm.prank(owner_); + reserveToken.approve(spender_, amount_); + _; + } + + function _createPosition( + address account_, + uint256 amount_, + uint256 conversionPrice_, + uint48 expiry_, + bool wrap_ + ) internal { + vm.prank(auctioneer); + facility.create(account_, amount_, conversionPrice_, expiry_, wrap_); + } + + modifier givenAddressHasPosition(address account_, uint256 amount_) { + _createPosition(account_, amount_, CONVERSION_PRICE, EXPIRY, false); + _; + } +} diff --git a/src/test/policies/ConvertibleDepositFacility/convert.t.sol b/src/test/policies/ConvertibleDepositFacility/convert.t.sol new file mode 100644 index 00000000..d83423c9 --- /dev/null +++ b/src/test/policies/ConvertibleDepositFacility/convert.t.sol @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: Unlicensed +pragma solidity 0.8.15; + +import {ConvertibleDepositFacilityTest} from "./ConvertibleDepositFacilityTest.sol"; +import {IConvertibleDepositFacility} from "src/policies/interfaces/IConvertibleDepositFacility.sol"; + +contract ConvertCDFTest is ConvertibleDepositFacilityTest { + // when the length of the positionIds_ array does not match the length of the amounts_ array + // [ ] it reverts + // when any position is not valid + // [ ] it reverts + // when any position has an owner that is not the caller + // [ ] it reverts + // when any position has a convertible deposit token that is not CDEPO + // [ ] it reverts + // when any position has expired + // [ ] it reverts + // when any position has an amount greater than the remaining deposit + // [ ] it reverts + // [ ] it mints the converted amount of OHM to the account_ + // [ ] it updates the remaining deposit of each position + // [ ] it transfers the redeemed vault shares to the TRSRY + // [ ] it returns the total deposit amount and the converted amount + // [ ] it emits a ConvertedDeposit event +} diff --git a/src/test/policies/ConvertibleDepositFacility/create.t.sol b/src/test/policies/ConvertibleDepositFacility/create.t.sol new file mode 100644 index 00000000..e13f45d5 --- /dev/null +++ b/src/test/policies/ConvertibleDepositFacility/create.t.sol @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: Unlicensed +pragma solidity 0.8.15; + +import {ConvertibleDepositFacilityTest} from "./ConvertibleDepositFacilityTest.sol"; +import {IConvertibleDepositFacility} from "src/policies/interfaces/IConvertibleDepositFacility.sol"; + +contract CreateCDFTest is ConvertibleDepositFacilityTest { + // when the caller does not have the CD_Auctioneer role + // [ ] it reverts + // when the caller has not approved CDEPO to spend the reserve token + // [ ] it reverts + // [ ] it mints the CD token to account_ + // [ ] it creates a new position in the CDPOS module + // [ ] it pre-emptively increases the mint approval equivalent to the converted amount of OHM + // [ ] it returns the position ID + // [ ] it emits a CreatedDeposit event +} diff --git a/src/test/policies/ConvertibleDepositFacility/reclaim.t.sol b/src/test/policies/ConvertibleDepositFacility/reclaim.t.sol new file mode 100644 index 00000000..8fbe34a3 --- /dev/null +++ b/src/test/policies/ConvertibleDepositFacility/reclaim.t.sol @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: Unlicensed +pragma solidity 0.8.15; + +import {ConvertibleDepositFacilityTest} from "./ConvertibleDepositFacilityTest.sol"; +import {IConvertibleDepositFacility} from "src/policies/interfaces/IConvertibleDepositFacility.sol"; + +contract ReclaimCDFTest is ConvertibleDepositFacilityTest { + // when the length of the positionIds_ array does not match the length of the amounts_ array + // [ ] it reverts + // when any position is not valid + // [ ] it reverts + // when any position has an owner that is not the caller + // [ ] it reverts + // when any position has a convertible deposit token that is not CDEPO + // [ ] it reverts + // when any position has not expired + // [ ] it reverts + // when any position has an amount greater than the remaining deposit + // [ ] it reverts + // [ ] it updates the remaining deposit of each position + // [ ] it transfers the redeemed reserve tokens to the owner + // [ ] it decreases the OHM mint approval by the amount of OHM that would have been converted + // [ ] it returns the reclaimed amount + // [ ] it emits a ReclaimedDeposit event +} From c4f56c2622265280735f19beed4b03a0945246a0 Mon Sep 17 00:00:00 2001 From: Jem <0x0xjem@gmail.com> Date: Fri, 20 Dec 2024 17:16:36 +0500 Subject: [PATCH 57/64] CDEPO: add mint/reclaim/redeem -For functions, add spending allowance checks, update tests --- src/modules/CDEPO/CDEPO.v1.sol | 60 ++++-- .../CDEPO/OlympusConvertibleDepository.sol | 82 ++++---- src/policies/CDFacility.sol | 2 +- src/test/modules/CDEPO/CDEPOTest.sol | 24 ++- src/test/modules/CDEPO/mintFor.t.sol | 81 ++++++++ src/test/modules/CDEPO/mintTo.t.sol | 68 ------- src/test/modules/CDEPO/reclaimFor.t.sol | 158 ++++++++++++++++ src/test/modules/CDEPO/reclaimTo.t.sol | 114 ------------ src/test/modules/CDEPO/redeemFor.t.sol | 175 ++++++++++++++++++ 9 files changed, 528 insertions(+), 236 deletions(-) create mode 100644 src/test/modules/CDEPO/mintFor.t.sol delete mode 100644 src/test/modules/CDEPO/mintTo.t.sol create mode 100644 src/test/modules/CDEPO/reclaimFor.t.sol delete mode 100644 src/test/modules/CDEPO/reclaimTo.t.sol create mode 100644 src/test/modules/CDEPO/redeemFor.t.sol diff --git a/src/modules/CDEPO/CDEPO.v1.sol b/src/modules/CDEPO/CDEPO.v1.sol index c9535460..eabd828c 100644 --- a/src/modules/CDEPO/CDEPO.v1.sol +++ b/src/modules/CDEPO/CDEPO.v1.sol @@ -47,14 +47,19 @@ abstract contract CDEPOv1 is Module, ERC20 { /// @param amount_ The amount of underlying asset to transfer function mint(uint256 amount_) external virtual; - /// @notice Mint tokens to `to_` in exchange for the underlying asset + /// @notice Mint tokens to `account_` in exchange for the underlying asset /// This function behaves the same as `mint`, but allows the caller to - /// specify the address to mint the tokens to and pull the underlying - /// asset from. + /// specify the address to mint the tokens to and pull the asset from. + /// The `account_` address must have approved the contract to spend the underlying asset. + /// @dev The implementing function should perform the following: + /// - Transfers the underlying asset from the `account_` address to the contract + /// - Mints the corresponding amount of convertible deposit tokens to the `account_` address + /// - Deposits the underlying asset into the ERC4626 vault + /// - Emits a `Transfer` event /// - /// @param to_ The address to mint the tokens to - /// @param amount_ The amount of underlying asset to transfer - function mintTo(address to_, uint256 amount_) external virtual; + /// @param account_ The address to mint the tokens to and pull the asset from + /// @param amount_ The amount of asset to transfer + function mintFor(address account_, uint256 amount_) external virtual; /// @notice Preview the amount of convertible deposit tokens that would be minted for a given amount of underlying asset /// @dev The implementing function should perform the following: @@ -65,8 +70,6 @@ abstract contract CDEPOv1 is Module, ERC20 { /// @return tokensOut The amount of convertible deposit tokens that would be minted function previewMint(uint256 amount_) external view virtual returns (uint256 tokensOut); - // TODO review docs - /// @notice Burn tokens from the caller and reclaim the underlying asset /// The amount of underlying asset may not be 1:1 with the amount of /// convertible deposit tokens, depending on the value of `burnRate` @@ -75,19 +78,25 @@ abstract contract CDEPOv1 is Module, ERC20 { /// - Transfers the underlying asset to the caller /// - Burns the corresponding amount of convertible deposit tokens from the caller /// - Marks the forfeited amount of the underlying asset as yield - /// - Emits a `Transfer` event /// /// @param amount_ The amount of convertible deposit tokens to burn function reclaim(uint256 amount_) external virtual; - /// @notice Burn tokens from `from_` and reclaim the underlying asset + /// @notice Burn tokens from `account_` and reclaim the underlying asset /// This function behaves the same as `reclaim`, but allows the caller to /// specify the address to burn the tokens from and transfer the underlying /// asset to. + /// The `account_` address must have approved the contract to spend the convertible deposit tokens. + /// @dev The implementing function should perform the following: + /// - Validates that the `account_` address has approved the contract to spend the convertible deposit tokens + /// - Withdraws the underlying asset from the ERC4626 vault + /// - Transfers the underlying asset to the `account_` address + /// - Burns the corresponding amount of convertible deposit tokens from the `account_` address + /// - Marks the forfeited amount of the underlying asset as yield /// - /// @param to_ The address to reclaim the underlying asset to - /// @param amount_ The amount of convertible deposit tokens to burn - function reclaimTo(address to_, uint256 amount_) external virtual; + /// @param account_ The address to burn the convertible deposit tokens from and transfer the underlying asset to + /// @param amount_ The amount of convertible deposit tokens to burn + function reclaimFor(address account_, uint256 amount_) external virtual; /// @notice Preview the amount of underlying asset that would be reclaimed for a given amount of convertible deposit tokens /// @dev The implementing function should perform the following: @@ -99,15 +108,32 @@ abstract contract CDEPOv1 is Module, ERC20 { function previewReclaim(uint256 amount_) external view virtual returns (uint256 assetsOut); /// @notice Redeem convertible deposit tokens for the underlying asset - /// This differs from the burn function, in that it is an admin-level and permissioned function that does not apply the burn rate. + /// This differs from the reclaim function, in that it is an admin-level and permissioned function that does not apply the burn rate. /// @dev The implementing function should perform the following: /// - Validates that the caller is permissioned - /// - Transfers the corresponding vault shares to the caller + /// - Transfers the corresponding underlying assets to the caller /// - Burns the corresponding amount of convertible deposit tokens from the caller /// /// @param amount_ The amount of convertible deposit tokens to burn - /// @return sharesOut The amount of shares that were transferred to the caller - function redeem(uint256 amount_) external virtual returns (uint256 sharesOut); + /// @return tokensOut The amount of underlying assets that were transferred to the caller + function redeem(uint256 amount_) external virtual returns (uint256 tokensOut); + + /// @notice Redeem convertible deposit tokens for the underlying asset + /// This differs from the redeem function, in that it allows the caller to specify the address to burn the convertible deposit tokens from. + /// The `account_` address must have approved the contract to spend the convertible deposit tokens. + /// @dev The implementing function should perform the following: + /// - Validates that the caller is permissioned + /// - Validates that the `account_` address has approved the contract to spend the convertible deposit tokens + /// - Burns the corresponding amount of convertible deposit tokens from the `account_` address + /// - Transfers the corresponding underlying assets to the caller (not the `account_` address) + /// + /// @param account_ The address to burn the convertible deposit tokens from + /// @param amount_ The amount of convertible deposit tokens to burn + /// @return tokensOut The amount of underlying assets that were transferred to the caller + function redeemFor( + address account_, + uint256 amount_ + ) external virtual returns (uint256 tokensOut); // ========== YIELD MANAGER ========== // diff --git a/src/modules/CDEPO/OlympusConvertibleDepository.sol b/src/modules/CDEPO/OlympusConvertibleDepository.sol index dbba254c..416eb040 100644 --- a/src/modules/CDEPO/OlympusConvertibleDepository.sol +++ b/src/modules/CDEPO/OlympusConvertibleDepository.sol @@ -60,35 +60,32 @@ contract OlympusConvertibleDepository is CDEPOv1 { /// @dev This function performs the following: /// - Calls `mintTo` with the caller as the recipient function mint(uint256 amount_) external virtual override { - mintTo(msg.sender, amount_); + mintFor(msg.sender, amount_); } /// @inheritdoc CDEPOv1 /// @dev This function performs the following: - /// - Transfers the underlying asset from the caller to the contract + /// - Transfers the underlying asset from the `account_` address to the contract /// - Deposits the underlying asset into the ERC4626 vault - /// - Mints the corresponding amount of convertible deposit tokens to `to_` + /// - Mints the corresponding amount of convertible deposit tokens to `account_` /// - Emits a `Transfer` event /// /// This function reverts if: /// - The amount is zero - /// - The caller has not approved this contract to spend `asset` - /// - /// @param to_ The address to mint the tokens to - /// @param amount_ The amount of underlying asset to transfer - function mintTo(address to_, uint256 amount_) public virtual override { + /// - The `account_` address has not approved this contract to spend `asset` + function mintFor(address account_, uint256 amount_) public virtual override { // Validate that the amount is greater than zero if (amount_ == 0) revert CDEPO_InvalidArgs("amount"); // Transfer the underlying asset to the contract - asset.safeTransferFrom(msg.sender, address(this), amount_); + asset.safeTransferFrom(account_, address(this), amount_); // Deposit the underlying asset into the vault and update the total shares asset.safeApprove(address(vault), amount_); totalShares += vault.deposit(amount_, address(this)); - // Mint the CD tokens to the caller - _mint(to_, amount_); + // Mint the CD tokens to the `account_` address + _mint(account_, amount_); } /// @inheritdoc CDEPOv1 @@ -105,24 +102,23 @@ contract OlympusConvertibleDepository is CDEPOv1 { /// @inheritdoc CDEPOv1 /// @dev This function performs the following: - /// - Calls `reclaimTo` with the caller as the address to reclaim the tokens to + /// - Calls `reclaimFor` with the caller as the address to reclaim the tokens to function reclaim(uint256 amount_) external virtual override { - reclaimTo(msg.sender, amount_); + reclaimFor(msg.sender, amount_); } /// @inheritdoc CDEPOv1 /// @dev This function performs the following: - /// - Burns the CD tokens from the caller + /// - Validates that the `account_` address has approved this contract to spend the convertible deposit tokens + /// - Burns the CD tokens from the `account_` address /// - Calculates the quantity of underlying asset to withdraw and return - /// - Returns the underlying asset to `to_` + /// - Returns the underlying asset to `account_` /// /// This function reverts if: /// - The amount is zero + /// - The `account_` address has not approved this contract to spend the convertible deposit tokens /// - The quantity of vault shares for the amount is zero - /// - /// @param to_ The address to reclaim the tokens to - /// @param amount_ The amount of CD tokens to burn - function reclaimTo(address to_, uint256 amount_) public virtual override { + function reclaimFor(address account_, uint256 amount_) public virtual override { // Validate that the amount is greater than zero if (amount_ == 0) revert CDEPO_InvalidArgs("amount"); @@ -136,15 +132,18 @@ contract OlympusConvertibleDepository is CDEPOv1 { // Although the ERC4626 vault will typically round up the number of shares withdrawn, if `discountedAssetsOut` is low enough, it will round down to 0 and `sharesOut` will be 0 if (sharesOut == 0) revert CDEPO_InvalidArgs("shares"); - // Burn the CD tokens from `from_` + // Validate that the `account_` address has approved this contract to spend the convertible deposit tokens + // Only if the caller is not the account address + if (account_ != msg.sender && allowance[account_][address(this)] < amount_) + revert CDEPO_InvalidArgs("allowance"); + + // Burn the CD tokens from `account_` // This uses the standard ERC20 implementation from solmate // It will revert if the caller does not have enough CD tokens - // Allowance is not checked, because the CD tokens belonging to the caller - // will be burned, and this function cannot be called on behalf of another address - _burn(msg.sender, amount_); + _burn(account_, amount_); - // Return the underlying asset to `to_` - vault.withdraw(discountedAssetsOut, to_, address(this)); + // Return the underlying asset to `account_` + vault.withdraw(discountedAssetsOut, account_, address(this)); } /// @inheritdoc CDEPOv1 @@ -160,24 +159,34 @@ contract OlympusConvertibleDepository is CDEPOv1 { assetsOut = FullMath.mulDiv(amount_, reclaimRate, ONE_HUNDRED_PERCENT); } + /// @inheritdoc CDEPOv1 + /// @dev This function performs the following: + /// - Calls `redeemFor` with the caller as the address to redeem the tokens to + function redeem(uint256 amount_) external override permissioned returns (uint256 tokensOut) { + return redeemFor(msg.sender, amount_); + } + /// @inheritdoc CDEPOv1 /// @dev This function performs the following: /// - Validates that the caller is permissioned - /// - Burns the CD tokens from the caller + /// - Validates that the `account_` address has approved this contract to spend the convertible deposit tokens + /// - Burns the CD tokens from the `account_` address /// - Calculates the quantity of underlying asset to withdraw and return /// - Returns the underlying asset to the caller /// /// This function reverts if: /// - The amount is zero /// - The quantity of vault shares for the amount is zero - /// - /// @param amount_ The amount of CD tokens to burn - function redeem(uint256 amount_) external override permissioned returns (uint256 sharesOut) { + /// - The `account_` address has not approved this contract to spend the convertible deposit tokens + function redeemFor( + address account_, + uint256 amount_ + ) public override permissioned returns (uint256 tokensOut) { // Validate that the amount is greater than zero if (amount_ == 0) revert CDEPO_InvalidArgs("amount"); // Calculate the quantity of shares to transfer - sharesOut = vault.previewWithdraw(amount_); + uint256 sharesOut = vault.previewWithdraw(amount_); totalShares -= sharesOut; // We want to avoid situations where the amount is low enough to be < 1 share, as that would enable users to manipulate the accounting with many small calls @@ -185,11 +194,18 @@ contract OlympusConvertibleDepository is CDEPOv1 { // However a different ERC4626 vault implementation may trigger the condition if (sharesOut == 0) revert CDEPO_InvalidArgs("shares"); - // Burn the CD tokens from the caller - _burn(msg.sender, amount_); + // Validate that the `account_` address has approved this contract to spend the convertible deposit tokens + // Only if the caller is not the account address + if (account_ != msg.sender && allowance[account_][address(this)] < amount_) + revert CDEPO_InvalidArgs("allowance"); - // Transfer the assets to the caller + // Burn the CD tokens from the `account_` address + _burn(account_, amount_); + + // Return the underlying asset to the caller vault.withdraw(amount_, msg.sender, address(this)); + + return amount_; } // ========== YIELD MANAGER ========== // diff --git a/src/policies/CDFacility.sol b/src/policies/CDFacility.sol index f1a11743..bf375303 100644 --- a/src/policies/CDFacility.sol +++ b/src/policies/CDFacility.sol @@ -87,7 +87,7 @@ contract CDFacility is Policy, RolesConsumer, IConvertibleDepositFacility { ) external onlyRole("CD_Auctioneer") returns (uint256 positionId) { // Mint the CD token to the account // This will also transfer the reserve token - CDEPO.mintTo(account_, amount_); + CDEPO.mintFor(account_, amount_); // Create a new term record in the CDPOS module positionId = CDPOS.create( diff --git a/src/test/modules/CDEPO/CDEPOTest.sol b/src/test/modules/CDEPO/CDEPOTest.sol index dd0c8bd8..d4b3828e 100644 --- a/src/test/modules/CDEPO/CDEPOTest.sol +++ b/src/test/modules/CDEPO/CDEPOTest.sol @@ -139,14 +139,32 @@ abstract contract CDEPOTest is Test { _; } + function _approveConvertibleDepositTokenSpending( + address owner_, + address spender_, + uint256 amount_ + ) internal { + vm.prank(owner_); + CDEPO.approve(spender_, amount_); + } + + modifier givenConvertibleDepositTokenSpendingIsApproved( + address owner_, + address spender_, + uint256 amount_ + ) { + _approveConvertibleDepositTokenSpending(owner_, spender_, amount_); + _; + } + function _mint(uint256 amount_) internal { vm.prank(recipient); CDEPO.mint(amount_); } - function _mintTo(address owner_, address to_, uint256 amount_) internal { + function _mintFor(address owner_, address to_, uint256 amount_) internal { vm.prank(owner_); - CDEPO.mintTo(to_, amount_); + CDEPO.mintFor(to_, amount_); } modifier givenRecipientHasCDEPO(uint256 amount_) { @@ -155,7 +173,7 @@ abstract contract CDEPOTest is Test { } modifier givenAddressHasCDEPO(address to_, uint256 amount_) { - _mintTo(to_, to_, amount_); + _mintFor(to_, to_, amount_); _; } diff --git a/src/test/modules/CDEPO/mintFor.t.sol b/src/test/modules/CDEPO/mintFor.t.sol new file mode 100644 index 00000000..fe8a1aff --- /dev/null +++ b/src/test/modules/CDEPO/mintFor.t.sol @@ -0,0 +1,81 @@ +// SPDX-License-Identifier: Unlicensed +pragma solidity 0.8.15; + +import {Test} from "forge-std/Test.sol"; +import {CDEPOTest} from "./CDEPOTest.sol"; + +import {CDEPOv1} from "src/modules/CDEPO/CDEPO.v1.sol"; + +contract MintForCDEPOTest is CDEPOTest { + // when the recipient is the zero address + // [X] it reverts + // when the amount is zero + // [X] it reverts + // when the account address has not approved CDEPO to spend reserve tokens + // when the account address is the same as the sender + // [X] it reverts + // [X] it reverts + // when the account address has an insufficient balance of reserve tokens + // [X] it reverts + // when the account address has a sufficient balance of reserve tokens + // [X] it transfers the reserve tokens to CDEPO + // [X] it mints an equal amount of convertible deposit tokens to the `account_` address + // [X] it deposits the reserve tokens into the vault + + function test_zeroAmount_reverts() public { + // Expect revert + vm.expectRevert(abi.encodeWithSelector(CDEPOv1.CDEPO_InvalidArgs.selector, "amount")); + + // Call function + _mintFor(recipient, recipientTwo, 0); + } + + function test_spendingNotApproved_reverts() + public + givenAddressHasReserveToken(recipientTwo, 10e18) + { + // Expect revert + vm.expectRevert("TRANSFER_FROM_FAILED"); + + // Call function + _mintFor(recipient, recipientTwo, 10e18); + } + + function test_spendingNotApproved_sameAddress_reverts() + public + givenAddressHasReserveToken(recipientTwo, 10e18) + { + // Expect revert + // This is because the underlying asset needs to be transferred to the CDEPO contract, regardless of the caller + vm.expectRevert("TRANSFER_FROM_FAILED"); + + // Call function + _mintFor(recipientTwo, recipientTwo, 10e18); + } + + function test_insufficientBalance_reverts() + public + givenAddressHasReserveToken(recipientTwo, 5e18) + givenReserveTokenSpendingIsApproved(recipientTwo, address(CDEPO), 10e18) + { + // Expect revert + vm.expectRevert("TRANSFER_FROM_FAILED"); + + // Call function + _mintFor(recipient, recipientTwo, 10e18); + } + + function test_success() + public + givenAddressHasReserveToken(recipientTwo, 10e18) + givenReserveTokenSpendingIsApproved(recipientTwo, address(CDEPO), 10e18) + { + // Call function + _mintFor(recipient, recipientTwo, 10e18); + + // Assert balances + _assertReserveTokenBalance(0, 0); + _assertCDEPOBalance(0, 10e18); + _assertVaultBalance(0, 10e18, 0); + } +} diff --git a/src/test/modules/CDEPO/mintTo.t.sol b/src/test/modules/CDEPO/mintTo.t.sol deleted file mode 100644 index 204d833a..00000000 --- a/src/test/modules/CDEPO/mintTo.t.sol +++ /dev/null @@ -1,68 +0,0 @@ -// SPDX-License-Identifier: Unlicensed -pragma solidity 0.8.15; - -import {Test} from "forge-std/Test.sol"; -import {CDEPOTest} from "./CDEPOTest.sol"; - -import {CDEPOv1} from "src/modules/CDEPO/CDEPO.v1.sol"; - -contract MintToCDEPOTest is CDEPOTest { - // when the recipient is the zero address - // [X] it reverts - // when the amount is zero - // [X] it reverts - // when the caller has not approved CDEPO to spend reserve tokens - // [X] it reverts - // when the caller has approved CDEPO to spend reserve tokens - // when the caller has an insufficient balance of reserve tokens - // [X] it reverts - // when the caller has a sufficient balance of reserve tokens - // [X] it transfers the reserve tokens to CDEPO - // [X] it mints an equal amount of convertible deposit tokens to the `to_` address - // [X] it deposits the reserve tokens into the vault - - function test_zeroAmount_reverts() public { - // Expect revert - vm.expectRevert(abi.encodeWithSelector(CDEPOv1.CDEPO_InvalidArgs.selector, "amount")); - - // Call function - _mintTo(recipient, recipientTwo, 0); - } - - function test_spendingNotApproved_reverts() - public - givenAddressHasReserveToken(recipient, 10e18) - { - // Expect revert - vm.expectRevert("TRANSFER_FROM_FAILED"); - - // Call function - _mintTo(recipient, recipientTwo, 10e18); - } - - function test_insufficientBalance_reverts() - public - givenAddressHasReserveToken(recipient, 5e18) - givenReserveTokenSpendingIsApproved(recipient, address(CDEPO), 10e18) - { - // Expect revert - vm.expectRevert("TRANSFER_FROM_FAILED"); - - // Call function - _mintTo(recipient, recipientTwo, 10e18); - } - - function test_success() - public - givenAddressHasReserveToken(recipient, 10e18) - givenReserveTokenSpendingIsApproved(recipient, address(CDEPO), 10e18) - { - // Call function - _mintTo(recipient, recipientTwo, 10e18); - - // Assert balances - _assertReserveTokenBalance(0, 0); - _assertCDEPOBalance(0, 10e18); - _assertVaultBalance(0, 10e18, 0); - } -} diff --git a/src/test/modules/CDEPO/reclaimFor.t.sol b/src/test/modules/CDEPO/reclaimFor.t.sol new file mode 100644 index 00000000..83fb1fdf --- /dev/null +++ b/src/test/modules/CDEPO/reclaimFor.t.sol @@ -0,0 +1,158 @@ +// SPDX-License-Identifier: Unlicensed +pragma solidity 0.8.15; + +import {Test, stdError} from "forge-std/Test.sol"; +import {CDEPOTest} from "./CDEPOTest.sol"; +import {FullMath} from "src/libraries/FullMath.sol"; + +import {CDEPOv1} from "src/modules/CDEPO/CDEPO.v1.sol"; + +import {console2} from "forge-std/console2.sol"; + +contract ReclaimForCDEPOTest is CDEPOTest { + // when the amount is zero + // [X] it reverts + // when the discounted amount is zero + // [X] it reverts + // when the account address has not approved CDEPO to spend the convertible deposit tokens + // when the account address is the same as the sender + // [X] it does not require the approval + // [X] it reverts + // when the account address has an insufficient balance of convertible deposit tokens + // [X] it reverts + // when the account address has a sufficient balance of convertible deposit tokens + // [X] it burns the corresponding amount of convertible deposit tokens from the account address + // [X] it withdraws the underlying asset from the vault + // [X] it transfers the underlying asset to the account address after applying the reclaim rate + // [X] it marks the forfeited amount of the underlying asset as yield + // [X] it updates the total deposits + + function test_amountIsZero_reverts() public { + // Expect revert + vm.expectRevert(abi.encodeWithSelector(CDEPOv1.CDEPO_InvalidArgs.selector, "amount")); + + // Call function + vm.prank(recipient); + CDEPO.reclaimFor(recipientTwo, 0); + } + + function test_discountedAmountIsZero_reverts() + public + givenAddressHasReserveToken(recipient, 10e18) + givenReserveTokenSpendingIsApproved(recipient, address(CDEPO), 10e18) + givenRecipientHasCDEPO(10e18) + { + // This amount would result in 0 shares being withdrawn, and should revert + uint256 amount = 1; + + // Expect revert + vm.expectRevert(abi.encodeWithSelector(CDEPOv1.CDEPO_InvalidArgs.selector, "shares")); + + // Call function + vm.prank(recipient); + CDEPO.reclaimFor(recipientTwo, amount); + } + + function test_spendingIsNotApproved_reverts() + public + givenAddressHasReserveToken(recipientTwo, 10e18) + givenReserveTokenSpendingIsApproved(recipientTwo, address(CDEPO), 10e18) + givenAddressHasCDEPO(recipientTwo, 10e18) + { + // Expect revert + vm.expectRevert(abi.encodeWithSelector(CDEPOv1.CDEPO_InvalidArgs.selector, "allowance")); + + // Call function + vm.prank(recipient); + CDEPO.reclaimFor(recipientTwo, 10e18); + } + + function test_insufficientBalance_reverts() + public + givenAddressHasReserveToken(recipientTwo, 5e18) + givenReserveTokenSpendingIsApproved(recipientTwo, address(CDEPO), 5e18) + givenAddressHasCDEPO(recipientTwo, 5e18) + givenConvertibleDepositTokenSpendingIsApproved(recipientTwo, address(CDEPO), 10e18) + { + // Expect revert + vm.expectRevert(stdError.arithmeticError); + + // Call function + vm.prank(recipient); + CDEPO.reclaimFor(recipientTwo, 10e18); + } + + function test_success() + public + givenAddressHasReserveToken(recipientTwo, 10e18) + givenReserveTokenSpendingIsApproved(recipientTwo, address(CDEPO), 10e18) + givenAddressHasCDEPO(recipientTwo, 10e18) + givenConvertibleDepositTokenSpendingIsApproved(recipientTwo, address(CDEPO), 10e18) + { + uint256 expectedReserveTokenAmount = FullMath.mulDiv(10e18, reclaimRate, 100e2); + assertEq(expectedReserveTokenAmount, 99e17, "expectedReserveTokenAmount"); + uint256 forfeitedAmount = 10e18 - expectedReserveTokenAmount; + + // Call function + vm.prank(recipient); + CDEPO.reclaimFor(recipientTwo, 10e18); + + // Assert balances + _assertReserveTokenBalance(0, expectedReserveTokenAmount); + _assertCDEPOBalance(0, 0); + _assertVaultBalance(0, 0, forfeitedAmount); + + // Assert deposits + _assertTotalShares(expectedReserveTokenAmount); + } + + function test_success_sameAddress() + public + givenAddressHasReserveToken(recipientTwo, 10e18) + givenReserveTokenSpendingIsApproved(recipientTwo, address(CDEPO), 10e18) + givenAddressHasCDEPO(recipientTwo, 10e18) + { + uint256 expectedReserveTokenAmount = FullMath.mulDiv(10e18, reclaimRate, 100e2); + assertEq(expectedReserveTokenAmount, 99e17, "expectedReserveTokenAmount"); + uint256 forfeitedAmount = 10e18 - expectedReserveTokenAmount; + + // Call function + vm.prank(recipientTwo); + CDEPO.reclaimFor(recipientTwo, 10e18); + + // Assert balances + _assertReserveTokenBalance(0, expectedReserveTokenAmount); + _assertCDEPOBalance(0, 0); + _assertVaultBalance(0, 0, forfeitedAmount); + + // Assert deposits + _assertTotalShares(expectedReserveTokenAmount); + } + + function test_success_fuzz( + uint256 amount_ + ) + public + givenAddressHasReserveToken(recipientTwo, 10e18) + givenReserveTokenSpendingIsApproved(recipientTwo, address(CDEPO), 10e18) + givenAddressHasCDEPO(recipientTwo, 10e18) + givenConvertibleDepositTokenSpendingIsApproved(recipientTwo, address(CDEPO), 10e18) + { + uint256 amount = bound(amount_, 2, 10e18); + + uint256 expectedReserveTokenAmount = FullMath.mulDiv(amount, reclaimRate, 100e2); + uint256 forfeitedAmount = amount - expectedReserveTokenAmount; + + // Call function + vm.prank(recipient); + CDEPO.reclaimFor(recipientTwo, amount); + + // Assert balances + _assertReserveTokenBalance(0, expectedReserveTokenAmount); + _assertCDEPOBalance(0, 10e18 - amount); + _assertVaultBalance(0, 10e18 - amount, forfeitedAmount); + + // Assert deposits + _assertTotalShares(expectedReserveTokenAmount); + } +} diff --git a/src/test/modules/CDEPO/reclaimTo.t.sol b/src/test/modules/CDEPO/reclaimTo.t.sol deleted file mode 100644 index 3014a681..00000000 --- a/src/test/modules/CDEPO/reclaimTo.t.sol +++ /dev/null @@ -1,114 +0,0 @@ -// SPDX-License-Identifier: Unlicensed -pragma solidity 0.8.15; - -import {Test, stdError} from "forge-std/Test.sol"; -import {CDEPOTest} from "./CDEPOTest.sol"; -import {FullMath} from "src/libraries/FullMath.sol"; - -import {CDEPOv1} from "src/modules/CDEPO/CDEPO.v1.sol"; - -import {console2} from "forge-std/console2.sol"; - -contract ReclaimToCDEPOTest is CDEPOTest { - // when the amount is zero - // [X] it reverts - // when the discounted amount is zero - // [X] it reverts - // when the caller has an insufficient balance of convertible deposit tokens - // [X] it reverts - // when the caller has a sufficient balance of convertible deposit tokens - // [X] it burns the corresponding amount of convertible deposit tokens - // [X] it withdraws the underlying asset from the vault - // [X] it transfers the underlying asset to the `to_` address after applying the reclaim rate - // [X] it marks the forfeited amount of the underlying asset as yield - // [X] it updates the total deposits - - function test_amountIsZero_reverts() public { - // Expect revert - vm.expectRevert(abi.encodeWithSelector(CDEPOv1.CDEPO_InvalidArgs.selector, "amount")); - - // Call function - vm.prank(recipient); - CDEPO.reclaimTo(recipientTwo, 0); - } - - function test_discountedAmountIsZero_reverts() - public - givenAddressHasReserveToken(recipient, 10e18) - givenReserveTokenSpendingIsApproved(recipient, address(CDEPO), 10e18) - givenRecipientHasCDEPO(10e18) - { - // This amount would result in 0 shares being withdrawn, and should revert - uint256 amount = 1; - - // Expect revert - vm.expectRevert(abi.encodeWithSelector(CDEPOv1.CDEPO_InvalidArgs.selector, "shares")); - - // Call function - vm.prank(recipient); - CDEPO.reclaimTo(recipientTwo, amount); - } - - function test_insufficientBalance_reverts() - public - givenAddressHasReserveToken(recipient, 5e18) - givenReserveTokenSpendingIsApproved(recipient, address(CDEPO), 5e18) - givenRecipientHasCDEPO(5e18) - { - // Expect revert - vm.expectRevert(stdError.arithmeticError); - - // Call function - vm.prank(recipient); - CDEPO.reclaimTo(recipientTwo, 10e18); - } - - function test_success() - public - givenAddressHasReserveToken(recipient, 10e18) - givenReserveTokenSpendingIsApproved(recipient, address(CDEPO), 10e18) - givenRecipientHasCDEPO(10e18) - { - uint256 expectedReserveTokenAmount = FullMath.mulDiv(10e18, reclaimRate, 100e2); - assertEq(expectedReserveTokenAmount, 99e17, "expectedReserveTokenAmount"); - uint256 forfeitedAmount = 10e18 - expectedReserveTokenAmount; - - // Call function - vm.prank(recipient); - CDEPO.reclaimTo(recipientTwo, 10e18); - - // Assert balances - _assertReserveTokenBalance(0, expectedReserveTokenAmount); - _assertCDEPOBalance(0, 0); - _assertVaultBalance(0, 0, forfeitedAmount); - - // Assert deposits - _assertTotalShares(expectedReserveTokenAmount); - } - - function test_success_fuzz( - uint256 amount_ - ) - public - givenAddressHasReserveToken(recipient, 10e18) - givenReserveTokenSpendingIsApproved(recipient, address(CDEPO), 10e18) - givenRecipientHasCDEPO(10e18) - { - uint256 amount = bound(amount_, 2, 10e18); - - uint256 expectedReserveTokenAmount = FullMath.mulDiv(amount, reclaimRate, 100e2); - uint256 forfeitedAmount = amount - expectedReserveTokenAmount; - - // Call function - vm.prank(recipient); - CDEPO.reclaimTo(recipientTwo, amount); - - // Assert balances - _assertReserveTokenBalance(0, expectedReserveTokenAmount); - _assertCDEPOBalance(10e18 - amount, 0); - _assertVaultBalance(10e18 - amount, 0, forfeitedAmount); - - // Assert deposits - _assertTotalShares(expectedReserveTokenAmount); - } -} diff --git a/src/test/modules/CDEPO/redeemFor.t.sol b/src/test/modules/CDEPO/redeemFor.t.sol new file mode 100644 index 00000000..9b8b7a4e --- /dev/null +++ b/src/test/modules/CDEPO/redeemFor.t.sol @@ -0,0 +1,175 @@ +// SPDX-License-Identifier: Unlicensed +pragma solidity 0.8.15; + +import {Test, stdError} from "forge-std/Test.sol"; +import {CDEPOTest} from "./CDEPOTest.sol"; + +import {CDEPOv1} from "src/modules/CDEPO/CDEPO.v1.sol"; +import {Module} from "src/Kernel.sol"; + +import {console2} from "forge-std/console2.sol"; + +contract RedeemForCDEPOTest is CDEPOTest { + // when the amount is zero + // [X] it reverts + // when the shares for the amount is zero + // [X] it reverts + // when the account address has not approved CDEPO to spend the convertible deposit tokens + // when the account address is the same as the sender + // [X] it does not require the approval + // [X] it reverts + // when the account address has an insufficient balance of convertible deposit tokens + // [X] it reverts + // when the account address has a sufficient balance of convertible deposit tokens + // [X] it burns the corresponding amount of convertible deposit tokens from the account address + // [X] it withdraws the underlying asset from the vault + // [X] it transfers the underlying asset to the caller and does not apply the reclaim rate + + function test_amountIsZero_reverts() public { + // Expect revert + vm.expectRevert(abi.encodeWithSelector(CDEPOv1.CDEPO_InvalidArgs.selector, "amount")); + + // Call function + vm.prank(godmode); + CDEPO.redeemFor(recipient, 0); + } + + function test_spendingNotApproved_reverts() + public + givenAddressHasReserveToken(recipient, 10e18) + givenReserveTokenSpendingIsApproved(recipient, address(CDEPO), 10e18) + givenAddressHasCDEPO(recipient, 10e18) + { + // Expect revert + vm.expectRevert(abi.encodeWithSelector(CDEPOv1.CDEPO_InvalidArgs.selector, "allowance")); + + // Call function + vm.prank(godmode); + CDEPO.redeemFor(recipient, 10e18); + } + + function test_insufficientBalance_reverts() + public + givenAddressHasReserveToken(recipient, 5e18) + givenReserveTokenSpendingIsApproved(recipient, address(CDEPO), 5e18) + givenAddressHasCDEPO(recipient, 5e18) + givenConvertibleDepositTokenSpendingIsApproved(recipient, address(CDEPO), 10e18) + { + // Expect revert + vm.expectRevert(stdError.arithmeticError); + + // Call function + vm.prank(godmode); + CDEPO.redeemFor(recipient, 10e18); + } + + function test_callerIsNotPermissioned_reverts() public { + // Expect revert + vm.expectRevert( + abi.encodeWithSelector(Module.Module_PolicyNotPermitted.selector, recipient) + ); + + // Call function + vm.prank(recipient); + CDEPO.redeemFor(recipient, 10e18); + } + + function test_success( + uint256 amount_ + ) + public + givenAddressHasReserveToken(recipient, 10e18) + givenReserveTokenSpendingIsApproved(recipient, address(CDEPO), 10e18) + givenAddressHasCDEPO(recipient, 10e18) + givenConvertibleDepositTokenSpendingIsApproved(recipient, address(CDEPO), 10e18) + { + uint256 amount = bound(amount_, 1, 10e18); + + uint256 vaultBalanceBefore = vault.balanceOf(address(CDEPO)); + uint256 expectedVaultSharesWithdrawn = vault.previewWithdraw(amount); + + // Call function + vm.prank(godmode); + CDEPO.redeemFor(recipient, amount); + + // Assert CD token balance + assertEq(CDEPO.balanceOf(recipient), 10e18 - amount, "CDEPO.balanceOf(recipient)"); + assertEq(CDEPO.balanceOf(godmode), 0, "CDEPO.balanceOf(godmode)"); + assertEq(CDEPO.totalSupply(), 10e18 - amount, "CDEPO.totalSupply()"); + + // Assert reserve token balance + // No reclaim rate is applied + assertEq(reserveToken.balanceOf(recipient), 0, "reserveToken.balanceOf(recipient)"); + assertEq(reserveToken.balanceOf(godmode), amount, "reserveToken.balanceOf(godmode)"); + assertEq( + reserveToken.balanceOf(address(CDEPO)), + 0, + "reserveToken.balanceOf(address(CDEPO))" + ); + assertEq( + reserveToken.balanceOf(address(vault)), + reserveToken.totalSupply() - amount, + "reserveToken.balanceOf(address(vault))" + ); + + // Assert vault balance + assertEq(vault.balanceOf(recipient), 0, "vault.balanceOf(recipient)"); + assertEq(vault.balanceOf(godmode), 0, "vault.balanceOf(godmode)"); + assertEq( + vault.balanceOf(address(CDEPO)), + vaultBalanceBefore - expectedVaultSharesWithdrawn, + "vault.balanceOf(address(CDEPO))" + ); + + // Assert total shares tracked + _assertTotalShares(amount); + } + + function test_success_sameAddress() + public + givenAddressHasReserveToken(godmode, 10e18) + givenReserveTokenSpendingIsApproved(godmode, address(CDEPO), 10e18) + givenAddressHasCDEPO(godmode, 10e18) + { + uint256 amount = 5e18; + + uint256 vaultBalanceBefore = vault.balanceOf(address(CDEPO)); + uint256 expectedVaultSharesWithdrawn = vault.previewWithdraw(amount); + + // Call function + vm.prank(godmode); + CDEPO.redeemFor(godmode, amount); + + // Assert CD token balance + assertEq(CDEPO.balanceOf(recipient), 0, "CDEPO.balanceOf(recipient)"); + assertEq(CDEPO.balanceOf(godmode), 10e18 - amount, "CDEPO.balanceOf(godmode)"); + assertEq(CDEPO.totalSupply(), 10e18 - amount, "CDEPO.totalSupply()"); + + // Assert reserve token balance + // No reclaim rate is applied + assertEq(reserveToken.balanceOf(recipient), 0, "reserveToken.balanceOf(recipient)"); + assertEq(reserveToken.balanceOf(godmode), amount, "reserveToken.balanceOf(godmode)"); + assertEq( + reserveToken.balanceOf(address(CDEPO)), + 0, + "reserveToken.balanceOf(address(CDEPO))" + ); + assertEq( + reserveToken.balanceOf(address(vault)), + reserveToken.totalSupply() - amount, + "reserveToken.balanceOf(address(vault))" + ); + + // Assert vault balance + assertEq(vault.balanceOf(recipient), 0, "vault.balanceOf(recipient)"); + assertEq(vault.balanceOf(godmode), 0, "vault.balanceOf(godmode)"); + assertEq( + vault.balanceOf(address(CDEPO)), + vaultBalanceBefore - expectedVaultSharesWithdrawn, + "vault.balanceOf(address(CDEPO))" + ); + + // Assert total shares tracked + _assertTotalShares(amount); + } +} From 2aca51a03f47421753952da1648418fa3c73b396 Mon Sep 17 00:00:00 2001 From: Jem <0x0xjem@gmail.com> Date: Fri, 20 Dec 2024 17:17:04 +0500 Subject: [PATCH 58/64] CDFacility: test updates --- src/test/policies/ConvertibleDepositFacility/convert.t.sol | 2 ++ src/test/policies/ConvertibleDepositFacility/create.t.sol | 4 ++-- src/test/policies/ConvertibleDepositFacility/reclaim.t.sol | 2 ++ 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/test/policies/ConvertibleDepositFacility/convert.t.sol b/src/test/policies/ConvertibleDepositFacility/convert.t.sol index d83423c9..ce68f1c1 100644 --- a/src/test/policies/ConvertibleDepositFacility/convert.t.sol +++ b/src/test/policies/ConvertibleDepositFacility/convert.t.sol @@ -17,6 +17,8 @@ contract ConvertCDFTest is ConvertibleDepositFacilityTest { // [ ] it reverts // when any position has an amount greater than the remaining deposit // [ ] it reverts + // when the caller has not approved CDEPO to spend the total amount of CD tokens + // [ ] it reverts // [ ] it mints the converted amount of OHM to the account_ // [ ] it updates the remaining deposit of each position // [ ] it transfers the redeemed vault shares to the TRSRY diff --git a/src/test/policies/ConvertibleDepositFacility/create.t.sol b/src/test/policies/ConvertibleDepositFacility/create.t.sol index e13f45d5..78937a5f 100644 --- a/src/test/policies/ConvertibleDepositFacility/create.t.sol +++ b/src/test/policies/ConvertibleDepositFacility/create.t.sol @@ -7,9 +7,9 @@ import {IConvertibleDepositFacility} from "src/policies/interfaces/IConvertibleD contract CreateCDFTest is ConvertibleDepositFacilityTest { // when the caller does not have the CD_Auctioneer role // [ ] it reverts - // when the caller has not approved CDEPO to spend the reserve token + // when the caller has not approved CDEPO to spend the reserve tokens // [ ] it reverts - // [ ] it mints the CD token to account_ + // [ ] it mints the CD tokens to account_ // [ ] it creates a new position in the CDPOS module // [ ] it pre-emptively increases the mint approval equivalent to the converted amount of OHM // [ ] it returns the position ID diff --git a/src/test/policies/ConvertibleDepositFacility/reclaim.t.sol b/src/test/policies/ConvertibleDepositFacility/reclaim.t.sol index 8fbe34a3..c3f6fc4b 100644 --- a/src/test/policies/ConvertibleDepositFacility/reclaim.t.sol +++ b/src/test/policies/ConvertibleDepositFacility/reclaim.t.sol @@ -17,6 +17,8 @@ contract ReclaimCDFTest is ConvertibleDepositFacilityTest { // [ ] it reverts // when any position has an amount greater than the remaining deposit // [ ] it reverts + // when the caller has not approved CDEPO to spend the total amount of CD tokens + // [ ] it reverts // [ ] it updates the remaining deposit of each position // [ ] it transfers the redeemed reserve tokens to the owner // [ ] it decreases the OHM mint approval by the amount of OHM that would have been converted From 0cf689219288b980d2e4566070501faaa78925d7 Mon Sep 17 00:00:00 2001 From: Jem <0x0xjem@gmail.com> Date: Mon, 23 Dec 2024 13:12:41 +0500 Subject: [PATCH 59/64] Add preview functions to CD facility --- src/policies/CDFacility.sol | 28 ++++++++++ .../IConvertibleDepositFacility.sol | 54 +++++++++++++++++-- 2 files changed, 79 insertions(+), 3 deletions(-) diff --git a/src/policies/CDFacility.sol b/src/policies/CDFacility.sol index bf375303..199a74ff 100644 --- a/src/policies/CDFacility.sol +++ b/src/policies/CDFacility.sol @@ -172,6 +172,13 @@ contract CDFacility is Policy, RolesConsumer, IConvertibleDepositFacility { return (totalDeposits, converted); } + function previewConvert( + uint256[] memory positionIds_, + uint256[] memory amounts_ + ) external view returns (uint256 cdTokenIn, uint256 convertedTokenOut, address cdTokenSpender) { + return (0, 0, address(0)); + } + /// @inheritdoc IConvertibleDepositFacility function reclaim( uint256[] memory positionIds_, @@ -230,4 +237,25 @@ contract CDFacility is Policy, RolesConsumer, IConvertibleDepositFacility { return reclaimed; } + + function previewReclaim( + uint256[] memory positionIds_, + uint256[] memory amounts_ + ) external view returns (uint256 reclaimed, address cdTokenSpender) { + return (0, address(0)); + } + + // ========== VIEW FUNCTIONS ========== // + + function depositToken() external view returns (address) { + return address(CDEPO.asset()); + } + + function convertibleDepositToken() external view returns (address) { + return address(CDEPO); + } + + function convertedToken() external view returns (address) { + return address(MINTR.ohm()); + } } diff --git a/src/policies/interfaces/IConvertibleDepositFacility.sol b/src/policies/interfaces/IConvertibleDepositFacility.sol index 686d12ac..5eb95dfc 100644 --- a/src/policies/interfaces/IConvertibleDepositFacility.sol +++ b/src/policies/interfaces/IConvertibleDepositFacility.sol @@ -65,12 +65,31 @@ interface IConvertibleDepositFacility { /// /// @param positionIds_ An array of position ids that will be converted /// @param amounts_ An array of amounts of convertible deposit tokens to convert - /// @return totalDeposit The total amount of convertible deposit tokens converted - /// @return converted The amount of OHM minted during conversion + /// @return cdTokenIn The total amount of convertible deposit tokens converted + /// @return convertedTokenOut The amount of OHM minted during conversion function convert( uint256[] memory positionIds_, uint256[] memory amounts_ - ) external returns (uint256 totalDeposit, uint256 converted); + ) external returns (uint256 cdTokenIn, uint256 convertedTokenOut); + + /// @notice Preview the amount of convertible deposit tokens and OHM that would be converted + /// @dev The implementing contract is expected to handle the following: + /// - Validating that the caller is the owner of all of the positions + /// - Validating that convertible deposit token in the position is CDEPO + /// - Validating that all of the positions are valid + /// - Validating that all of the positions have not expired + /// - Validating that the caller has approved CDEPO to spend the total amount of CD tokens + /// - Returning the total amount of convertible deposit tokens and OHM that would be converted + /// + /// @param positionIds_ An array of position ids that will be converted + /// @param amounts_ An array of amounts of convertible deposit tokens to convert + /// @return cdTokenIn The total amount of convertible deposit tokens converted + /// @return convertedTokenOut The amount of OHM minted during conversion + /// @return cdTokenSpender The address that will spend the convertible deposit tokens. The caller must have approved this address to spend the total amount of CD tokens. + function previewConvert( + uint256[] memory positionIds_, + uint256[] memory amounts_ + ) external view returns (uint256 cdTokenIn, uint256 convertedTokenOut, address cdTokenSpender); /// @notice Reclaims convertible deposit tokens after expiry /// @dev The implementing contract is expected to handle the following: @@ -89,4 +108,33 @@ interface IConvertibleDepositFacility { uint256[] memory positionIds_, uint256[] memory amounts_ ) external returns (uint256 reclaimed); + + /// @notice Preview the amount of reserve token that would be reclaimed + /// @dev The implementing contract is expected to handle the following: + /// - Validating that the caller is the owner of all of the positions + /// - Validating that convertible deposit token in the position is CDEPO + /// - Validating that all of the positions are valid + /// - Validating that all of the positions have expired + /// - Validating that the caller has approved CDEPO to spend the total amount of CD tokens + /// - Returning the total amount of reserve token that would be reclaimed + /// + /// @param positionIds_ An array of position ids that will be reclaimed + /// @param amounts_ An array of amounts of convertible deposit tokens to reclaim + /// @return reclaimed The amount of reserve token returned to the caller + /// @return cdTokenSpender The address that will spend the convertible deposit tokens. The caller must have approved this address to spend the total amount of CD tokens. + function previewReclaim( + uint256[] memory positionIds_, + uint256[] memory amounts_ + ) external view returns (uint256 reclaimed, address cdTokenSpender); + + // ========== VIEW FUNCTIONS ========== // + + /// @notice The address of token accepted by the facility + function depositToken() external view returns (address); + + /// @notice The address of the convertible deposit token that is minted by the facility + function convertibleDepositToken() external view returns (address); + + /// @notice The address of the token that is converted to by the facility + function convertedToken() external view returns (address); } From 21580dbf7e317440b5014a1a409913785c4c04af Mon Sep 17 00:00:00 2001 From: Jem <0x0xjem@gmail.com> Date: Mon, 23 Dec 2024 13:59:27 +0500 Subject: [PATCH 60/64] CD Facility: role must be lowercase. Add previewConvert and previewReclaim test stubs. create tests. --- src/policies/CDFacility.sol | 5 +- .../IConvertibleDepositFacility.sol | 4 - .../ConvertibleDepositFacilityTest.sol | 7 +- .../ConvertibleDepositFacility/create.t.sol | 128 +++++++++++- .../previewConvert.t.sol | 192 ++++++++++++++++++ .../previewReclaim.t.sol | 20 ++ 6 files changed, 337 insertions(+), 19 deletions(-) create mode 100644 src/test/policies/ConvertibleDepositFacility/previewConvert.t.sol create mode 100644 src/test/policies/ConvertibleDepositFacility/previewReclaim.t.sol diff --git a/src/policies/CDFacility.sol b/src/policies/CDFacility.sol index 199a74ff..f4dab7ba 100644 --- a/src/policies/CDFacility.sol +++ b/src/policies/CDFacility.sol @@ -84,7 +84,7 @@ contract CDFacility is Policy, RolesConsumer, IConvertibleDepositFacility { uint256 conversionPrice_, uint48 expiry_, bool wrap_ - ) external onlyRole("CD_Auctioneer") returns (uint256 positionId) { + ) external onlyRole("cd_auctioneer") returns (uint256 positionId) { // Mint the CD token to the account // This will also transfer the reserve token CDEPO.mintFor(account_, amount_); @@ -119,8 +119,7 @@ contract CDFacility is Policy, RolesConsumer, IConvertibleDepositFacility { uint256[] memory amounts_ ) external returns (uint256 totalDeposit, uint256 converted) { // Make sure the lengths of the arrays are the same - if (positionIds_.length != amounts_.length) - revert CDF_InvalidArgs("array lengths must match"); + if (positionIds_.length != amounts_.length) revert CDF_InvalidArgs("array length"); uint256 totalDeposits; diff --git a/src/policies/interfaces/IConvertibleDepositFacility.sol b/src/policies/interfaces/IConvertibleDepositFacility.sol index 5eb95dfc..b9440d27 100644 --- a/src/policies/interfaces/IConvertibleDepositFacility.sol +++ b/src/policies/interfaces/IConvertibleDepositFacility.sol @@ -74,11 +74,9 @@ interface IConvertibleDepositFacility { /// @notice Preview the amount of convertible deposit tokens and OHM that would be converted /// @dev The implementing contract is expected to handle the following: - /// - Validating that the caller is the owner of all of the positions /// - Validating that convertible deposit token in the position is CDEPO /// - Validating that all of the positions are valid /// - Validating that all of the positions have not expired - /// - Validating that the caller has approved CDEPO to spend the total amount of CD tokens /// - Returning the total amount of convertible deposit tokens and OHM that would be converted /// /// @param positionIds_ An array of position ids that will be converted @@ -111,11 +109,9 @@ interface IConvertibleDepositFacility { /// @notice Preview the amount of reserve token that would be reclaimed /// @dev The implementing contract is expected to handle the following: - /// - Validating that the caller is the owner of all of the positions /// - Validating that convertible deposit token in the position is CDEPO /// - Validating that all of the positions are valid /// - Validating that all of the positions have expired - /// - Validating that the caller has approved CDEPO to spend the total amount of CD tokens /// - Returning the total amount of reserve token that would be reclaimed /// /// @param positionIds_ An array of position ids that will be reclaimed diff --git a/src/test/policies/ConvertibleDepositFacility/ConvertibleDepositFacilityTest.sol b/src/test/policies/ConvertibleDepositFacility/ConvertibleDepositFacilityTest.sol index 3a54409c..d61464bc 100644 --- a/src/test/policies/ConvertibleDepositFacility/ConvertibleDepositFacilityTest.sol +++ b/src/test/policies/ConvertibleDepositFacility/ConvertibleDepositFacilityTest.sol @@ -34,6 +34,7 @@ contract ConvertibleDepositFacilityTest is Test { uint48 public constant INITIAL_BLOCK = 1_000_000; uint256 public constant CONVERSION_PRICE = 2e18; uint48 public constant EXPIRY = INITIAL_BLOCK + 1 days; + uint256 public constant RESERVE_TOKEN_AMOUNT = 10e18; function setUp() public { vm.warp(INITIAL_BLOCK); @@ -62,7 +63,7 @@ contract ConvertibleDepositFacilityTest is Test { kernel.executeAction(Actions.ActivatePolicy, address(rolesAdmin)); // Grant roles - rolesAdmin.grantRole(bytes32("CD_Auctioneer"), auctioneer); + rolesAdmin.grantRole(bytes32("cd_auctioneer"), auctioneer); } // ========== MODIFIERS ========== // @@ -88,9 +89,9 @@ contract ConvertibleDepositFacilityTest is Test { uint256 conversionPrice_, uint48 expiry_, bool wrap_ - ) internal { + ) internal returns (uint256 positionId) { vm.prank(auctioneer); - facility.create(account_, amount_, conversionPrice_, expiry_, wrap_); + positionId = facility.create(account_, amount_, conversionPrice_, expiry_, wrap_); } modifier givenAddressHasPosition(address account_, uint256 amount_) { diff --git a/src/test/policies/ConvertibleDepositFacility/create.t.sol b/src/test/policies/ConvertibleDepositFacility/create.t.sol index 78937a5f..2d90ab1b 100644 --- a/src/test/policies/ConvertibleDepositFacility/create.t.sol +++ b/src/test/policies/ConvertibleDepositFacility/create.t.sol @@ -3,15 +3,125 @@ pragma solidity 0.8.15; import {ConvertibleDepositFacilityTest} from "./ConvertibleDepositFacilityTest.sol"; import {IConvertibleDepositFacility} from "src/policies/interfaces/IConvertibleDepositFacility.sol"; +import {ROLESv1} from "src/modules/ROLES/ROLES.v1.sol"; +import {CDEPOv1} from "src/modules/CDEPO/CDEPO.v1.sol"; contract CreateCDFTest is ConvertibleDepositFacilityTest { - // when the caller does not have the CD_Auctioneer role - // [ ] it reverts - // when the caller has not approved CDEPO to spend the reserve tokens - // [ ] it reverts - // [ ] it mints the CD tokens to account_ - // [ ] it creates a new position in the CDPOS module - // [ ] it pre-emptively increases the mint approval equivalent to the converted amount of OHM - // [ ] it returns the position ID - // [ ] it emits a CreatedDeposit event + event CreatedDeposit(address indexed user, uint256 indexed termId, uint256 amount); + + // when the caller does not have the cd_auctioneer role + // [X] it reverts + // when the recipient has not approved CDEPO to spend the reserve tokens + // [X] it reverts + // when multiple positions are created + // [X] it succeeds + // [X] it mints the CD tokens to account_ + // [X] it creates a new position in the CDPOS module + // [X] it pre-emptively increases the mint approval equivalent to the converted amount of OHM + // [X] it returns the position ID + // [X] it emits a CreatedDeposit event + + function test_callerNotAuctioneer_reverts() + public + givenAddressHasReserveToken(recipient, RESERVE_TOKEN_AMOUNT) + { + // Expect revert + vm.expectRevert( + abi.encodeWithSelector(ROLESv1.ROLES_RequireRole.selector, bytes32("cd_auctioneer")) + ); + + // Call function + facility.create(recipient, RESERVE_TOKEN_AMOUNT, CONVERSION_PRICE, EXPIRY, false); + } + + function test_spendingNotApproved_reverts() + public + givenAddressHasReserveToken(recipient, RESERVE_TOKEN_AMOUNT) + { + // Expect revert + vm.expectRevert("TRANSFER_FROM_FAILED"); + + // Call function + _createPosition(recipient, RESERVE_TOKEN_AMOUNT, CONVERSION_PRICE, EXPIRY, false); + } + + function test_success() + public + givenAddressHasReserveToken(recipient, RESERVE_TOKEN_AMOUNT) + givenReserveTokenSpendingIsApproved( + recipient, + address(convertibleDepository), + RESERVE_TOKEN_AMOUNT + ) + { + // Expect event + vm.expectEmit(true, true, true, true); + emit CreatedDeposit(recipient, 0, RESERVE_TOKEN_AMOUNT); + + // Call function + uint256 positionId = _createPosition( + recipient, + RESERVE_TOKEN_AMOUNT, + CONVERSION_PRICE, + EXPIRY, + false + ); + + // Assert that the position ID is 0 + assertEq(positionId, 0); + + // Assert that the reserve token was transferred from the recipient + assertEq(reserveToken.balanceOf(recipient), 0); + + // Assert that the CDEPO token was minted to the recipient + assertEq(convertibleDepository.balanceOf(recipient), RESERVE_TOKEN_AMOUNT); + + // Assert that the recipient has a CDPOS position + uint256[] memory positionIds = convertibleDepositPositions.getUserPositionIds(recipient); + assertEq(positionIds.length, 1); + assertEq(positionIds[0], 0); + + // Assert that the mint approval was increased + assertEq(minter.mintApproval(address(facility)), RESERVE_TOKEN_AMOUNT); + } + + function test_success_multiple() + public + givenAddressHasReserveToken(recipient, RESERVE_TOKEN_AMOUNT) + givenReserveTokenSpendingIsApproved( + recipient, + address(convertibleDepository), + RESERVE_TOKEN_AMOUNT + ) + { + // Call function + _createPosition(recipient, RESERVE_TOKEN_AMOUNT / 2, CONVERSION_PRICE, EXPIRY, false); + + // Call function again + uint256 positionId2 = _createPosition( + recipient, + RESERVE_TOKEN_AMOUNT / 2, + CONVERSION_PRICE, + EXPIRY, + false + ); + + // Assert that the position ID is 1 + assertEq(positionId2, 1); + + // Assert that the reserve token was transferred from the recipient + assertEq(reserveToken.balanceOf(recipient), 0); + + // Assert that the CDEPO token was minted to the recipient + assertEq(convertibleDepository.balanceOf(recipient), RESERVE_TOKEN_AMOUNT); + + // Assert that the recipient has two CDPOS positions + uint256[] memory positionIds = convertibleDepositPositions.getUserPositionIds(recipient); + assertEq(positionIds.length, 2); + assertEq(positionIds[0], 0); + assertEq(positionIds[1], 1); + + // Assert that the mint approval was increased + assertEq(minter.mintApproval(address(facility)), RESERVE_TOKEN_AMOUNT); + } } diff --git a/src/test/policies/ConvertibleDepositFacility/previewConvert.t.sol b/src/test/policies/ConvertibleDepositFacility/previewConvert.t.sol new file mode 100644 index 00000000..0eb2f248 --- /dev/null +++ b/src/test/policies/ConvertibleDepositFacility/previewConvert.t.sol @@ -0,0 +1,192 @@ +// SPDX-License-Identifier: Unlicensed +pragma solidity 0.8.15; + +import {ConvertibleDepositFacilityTest} from "./ConvertibleDepositFacilityTest.sol"; +import {IConvertibleDepositFacility} from "src/policies/interfaces/IConvertibleDepositFacility.sol"; +import {CDPOSv1} from "src/modules/CDPOS/CDPOS.v1.sol"; + +contract PreviewConvertCDFTest is ConvertibleDepositFacilityTest { + // when the length of the positionIds_ array does not match the length of the amounts_ array + // [X] it reverts + // when any position is not valid + // [X] it reverts + // when any position has expired + // [X] it reverts + // when any position has an amount greater than the remaining deposit + // [X] it reverts + // [X] it returns the total CD token amount that would be converted + // [X] it returns the amount of OHM that would be minted + // [X] it returns the address that will spend the convertible deposit tokens + + function test_arrayLengthMismatch_reverts() public { + uint256[] memory positionIds_ = new uint256[](1); + uint256[] memory amounts_ = new uint256[](2); + + // Expect revert + vm.expectRevert( + abi.encodeWithSelector( + IConvertibleDepositFacility.CDF_InvalidArgs.selector, + "array length" + ) + ); + + // Call function + facility.previewConvert(positionIds_, amounts_); + } + + function test_anyPositionIsNotValid_reverts( + uint256 positionIndex_ + ) + public + givenAddressHasReserveToken(recipient, RESERVE_TOKEN_AMOUNT) + givenReserveTokenSpendingIsApproved( + recipient, + address(convertibleDepository), + RESERVE_TOKEN_AMOUNT + ) + givenAddressHasPosition(recipient, RESERVE_TOKEN_AMOUNT / 2) + givenAddressHasPosition(recipient, RESERVE_TOKEN_AMOUNT / 2) + { + uint256 positionIndex = bound(positionIndex_, 0, 2); + + uint256[] memory positionIds_ = new uint256[](3); + uint256[] memory amounts_ = new uint256[](3); + + for (uint256 i; i < 3; i++) { + // Invalid position + if (positionIndex == i) { + positionIds_[i] = 2; + amounts_[i] = RESERVE_TOKEN_AMOUNT / 2; + } + // Valid position + else { + positionIds_[i] = i; + amounts_[i] = RESERVE_TOKEN_AMOUNT / 2; + } + } + + // Expect revert + vm.expectRevert( + abi.encodeWithSelector(CDPOSv1.CDPOS_InvalidPositionId.selector, positionIndex) + ); + + // Call function + facility.previewConvert(positionIds_, amounts_); + } + + function test_anyPositionHasExpired_reverts( + uint256 positionIndex_ + ) + public + givenAddressHasReserveToken(recipient, 9e18) + givenReserveTokenSpendingIsApproved(recipient, address(convertibleDepository), 9e18) + { + uint256 positionIndex = bound(positionIndex_, 0, 2); + + uint256[] memory positionIds_ = new uint256[](3); + uint256[] memory amounts_ = new uint256[](3); + + for (uint256 i; i < 3; i++) { + uint48 expiry = uint48(block.timestamp + 1 days); + if (positionIndex == i) { + expiry = uint48(block.timestamp + 1); + } + + // Create position + uint256 positionId = _createPosition(recipient, 3e18, CONVERSION_PRICE, expiry, false); + + positionIds_[i] = positionId; + amounts_[i] = 3e18; + } + + // Expect revert + vm.expectRevert( + abi.encodeWithSelector( + IConvertibleDepositFacility.CDF_PositionExpired.selector, + positionIndex + ) + ); + + // Call function + facility.previewConvert(positionIds_, amounts_); + } + + function test_anyAmountIsGreaterThanRemainingDeposit_reverts( + uint256 positionIndex_ + ) + public + givenAddressHasReserveToken(recipient, 9e18) + givenReserveTokenSpendingIsApproved(recipient, address(convertibleDepository), 9e18) + givenAddressHasPosition(recipient, 3e18) + givenAddressHasPosition(recipient, 3e18) + givenAddressHasPosition(recipient, 3e18) + { + uint256 positionIndex = bound(positionIndex_, 0, 2); + + uint256[] memory positionIds_ = new uint256[](3); + uint256[] memory amounts_ = new uint256[](3); + + for (uint256 i; i < 3; i++) { + positionIds_[i] = i; + + // Invalid position + if (positionIndex == i) { + amounts_[i] = 4e18; + } + // Valid position + else { + amounts_[i] = 3e18; + } + } + + // Expect revert + vm.expectRevert( + abi.encodeWithSelector( + IConvertibleDepositFacility.CDF_InvalidAmount.selector, + positionIndex, + 4e18 + ) + ); + + // Call function + facility.previewConvert(positionIds_, amounts_); + } + + function test_success( + uint256 amountOne_, + uint256 amountTwo_, + uint256 amountThree_ + ) + public + givenAddressHasReserveToken(recipient, 9e18) + givenReserveTokenSpendingIsApproved(recipient, address(convertibleDepository), 9e18) + givenAddressHasPosition(recipient, 3e18) + givenAddressHasPosition(recipient, 3e18) + givenAddressHasPosition(recipient, 3e18) + { + uint256 amountOne = bound(amountOne_, 0, 3e18); + uint256 amountTwo = bound(amountTwo_, 0, 3e18); + uint256 amountThree = bound(amountThree_, 0, 3e18); + + uint256[] memory positionIds_ = new uint256[](3); + uint256[] memory amounts_ = new uint256[](3); + + positionIds_[0] = 0; + amounts_[0] = amountOne; + positionIds_[1] = 1; + amounts_[1] = amountTwo; + positionIds_[2] = 2; + amounts_[2] = amountThree; + + // Call function + (uint256 totalDeposits, uint256 converted, address spender) = facility.previewConvert( + positionIds_, + amounts_ + ); + + // Assertions + assertEq(totalDeposits, amountOne + amountTwo + amountThree); + assertEq(converted, ((amountOne + amountTwo + amountThree) * 1e18) / CONVERSION_PRICE); + assertEq(spender, address(convertibleDepository)); + } +} diff --git a/src/test/policies/ConvertibleDepositFacility/previewReclaim.t.sol b/src/test/policies/ConvertibleDepositFacility/previewReclaim.t.sol new file mode 100644 index 00000000..6ad24d6d --- /dev/null +++ b/src/test/policies/ConvertibleDepositFacility/previewReclaim.t.sol @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: Unlicensed +pragma solidity 0.8.15; + +import {ConvertibleDepositFacilityTest} from "./ConvertibleDepositFacilityTest.sol"; +import {IConvertibleDepositFacility} from "src/policies/interfaces/IConvertibleDepositFacility.sol"; + +contract PreviewReclaimCDFTest is ConvertibleDepositFacilityTest { + // when the length of the positionIds_ array does not match the length of the amounts_ array + // [ ] it reverts + // when any position is not valid + // [ ] it reverts + // when any position has a convertible deposit token that is not CDEPO + // [ ] it reverts + // when any position has not expired + // [ ] it reverts + // when any position has an amount greater than the remaining deposit + // [ ] it reverts + // [ ] it returns the total amount of deposit token that would be reclaimed + // [ ] it returns the address that will spend the convertible deposit tokens +} From 3fb895cc4e76ce19a2e2eb8f38278b613e8481d3 Mon Sep 17 00:00:00 2001 From: Jem <0x0xjem@gmail.com> Date: Mon, 23 Dec 2024 15:30:42 +0500 Subject: [PATCH 61/64] CDFacility: convert tests --- src/policies/CDFacility.sol | 23 +- .../ConvertibleDepositFacilityTest.sol | 11 + .../ConvertibleDepositFacility/convert.t.sol | 328 +++++++++++++++++- .../ConvertibleDepositFacility/create.t.sol | 48 ++- .../previewConvert.t.sol | 7 +- 5 files changed, 380 insertions(+), 37 deletions(-) diff --git a/src/policies/CDFacility.sol b/src/policies/CDFacility.sol index f4dab7ba..6b9b5352 100644 --- a/src/policies/CDFacility.sol +++ b/src/policies/CDFacility.sol @@ -53,6 +53,8 @@ contract CDFacility is Policy, RolesConsumer, IConvertibleDepositFacility { ROLES = ROLESv1(getModuleAddress(dependencies[2])); CDEPO = CDEPOv1(getModuleAddress(dependencies[3])); CDPOS = CDPOSv1(getModuleAddress(dependencies[4])); + + // TODO set decimals } function requestPermissions() @@ -69,7 +71,7 @@ contract CDFacility is Policy, RolesConsumer, IConvertibleDepositFacility { permissions[0] = Permissions(mintrKeycode, MINTR.increaseMintApproval.selector); permissions[1] = Permissions(mintrKeycode, MINTR.mintOhm.selector); permissions[2] = Permissions(mintrKeycode, MINTR.decreaseMintApproval.selector); - permissions[3] = Permissions(cdepoKeycode, CDEPO.redeem.selector); + permissions[3] = Permissions(cdepoKeycode, CDEPO.redeemFor.selector); permissions[4] = Permissions(cdepoKeycode, CDEPO.sweepYield.selector); permissions[5] = Permissions(cdposKeycode, CDPOS.create.selector); permissions[6] = Permissions(cdposKeycode, CDPOS.update.selector); @@ -99,8 +101,11 @@ contract CDFacility is Policy, RolesConsumer, IConvertibleDepositFacility { wrap_ ); + // Calculate the expected OHM amount + uint256 expectedOhmAmount = (amount_ * DECIMALS) / conversionPrice_; + // Pre-emptively increase the OHM mint approval - MINTR.increaseMintApproval(address(this), amount_); + MINTR.increaseMintApproval(address(this), expectedOhmAmount); // Emit an event emit CreatedDeposit(account_, positionId, amount_); @@ -128,13 +133,13 @@ contract CDFacility is Policy, RolesConsumer, IConvertibleDepositFacility { uint256 positionId = positionIds_[i]; uint256 depositAmount = amounts_[i]; - // Validate that the caller is the owner of the position - if (CDPOS.ownerOf(positionId) != msg.sender) revert CDF_NotOwner(positionId); - // Validate that the position is valid // This will revert if the position is not valid CDPOSv1.Position memory position = CDPOS.getPosition(positionId); + // Validate that the caller is the owner of the position + if (position.owner != msg.sender) revert CDF_NotOwner(positionId); + // Validate that the position is CDEPO if (position.convertibleDepositToken != address(CDEPO)) revert CDF_InvalidToken(positionId, position.convertibleDepositToken); @@ -157,10 +162,12 @@ contract CDFacility is Policy, RolesConsumer, IConvertibleDepositFacility { } // Redeem the CD deposits in bulk - uint256 sharesOut = CDEPO.redeem(totalDeposits); + uint256 tokensOut = CDEPO.redeemFor(msg.sender, totalDeposits); - // Transfer the redeemed assets to the TRSRY - CDEPO.vault().transfer(address(TRSRY), sharesOut); + // Wrap the tokens and transfer to the TRSRY + ERC4626 vault = CDEPO.vault(); + CDEPO.asset().approve(address(vault), tokensOut); + vault.deposit(tokensOut, address(TRSRY)); // Mint OHM to the owner/caller MINTR.mintOhm(msg.sender, converted); diff --git a/src/test/policies/ConvertibleDepositFacility/ConvertibleDepositFacilityTest.sol b/src/test/policies/ConvertibleDepositFacility/ConvertibleDepositFacilityTest.sol index d61464bc..4149f211 100644 --- a/src/test/policies/ConvertibleDepositFacility/ConvertibleDepositFacilityTest.sol +++ b/src/test/policies/ConvertibleDepositFacility/ConvertibleDepositFacilityTest.sol @@ -30,6 +30,7 @@ contract ConvertibleDepositFacilityTest is Test { address public recipient = address(0x1); address public auctioneer = address(0x2); + address public recipientTwo = address(0x3); uint48 public constant INITIAL_BLOCK = 1_000_000; uint256 public constant CONVERSION_PRICE = 2e18; @@ -98,4 +99,14 @@ contract ConvertibleDepositFacilityTest is Test { _createPosition(account_, amount_, CONVERSION_PRICE, EXPIRY, false); _; } + + modifier givenConvertibleDepositTokenSpendingIsApproved( + address owner_, + address spender_, + uint256 amount_ + ) { + vm.prank(owner_); + convertibleDepository.approve(spender_, amount_); + _; + } } diff --git a/src/test/policies/ConvertibleDepositFacility/convert.t.sol b/src/test/policies/ConvertibleDepositFacility/convert.t.sol index ce68f1c1..a450e764 100644 --- a/src/test/policies/ConvertibleDepositFacility/convert.t.sol +++ b/src/test/policies/ConvertibleDepositFacility/convert.t.sol @@ -3,25 +3,327 @@ pragma solidity 0.8.15; import {ConvertibleDepositFacilityTest} from "./ConvertibleDepositFacilityTest.sol"; import {IConvertibleDepositFacility} from "src/policies/interfaces/IConvertibleDepositFacility.sol"; +import {CDEPOv1} from "src/modules/CDEPO/CDEPO.v1.sol"; +import {CDPOSv1} from "src/modules/CDPOS/CDPOS.v1.sol"; contract ConvertCDFTest is ConvertibleDepositFacilityTest { + event ConvertedDeposit(address indexed user, uint256 depositAmount, uint256 convertedAmount); + // when the length of the positionIds_ array does not match the length of the amounts_ array - // [ ] it reverts + // [X] it reverts // when any position is not valid - // [ ] it reverts + // [X] it reverts // when any position has an owner that is not the caller - // [ ] it reverts - // when any position has a convertible deposit token that is not CDEPO - // [ ] it reverts + // [X] it reverts // when any position has expired - // [ ] it reverts + // [X] it reverts // when any position has an amount greater than the remaining deposit - // [ ] it reverts + // [X] it reverts // when the caller has not approved CDEPO to spend the total amount of CD tokens - // [ ] it reverts - // [ ] it mints the converted amount of OHM to the account_ - // [ ] it updates the remaining deposit of each position - // [ ] it transfers the redeemed vault shares to the TRSRY - // [ ] it returns the total deposit amount and the converted amount - // [ ] it emits a ConvertedDeposit event + // [X] it reverts + // [X] it mints the converted amount of OHM to the account_ + // [X] it updates the remaining deposit of each position + // [X] it transfers the redeemed vault shares to the TRSRY + // [X] it returns the total deposit amount and the converted amount + // [X] it emits a ConvertedDeposit event + + function test_arrayLengthMismatch_reverts() public { + uint256[] memory positionIds_ = new uint256[](1); + uint256[] memory amounts_ = new uint256[](2); + + // Expect revert + vm.expectRevert( + abi.encodeWithSelector( + IConvertibleDepositFacility.CDF_InvalidArgs.selector, + "array length" + ) + ); + + // Call function + vm.prank(recipient); + facility.convert(positionIds_, amounts_); + } + + function test_anyPositionIsNotValid_reverts( + uint256 positionIndex_ + ) + public + givenAddressHasReserveToken(recipient, RESERVE_TOKEN_AMOUNT) + givenReserveTokenSpendingIsApproved( + recipient, + address(convertibleDepository), + RESERVE_TOKEN_AMOUNT + ) + givenAddressHasPosition(recipient, RESERVE_TOKEN_AMOUNT / 2) + givenAddressHasPosition(recipient, RESERVE_TOKEN_AMOUNT / 2) + { + uint256 positionIndex = bound(positionIndex_, 0, 2); + + uint256[] memory positionIds_ = new uint256[](3); + uint256[] memory amounts_ = new uint256[](3); + + for (uint256 i; i < 3; i++) { + // Invalid position + if (positionIndex == i) { + positionIds_[i] = 2; + amounts_[i] = RESERVE_TOKEN_AMOUNT / 2; + } + // Valid position + else { + positionIds_[i] = i < positionIndex ? i : i - 1; + amounts_[i] = RESERVE_TOKEN_AMOUNT / 2; + } + } + + // Expect revert + vm.expectRevert(abi.encodeWithSelector(CDPOSv1.CDPOS_InvalidPositionId.selector, 2)); + + // Call function + vm.prank(recipient); + facility.convert(positionIds_, amounts_); + } + + function test_anyPositionHasDifferentOwner_reverts( + uint256 positionIndex_ + ) + public + givenAddressHasReserveToken(recipient, 10e18) + givenReserveTokenSpendingIsApproved(recipient, address(convertibleDepository), 10e18) + givenAddressHasReserveToken(recipientTwo, 5e18) + givenReserveTokenSpendingIsApproved(recipientTwo, address(convertibleDepository), 5e18) + { + uint256 positionIndex = bound(positionIndex_, 0, 2); + + uint256[] memory positionIds_ = new uint256[](3); + uint256[] memory amounts_ = new uint256[](3); + + for (uint256 i; i < 3; i++) { + uint256 positionId; + if (positionIndex == i) { + positionId = _createPosition(recipientTwo, 5e18, CONVERSION_PRICE, EXPIRY, false); + } else { + positionId = _createPosition(recipient, 5e18, CONVERSION_PRICE, EXPIRY, false); + } + + positionIds_[i] = positionId; + amounts_[i] = 5e18; + } + + // Expect revert + vm.expectRevert( + abi.encodeWithSelector(IConvertibleDepositFacility.CDF_NotOwner.selector, positionIndex) + ); + + // Call function + vm.prank(recipient); + facility.convert(positionIds_, amounts_); + } + + function test_anyPositionHasExpired_reverts( + uint256 positionIndex_ + ) + public + givenAddressHasReserveToken(recipient, 9e18) + givenReserveTokenSpendingIsApproved(recipient, address(convertibleDepository), 9e18) + { + uint256 positionIndex = bound(positionIndex_, 0, 2); + + uint256[] memory positionIds_ = new uint256[](3); + uint256[] memory amounts_ = new uint256[](3); + + for (uint256 i; i < 3; i++) { + uint48 expiry = uint48(block.timestamp + 1 days); + if (positionIndex == i) { + expiry = uint48(block.timestamp + 1); + } + + // Create position + uint256 positionId = _createPosition(recipient, 3e18, CONVERSION_PRICE, expiry, false); + + positionIds_[i] = positionId; + amounts_[i] = 3e18; + } + + // Warp to beyond the expiry of positionIndex + vm.warp(INITIAL_BLOCK + 1); + + // Expect revert + vm.expectRevert( + abi.encodeWithSelector( + IConvertibleDepositFacility.CDF_PositionExpired.selector, + positionIndex + ) + ); + + // Call function + vm.prank(recipient); + facility.convert(positionIds_, amounts_); + } + + function test_anyAmountIsGreaterThanRemainingDeposit_reverts( + uint256 positionIndex_ + ) + public + givenAddressHasReserveToken(recipient, 9e18) + givenReserveTokenSpendingIsApproved(recipient, address(convertibleDepository), 9e18) + givenAddressHasPosition(recipient, 3e18) + givenAddressHasPosition(recipient, 3e18) + givenAddressHasPosition(recipient, 3e18) + { + uint256 positionIndex = bound(positionIndex_, 0, 2); + + uint256[] memory positionIds_ = new uint256[](3); + uint256[] memory amounts_ = new uint256[](3); + + for (uint256 i; i < 3; i++) { + positionIds_[i] = i; + + // Invalid position + if (positionIndex == i) { + amounts_[i] = 4e18; + } + // Valid position + else { + amounts_[i] = 3e18; + } + } + + // Expect revert + vm.expectRevert( + abi.encodeWithSelector( + IConvertibleDepositFacility.CDF_InvalidAmount.selector, + positionIndex, + 4e18 + ) + ); + + // Call function + vm.prank(recipient); + facility.convert(positionIds_, amounts_); + } + + function test_spendingIsNotApproved_reverts() + public + givenAddressHasReserveToken(recipient, RESERVE_TOKEN_AMOUNT) + givenReserveTokenSpendingIsApproved( + recipient, + address(convertibleDepository), + RESERVE_TOKEN_AMOUNT + ) + givenAddressHasPosition(recipient, RESERVE_TOKEN_AMOUNT / 2) + givenAddressHasPosition(recipient, RESERVE_TOKEN_AMOUNT / 2) + givenConvertibleDepositTokenSpendingIsApproved( + recipient, + address(convertibleDepository), + RESERVE_TOKEN_AMOUNT - 1 + ) + { + uint256[] memory positionIds_ = new uint256[](2); + uint256[] memory amounts_ = new uint256[](2); + + positionIds_[0] = 0; + amounts_[0] = 5e18; + positionIds_[1] = 1; + amounts_[1] = 5e18; + + // Expect revert + vm.expectRevert(abi.encodeWithSelector(CDEPOv1.CDEPO_InvalidArgs.selector, "allowance")); + + // Call function + vm.prank(recipient); + facility.convert(positionIds_, amounts_); + } + + function test_success() + public + givenAddressHasReserveToken(recipient, RESERVE_TOKEN_AMOUNT) + givenReserveTokenSpendingIsApproved( + recipient, + address(convertibleDepository), + RESERVE_TOKEN_AMOUNT + ) + givenAddressHasPosition(recipient, RESERVE_TOKEN_AMOUNT / 2) + givenAddressHasPosition(recipient, RESERVE_TOKEN_AMOUNT / 2) + givenConvertibleDepositTokenSpendingIsApproved( + recipient, + address(convertibleDepository), + RESERVE_TOKEN_AMOUNT + ) + { + uint256[] memory positionIds_ = new uint256[](2); + uint256[] memory amounts_ = new uint256[](2); + + positionIds_[0] = 0; + amounts_[0] = 5e18; + positionIds_[1] = 1; + amounts_[1] = 5e18; + + uint256 expectedConvertedAmount = (RESERVE_TOKEN_AMOUNT * 1e18) / CONVERSION_PRICE; + uint256 expectedVaultShares = vault.previewDeposit(RESERVE_TOKEN_AMOUNT); + + // Expect event + vm.expectEmit(true, true, true, true); + emit ConvertedDeposit(recipient, RESERVE_TOKEN_AMOUNT, expectedConvertedAmount); + + // Call function + vm.prank(recipient); + (uint256 totalDeposit, uint256 convertedAmount) = facility.convert(positionIds_, amounts_); + + // Assert total deposit + assertEq(totalDeposit, RESERVE_TOKEN_AMOUNT, "totalDeposit"); + + // Assert converted amount + assertEq(convertedAmount, expectedConvertedAmount, "convertedAmount"); + + // Assert convertible deposit tokens are transferred from the recipient + assertEq( + convertibleDepository.balanceOf(recipient), + 0, + "convertibleDepository.balanceOf(recipient)" + ); + + // Assert OHM minted to the recipient + assertEq(ohm.balanceOf(recipient), expectedConvertedAmount, "ohm.balanceOf(recipient)"); + + // No dangling mint approval + assertEq( + minter.mintApproval(address(facility)), + 0, + "minter.mintApproval(address(facility))" + ); + + // Assert remaining deposit + assertEq( + convertibleDepositPositions.getPosition(0).remainingDeposit, + 0, + "convertibleDepositPositions.getPosition(0).remainingDeposit" + ); + assertEq( + convertibleDepositPositions.getPosition(1).remainingDeposit, + 0, + "convertibleDepositPositions.getPosition(1).remainingDeposit" + ); + + // Deposit token is transferred to the TRSRY + assertEq( + reserveToken.balanceOf(address(treasury)), + 0, + "reserveToken.balanceOf(address(treasury))" + ); + assertEq( + reserveToken.balanceOf(address(facility)), + 0, + "reserveToken.balanceOf(address(facility))" + ); + assertEq(reserveToken.balanceOf(recipient), 0, "reserveToken.balanceOf(recipient)"); + + // Vault shares are transferred to the TRSRY + assertEq( + vault.balanceOf(address(treasury)), + expectedVaultShares, + "vault.balanceOf(address(treasury))" + ); + assertEq(vault.balanceOf(address(facility)), 0, "vault.balanceOf(address(facility))"); + assertEq(vault.balanceOf(recipient), 0, "vault.balanceOf(recipient)"); + } } diff --git a/src/test/policies/ConvertibleDepositFacility/create.t.sol b/src/test/policies/ConvertibleDepositFacility/create.t.sol index 2d90ab1b..ba2715fb 100644 --- a/src/test/policies/ConvertibleDepositFacility/create.t.sol +++ b/src/test/policies/ConvertibleDepositFacility/create.t.sol @@ -54,6 +54,9 @@ contract CreateCDFTest is ConvertibleDepositFacilityTest { RESERVE_TOKEN_AMOUNT ) { + // Calculate the expected OHM amount + uint256 expectedOhmAmount = (RESERVE_TOKEN_AMOUNT * 1e18) / CONVERSION_PRICE; + // Expect event vm.expectEmit(true, true, true, true); emit CreatedDeposit(recipient, 0, RESERVE_TOKEN_AMOUNT); @@ -68,21 +71,29 @@ contract CreateCDFTest is ConvertibleDepositFacilityTest { ); // Assert that the position ID is 0 - assertEq(positionId, 0); + assertEq(positionId, 0, "positionId"); // Assert that the reserve token was transferred from the recipient - assertEq(reserveToken.balanceOf(recipient), 0); + assertEq(reserveToken.balanceOf(recipient), 0, "reserveToken.balanceOf(recipient)"); // Assert that the CDEPO token was minted to the recipient - assertEq(convertibleDepository.balanceOf(recipient), RESERVE_TOKEN_AMOUNT); + assertEq( + convertibleDepository.balanceOf(recipient), + RESERVE_TOKEN_AMOUNT, + "convertibleDepository.balanceOf(recipient)" + ); // Assert that the recipient has a CDPOS position uint256[] memory positionIds = convertibleDepositPositions.getUserPositionIds(recipient); - assertEq(positionIds.length, 1); - assertEq(positionIds[0], 0); + assertEq(positionIds.length, 1, "positionIds.length"); + assertEq(positionIds[0], 0, "positionIds[0]"); // Assert that the mint approval was increased - assertEq(minter.mintApproval(address(facility)), RESERVE_TOKEN_AMOUNT); + assertEq( + minter.mintApproval(address(facility)), + expectedOhmAmount, + "minter.mintApproval(address(facility))" + ); } function test_success_multiple() @@ -94,6 +105,9 @@ contract CreateCDFTest is ConvertibleDepositFacilityTest { RESERVE_TOKEN_AMOUNT ) { + // Calculate the expected OHM amount + uint256 expectedOhmAmount = (RESERVE_TOKEN_AMOUNT * 1e18) / CONVERSION_PRICE; + // Call function _createPosition(recipient, RESERVE_TOKEN_AMOUNT / 2, CONVERSION_PRICE, EXPIRY, false); @@ -107,21 +121,29 @@ contract CreateCDFTest is ConvertibleDepositFacilityTest { ); // Assert that the position ID is 1 - assertEq(positionId2, 1); + assertEq(positionId2, 1, "positionId2"); // Assert that the reserve token was transferred from the recipient - assertEq(reserveToken.balanceOf(recipient), 0); + assertEq(reserveToken.balanceOf(recipient), 0, "reserveToken.balanceOf(recipient)"); // Assert that the CDEPO token was minted to the recipient - assertEq(convertibleDepository.balanceOf(recipient), RESERVE_TOKEN_AMOUNT); + assertEq( + convertibleDepository.balanceOf(recipient), + RESERVE_TOKEN_AMOUNT, + "convertibleDepository.balanceOf(recipient)" + ); // Assert that the recipient has two CDPOS positions uint256[] memory positionIds = convertibleDepositPositions.getUserPositionIds(recipient); - assertEq(positionIds.length, 2); - assertEq(positionIds[0], 0); - assertEq(positionIds[1], 1); + assertEq(positionIds.length, 2, "positionIds.length"); + assertEq(positionIds[0], 0, "positionIds[0]"); + assertEq(positionIds[1], 1, "positionIds[1]"); // Assert that the mint approval was increased - assertEq(minter.mintApproval(address(facility)), RESERVE_TOKEN_AMOUNT); + assertEq( + minter.mintApproval(address(facility)), + expectedOhmAmount, + "minter.mintApproval(address(facility))" + ); } } diff --git a/src/test/policies/ConvertibleDepositFacility/previewConvert.t.sol b/src/test/policies/ConvertibleDepositFacility/previewConvert.t.sol index 0eb2f248..fedfb7fd 100644 --- a/src/test/policies/ConvertibleDepositFacility/previewConvert.t.sol +++ b/src/test/policies/ConvertibleDepositFacility/previewConvert.t.sol @@ -66,9 +66,7 @@ contract PreviewConvertCDFTest is ConvertibleDepositFacilityTest { } // Expect revert - vm.expectRevert( - abi.encodeWithSelector(CDPOSv1.CDPOS_InvalidPositionId.selector, positionIndex) - ); + vm.expectRevert(abi.encodeWithSelector(CDPOSv1.CDPOS_InvalidPositionId.selector, 2)); // Call function facility.previewConvert(positionIds_, amounts_); @@ -99,6 +97,9 @@ contract PreviewConvertCDFTest is ConvertibleDepositFacilityTest { amounts_[i] = 3e18; } + // Warp to beyond the expiry of positionIndex + vm.warp(INITIAL_BLOCK + 1); + // Expect revert vm.expectRevert( abi.encodeWithSelector( From 1820d39c116bf386955b6cb7fd0459c36b85484f Mon Sep 17 00:00:00 2001 From: Jem <0x0xjem@gmail.com> Date: Mon, 23 Dec 2024 16:32:46 +0500 Subject: [PATCH 62/64] CDFacility: more comprehensive tests --- src/policies/CDFacility.sol | 124 ++++++++++++------ .../ConvertibleDepositFacility/convert.t.sol | 97 ++++++++++++++ .../previewConvert.t.sol | 71 +++++++++- 3 files changed, 245 insertions(+), 47 deletions(-) diff --git a/src/policies/CDFacility.sol b/src/policies/CDFacility.sol index 6b9b5352..2e0753ac 100644 --- a/src/policies/CDFacility.sol +++ b/src/policies/CDFacility.sol @@ -22,7 +22,10 @@ contract CDFacility is Policy, RolesConsumer, IConvertibleDepositFacility { // ========== STATE VARIABLES ========== // // Constants - uint256 public constant DECIMALS = 1e18; + + /// @notice The scale of the convertible deposit token + /// @dev This will typically be 10 ** decimals + uint256 public SCALE; // Modules TRSRYv1 public TRSRY; @@ -32,10 +35,9 @@ contract CDFacility is Policy, RolesConsumer, IConvertibleDepositFacility { // ========== ERRORS ========== // + /// @notice An error that is thrown when the parameters are invalid error CDFacility_InvalidParams(string reason); - error Misconfigured(); - // ========== SETUP ========== // constructor(address kernel_) Policy(Kernel(kernel_)) {} @@ -54,7 +56,7 @@ contract CDFacility is Policy, RolesConsumer, IConvertibleDepositFacility { CDEPO = CDEPOv1(getModuleAddress(dependencies[3])); CDPOS = CDPOSv1(getModuleAddress(dependencies[4])); - // TODO set decimals + SCALE = 10 ** CDEPO.decimals(); } function requestPermissions() @@ -102,7 +104,7 @@ contract CDFacility is Policy, RolesConsumer, IConvertibleDepositFacility { ); // Calculate the expected OHM amount - uint256 expectedOhmAmount = (amount_ * DECIMALS) / conversionPrice_; + uint256 expectedOhmAmount = (amount_ * SCALE) / conversionPrice_; // Pre-emptively increase the OHM mint approval MINTR.increaseMintApproval(address(this), expectedOhmAmount); @@ -111,58 +113,100 @@ contract CDFacility is Policy, RolesConsumer, IConvertibleDepositFacility { emit CreatedDeposit(account_, positionId, amount_); } + function _previewConvert( + uint256 positionId_, + uint256 amount_, + bool checkOwner_ + ) internal view returns (uint256 convertedTokenOut) { + // Validate that the position is valid + // This will revert if the position is not valid + CDPOSv1.Position memory position = CDPOS.getPosition(positionId_); + + // Validate that the caller is the owner of the position + if (checkOwner_ && position.owner != msg.sender) revert CDF_NotOwner(positionId_); + + // Validate that the position is CDEPO + if (position.convertibleDepositToken != address(CDEPO)) + revert CDF_InvalidToken(positionId_, position.convertibleDepositToken); + + // Validate that the position has not expired + if (block.timestamp >= position.expiry) revert CDF_PositionExpired(positionId_); + + // Validate that the deposit amount is not greater than the remaining deposit + if (amount_ > position.remainingDeposit) revert CDF_InvalidAmount(positionId_, amount_); + + convertedTokenOut = (amount_ * SCALE) / position.conversionPrice; // TODO check SCALE, rounding + + return convertedTokenOut; + } + /// @inheritdoc IConvertibleDepositFacility /// @dev This function reverts if: /// - The length of the positionIds_ array does not match the length of the amounts_ array - /// - The caller is not the owner of all of the positions /// - The position is not valid /// - The position is not CDEPO /// - The position has expired /// - The deposit amount is greater than the remaining deposit - function convert( + /// - The deposit amount is 0 + /// - The converted amount is 0 + function previewConvert( uint256[] memory positionIds_, uint256[] memory amounts_ - ) external returns (uint256 totalDeposit, uint256 converted) { + ) external view returns (uint256 cdTokenIn, uint256 convertedTokenOut, address cdTokenSpender) { // Make sure the lengths of the arrays are the same if (positionIds_.length != amounts_.length) revert CDF_InvalidArgs("array length"); - uint256 totalDeposits; - - // Iterate over all positions for (uint256 i; i < positionIds_.length; ++i) { uint256 positionId = positionIds_[i]; - uint256 depositAmount = amounts_[i]; - - // Validate that the position is valid - // This will revert if the position is not valid - CDPOSv1.Position memory position = CDPOS.getPosition(positionId); + uint256 amount = amounts_[i]; + cdTokenIn += amount; + convertedTokenOut += _previewConvert(positionId, amount, false); + } - // Validate that the caller is the owner of the position - if (position.owner != msg.sender) revert CDF_NotOwner(positionId); + // If the amount is 0, revert + if (cdTokenIn == 0) revert CDF_InvalidArgs("amount"); - // Validate that the position is CDEPO - if (position.convertibleDepositToken != address(CDEPO)) - revert CDF_InvalidToken(positionId, position.convertibleDepositToken); + // If the converted amount is 0, revert + if (convertedTokenOut == 0) revert CDF_InvalidArgs("converted amount"); - // Validate that the position has not expired - if (block.timestamp >= position.expiry) revert CDF_PositionExpired(positionId); + return (cdTokenIn, convertedTokenOut, address(CDEPO)); + } - // Validate that the deposit amount is not greater than the remaining deposit - if (depositAmount > position.remainingDeposit) - revert CDF_InvalidAmount(positionId, depositAmount); + /// @inheritdoc IConvertibleDepositFacility + /// @dev This function reverts if: + /// - The length of the positionIds_ array does not match the length of the amounts_ array + /// - The caller is not the owner of all of the positions + /// - The position is not valid + /// - The position is not CDEPO + /// - The position has expired + /// - The deposit amount is greater than the remaining deposit + /// - The deposit amount is 0 + /// - The converted amount is 0 + function convert( + uint256[] memory positionIds_, + uint256[] memory amounts_ + ) external returns (uint256 cdTokenIn, uint256 convertedTokenOut) { + // Make sure the lengths of the arrays are the same + if (positionIds_.length != amounts_.length) revert CDF_InvalidArgs("array length"); - uint256 convertedAmount = (depositAmount * DECIMALS) / position.conversionPrice; // TODO check decimals, rounding + // Iterate over all positions + for (uint256 i; i < positionIds_.length; ++i) { + uint256 positionId = positionIds_[i]; + uint256 depositAmount = amounts_[i]; - // Increment running totals - totalDeposits += depositAmount; - converted += convertedAmount; + cdTokenIn += depositAmount; + convertedTokenOut += _previewConvert(positionId, depositAmount, true); // Update the position - CDPOS.update(positionId, position.remainingDeposit - depositAmount); + CDPOS.update( + positionId, + CDPOS.getPosition(positionId).remainingDeposit - depositAmount + ); } // Redeem the CD deposits in bulk - uint256 tokensOut = CDEPO.redeemFor(msg.sender, totalDeposits); + // This will revert if cdTokenIn is 0 + uint256 tokensOut = CDEPO.redeemFor(msg.sender, cdTokenIn); // Wrap the tokens and transfer to the TRSRY ERC4626 vault = CDEPO.vault(); @@ -170,19 +214,13 @@ contract CDFacility is Policy, RolesConsumer, IConvertibleDepositFacility { vault.deposit(tokensOut, address(TRSRY)); // Mint OHM to the owner/caller - MINTR.mintOhm(msg.sender, converted); + // No need to check if `convertedTokenOut` is 0, as MINTR will revert + MINTR.mintOhm(msg.sender, convertedTokenOut); // Emit event - emit ConvertedDeposit(msg.sender, totalDeposits, converted); - - return (totalDeposits, converted); - } + emit ConvertedDeposit(msg.sender, cdTokenIn, convertedTokenOut); - function previewConvert( - uint256[] memory positionIds_, - uint256[] memory amounts_ - ) external view returns (uint256 cdTokenIn, uint256 convertedTokenOut, address cdTokenSpender) { - return (0, 0, address(0)); + return (cdTokenIn, convertedTokenOut); } /// @inheritdoc IConvertibleDepositFacility @@ -219,7 +257,7 @@ contract CDFacility is Policy, RolesConsumer, IConvertibleDepositFacility { if (depositAmount > position.remainingDeposit) revert CDF_InvalidAmount(positionId, depositAmount); - uint256 convertedAmount = (depositAmount * DECIMALS) / position.conversionPrice; // TODO check decimals, rounding + uint256 convertedAmount = (depositAmount * SCALE) / position.conversionPrice; // TODO check SCALE, rounding // Increment running totals reclaimed += depositAmount; diff --git a/src/test/policies/ConvertibleDepositFacility/convert.t.sol b/src/test/policies/ConvertibleDepositFacility/convert.t.sol index a450e764..b366fefc 100644 --- a/src/test/policies/ConvertibleDepositFacility/convert.t.sol +++ b/src/test/policies/ConvertibleDepositFacility/convert.t.sol @@ -5,6 +5,7 @@ import {ConvertibleDepositFacilityTest} from "./ConvertibleDepositFacilityTest.s import {IConvertibleDepositFacility} from "src/policies/interfaces/IConvertibleDepositFacility.sol"; import {CDEPOv1} from "src/modules/CDEPO/CDEPO.v1.sol"; import {CDPOSv1} from "src/modules/CDPOS/CDPOS.v1.sol"; +import {MINTRv1} from "src/modules/MINTR/MINTR.v1.sol"; contract ConvertCDFTest is ConvertibleDepositFacilityTest { event ConvertedDeposit(address indexed user, uint256 depositAmount, uint256 convertedAmount); @@ -21,6 +22,8 @@ contract ConvertCDFTest is ConvertibleDepositFacilityTest { // [X] it reverts // when the caller has not approved CDEPO to spend the total amount of CD tokens // [X] it reverts + // when the converted amount is 0 + // [X] it reverts // [X] it mints the converted amount of OHM to the account_ // [X] it updates the remaining deposit of each position // [X] it transfers the redeemed vault shares to the TRSRY @@ -234,6 +237,35 @@ contract ConvertCDFTest is ConvertibleDepositFacilityTest { facility.convert(positionIds_, amounts_); } + function test_convertedAmountIsZero_reverts() + public + givenAddressHasReserveToken(recipient, RESERVE_TOKEN_AMOUNT) + givenReserveTokenSpendingIsApproved( + recipient, + address(convertibleDepository), + RESERVE_TOKEN_AMOUNT + ) + givenAddressHasPosition(recipient, RESERVE_TOKEN_AMOUNT) + givenConvertibleDepositTokenSpendingIsApproved( + recipient, + address(convertibleDepository), + RESERVE_TOKEN_AMOUNT + ) + { + uint256[] memory positionIds_ = new uint256[](1); + uint256[] memory amounts_ = new uint256[](1); + + positionIds_[0] = 0; + amounts_[0] = 1; // 1 / 2 = 0 + + // Expect revert + vm.expectRevert(abi.encodeWithSelector(MINTRv1.MINTR_ZeroAmount.selector)); + + // Call function + vm.prank(recipient); + facility.convert(positionIds_, amounts_); + } + function test_success() public givenAddressHasReserveToken(recipient, RESERVE_TOKEN_AMOUNT) @@ -326,4 +358,69 @@ contract ConvertCDFTest is ConvertibleDepositFacilityTest { assertEq(vault.balanceOf(address(facility)), 0, "vault.balanceOf(address(facility))"); assertEq(vault.balanceOf(recipient), 0, "vault.balanceOf(recipient)"); } + + function test_success_fuzz( + uint256 amountOne_, + uint256 amountTwo_ + ) + public + givenAddressHasReserveToken(recipient, RESERVE_TOKEN_AMOUNT) + givenReserveTokenSpendingIsApproved( + recipient, + address(convertibleDepository), + RESERVE_TOKEN_AMOUNT + ) + givenAddressHasPosition(recipient, 5e18) + givenAddressHasPosition(recipient, 5e18) + givenConvertibleDepositTokenSpendingIsApproved( + recipient, + address(convertibleDepository), + RESERVE_TOKEN_AMOUNT + ) + { + // Both 2+ so that the converted amount is not 0 + uint256 amountOne = bound(amountOne_, 2, 5e18); + uint256 amountTwo = bound(amountTwo_, 2, 5e18); + + uint256[] memory positionIds_ = new uint256[](2); + uint256[] memory amounts_ = new uint256[](2); + + positionIds_[0] = 0; + amounts_[0] = amountOne; + positionIds_[1] = 1; + amounts_[1] = amountTwo; + + uint256 expectedConvertedAmount = (amountOne * 1e18) / + CONVERSION_PRICE + + (amountTwo * 1e18) / + CONVERSION_PRICE; + uint256 expectedVaultShares = vault.previewDeposit(amountOne + amountTwo); + + // Call function + vm.prank(recipient); + (uint256 totalDeposit, uint256 convertedAmount) = facility.convert(positionIds_, amounts_); + + // Assert total deposit + assertEq(totalDeposit, amountOne + amountTwo, "totalDeposit"); + + // Assert converted amount + assertEq(convertedAmount, expectedConvertedAmount, "convertedAmount"); + + // Assert convertible deposit tokens are transferred from the recipient + assertEq( + convertibleDepository.balanceOf(recipient), + RESERVE_TOKEN_AMOUNT - amountOne - amountTwo, + "convertibleDepository.balanceOf(recipient)" + ); + + // Assert OHM minted to the recipient + assertEq(ohm.balanceOf(recipient), expectedConvertedAmount, "ohm.balanceOf(recipient)"); + + // Vault shares are transferred to the TRSRY + assertEq( + vault.balanceOf(address(treasury)), + expectedVaultShares, + "vault.balanceOf(address(treasury))" + ); + } } diff --git a/src/test/policies/ConvertibleDepositFacility/previewConvert.t.sol b/src/test/policies/ConvertibleDepositFacility/previewConvert.t.sol index fedfb7fd..affe6365 100644 --- a/src/test/policies/ConvertibleDepositFacility/previewConvert.t.sol +++ b/src/test/policies/ConvertibleDepositFacility/previewConvert.t.sol @@ -14,6 +14,10 @@ contract PreviewConvertCDFTest is ConvertibleDepositFacilityTest { // [X] it reverts // when any position has an amount greater than the remaining deposit // [X] it reverts + // when the amount is 0 + // [X] it reverts + // when the converted amount is 0 + // [X] it reverts // [X] it returns the total CD token amount that would be converted // [X] it returns the amount of OHM that would be minted // [X] it returns the address that will spend the convertible deposit tokens @@ -153,6 +157,51 @@ contract PreviewConvertCDFTest is ConvertibleDepositFacilityTest { facility.previewConvert(positionIds_, amounts_); } + function test_amountIsZero_reverts() + public + givenAddressHasReserveToken(recipient, 9e18) + givenReserveTokenSpendingIsApproved(recipient, address(convertibleDepository), 9e18) + givenAddressHasPosition(recipient, 3e18) + { + uint256[] memory positionIds_ = new uint256[](1); + uint256[] memory amounts_ = new uint256[](1); + + positionIds_[0] = 0; + amounts_[0] = 0; + + // Expect revert + vm.expectRevert( + abi.encodeWithSelector(IConvertibleDepositFacility.CDF_InvalidArgs.selector, "amount") + ); + + // Call function + facility.previewConvert(positionIds_, amounts_); + } + + function test_convertedAmountIsZero_reverts() + public + givenAddressHasReserveToken(recipient, 9e18) + givenReserveTokenSpendingIsApproved(recipient, address(convertibleDepository), 9e18) + givenAddressHasPosition(recipient, 3e18) + { + uint256[] memory positionIds_ = new uint256[](1); + uint256[] memory amounts_ = new uint256[](1); + + positionIds_[0] = 0; + amounts_[0] = 1; + + // Expect revert + vm.expectRevert( + abi.encodeWithSelector( + IConvertibleDepositFacility.CDF_InvalidArgs.selector, + "converted amount" + ) + ); + + // Call function + facility.previewConvert(positionIds_, amounts_); + } + function test_success( uint256 amountOne_, uint256 amountTwo_, @@ -185,9 +234,23 @@ contract PreviewConvertCDFTest is ConvertibleDepositFacilityTest { amounts_ ); - // Assertions - assertEq(totalDeposits, amountOne + amountTwo + amountThree); - assertEq(converted, ((amountOne + amountTwo + amountThree) * 1e18) / CONVERSION_PRICE); - assertEq(spender, address(convertibleDepository)); + // Assertion that the total deposits are the sum of the amounts + assertEq(totalDeposits, amountOne + amountTwo + amountThree, "totalDeposits"); + + // Assertion that the converted amount is the sum of the amounts converted at the conversion price + // Each amount is converted separately to avoid rounding errors + assertEq( + converted, + (amountOne * 1e18) / + CONVERSION_PRICE + + (amountTwo * 1e18) / + CONVERSION_PRICE + + (amountThree * 1e18) / + CONVERSION_PRICE, + "converted" + ); + + // Assertion that the spender is the convertible depository + assertEq(spender, address(convertibleDepository), "spender"); } } From 34307b40d7b06ad4db10630fb4c4fbcb143097ed Mon Sep 17 00:00:00 2001 From: Jem <0x0xjem@gmail.com> Date: Mon, 23 Dec 2024 16:45:29 +0500 Subject: [PATCH 63/64] CDFacility: better handling of ownership check --- src/policies/CDFacility.sol | 12 +-- .../IConvertibleDepositFacility.sol | 3 + .../previewConvert.t.sol | 77 +++++++++++++++++-- 3 files changed, 81 insertions(+), 11 deletions(-) diff --git a/src/policies/CDFacility.sol b/src/policies/CDFacility.sol index 2e0753ac..7b385f8f 100644 --- a/src/policies/CDFacility.sol +++ b/src/policies/CDFacility.sol @@ -114,16 +114,16 @@ contract CDFacility is Policy, RolesConsumer, IConvertibleDepositFacility { } function _previewConvert( + address account_, uint256 positionId_, - uint256 amount_, - bool checkOwner_ + uint256 amount_ ) internal view returns (uint256 convertedTokenOut) { // Validate that the position is valid // This will revert if the position is not valid CDPOSv1.Position memory position = CDPOS.getPosition(positionId_); // Validate that the caller is the owner of the position - if (checkOwner_ && position.owner != msg.sender) revert CDF_NotOwner(positionId_); + if (position.owner != account_) revert CDF_NotOwner(positionId_); // Validate that the position is CDEPO if (position.convertibleDepositToken != address(CDEPO)) @@ -143,6 +143,7 @@ contract CDFacility is Policy, RolesConsumer, IConvertibleDepositFacility { /// @inheritdoc IConvertibleDepositFacility /// @dev This function reverts if: /// - The length of the positionIds_ array does not match the length of the amounts_ array + /// - account_ is not the owner of all of the positions /// - The position is not valid /// - The position is not CDEPO /// - The position has expired @@ -150,6 +151,7 @@ contract CDFacility is Policy, RolesConsumer, IConvertibleDepositFacility { /// - The deposit amount is 0 /// - The converted amount is 0 function previewConvert( + address account_, uint256[] memory positionIds_, uint256[] memory amounts_ ) external view returns (uint256 cdTokenIn, uint256 convertedTokenOut, address cdTokenSpender) { @@ -160,7 +162,7 @@ contract CDFacility is Policy, RolesConsumer, IConvertibleDepositFacility { uint256 positionId = positionIds_[i]; uint256 amount = amounts_[i]; cdTokenIn += amount; - convertedTokenOut += _previewConvert(positionId, amount, false); + convertedTokenOut += _previewConvert(account_, positionId, amount); } // If the amount is 0, revert @@ -195,7 +197,7 @@ contract CDFacility is Policy, RolesConsumer, IConvertibleDepositFacility { uint256 depositAmount = amounts_[i]; cdTokenIn += depositAmount; - convertedTokenOut += _previewConvert(positionId, depositAmount, true); + convertedTokenOut += _previewConvert(msg.sender, positionId, depositAmount); // Update the position CDPOS.update( diff --git a/src/policies/interfaces/IConvertibleDepositFacility.sol b/src/policies/interfaces/IConvertibleDepositFacility.sol index b9440d27..05a74c10 100644 --- a/src/policies/interfaces/IConvertibleDepositFacility.sol +++ b/src/policies/interfaces/IConvertibleDepositFacility.sol @@ -74,17 +74,20 @@ interface IConvertibleDepositFacility { /// @notice Preview the amount of convertible deposit tokens and OHM that would be converted /// @dev The implementing contract is expected to handle the following: + /// - Validating that `account_` is the owner of all of the positions /// - Validating that convertible deposit token in the position is CDEPO /// - Validating that all of the positions are valid /// - Validating that all of the positions have not expired /// - Returning the total amount of convertible deposit tokens and OHM that would be converted /// + /// @param account_ The address to preview the conversion for /// @param positionIds_ An array of position ids that will be converted /// @param amounts_ An array of amounts of convertible deposit tokens to convert /// @return cdTokenIn The total amount of convertible deposit tokens converted /// @return convertedTokenOut The amount of OHM minted during conversion /// @return cdTokenSpender The address that will spend the convertible deposit tokens. The caller must have approved this address to spend the total amount of CD tokens. function previewConvert( + address account_, uint256[] memory positionIds_, uint256[] memory amounts_ ) external view returns (uint256 cdTokenIn, uint256 convertedTokenOut, address cdTokenSpender); diff --git a/src/test/policies/ConvertibleDepositFacility/previewConvert.t.sol b/src/test/policies/ConvertibleDepositFacility/previewConvert.t.sol index affe6365..b5204de6 100644 --- a/src/test/policies/ConvertibleDepositFacility/previewConvert.t.sol +++ b/src/test/policies/ConvertibleDepositFacility/previewConvert.t.sol @@ -18,6 +18,8 @@ contract PreviewConvertCDFTest is ConvertibleDepositFacilityTest { // [X] it reverts // when the converted amount is 0 // [X] it reverts + // when the account is not the owner of all of the positions + // [X] it reverts // [X] it returns the total CD token amount that would be converted // [X] it returns the amount of OHM that would be minted // [X] it returns the address that will spend the convertible deposit tokens @@ -35,7 +37,7 @@ contract PreviewConvertCDFTest is ConvertibleDepositFacilityTest { ); // Call function - facility.previewConvert(positionIds_, amounts_); + facility.previewConvert(recipient, positionIds_, amounts_); } function test_anyPositionIsNotValid_reverts( @@ -73,7 +75,69 @@ contract PreviewConvertCDFTest is ConvertibleDepositFacilityTest { vm.expectRevert(abi.encodeWithSelector(CDPOSv1.CDPOS_InvalidPositionId.selector, 2)); // Call function - facility.previewConvert(positionIds_, amounts_); + facility.previewConvert(recipient, positionIds_, amounts_); + } + + function test_anyPositionHasDifferentOwner_reverts( + uint256 positionIndex_ + ) + public + givenAddressHasReserveToken(recipient, 9e18) + givenReserveTokenSpendingIsApproved(recipient, address(convertibleDepository), 9e18) + givenAddressHasReserveToken(recipientTwo, 9e18) + givenReserveTokenSpendingIsApproved(recipientTwo, address(convertibleDepository), 9e18) + { + uint256 positionIndex = bound(positionIndex_, 0, 2); + + uint256[] memory positionIds_ = new uint256[](3); + uint256[] memory amounts_ = new uint256[](3); + + for (uint256 i; i < 3; i++) { + uint256 positionId; + if (positionIndex == i) { + positionId = _createPosition(recipientTwo, 3e18, CONVERSION_PRICE, EXPIRY, false); + } else { + positionId = _createPosition(recipient, 3e18, CONVERSION_PRICE, EXPIRY, false); + } + + positionIds_[i] = positionId; + amounts_[i] = 3e18; + } + + // Expect revert + vm.expectRevert( + abi.encodeWithSelector(IConvertibleDepositFacility.CDF_NotOwner.selector, positionIndex) + ); + + // Call function + facility.previewConvert(recipient, positionIds_, amounts_); + } + + function test_allPositionsHaveDifferentOwner_reverts() + public + givenAddressHasReserveToken(recipient, 9e18) + givenReserveTokenSpendingIsApproved(recipient, address(convertibleDepository), 9e18) + givenAddressHasPosition(recipient, 3e18) + givenAddressHasPosition(recipient, 3e18) + givenAddressHasPosition(recipient, 3e18) + { + uint256[] memory positionIds_ = new uint256[](3); + uint256[] memory amounts_ = new uint256[](3); + + positionIds_[0] = 0; + amounts_[0] = 3e18; + positionIds_[1] = 1; + amounts_[1] = 3e18; + positionIds_[2] = 2; + amounts_[2] = 3e18; + + // Expect revert + vm.expectRevert( + abi.encodeWithSelector(IConvertibleDepositFacility.CDF_NotOwner.selector, 0) + ); + + // Call function + facility.previewConvert(recipientTwo, positionIds_, amounts_); } function test_anyPositionHasExpired_reverts( @@ -113,7 +177,7 @@ contract PreviewConvertCDFTest is ConvertibleDepositFacilityTest { ); // Call function - facility.previewConvert(positionIds_, amounts_); + facility.previewConvert(recipient, positionIds_, amounts_); } function test_anyAmountIsGreaterThanRemainingDeposit_reverts( @@ -154,7 +218,7 @@ contract PreviewConvertCDFTest is ConvertibleDepositFacilityTest { ); // Call function - facility.previewConvert(positionIds_, amounts_); + facility.previewConvert(recipient, positionIds_, amounts_); } function test_amountIsZero_reverts() @@ -175,7 +239,7 @@ contract PreviewConvertCDFTest is ConvertibleDepositFacilityTest { ); // Call function - facility.previewConvert(positionIds_, amounts_); + facility.previewConvert(recipient, positionIds_, amounts_); } function test_convertedAmountIsZero_reverts() @@ -199,7 +263,7 @@ contract PreviewConvertCDFTest is ConvertibleDepositFacilityTest { ); // Call function - facility.previewConvert(positionIds_, amounts_); + facility.previewConvert(recipient, positionIds_, amounts_); } function test_success( @@ -230,6 +294,7 @@ contract PreviewConvertCDFTest is ConvertibleDepositFacilityTest { // Call function (uint256 totalDeposits, uint256 converted, address spender) = facility.previewConvert( + recipient, positionIds_, amounts_ ); From e9146a3b6682a70992f0b085d91941713a0e8542 Mon Sep 17 00:00:00 2001 From: Jem <0x0xjem@gmail.com> Date: Mon, 23 Dec 2024 16:49:26 +0500 Subject: [PATCH 64/64] CDFacility: WIP previewReclaim tests --- src/policies/CDFacility.sol | 1 + .../IConvertibleDepositFacility.sol | 3 + .../previewReclaim.t.sol | 75 ++++++++++++++++++- .../ConvertibleDepositFacility/reclaim.t.sol | 2 + 4 files changed, 80 insertions(+), 1 deletion(-) diff --git a/src/policies/CDFacility.sol b/src/policies/CDFacility.sol index 7b385f8f..9d3bcf69 100644 --- a/src/policies/CDFacility.sol +++ b/src/policies/CDFacility.sol @@ -285,6 +285,7 @@ contract CDFacility is Policy, RolesConsumer, IConvertibleDepositFacility { } function previewReclaim( + address account_, uint256[] memory positionIds_, uint256[] memory amounts_ ) external view returns (uint256 reclaimed, address cdTokenSpender) { diff --git a/src/policies/interfaces/IConvertibleDepositFacility.sol b/src/policies/interfaces/IConvertibleDepositFacility.sol index 05a74c10..a74ecf09 100644 --- a/src/policies/interfaces/IConvertibleDepositFacility.sol +++ b/src/policies/interfaces/IConvertibleDepositFacility.sol @@ -112,16 +112,19 @@ interface IConvertibleDepositFacility { /// @notice Preview the amount of reserve token that would be reclaimed /// @dev The implementing contract is expected to handle the following: + /// - Validating that `account_` is the owner of all of the positions /// - Validating that convertible deposit token in the position is CDEPO /// - Validating that all of the positions are valid /// - Validating that all of the positions have expired /// - Returning the total amount of reserve token that would be reclaimed /// + /// @param account_ The address to preview the reclaim for /// @param positionIds_ An array of position ids that will be reclaimed /// @param amounts_ An array of amounts of convertible deposit tokens to reclaim /// @return reclaimed The amount of reserve token returned to the caller /// @return cdTokenSpender The address that will spend the convertible deposit tokens. The caller must have approved this address to spend the total amount of CD tokens. function previewReclaim( + address account_, uint256[] memory positionIds_, uint256[] memory amounts_ ) external view returns (uint256 reclaimed, address cdTokenSpender); diff --git a/src/test/policies/ConvertibleDepositFacility/previewReclaim.t.sol b/src/test/policies/ConvertibleDepositFacility/previewReclaim.t.sol index 6ad24d6d..ebd20dc9 100644 --- a/src/test/policies/ConvertibleDepositFacility/previewReclaim.t.sol +++ b/src/test/policies/ConvertibleDepositFacility/previewReclaim.t.sol @@ -6,7 +6,9 @@ import {IConvertibleDepositFacility} from "src/policies/interfaces/IConvertibleD contract PreviewReclaimCDFTest is ConvertibleDepositFacilityTest { // when the length of the positionIds_ array does not match the length of the amounts_ array - // [ ] it reverts + // [X] it reverts + // when the account_ is not the owner of all of the positions + // [X] it reverts // when any position is not valid // [ ] it reverts // when any position has a convertible deposit token that is not CDEPO @@ -15,6 +17,77 @@ contract PreviewReclaimCDFTest is ConvertibleDepositFacilityTest { // [ ] it reverts // when any position has an amount greater than the remaining deposit // [ ] it reverts + // when the reclaim amount is 0 + // [ ] it reverts // [ ] it returns the total amount of deposit token that would be reclaimed // [ ] it returns the address that will spend the convertible deposit tokens + + function test_arrayLengthMismatch_reverts() public { + uint256[] memory positionIds_ = new uint256[](1); + uint256[] memory amounts_ = new uint256[](2); + + // Expect revert + vm.expectRevert( + abi.encodeWithSelector( + IConvertibleDepositFacility.CDF_InvalidArgs.selector, + "array length" + ) + ); + + // Call function + facility.previewReclaim(recipient, positionIds_, amounts_); + } + + function test_anyPositionHasDifferentOwner_reverts() + public + givenAddressHasReserveToken(recipient, 3e18) + givenReserveTokenSpendingIsApproved(recipient, address(convertibleDepository), 3e18) + givenAddressHasPosition(recipient, 3e18) + givenAddressHasReserveToken(recipientTwo, 3e18) + givenReserveTokenSpendingIsApproved(recipientTwo, address(convertibleDepository), 3e18) + givenAddressHasPosition(recipientTwo, 3e18) + { + uint256[] memory positionIds_ = new uint256[](2); + uint256[] memory amounts_ = new uint256[](2); + + positionIds_[0] = 0; + amounts_[0] = 3e18; + positionIds_[1] = 1; + amounts_[1] = 3e18; + + // Expect revert + vm.expectRevert( + abi.encodeWithSelector(IConvertibleDepositFacility.CDF_NotOwner.selector, 0) + ); + + // Call function + facility.previewReclaim(recipientTwo, positionIds_, amounts_); + } + + function test_allPositionsHaveDifferentOwner_reverts() + public + givenAddressHasReserveToken(recipient, 9e18) + givenReserveTokenSpendingIsApproved(recipient, address(convertibleDepository), 9e18) + givenAddressHasPosition(recipient, 3e18) + givenAddressHasPosition(recipient, 3e18) + givenAddressHasPosition(recipient, 3e18) + { + uint256[] memory positionIds_ = new uint256[](3); + uint256[] memory amounts_ = new uint256[](3); + + positionIds_[0] = 0; + amounts_[0] = 3e18; + positionIds_[1] = 1; + amounts_[1] = 3e18; + positionIds_[2] = 2; + amounts_[2] = 3e18; + + // Expect revert + vm.expectRevert( + abi.encodeWithSelector(IConvertibleDepositFacility.CDF_NotOwner.selector, 0) + ); + + // Call function + facility.previewReclaim(recipientTwo, positionIds_, amounts_); + } } diff --git a/src/test/policies/ConvertibleDepositFacility/reclaim.t.sol b/src/test/policies/ConvertibleDepositFacility/reclaim.t.sol index c3f6fc4b..bd84b8b2 100644 --- a/src/test/policies/ConvertibleDepositFacility/reclaim.t.sol +++ b/src/test/policies/ConvertibleDepositFacility/reclaim.t.sol @@ -19,6 +19,8 @@ contract ReclaimCDFTest is ConvertibleDepositFacilityTest { // [ ] it reverts // when the caller has not approved CDEPO to spend the total amount of CD tokens // [ ] it reverts + // when the reclaim amount is 0 + // [ ] it reverts // [ ] it updates the remaining deposit of each position // [ ] it transfers the redeemed reserve tokens to the owner // [ ] it decreases the OHM mint approval by the amount of OHM that would have been converted