diff --git a/.solhint.json b/.solhint.json index e6e8be64..bfbec802 100644 --- a/.solhint.json +++ b/.solhint.json @@ -8,7 +8,7 @@ "func-visibility": ["warn", {"ignoreConstructors": true}], "no-console": "off", "no-empty-blocks": "off", - "no-global-import": "off", + "no-global-import": "warn", "no-inline-assembly": "off", "not-rely-on-time": "off", "quotes": ["warn", "double"], diff --git a/ROLES.md b/ROLES.md index 6bb80d85..56740b76 100644 --- a/ROLES.md +++ b/ROLES.md @@ -10,16 +10,20 @@ This document describes the roles that are used in the Olympus protocol. | bridge_admin | CrossChainBridge | Allows configuring the CrossChainBridge | | callback_admin | BondCallback | Administers the policy | | callback_whitelist | BondCallback | Whitelists/blacklists tellers for callback | +| cd_admin | CDAuctioneer | Allows updating the parameters | | contract_registry_admin | ContractRegistryAdmin | Allows registering/deregistering contracts | | cooler_overseer | Clearinghouse | Allows activating the Clearinghouse | | custodian | TreasuryCustodian | Deposit/withdraw reserves and grant/revoke approvals | | distributor_admin | Distributor | Set reward rate, bounty, and other parameters | | emergency_restart | Emergency | Reactivates the TRSRY and/or MINTR modules | | emergency_restart | EmissionManager | Reactivates the EmissionManager | +| emergency_shutdown | CDAuctioneer | Activate/deactivate the CDAuctioneer | +| emergency_shutdown | CDFacility | Activate/deactivate the CDFacility | | emergency_shutdown | Clearinghouse | Allows shutting down the protocol in an emergency | | emergency_shutdown | Emergency | Deactivates the TRSRY and/or MINTR modules | | emergency_shutdown | EmissionManager | Deactivates the EmissionManager | | emissions_admin | EmissionManager | Set configuration parameters | +| heart | CDAuctioneer | Calls the setAuctionParameters() function | | heart | EmissionManager | Calls the execute() function | | heart | Operator | Call the operate() function | | heart | ReserveMigrator | Allows migrating reserves from one reserve token to another | diff --git a/foundry.toml b/foundry.toml index dd8f3949..d25a20bf 100644 --- a/foundry.toml +++ b/foundry.toml @@ -35,3 +35,4 @@ remappings_generate = false [dependencies] surl = { version = "1.0.0", git = "https://github.com/memester-xyz/surl.git", rev = "034c912ae9b5e707a5afd21f145b452ad8e800df" } +base64 = { version = "1.1.0", git = "https://github.com/Brechtpd/base64.git", rev = "4d85607b18d981acff392d2e99ba654305552a97" } diff --git a/remappings.txt b/remappings.txt index d3d7c251..42b7c305 100644 --- a/remappings.txt +++ b/remappings.txt @@ -28,3 +28,4 @@ openzeppelin/=lib/forge-proposal-simulator/lib/openzeppelin-contracts/contracts/ solidity-code-metrics/=node_modules/solidity-code-metrics/ solidity-examples/=lib/solidity-examples/contracts/ surl-1.0.0/=dependencies/surl-1.0.0/src/ +base64-1.1.0/=dependencies/base64-1.1.0/ diff --git a/soldeer.lock b/soldeer.lock index a25fbe0e..029e94b2 100644 --- a/soldeer.lock +++ b/soldeer.lock @@ -1,3 +1,9 @@ +[[dependencies]] +name = "base64" +version = "1.1.0" +git = "https://github.com/Brechtpd/base64.git" +rev = "4d85607b18d981acff392d2e99ba654305552a97" + [[dependencies]] name = "surl" version = "1.0.0" 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/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/CDEPO/CDEPO.v1.sol b/src/modules/CDEPO/CDEPO.v1.sol new file mode 100644 index 00000000..1e83d1e9 --- /dev/null +++ b/src/modules/CDEPO/CDEPO.v1.sol @@ -0,0 +1,187 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity 0.8.15; + +import {Module} from "src/Kernel.sol"; +import {ERC20} from "solmate/tokens/ERC20.sol"; +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 reclaim rate is updated + event ReclaimRateUpdated(uint16 newReclaimRate); + + /// @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% + uint16 public constant ONE_HUNDRED_PERCENT = 100e2; + + // ========== STATE VARIABLES ========== // + + /// @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; + + // ========== 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 `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 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 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: + /// - 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 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: + /// - 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 + /// + /// @param amount_ The amount of convertible deposit tokens to burn + function reclaim(uint256 amount_) external virtual; + + /// @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 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: + /// - 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 reclaimed + function previewReclaim(uint256 amount_) external view virtual returns (uint256 assetsOut); + + /// @notice Redeem convertible deposit tokens for the underlying asset + /// 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 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 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 ========== // + + /// @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 + /// + /// @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( + address to_ + ) 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 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 reclaim rate + /// - Emitting an event + /// + /// @param newReclaimRate_ The new reclaim rate + function setReclaimRate(uint16 newReclaimRate_) external virtual; + + // ========== STATE VARIABLES ========== // + + /// @notice The ERC4626 vault that holds the underlying asset + function vault() external view virtual returns (ERC4626); + + /// @notice The underlying ERC20 asset + function asset() external view virtual returns (ERC20); + + /// @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 new file mode 100644 index 00000000..416eb040 --- /dev/null +++ b/src/modules/CDEPO/OlympusConvertibleDepository.sol @@ -0,0 +1,280 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity 0.8.15; + +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"; +import {SafeTransferLib} from "solmate/utils/SafeTransferLib.sol"; + +contract OlympusConvertibleDepository is CDEPOv1 { + using SafeTransferLib for ERC20; + using SafeTransferLib for ERC4626; + + // ========== STATE VARIABLES ========== // + + /// @inheritdoc CDEPOv1 + ERC4626 public immutable override vault; + + /// @inheritdoc CDEPOv1 + ERC20 public immutable override asset; + + /// @inheritdoc CDEPOv1 + uint16 public override reclaimRate; + + // ========== 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()); + } + + // ========== 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 + /// @dev This function performs the following: + /// - Calls `mintTo` with the caller as the recipient + function mint(uint256 amount_) external virtual override { + mintFor(msg.sender, amount_); + } + + /// @inheritdoc CDEPOv1 + /// @dev This function performs the following: + /// - 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 `account_` + /// - Emits a `Transfer` event + /// + /// This function reverts if: + /// - The amount is zero + /// - 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(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 `account_` address + _mint(account_, 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) { + // Validate that the amount is greater than zero + if (amount_ == 0) revert CDEPO_InvalidArgs("amount"); + + // Return the same amount of CD tokens + return amount_; + } + + /// @inheritdoc CDEPOv1 + /// @dev This function performs the following: + /// - Calls `reclaimFor` with the caller as the address to reclaim the tokens to + function reclaim(uint256 amount_) external virtual override { + reclaimFor(msg.sender, amount_); + } + + /// @inheritdoc CDEPOv1 + /// @dev This function performs the following: + /// - 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 `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 + function reclaimFor(address account_, uint256 amount_) public virtual override { + // 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"); + + // 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 + _burn(account_, amount_); + + // Return the underlying asset to `account_` + vault.withdraw(discountedAssetsOut, account_, address(this)); + } + + /// @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"); + + // 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); + } + + /// @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 + /// - 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 + /// - 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 + 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 + // 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"); + + // 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 the `account_` address + _burn(account_, amount_); + + // Return the underlying asset to the caller + vault.withdraw(amount_, msg.sender, address(this)); + + return amount_; + } + + // ========== 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 + /// - 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 recipient + vault.safeTransfer(recipient_, yieldSReserve); + + // Emit the event + emit YieldSwept(recipient_, 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 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 reclaim rate + reclaimRate = newReclaimRate_; + + // Emit the event + emit ReclaimRateUpdated(newReclaimRate_); + } +} diff --git a/src/modules/CDPOS/CDPOS.v1.sol b/src/modules/CDPOS/CDPOS.v1.sol new file mode 100644 index 00000000..1d695d6c --- /dev/null +++ b/src/modules/CDPOS/CDPOS.v1.sol @@ -0,0 +1,209 @@ +// 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 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 The amount of convertible deposit tokens per OHM token + /// @param expiry Timestamp when the term expires + /// @param wrapped Whether the term is wrapped + struct Position { + address owner; + address convertibleDepositToken; + uint256 remainingDeposit; + uint256 conversionPrice; + uint48 expiry; + bool wrapped; + } + + // ========== EVENTS ========== // + + /// @notice Emitted when a position is created + event PositionCreated( + uint256 indexed positionId, + address indexed owner, + address indexed convertibleDepositToken, + 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, + address indexed convertibleDepositToken, + 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 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 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_, + 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); + + /// @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); + + /// @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 new file mode 100644 index 00000000..6832cd89 --- /dev/null +++ b/src/modules/CDPOS/OlympusConvertibleDepositPositions.sol @@ -0,0 +1,491 @@ +// SPDX-License-Identifier: AGPL-3.0-only +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, 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"; +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 public displayDecimals = 2; + + // ========== CONSTRUCTOR ========== // + + constructor( + 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 + /// @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 + _safeMint(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_, + address convertibleDepositToken_, + uint256 remainingDeposit_, + uint256 conversionPrice_, + uint48 expiry_, + bool wrap_ + ) internal returns (uint256 positionId) { + // Create the position record + positionId = positionCount++; + _positions[positionId] = Position({ + owner: owner_, + convertibleDepositToken: convertibleDepositToken_, + remainingDeposit: remainingDeposit_, + conversionPrice: conversionPrice_, + expiry: expiry_, + wrapped: wrap_ + }); + + // Add the position ID to the user's list of positions + _userPositions[owner_].push(positionId); + + // If specified, wrap the position + if (wrap_) _safeMint(owner_, positionId); + + // Emit the event + emit PositionCreated( + positionId, + owner_, + convertibleDepositToken_, + 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 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_, + 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 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"); + + // 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_, + convertibleDepositToken_, + 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_, + position.convertibleDepositToken, + amount_, + position.conversionPrice, + position.expiry, + wrap_ + ); + + // Emit the event + emit PositionSplit( + positionId_, + newPositionId, + position.convertibleDepositToken, + amount_, + to_, + wrap_ + ); + + return newPositionId; + } + + // ========== 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 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', + string.concat( + 'ID: ', + Strings.toString(positionId_), + "" + ), + string.concat( + 'Expiry: ', + _getTimeString(position_.expiry), + "" + ), + string.concat( + 'Remaining: ', + DecimalString.toDecimalString( + position_.remainingDeposit, + depositDecimals, + displayDecimals + ), + "" + ), + string.concat( + 'Conversion: ', + DecimalString.toDecimalString( + position_.conversionPrice, + depositDecimals, + displayDecimals + ), + "" + ), + "" + ); + } + + // solhint-enable quotes + + /// @inheritdoc ERC721 + // solhint-disable quotes + 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( + "{", + 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( + '{"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": "', + "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 + /// - 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_); + + // 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_); + + // 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_); + } + + /// @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; + } + + function _previewConvert( + uint256 amount_, + uint256 conversionPrice_ + ) internal pure returns (uint256) { + // 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 + function previewConvert( + uint256 positionId_, + uint256 amount_ + ) public view virtual override onlyValidPosition(positionId_) returns (uint256) { + 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 ========== // + + /// @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_) { + 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 (_getPosition(positionId_).owner != msg.sender) revert CDPOS_NotOwner(positionId_); + _; + } +} diff --git a/src/policies/CDAuctioneer.sol b/src/policies/CDAuctioneer.sol new file mode 100644 index 00000000..ebd53562 --- /dev/null +++ b/src/policies/CDAuctioneer.sol @@ -0,0 +1,682 @@ +// SPDX-License-Identifier: AGPL-3.0 +pragma solidity 0.8.15; + +// Libraries +import {ReentrancyGuard} from "solmate/utils/ReentrancyGuard.sol"; +import {ERC20} from "solmate/tokens/ERC20.sol"; +import {FullMath} from "src/libraries/FullMath.sol"; + +// Bophades dependencies +import {Kernel, Keycode, Permissions, Policy, toKeycode} from "src/Kernel.sol"; +import {CDEPOv1} from "src/modules/CDEPO/CDEPO.v1.sol"; +import {RolesConsumer, ROLESv1} from "src/modules/ROLES/OlympusRoles.sol"; +import {IConvertibleDepositAuctioneer} from "src/policies/interfaces/IConvertibleDepositAuctioneer.sol"; +import {CDFacility} from "./CDFacility.sol"; + +/// @title Convertible Deposit Auctioneer +/// @notice Implementation of the IConvertibleDepositAuctioneer interface +/// @dev This contract implements an auction for convertible deposit tokens. It runs these auctions according to the following principles: +/// - Auctions are of infinite duration +/// - Auctions are of infinite capacity +/// - Users place bids by supplying an amount of the quote token +/// - The quote token is the deposit token from the CDEPO module +/// - The payout token is the CDEPO token, which can be converted to OHM at the conversion price that was set at the time of the bid +/// - During periods of greater demand, the conversion price will increase +/// - During periods of lower demand, the conversion price will decrease +/// - The auction has a minimum price, below which the conversion price will not decrease +/// - The auction has a target amount of convertible OHM to sell per day +/// - When the target is reached, the amount of OHM required to increase the conversion price will decrease, resulting in more rapid price increases (assuming there is demand) +/// - The auction parameters are able to be updated in order to tweak the auction's behaviour +contract CDAuctioneer is IConvertibleDepositAuctioneer, Policy, RolesConsumer, ReentrancyGuard { + using FullMath for uint256; + + // ========== STATE VARIABLES ========== // + + /// @notice The role that can perform periodic actions, such as updating the auction parameters + bytes32 public constant ROLE_HEART = "heart"; + + /// @notice The role that can perform administrative actions, such as changing parameters + bytes32 public constant ROLE_ADMIN = "cd_admin"; + + /// @notice The role that can perform emergency actions, such as shutting down the contract + bytes32 public constant ROLE_EMERGENCY_SHUTDOWN = "emergency_shutdown"; + + /// @notice Address of the CDEPO module + CDEPOv1 public CDEPO; + + /// @notice Address of the token that is being bid + /// @dev This is populated by the `configureDependencies()` function + address public bidToken; + + /// @notice Scale of the bid token + /// @dev This is populated by the `configureDependencies()` function + uint256 public bidTokenScale; + + /// @notice Previous tick of the auction + /// @dev Use `getCurrentTick()` to recalculate and access the latest data + Tick internal _previousTick; + + /// @notice Auction parameters + /// @dev These values should only be set through the `setAuctionParameters()` function + AuctionParameters internal _auctionParameters; + + /// @notice Auction state for the day + Day internal _dayState; + + /// @notice Scale of the OHM token + uint256 internal constant _ohmScale = 1e9; + + /// @notice Address of the Convertible Deposit Facility + CDFacility public cdFacility; + + /// @notice Whether the contract functionality has been activated + bool public locallyActive; + + /// @notice Whether the contract has been initialized + /// @dev When the contract has been initialized, the following can be assumed: + /// - The auction parameters have been set + /// - The tick step has been set + /// - The time to expiry has been set + /// - The tick capacity and price have been set to the standard tick size and minimum price + /// - The last update has been set to the current block timestamp + bool public initialized; + + /// @notice The tick step + /// @dev See `getTickStep()` for more information + uint24 internal _tickStep; + + uint24 public constant ONE_HUNDRED_PERCENT = 100e2; + + /// @notice The number of seconds between creation and expiry of convertible deposits + /// @dev See `getTimeToExpiry()` for more information + uint48 internal _timeToExpiry; + + /// @notice The index of the next auction result + uint8 internal _auctionResultsNextIndex; + + /// @notice The number of days that auction results are tracked for + uint8 internal _auctionTrackingPeriod; + + /// @notice The auction results, where a positive number indicates an over-subscription for the day. + /// @dev The length of this array is equal to the auction tracking period + int256[] internal _auctionResults; + + // ========== SETUP ========== // + + constructor(address kernel_, address cdFacility_) Policy(Kernel(kernel_)) { + if (cdFacility_ == address(0)) + revert CDAuctioneer_InvalidParams("CD Facility address cannot be 0"); + + cdFacility = CDFacility(cdFacility_); + + // Disable functionality until initialized + locallyActive = false; + } + + /// @inheritdoc Policy + function configureDependencies() external override returns (Keycode[] memory dependencies) { + dependencies = new Keycode[](2); + dependencies[0] = toKeycode("ROLES"); + dependencies[1] = toKeycode("CDEPO"); + + ROLES = ROLESv1(getModuleAddress(dependencies[0])); + CDEPO = CDEPOv1(getModuleAddress(dependencies[1])); + + bidToken = address(CDEPO.asset()); + bidTokenScale = 10 ** ERC20(bidToken).decimals(); + } + + /// @inheritdoc Policy + function requestPermissions() + external + view + override + returns (Permissions[] memory permissions) + {} + + function VERSION() external pure returns (uint8 major, uint8 minor) { + major = 1; + minor = 0; + + return (major, minor); + } + + // ========== AUCTION ========== // + + /// @inheritdoc IConvertibleDepositAuctioneer + /// @dev This function performs the following: + /// - Updates the current tick based on the current state + /// - Determines the amount of OHM that can be purchased for the deposit amount, and the updated tick capacity and price + /// - Updates the day state, if necessary + /// - Creates a convertible deposit position using the deposit amount, the average conversion price and the configured time to expiry + /// + /// This function reverts if: + /// - The contract is not active + /// - The calculated converted amount is 0 + function bid( + uint256 deposit_ + ) external override nonReentrant onlyActive returns (uint256 ohmOut, uint256 positionId) { + // Update the current tick based on the current state + // lastUpdate is updated after this, otherwise time calculations will be incorrect + _previousTick = getCurrentTick(); + + // Get bid results + uint256 currentTickPrice; + uint256 currentTickCapacity; + uint256 currentTickSize; + uint256 depositIn; + (currentTickCapacity, currentTickPrice, currentTickSize, depositIn, ohmOut) = _previewBid( + deposit_, + _previousTick + ); + + // Reject if the OHM out is 0 + if (ohmOut == 0) revert CDAuctioneer_InvalidParams("converted amount"); + + // Update state + _dayState.deposits += depositIn; + _dayState.convertible += ohmOut; + + // Update current tick + _previousTick.price = currentTickPrice; + _previousTick.capacity = currentTickCapacity; + _previousTick.tickSize = currentTickSize; + _previousTick.lastUpdate = uint48(block.timestamp); + + // Calculate average price based on the total deposit and ohmOut + // This is the number of deposit tokens per OHM token + // We round up to be conservative + uint256 conversionPrice = depositIn.mulDivUp(_ohmScale, ohmOut); + + // Create the CD tokens and position + positionId = cdFacility.create( + msg.sender, + depositIn, + conversionPrice, + uint48(block.timestamp + _timeToExpiry), + false + ); + + return (ohmOut, positionId); + } + + /// @notice Internal function to preview the quantity of OHM tokens that can be purchased for a given deposit amount + /// @dev This function performs the following: + /// - Cycles through ticks until the deposit is fully converted + /// - If the current tick has enough capacity, it will be used + /// - If the current tick does not have enough capacity, the remaining capacity will be used. The current tick will then shift to the next tick, resulting in the capacity being filled to the tick size, and the price being multiplied by the tick step. + /// + /// Notes: + /// - The function returns the updated tick capacity and price after the bid + /// - If the capacity of a tick is depleted (but does not cross into the next tick), the current tick will be shifted to the next one. This ensures that `getCurrentTick()` will not return a tick that has been depleted. + /// + /// @param deposit_ The amount of deposit to be bid + /// @return updatedTickCapacity The adjusted capacity of the current tick + /// @return updatedTickPrice The adjusted price of the current tick + /// @return updatedTickSize The adjusted size of the current tick + /// @return depositIn The amount of deposit that was converted + /// @return ohmOut The quantity of OHM tokens that can be purchased + function _previewBid( + uint256 deposit_, + Tick memory tick_ + ) + internal + view + returns ( + uint256 updatedTickCapacity, + uint256 updatedTickPrice, + uint256 updatedTickSize, + uint256 depositIn, + uint256 ohmOut + ) + { + uint256 remainingDeposit = deposit_; + updatedTickCapacity = tick_.capacity; + updatedTickPrice = tick_.price; + updatedTickSize = tick_.tickSize; + + // Cycle through the ticks until the deposit is fully converted + while (remainingDeposit > 0) { + uint256 depositAmount = remainingDeposit; + uint256 convertibleAmount = _getConvertedDeposit(remainingDeposit, updatedTickPrice); + + // No point in continuing if the converted amount is 0 + if (convertibleAmount == 0) break; + + // If there is not enough capacity in the current tick, use the remaining capacity + if (updatedTickCapacity <= convertibleAmount) { + convertibleAmount = updatedTickCapacity; + // Convertible = deposit * OHM scale / price, so this is the inverse + depositAmount = convertibleAmount.mulDiv(updatedTickPrice, _ohmScale); + + // The tick has also been depleted, so update the price + updatedTickPrice = _getNewTickPrice(updatedTickPrice, _tickStep); + updatedTickSize = _getNewTickSize( + _dayState.convertible + convertibleAmount + ohmOut + ); + updatedTickCapacity = updatedTickSize; + } + // Otherwise, the tick has enough capacity and needs to be updated + else { + updatedTickCapacity -= convertibleAmount; + } + + // Record updates to the deposit and OHM + remainingDeposit -= depositAmount; + ohmOut += convertibleAmount; + } + + return ( + updatedTickCapacity, + updatedTickPrice, + updatedTickSize, + deposit_ - remainingDeposit, + ohmOut + ); + } + + /// @inheritdoc IConvertibleDepositAuctioneer + function previewBid( + uint256 bidAmount_ + ) external view override returns (uint256 ohmOut, address depositSpender) { + // Get the updated tick based on the current state + Tick memory currentTick = getCurrentTick(); + + // Preview the bid results + (, , , , ohmOut) = _previewBid(bidAmount_, currentTick); + + return (ohmOut, address(CDEPO)); + } + + // ========== VIEW FUNCTIONS ========== // + + /// @notice Internal function to preview the quantity of OHM tokens that can be purchased for a given deposit amount + /// @dev This function does not take into account the capacity of the current tick + /// + /// @param deposit_ The amount of deposit to be converted + /// @param price_ The price of the deposit in OHM + /// @return convertibleAmount The quantity of OHM tokens that can be purchased + function _getConvertedDeposit( + uint256 deposit_, + uint256 price_ + ) internal pure returns (uint256 convertibleAmount) { + // As price represents the number of bid tokens per OHM, we can convert the deposit to OHM by dividing by the price and adjusting for the decimal scale + convertibleAmount = deposit_.mulDiv(_ohmScale, price_); + return convertibleAmount; + } + + /// @notice Internal function to preview the new price of the current tick after applying the tick step + /// @dev This function does not take into account the capacity of the current tick + /// + /// @param currentPrice_ The current price of the tick in terms of the bid token + /// @param tickStep_ The step size of the tick + /// @return newPrice The new price of the tick + function _getNewTickPrice( + uint256 currentPrice_, + uint256 tickStep_ + ) internal pure returns (uint256 newPrice) { + newPrice = currentPrice_.mulDivUp(tickStep_, ONE_HUNDRED_PERCENT); + return newPrice; + } + + /// @notice Internal function to calculate the new tick size based on the amount of OHM that has been converted in the current day + /// + /// @param ohmOut_ The amount of OHM that has been converted in the current day + /// @return newTickSize The new tick size + function _getNewTickSize(uint256 ohmOut_) internal view returns (uint256 newTickSize) { + // Calculate the multiplier + uint256 multiplier = ohmOut_ / _auctionParameters.target; + + // If the day target has not been met, the tick size remains the standard + if (multiplier == 0) { + newTickSize = _auctionParameters.tickSize; + return newTickSize; + } + + // Otherwise the tick size is halved as many times as the multiplier + newTickSize = _auctionParameters.tickSize / (multiplier * 2); + return newTickSize; + } + + /// @inheritdoc IConvertibleDepositAuctioneer + /// @dev This function calculates the tick at the current time. + /// + /// It uses the following approach: + /// - Calculate the added capacity based on the time passed since the last bid, and add it to the current capacity to get the new capacity + /// - If the calculation is occurring on a new day, the tick size will reset to the standard + /// - Until the new capacity is <= to the tick size, reduce the capacity by the tick size and reduce the price by the tick step + /// - If the calculated price is ever lower than the minimum price, the new price is set to the minimum price and the capacity is set to the tick size + function getCurrentTick() public view onlyActive returns (Tick memory tick) { + // Find amount of time passed and new capacity to add + uint256 timePassed = block.timestamp - _previousTick.lastUpdate; + uint256 capacityToAdd = (_auctionParameters.target * timePassed) / 1 days; + + // Skip if the new capacity is 0 + if (capacityToAdd == 0) return _previousTick; + + tick = _previousTick; + uint256 newCapacity = tick.capacity + capacityToAdd; + + // If the current date is on a different day to the last bid, the tick size will reset to the standard + if (isDayComplete()) { + tick.tickSize = _auctionParameters.tickSize; + } + + // Iterate over the ticks until the capacity is within the tick size + // This is the opposite of what happens in the bid function + while (newCapacity > tick.tickSize) { + // Reduce the capacity by the tick size + newCapacity -= tick.tickSize; + + // Adjust the tick price by the tick step, in the opposite direction to the bid function + tick.price = tick.price.mulDivUp(ONE_HUNDRED_PERCENT, _tickStep); + + // Tick price does not go below the minimum + // Tick capacity is full if the min price is exceeded + if (tick.price < _auctionParameters.minPrice) { + tick.price = _auctionParameters.minPrice; + newCapacity = tick.tickSize; + break; + } + } + + // Set the capacity + tick.capacity = newCapacity; + + return tick; + } + + /// @inheritdoc IConvertibleDepositAuctioneer + function getPreviousTick() public view override returns (Tick memory tick) { + return _previousTick; + } + + /// @inheritdoc IConvertibleDepositAuctioneer + function getAuctionParameters() external view override returns (AuctionParameters memory) { + return _auctionParameters; + } + + /// @inheritdoc IConvertibleDepositAuctioneer + function getDayState() external view override returns (Day memory) { + return _dayState; + } + + /// @inheritdoc IConvertibleDepositAuctioneer + function getTickStep() external view override returns (uint24) { + return _tickStep; + } + + /// @inheritdoc IConvertibleDepositAuctioneer + function getTimeToExpiry() external view override returns (uint48) { + return _timeToExpiry; + } + + /// @inheritdoc IConvertibleDepositAuctioneer + function getAuctionTrackingPeriod() external view override returns (uint8) { + return _auctionTrackingPeriod; + } + + /// @inheritdoc IConvertibleDepositAuctioneer + function getAuctionResultsNextIndex() external view override returns (uint8) { + return _auctionResultsNextIndex; + } + + /// @inheritdoc IConvertibleDepositAuctioneer + function getAuctionResults() external view override returns (int256[] memory) { + return _auctionResults; + } + + /// @inheritdoc IConvertibleDepositAuctioneer + function isDayComplete() public view override returns (bool) { + return block.timestamp / 86400 > _dayState.initTimestamp / 86400; + } + + // ========== ADMIN FUNCTIONS ========== // + + function _setAuctionParameters(uint256 target_, uint256 tickSize_, uint256 minPrice_) internal { + // Tick size must be non-zero + if (tickSize_ == 0) revert CDAuctioneer_InvalidParams("tick size"); + + // Min price must be non-zero + if (minPrice_ == 0) revert CDAuctioneer_InvalidParams("min price"); + + // Target must be non-zero + if (target_ == 0) revert CDAuctioneer_InvalidParams("target"); + + _auctionParameters = AuctionParameters(target_, tickSize_, minPrice_); + + // Emit event + emit AuctionParametersUpdated(target_, tickSize_, minPrice_); + } + + function _storeAuctionResults(uint256 previousTarget_) internal { + // Skip if inactive + if (!locallyActive) return; + + // Skip if the day state was set on the same day + if (!isDayComplete()) return; + + // If the next index is 0, reset the results before inserting + // This ensures that the previous results are available for 24 hours + if (_auctionResultsNextIndex == 0) { + _auctionResults = new int256[](_auctionTrackingPeriod); + } + + // Store the auction results + // Negative values will indicate under-selling + _auctionResults[_auctionResultsNextIndex] = + int256(_dayState.convertible) - + int256(previousTarget_); + + // Emit event + emit AuctionResult(_dayState.convertible, previousTarget_, _auctionResultsNextIndex); + + // Increment the index (or loop around) + _auctionResultsNextIndex++; + // Loop around if necessary + if (_auctionResultsNextIndex >= _auctionTrackingPeriod) { + _auctionResultsNextIndex = 0; + } + + // Reset the day state + _dayState = Day(uint48(block.timestamp), 0, 0); + } + + /// @inheritdoc IConvertibleDepositAuctioneer + /// @dev This function performs the following: + /// - Performs validation of the inputs + /// - Sets the auction parameters + /// - Adjusts the current tick capacity and price, if necessary + /// + /// This function reverts if: + /// - The caller does not have the ROLE_HEART role + /// - The new tick size is 0 + /// - The new min price is 0 + /// - The new target is 0 + function setAuctionParameters( + uint256 target_, + uint256 tickSize_, + uint256 minPrice_ + ) external override onlyRole(ROLE_HEART) { + uint256 previousTarget = _auctionParameters.target; + + _setAuctionParameters(target_, tickSize_, minPrice_); + + // The following can be done even if the contract is not active nor initialized, since activating/initializing will set the tick capacity and price + + // Set the tick size + _previousTick.tickSize = tickSize_; + + // Ensure that the tick capacity is not larger than the new tick size + // Otherwise, excess OHM will be converted + if (tickSize_ < _previousTick.capacity) { + _previousTick.capacity = tickSize_; + } + + // Ensure that the minimum price is enforced + // Otherwise, OHM will be converted at a price lower than the minimum + if (minPrice_ > _previousTick.price) { + _previousTick.price = minPrice_; + } + + // Store the auction results, if necessary + _storeAuctionResults(previousTarget); + } + + /// @inheritdoc IConvertibleDepositAuctioneer + /// @dev This function will revert if: + /// - The caller does not have the ROLE_ADMIN role + /// - The new time to expiry is 0 + /// + /// @param newTime_ The new time to expiry + function setTimeToExpiry(uint48 newTime_) public override onlyRole(ROLE_ADMIN) { + // Value must be non-zero + if (newTime_ == 0) revert CDAuctioneer_InvalidParams("time to expiry"); + + _timeToExpiry = newTime_; + + // Emit event + emit TimeToExpiryUpdated(newTime_); + } + + /// @inheritdoc IConvertibleDepositAuctioneer + /// @dev This function will revert if: + /// - The caller does not have the ROLE_ADMIN role + /// - The new tick step is < 100e2 + /// + /// @param newStep_ The new tick step + function setTickStep(uint24 newStep_) public override onlyRole(ROLE_ADMIN) { + // Value must be more than 100e2 + if (newStep_ < ONE_HUNDRED_PERCENT) revert CDAuctioneer_InvalidParams("tick step"); + + _tickStep = newStep_; + + // Emit event + emit TickStepUpdated(newStep_); + } + + /// @inheritdoc IConvertibleDepositAuctioneer + /// @dev This function will revert if: + /// - The caller does not have the ROLE_ADMIN role + /// - The new auction tracking period is 0 + /// + /// @param days_ The new auction tracking period + function setAuctionTrackingPeriod(uint8 days_) public override onlyRole(ROLE_ADMIN) { + // Value must be non-zero + if (days_ == 0) revert CDAuctioneer_InvalidParams("auction tracking period"); + + _auctionTrackingPeriod = days_; + + // Reset the auction results and index and set to the new length + _auctionResults = new int256[](days_); + _auctionResultsNextIndex = 0; + + // Emit event + emit AuctionTrackingPeriodUpdated(days_); + } + + // ========== ACTIVATION/DEACTIVATION ========== // + + /// @inheritdoc IConvertibleDepositAuctioneer + /// @dev This function will revert if: + /// - The caller does not have the ROLE_ADMIN role + /// - The contract is already initialized + /// - The contract is already active + /// - Validation of the inputs fails + /// + /// The outcome of running this function is that the contract will be in a valid state for bidding to take place. + function initialize( + uint256 target_, + uint256 tickSize_, + uint256 minPrice_, + uint24 tickStep_, + uint48 timeToExpiry_, + uint8 auctionTrackingPeriod_ + ) external onlyRole(ROLE_ADMIN) { + // If initialized, revert + if (initialized) revert CDAuctioneer_InvalidState(); + + // Set the auction parameters + _setAuctionParameters(target_, tickSize_, minPrice_); + + // Set the tick step + // This emits the event + setTickStep(tickStep_); + + // Set the time to expiry + // This emits the event + setTimeToExpiry(timeToExpiry_); + + // Set the auction tracking period + // This emits the event + setAuctionTrackingPeriod(auctionTrackingPeriod_); + + // Initialize the current tick + _previousTick.capacity = tickSize_; + _previousTick.price = minPrice_; + _previousTick.tickSize = tickSize_; + + // Set the initialized flag + initialized = true; + + // Activate the contract + // This emits the event + _activate(); + } + + function _activate() internal { + // If not initialized, revert + if (!initialized) revert CDAuctioneer_NotInitialized(); + + // If the contract is already active, revert + if (locallyActive) revert CDAuctioneer_InvalidState(); + + // Set the contract to active + locallyActive = true; + + // Also set the lastUpdate to the current block timestamp + // Otherwise, getCurrentTick() will calculate a long period of time having passed + _previousTick.lastUpdate = uint48(block.timestamp); + + // Reset the day state + _dayState = Day(uint48(block.timestamp), 0, 0); + + // Reset the auction results + _auctionResults = new int256[](_auctionTrackingPeriod); + _auctionResultsNextIndex = 0; + + // Emit event + emit Activated(); + } + + /// @notice Activate the contract functionality + /// @dev This function will revert if: + /// - The caller does not have the ROLE_EMERGENCY_SHUTDOWN role + /// - The contract has not previously been initialized + /// - The contract is already active + function activate() external onlyRole(ROLE_EMERGENCY_SHUTDOWN) { + _activate(); + } + + /// @notice Deactivate the contract functionality + /// @dev This function will revert if: + /// - The caller does not have the ROLE_EMERGENCY_SHUTDOWN role + /// - The contract is already inactive + function deactivate() external onlyRole(ROLE_EMERGENCY_SHUTDOWN) { + // If the contract is already inactive, revert + if (!locallyActive) revert CDAuctioneer_InvalidState(); + + // Set the contract to inactive + locallyActive = false; + + // Emit event + emit Deactivated(); + } + + // ========== MODIFIERS ========== // + + modifier onlyActive() { + if (!locallyActive) revert CDAuctioneer_NotActive(); + _; + } +} diff --git a/src/policies/CDFacility.sol b/src/policies/CDFacility.sol new file mode 100644 index 00000000..bfac9982 --- /dev/null +++ b/src/policies/CDFacility.sol @@ -0,0 +1,429 @@ +// SPDX-License-Identifier: AGPL-3.0 +pragma solidity 0.8.15; + +import {Kernel, Keycode, Permissions, Policy, toKeycode} from "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 {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 {CDEPOv1} from "src/modules/CDEPO/CDEPO.v1.sol"; +import {CDPOSv1} from "src/modules/CDPOS/CDPOS.v1.sol"; + +import {FullMath} from "src/libraries/FullMath.sol"; + +contract CDFacility is Policy, RolesConsumer, IConvertibleDepositFacility, ReentrancyGuard { + using FullMath for uint256; + + // ========== STATE VARIABLES ========== // + + // Constants + + /// @notice The scale of the convertible deposit token + /// @dev This will typically be 10 ** decimals, and is set by the `configureDependencies()` function + uint256 public SCALE; + + // Modules + TRSRYv1 public TRSRY; + MINTRv1 public MINTR; + CDEPOv1 public CDEPO; + CDPOSv1 public CDPOS; + + /// @notice Whether the contract functionality has been activated + bool public locallyActive; + + bytes32 public constant ROLE_EMERGENCY_SHUTDOWN = "emergency_shutdown"; + + bytes32 public constant ROLE_AUCTIONEER = "cd_auctioneer"; + + // ========== ERRORS ========== // + + /// @notice An error that is thrown when the parameters are invalid + error CDFacility_InvalidParams(string reason); + + // ========== SETUP ========== // + + constructor(address kernel_) Policy(Kernel(kernel_)) { + // Disable functionality until initialized + locallyActive = false; + } + + /// @inheritdoc Policy + function configureDependencies() external override returns (Keycode[] memory dependencies) { + dependencies = new Keycode[](5); + dependencies[0] = toKeycode("TRSRY"); + dependencies[1] = toKeycode("MINTR"); + dependencies[2] = toKeycode("ROLES"); + dependencies[3] = toKeycode("CDEPO"); + dependencies[4] = toKeycode("CDPOS"); + + TRSRY = TRSRYv1(getModuleAddress(dependencies[0])); + MINTR = MINTRv1(getModuleAddress(dependencies[1])); + ROLES = ROLESv1(getModuleAddress(dependencies[2])); + CDEPO = CDEPOv1(getModuleAddress(dependencies[3])); + CDPOS = CDPOSv1(getModuleAddress(dependencies[4])); + + SCALE = 10 ** CDEPO.decimals(); + } + + /// @inheritdoc Policy + function requestPermissions() + external + view + override + returns (Permissions[] memory permissions) + { + Keycode mintrKeycode = toKeycode("MINTR"); + Keycode cdepoKeycode = toKeycode("CDEPO"); + Keycode cdposKeycode = toKeycode("CDPOS"); + + permissions = new Permissions[](7); + 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.redeemFor.selector); + permissions[4] = Permissions(cdepoKeycode, CDEPO.sweepYield.selector); + permissions[5] = Permissions(cdposKeycode, CDPOS.create.selector); + permissions[6] = Permissions(cdposKeycode, CDPOS.update.selector); + } + + function VERSION() external pure returns (uint8 major, uint8 minor) { + major = 1; + minor = 0; + + return (major, minor); + } + + // ========== CONVERTIBLE DEPOSIT ACTIONS ========== // + + /// @inheritdoc IConvertibleDepositFacility + /// @dev This function reverts if: + /// - The caller does not have the ROLE_AUCTIONEER role + /// - The contract is not active + function create( + address account_, + uint256 amount_, + uint256 conversionPrice_, + uint48 expiry_, + bool wrap_ + ) external onlyRole(ROLE_AUCTIONEER) nonReentrant onlyActive returns (uint256 positionId) { + // Mint the CD token to the account + // This will also transfer the reserve token + CDEPO.mintFor(account_, amount_); + + // Create a new term record in the CDPOS module + positionId = CDPOS.create( + account_, + address(CDEPO), + amount_, + conversionPrice_, + expiry_, + wrap_ + ); + + // Calculate the expected OHM amount + uint256 expectedOhmAmount = (amount_ * SCALE) / conversionPrice_; + + // Pre-emptively increase the OHM mint approval + MINTR.increaseMintApproval(address(this), expectedOhmAmount); + + // Emit an event + emit CreatedDeposit(account_, positionId, amount_); + } + + function _previewConvert( + address account_, + uint256 positionId_, + 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 (position.owner != account_) 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; + + return convertedTokenOut; + } + + /// @inheritdoc IConvertibleDepositFacility + /// @dev This function reverts if: + /// - The contract is not active + /// - 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 + /// - The deposit amount is greater than the remaining deposit + /// - The deposit amount is 0 + /// - The converted amount is 0 + function previewConvert( + address account_, + uint256[] memory positionIds_, + uint256[] memory amounts_ + ) + external + view + onlyActive + 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"); + + for (uint256 i; i < positionIds_.length; ++i) { + uint256 positionId = positionIds_[i]; + uint256 amount = amounts_[i]; + cdTokenIn += amount; + convertedTokenOut += _previewConvert(account_, positionId, amount); + } + + // If the amount is 0, revert + if (cdTokenIn == 0) revert CDF_InvalidArgs("amount"); + + // If the converted amount is 0, revert + if (convertedTokenOut == 0) revert CDF_InvalidArgs("converted amount"); + + return (cdTokenIn, convertedTokenOut, address(CDEPO)); + } + + /// @inheritdoc IConvertibleDepositFacility + /// @dev This function reverts if: + /// - The contract is not active + /// - 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 nonReentrant onlyActive 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"); + + // Iterate over all positions + for (uint256 i; i < positionIds_.length; ++i) { + uint256 positionId = positionIds_[i]; + uint256 depositAmount = amounts_[i]; + + cdTokenIn += depositAmount; + convertedTokenOut += _previewConvert(msg.sender, positionId, depositAmount); + + // Update the position + CDPOS.update( + positionId, + CDPOS.getPosition(positionId).remainingDeposit - depositAmount + ); + } + + // Redeem the CD deposits in bulk + // 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(); + CDEPO.asset().approve(address(vault), tokensOut); + vault.deposit(tokensOut, address(TRSRY)); + + // Mint OHM to the owner/caller + // No need to check if `convertedTokenOut` is 0, as MINTR will revert + MINTR.mintOhm(msg.sender, convertedTokenOut); + + // Emit event + emit ConvertedDeposit(msg.sender, cdTokenIn, convertedTokenOut); + + return (cdTokenIn, convertedTokenOut); + } + + function _previewReclaim( + address account_, + uint256 positionId_, + uint256 amount_ + ) internal view returns (uint256 reclaimed) { + // 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 != account_) 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 expired + if (block.timestamp < position.expiry) revert CDF_PositionNotExpired(positionId_); + + // Validate that the deposit amount is not greater than the remaining deposit + if (amount_ > position.remainingDeposit) revert CDF_InvalidAmount(positionId_, amount_); + + reclaimed = amount_; + return reclaimed; + } + + /// @inheritdoc IConvertibleDepositFacility + /// @dev This function reverts if: + /// - The contract is not active + /// - 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 not expired + /// - The deposit amount is greater than the remaining deposit + /// - The deposit amount is 0 + function previewReclaim( + address account_, + uint256[] memory positionIds_, + uint256[] memory amounts_ + ) external view onlyActive returns (uint256 reclaimed, address cdTokenSpender) { + // Make sure the lengths of the arrays are the same + if (positionIds_.length != amounts_.length) revert CDF_InvalidArgs("array length"); + + for (uint256 i; i < positionIds_.length; ++i) { + uint256 positionId = positionIds_[i]; + uint256 amount = amounts_[i]; + reclaimed += _previewReclaim(account_, positionId, amount); + } + + // If the reclaimed amount is 0, revert + if (reclaimed == 0) revert CDF_InvalidArgs("amount"); + + return (reclaimed, address(CDEPO)); + } + + /// @inheritdoc IConvertibleDepositFacility + /// @dev This function reverts if: + /// - The contract is not active + /// - 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 not expired + /// - The deposit amount is greater than the remaining deposit + /// - The deposit amount is 0 + function reclaim( + uint256[] memory positionIds_, + uint256[] memory amounts_ + ) external nonReentrant onlyActive returns (uint256 reclaimed) { + // Make sure the lengths of the arrays are the same + if (positionIds_.length != amounts_.length) revert CDF_InvalidArgs("array length"); + + uint256 unconverted; + + // Iterate over all positions + for (uint256 i; i < positionIds_.length; ++i) { + uint256 positionId = positionIds_[i]; + uint256 depositAmount = amounts_[i]; + + uint256 reclaimedAmount = _previewReclaim(msg.sender, positionId, depositAmount); + reclaimed += reclaimedAmount; + unconverted += + (reclaimedAmount * SCALE) / + CDPOS.getPosition(positionId).conversionPrice; + + // Update the position + CDPOS.update( + positionId, + CDPOS.getPosition(positionId).remainingDeposit - depositAmount + ); + } + + // Redeem the CD deposits in bulk + // This will revert if the reclaimed amount is 0 + uint256 tokensOut = CDEPO.redeemFor(msg.sender, reclaimed); + + // Transfer the tokens to the caller + ERC20 cdepoAsset = CDEPO.asset(); + cdepoAsset.transfer(msg.sender, tokensOut); + + // Wrap any remaining tokens and transfer to the TRSRY + uint256 remainingTokens = cdepoAsset.balanceOf(address(this)); + if (remainingTokens > 0) { + ERC4626 vault = CDEPO.vault(); + cdepoAsset.approve(address(vault), remainingTokens); + vault.deposit(remainingTokens, address(TRSRY)); + } + + // Decrease the mint approval + MINTR.decreaseMintApproval(address(this), unconverted); + + // Emit event + emit ReclaimedDeposit(msg.sender, reclaimed); + + return reclaimed; + } + + // ========== 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()); + } + + // ========== ADMIN FUNCTIONS ========== // + + /// @notice Activate the contract functionality + /// @dev This function will revert if: + /// - The caller does not have the ROLE_EMERGENCY_SHUTDOWN role + /// + /// Note that if the contract is already active, this function will do nothing. + function activate() external onlyRole(ROLE_EMERGENCY_SHUTDOWN) { + // If the contract is already active, do nothing + if (locallyActive) return; + + // Set the contract to active + locallyActive = true; + + // Emit event + emit Activated(); + } + + /// @notice Deactivate the contract functionality + /// @dev This function will revert if: + /// - The caller does not have the ROLE_EMERGENCY_SHUTDOWN role + /// + /// Note that if the contract is already inactive, this function will do nothing. + function deactivate() external onlyRole(ROLE_EMERGENCY_SHUTDOWN) { + // If the contract is already inactive, do nothing + if (!locallyActive) return; + + // Set the contract to inactive + locallyActive = false; + + // Emit event + emit Deactivated(); + } + + // ========== MODIFIERS ========== // + + modifier onlyActive() { + if (!locallyActive) revert CDF_NotActive(); + _; + } +} diff --git a/src/policies/EmissionManager.sol b/src/policies/EmissionManager.sol index 2afa9493..76cdf793 100644 --- a/src/policies/EmissionManager.sol +++ b/src/policies/EmissionManager.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0 pragma solidity 0.8.15; -import "src/Kernel.sol"; +import {Kernel, Keycode, Permissions, Policy, toKeycode} from "src/Kernel.sol"; import {ERC20} from "solmate/tokens/ERC20.sol"; import {ERC4626} from "solmate/mixins/ERC4626.sol"; @@ -19,10 +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"; - -interface BurnableERC20 { - function burn(uint256 amount) external; -} +import {IConvertibleDepositAuctioneer} from "src/policies/interfaces/IConvertibleDepositAuctioneer.sol"; interface Clearinghouse { function principalReceivables() external view returns (uint256); @@ -46,15 +43,17 @@ contract EmissionManager is IEmissionManager, Policy, RolesConsumer { CHREGv1 public CHREG; // Tokens - // solhint-disable const-name-snakecase + // solhint-disable immutable-vars-naming ERC20 public immutable ohm; IgOHM public immutable gohm; ERC20 public immutable reserve; ERC4626 public immutable sReserve; + // solhint-enable immutable-vars-naming // External contracts - IBondSDA public auctioneer; + IBondSDA public bondAuctioneer; address public teller; + IConvertibleDepositAuctioneer public cdAuctioneer; // Manager variables uint256 public baseEmissionRate; @@ -64,11 +63,15 @@ contract EmissionManager is IEmissionManager, Policy, RolesConsumer { uint8 public beatCounter; bool public locallyActive; uint256 public activeMarketId; + uint256 public tickSizeScalar; + uint256 public minPriceScalar; uint8 internal _oracleDecimals; + // solhint-disable immutable-vars-naming uint8 internal immutable _ohmDecimals; uint8 internal immutable _gohmDecimals; uint8 internal immutable _reserveDecimals; + // solhint-enable immutable-vars-naming /// @notice timestamp of last shutdown uint48 public shutdownTimestamp; @@ -85,21 +88,25 @@ 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 - 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"); + if (ohm_ == address(0)) revert InvalidParam("OHM address cannot be 0"); + if (gohm_ == address(0)) revert InvalidParam("gOHM address cannot be 0"); + if (reserve_ == address(0)) revert InvalidParam("DAI address cannot be 0"); + if (sReserve_ == address(0)) revert InvalidParam("sDAI address cannot be 0"); + if (bondAuctioneer_ == address(0)) + revert InvalidParam("Bond Auctioneer address cannot be 0"); + if (cdAuctioneer_ == address(0)) revert InvalidParam("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 = IConvertibleDepositAuctioneer(cdAuctioneer_); teller = teller_; _ohmDecimals = ohm.decimals(); @@ -110,6 +117,7 @@ contract EmissionManager is IEmissionManager, Policy, RolesConsumer { reserve.approve(address(sReserve), type(uint256).max); } + /// @inheritdoc Policy function configureDependencies() external override returns (Keycode[] memory dependencies) { dependencies = new Keycode[](5); dependencies[0] = toKeycode("TRSRY"); @@ -125,8 +133,11 @@ contract EmissionManager is IEmissionManager, Policy, RolesConsumer { ROLES = ROLESv1(getModuleAddress(dependencies[4])); _oracleDecimals = PRICE.decimals(); + + return dependencies; } + /// @inheritdoc Policy function requestPermissions() external view @@ -138,6 +149,15 @@ contract EmissionManager is IEmissionManager, Policy, RolesConsumer { permissions = new Permissions[](2); permissions[0] = Permissions(mintrKeycode, MINTR.increaseMintApproval.selector); permissions[1] = Permissions(mintrKeycode, MINTR.mintOhm.selector); + + return permissions; + } + + function VERSION() external pure returns (uint8 major, uint8 minor) { + major = 1; + minor = 2; + + return (major, minor); } // ========== HEARTBEAT ========== // @@ -155,27 +175,52 @@ contract EmissionManager is IEmissionManager, Policy, RolesConsumer { else baseEmissionRate -= rateChange.changeBy; } + // Cache if the day is complete + bool isDayComplete = cdAuctioneer.isDayComplete(); + // It then calculates the amount to sell for the coming day - (, , uint256 sell) = getNextSale(); + (, , uint256 emission) = getNextEmission(); + + // Update the parameters for the convertible deposit auction + cdAuctioneer.setAuctionParameters( + emission, + getSizeFor(emission), + getMinPriceFor(PRICE.getCurrentPrice()) + ); - // And then opens a market if applicable - if (sell != 0) { - MINTR.increaseMintApproval(address(this), sell); - _createMarket(sell); + // If the tracking period is complete, determine if there was under-selling of OHM + if (isDayComplete && cdAuctioneer.getAuctionResultsNextIndex() == 0) { + int256[] memory auctionResults = cdAuctioneer.getAuctionResults(); + int256 difference; + for (uint256 i = 0; i < auctionResults.length; i++) { + difference += auctionResults[i]; + } + + // If there was under-selling, create a market to sell the remaining OHM + if (difference < 0) { + uint256 remainder = uint256(-difference); + MINTR.increaseMintApproval(address(this), remainder); + _createMarket(remainder); + } } } // ========== 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 tickSizeScalar_, + uint256 minPriceScalar_, uint48 restartTimeframe_ ) external onlyRole("emissions_admin") { // Cannot initialize if currently active @@ -192,12 +237,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 (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 = tickSizeScalar_; + minPriceScalar = minPriceScalar_; // Activate locallyActive = true; @@ -206,6 +257,8 @@ contract EmissionManager is IEmissionManager, Policy, RolesConsumer { emit MinimumPremiumChanged(minimumPremium_); emit BackingChanged(backing_); emit RestartTimeframeChanged(restartTimeframe_); + emit TickSizeScalarChanged(tickSizeScalar_); + emit MinPriceScalarChanged(minPriceScalar_); } // ========== BOND CALLBACK ========== // @@ -258,7 +311,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, @@ -323,8 +376,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(); @@ -417,20 +470,49 @@ 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_); + emit BondContractsSet(bondAuctioneer_, 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 = IConvertibleDepositAuctioneer(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; + + emit TickSizeScalarChanged(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; + + emit MinPriceScalarChanged(newScalar); } // =========- VIEW FUNCTIONS ========== // @@ -460,7 +542,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 +558,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; + } } diff --git a/src/policies/interfaces/IConvertibleDepositAuctioneer.sol b/src/policies/interfaces/IConvertibleDepositAuctioneer.sol new file mode 100644 index 00000000..e89d8926 --- /dev/null +++ b/src/policies/interfaces/IConvertibleDepositAuctioneer.sol @@ -0,0 +1,236 @@ +// SPDX-License-Identifier: AGPL-3.0 +pragma solidity >=0.8.0; + +/// @title IConvertibleDepositAuctioneer +/// @notice Interface for a contract that runs auctions for convertible deposit tokens +interface IConvertibleDepositAuctioneer { + // ========== EVENTS ========== // + + /// @notice Emitted when the auction parameters are updated + /// + /// @param newTarget Target for OHM sold per day + /// @param newTickSize Number of OHM in a tick + /// @param newMinPrice Minimum tick price + event AuctionParametersUpdated(uint256 newTarget, uint256 newTickSize, uint256 newMinPrice); + + /// @notice Emitted when the auction result is recorded + /// + /// @param ohmConvertible Amount of OHM that was converted + /// @param target Target for OHM sold per day + /// @param periodIndex The index of the auction result in the tracking period + event AuctionResult(uint256 ohmConvertible, uint256 target, uint8 periodIndex); + + /// @notice Emitted when the time to expiry is updated + /// + /// @param newTimeToExpiry Time to expiry + event TimeToExpiryUpdated(uint48 newTimeToExpiry); + + /// @notice Emitted when the tick step is updated + /// + /// @param newTickStep Percentage increase (decrease) per tick + event TickStepUpdated(uint24 newTickStep); + + /// @notice Emitted when the auction tracking period is updated + /// + /// @param newAuctionTrackingPeriod The number of days that auction results are tracked for + event AuctionTrackingPeriodUpdated(uint8 newAuctionTrackingPeriod); + + /// @notice Emitted when the contract is activated + event Activated(); + + /// @notice Emitted when the contract is deactivated + event Deactivated(); + + // ========== ERRORS ========== // + + /// @notice Emitted when the parameters are invalid + /// + /// @param reason Reason for invalid parameters + error CDAuctioneer_InvalidParams(string reason); + + /// @notice Emitted when the contract is not active + error CDAuctioneer_NotActive(); + + /// @notice Emitted when the state is invalid + error CDAuctioneer_InvalidState(); + + /// @notice Emitted when the contract is not initialized + error CDAuctioneer_NotInitialized(); + + // ========== DATA STRUCTURES ========== // + + /// @notice Auction parameters + /// @dev These values should only be set through the `setAuctionParameters()` function + /// + /// @param target Number of OHM available to sell per day + /// @param tickSize Number of OHM in a tick + /// @param minPrice Minimum price that OHM can be sold for, in terms of the bid token + struct AuctionParameters { + uint256 target; + uint256 tickSize; + uint256 minPrice; + } + + /// @notice Tracks auction activity for a given day + /// + /// @param initTimestamp Timestamp when the day state was initialized + /// @param deposits Quantity of bid tokens deposited for the day + /// @param convertible Quantity of OHM that will be issued for the day's deposits + struct Day { + uint48 initTimestamp; + uint256 deposits; + uint256 convertible; + } + + /// @notice Information about a tick + /// + /// @param price Price of the tick, in terms of the bid token + /// @param capacity Capacity of the tick, in terms of OHM + /// @param tickSize Size of the tick, in terms of OHM + /// @param lastUpdate Timestamp of last update to the tick + struct Tick { + uint256 price; + uint256 capacity; + uint256 tickSize; + uint48 lastUpdate; + } + + // ========== AUCTION ========== // + + /// @notice Deposit reserve tokens to bid for convertible deposit tokens + /// + /// @param deposit_ Amount of reserve tokens to deposit + /// @return ohmOut Amount of OHM tokens that the deposit can be converted to + /// @return positionId The ID of the position created by the CDPOS module to represent the convertible deposit terms + function bid(uint256 deposit_) external returns (uint256 ohmOut, uint256 positionId); + + /// @notice Get the amount of OHM tokens that could be converted for a bid + /// + /// @param bidAmount_ Amount of reserve tokens + /// @return ohmOut Amount of OHM tokens that the bid amount could be converted to + /// @return depositSpender The address of the contract that would spend the reserve tokens + function previewBid( + uint256 bidAmount_ + ) external view returns (uint256 ohmOut, address depositSpender); + + // ========== STATE VARIABLES ========== // + + /// @notice Get the previous tick of the auction + /// + /// @return tick Tick info + function getPreviousTick() external view returns (Tick memory tick); + + /// @notice Calculate the current tick of the auction + /// @dev This function should calculate the current tick based on the previous tick and the time passed since the last update + /// + /// @return tick Tick info + function getCurrentTick() external view returns (Tick memory tick); + + /// @notice Get the current auction parameters + /// + /// @return auctionParameters Auction parameters + function getAuctionParameters() + external + view + returns (AuctionParameters memory auctionParameters); + + /// @notice Get the auction state for the current day + /// + /// @return day Day info + function getDayState() external view returns (Day memory day); + + /// @notice The multiplier applied to the conversion price at every tick, in terms of `ONE_HUNDRED_PERCENT` + /// @dev This is stored as a percentage, where 100e2 = 100% (no increase) + /// + /// @return tickStep The tick step, in terms of `ONE_HUNDRED_PERCENT` + function getTickStep() external view returns (uint24 tickStep); + + /// @notice Get the number of seconds between creation and expiry of convertible deposits + /// + /// @return timeToExpiry The time to expiry + function getTimeToExpiry() external view returns (uint48 timeToExpiry); + + /// @notice The token that is being bid + /// + /// @return token The token that is being bid + function bidToken() external view returns (address token); + + /// @notice Get the number of days that auction results are tracked for + /// + /// @return daysTracked The number of days that auction results are tracked for + function getAuctionTrackingPeriod() external view returns (uint8 daysTracked); + + /// @notice Get the auction results for the tracking period + /// + /// @return results The auction results, where a positive number indicates an over-subscription for the day. + function getAuctionResults() external view returns (int256[] memory results); + + /// @notice Get the index of the next auction result + /// + /// @return index The index where the next auction result will be stored + function getAuctionResultsNextIndex() external view returns (uint8 index); + + /// @notice Check if enough time has passed since the last day to allow for a new day to start + /// + /// @return isComplete True if the day is complete, false otherwise + function isDayComplete() external view returns (bool isComplete); + + // ========== ADMIN ========== // + + /// @notice Update the auction parameters + /// @dev This function is expected to be called periodically. + /// Only callable by the auction admin + /// + /// @param target_ new target sale per day + /// @param tickSize_ new size per tick + /// @param minPrice_ new minimum tick price + function setAuctionParameters(uint256 target_, uint256 tickSize_, uint256 minPrice_) external; + + /// @notice Set the time to expiry + /// @dev See `getTimeToExpiry()` for more information + /// Only callable by the admin + /// + /// @param timeToExpiry_ new time to expiry + function setTimeToExpiry(uint48 timeToExpiry_) external; + + /// @notice Sets the multiplier applied to the conversion price at every tick, in terms of `ONE_HUNDRED_PERCENT` + /// @dev See `getTickStep()` for more information + /// Only callable by the admin + /// + /// @param tickStep_ new tick step, in terms of `ONE_HUNDRED_PERCENT` + function setTickStep(uint24 tickStep_) external; + + /// @notice Set the number of days that auction results are tracked for + /// @dev Only callable by the admin + /// + /// @param days_ The number of days that auction results are tracked for + function setAuctionTrackingPeriod(uint8 days_) external; + + // ========== ACTIVATION/DEACTIVATION ========== // + + /// @notice Enables governance to initialize and activate the contract. This ensures that the contract is in a valid state when activated. + /// @dev Only callable by the admin role + /// + /// @param target_ The target for OHM sold per day + /// @param tickSize_ The size of each tick + /// @param minPrice_ The minimum price that OHM can be sold for, in terms of the bid token + /// @param tickStep_ The tick step, in terms of `ONE_HUNDRED_PERCENT` + /// @param timeToExpiry_ The number of seconds between creation and expiry of convertible deposits + /// @param auctionTrackingPeriod_ The number of days that auction results are tracked for + function initialize( + uint256 target_, + uint256 tickSize_, + uint256 minPrice_, + uint24 tickStep_, + uint48 timeToExpiry_, + uint8 auctionTrackingPeriod_ + ) external; + + /// @notice Activate the contract functionality + /// @dev Only callable by the emergency role + function activate() external; + + /// @notice Deactivate the contract functionality + /// @dev Only callable by the emergency role + function deactivate() external; +} diff --git a/src/policies/interfaces/IConvertibleDepositFacility.sol b/src/policies/interfaces/IConvertibleDepositFacility.sol new file mode 100644 index 00000000..058645b1 --- /dev/null +++ b/src/policies/interfaces/IConvertibleDepositFacility.sol @@ -0,0 +1,144 @@ +// SPDX-License-Identifier: AGPL-3.0 +pragma solidity ^0.8.0; + +/// @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 depositAmount, uint256 convertedAmount); + event ReclaimedDeposit(address indexed user, uint256 reclaimedAmount); + + event Activated(); + event Deactivated(); + + // ========== 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_); + + error CDF_InvalidToken(uint256 positionId_, address token_); + + error CDF_NotActive(); + + // ========== 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 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 + 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 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 + /// - Minting OHM to `account_` + /// - Transferring the sReserve token to the treasury + /// - Emitting an event + /// + /// @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 + function convert( + uint256[] memory positionIds_, + uint256[] memory amounts_ + ) 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 `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); + + /// @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 + /// - Transferring the reserve token to `account_` + /// - Emitting an event + /// + /// @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 + function reclaim( + 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 `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); + + // ========== 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); +} diff --git a/src/policies/interfaces/IEmissionManager.sol b/src/policies/interfaces/IEmissionManager.sol index a2656dcf..74f851c7 100644 --- a/src/policies/interfaces/IEmissionManager.sol +++ b/src/policies/interfaces/IEmissionManager.sol @@ -37,6 +37,12 @@ interface IEmissionManager { /// @notice Emitted when the bond contracts are set event BondContractsSet(address auctioneer, address teller); + /// @notice Emitted when the tick size scalar is changed + event TickSizeScalarChanged(uint256 newTickSizeScalar); + + /// @notice Emitted when the minimum price scalar is changed + event MinPriceScalarChanged(uint256 newMinPriceScalar); + /// @notice Emitted when the contract is activated event Activated(); diff --git a/src/scripts/deploy/DeployV2.sol b/src/scripts/deploy/DeployV2.sol index 3383922d..9076aad3 100644 --- a/src/scripts/deploy/DeployV2.sol +++ b/src/scripts/deploy/DeployV2.sol @@ -65,6 +65,8 @@ import {OlympusContractRegistry} from "modules/RGSTY/OlympusContractRegistry.sol import {ContractRegistryAdmin} from "policies/ContractRegistryAdmin.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"; @@ -116,6 +118,8 @@ contract OlympusDeploy is Script { YieldRepurchaseFacility public yieldRepo; ReserveMigrator public reserveMigrator; EmissionManager public emissionManager; + CDAuctioneer public cdAuctioneer; + CDFacility public cdFacility; /// Other Olympus contracts OlympusAuthority public burnerReplacementAuthority; @@ -231,6 +235,10 @@ contract OlympusDeploy is Script { selectorMap["ContractRegistryAdmin"] = this._deployContractRegistryAdmin.selector; selectorMap["ReserveMigrator"] = this._deployReserveMigrator.selector; selectorMap["EmissionManager"] = this._deployEmissionManager.selector; + selectorMap["ConvertibleDepositAuctioneer"] = this + ._deployConvertibleDepositAuctioneer + .selector; + selectorMap["ConvertibleDepositFacility"] = this._deployConvertibleDepositFacility.selector; // Governance selectorMap["Timelock"] = this._deployTimelock.selector; @@ -327,6 +335,8 @@ contract OlympusDeploy is Script { loanConsolidator = LoanConsolidator(envAddress("olympus.policies.LoanConsolidator")); reserveMigrator = ReserveMigrator(envAddress("olympus.policies.ReserveMigrator")); emissionManager = EmissionManager(envAddress("olympus.policies.EmissionManager")); + cdAuctioneer = CDAuctioneer(envAddress("olympus.policies.ConvertibleDepositAuctioneer")); + cdFacility = CDFacility(envAddress("olympus.policies.ConvertibleDepositFacility")); // Governance timelock = Timelock(payable(envAddress("olympus.governance.Timelock"))); @@ -1232,7 +1242,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 @@ -1244,6 +1255,7 @@ contract OlympusDeploy is Script { address(reserve), address(sReserve), address(bondAuctioneer), + address(cdAuctioneer), address(bondFixedTermTeller) ); @@ -1252,6 +1264,37 @@ contract OlympusDeploy is Script { return address(emissionManager); } + function _deployConvertibleDepositAuctioneer(bytes calldata) public returns (address) { + // No additional arguments for ConvertibleDepositAuctioneer + + // Log dependencies + console2.log("ConvertibleDepositAuctioneer parameters:"); + console2.log(" kernel", address(kernel)); + console2.log(" cdFacility", address(cdFacility)); + + // Deploy ConvertibleDepositAuctioneer + vm.broadcast(); + cdAuctioneer = new CDAuctioneer(address(kernel), address(cdFacility)); + console2.log("ConvertibleDepositAuctioneer deployed at:", address(cdAuctioneer)); + + return address(cdAuctioneer); + } + + function _deployConvertibleDepositFacility(bytes calldata) public returns (address) { + // No additional arguments for ConvertibleDepositFacility + + // Log dependencies + console2.log("ConvertibleDepositFacility parameters:"); + console2.log(" kernel", address(kernel)); + + // Deploy ConvertibleDepositFacility + vm.broadcast(); + cdFacility = new CDFacility(address(kernel)); + console2.log("ConvertibleDepositFacility deployed at:", address(cdFacility)); + + return address(cdFacility); + } + // ========== VERIFICATION ========== // /// @dev Verifies that the environment variable addresses were set correctly following deployment 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"); + } +} diff --git a/src/test/mocks/MockConvertibleDepositAuctioneer.sol b/src/test/mocks/MockConvertibleDepositAuctioneer.sol new file mode 100644 index 00000000..f0c1b978 --- /dev/null +++ b/src/test/mocks/MockConvertibleDepositAuctioneer.sol @@ -0,0 +1,111 @@ +// 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 {IConvertibleDepositAuctioneer} from "src/policies/interfaces/IConvertibleDepositAuctioneer.sol"; + +contract MockConvertibleDepositAuctioneer is IConvertibleDepositAuctioneer, Policy, RolesConsumer { + uint48 internal _initTimestamp; + int256[] internal _auctionResults; + + uint256 public target; + uint256 public tickSize; + uint256 public minPrice; + + constructor(Kernel kernel_) Policy(kernel_) {} + + function configureDependencies() external override returns (Keycode[] memory dependencies) { + dependencies = new Keycode[](1); + dependencies[0] = toKeycode("ROLES"); + + ROLES = ROLESv1(getModuleAddress(dependencies[0])); + + return dependencies; + } + + function activate() external override {} + + function deactivate() external override {} + + function initialize( + uint256 target_, + uint256 tickSize_, + uint256 minPrice_, + uint24 tickStep_, + uint48 timeToExpiry_, + uint8 auctionTrackingPeriod_ + ) external override {} + + function requestPermissions() + external + view + override + returns (Permissions[] memory permissions) + {} + + function bid( + uint256 deposit + ) external override returns (uint256 convertable, uint256 positionId) { + return (deposit, 0); + } + + function getPreviousTick() external view override returns (Tick memory tick) {} + + function getCurrentTick() external view override returns (Tick memory tick) {} + + function getAuctionParameters() + external + view + override + returns (AuctionParameters memory auctionParameters) + {} + + function getDayState() external view override returns (Day memory day) {} + + function isDayComplete() public view override returns (bool isComplete) { + return block.timestamp / 86400 > _initTimestamp / 86400; + } + + function bidToken() external view override returns (address token) {} + + function previewBid( + uint256 deposit + ) external view override returns (uint256 convertable, address depositSpender) {} + + function setAuctionParameters( + uint256 newTarget, + uint256 newSize, + uint256 newMinPrice + ) external override { + if (isDayComplete()) { + _initTimestamp = uint48(block.timestamp); + } + + target = newTarget; + tickSize = newSize; + minPrice = newMinPrice; + } + + function setAuctionResults(int256[] memory results) external { + _auctionResults = results; + } + + function setTimeToExpiry(uint48 newTime) external override {} + + function setTickStep(uint24 newStep) external override {} + + function getTickStep() external view override returns (uint24) {} + + function getTimeToExpiry() external view override returns (uint48) {} + + function getAuctionTrackingPeriod() external view override returns (uint8) {} + + function getAuctionResults() external view override returns (int256[] memory) { + return _auctionResults; + } + + function getAuctionResultsNextIndex() external view override returns (uint8) {} + + function setAuctionTrackingPeriod(uint8 newPeriod) external override {} +} diff --git a/src/test/modules/CDEPO/CDEPOTest.sol b/src/test/modules/CDEPO/CDEPOTest.sol new file mode 100644 index 00000000..b1c4bcc3 --- /dev/null +++ b/src/test/modules/CDEPO/CDEPOTest.sol @@ -0,0 +1,186 @@ +// 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 {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; + 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; + + 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), INITIAL_VAULT_BALANCE); + + 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); + + // Set reclaim rate + vm.prank(godmode); + CDEPO.setReclaimRate(reclaimRate); + } + + // ========== ASSERTIONS ========== // + + function _assertReserveTokenBalance( + uint256 recipientAmount_, + uint256 recipientTwoAmount_ + ) internal { + 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_, + "reserve token balance: total supply" + ); + } + + 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_, + "CDEPO balance: total supply" + ); + } + + function _assertVaultBalance( + uint256 recipientAmount_, + uint256 recipientTwoAmount_, + uint256 forfeitedAmount_ + ) internal { + assertEq( + vault.totalAssets(), + 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 ========== // + + function _mintReserveToken(address to_, uint256 amount_) internal { + reserveToken.mint(to_, amount_); + } + + modifier givenAddressHasReserveToken(address to_, uint256 amount_) { + _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 _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 _mintFor(address owner_, address to_, uint256 amount_) internal { + vm.prank(owner_); + CDEPO.mintFor(to_, amount_); + } + + modifier givenRecipientHasCDEPO(uint256 amount_) { + _mint(amount_); + _; + } + + modifier givenAddressHasCDEPO(address to_, uint256 amount_) { + _mintFor(to_, to_, amount_); + _; + } + + modifier givenReclaimRateIsSet(uint16 reclaimRate_) { + vm.prank(godmode); + CDEPO.setReclaimRate(reclaimRate_); + + reclaimRate = reclaimRate_; + _; + } +} diff --git a/src/test/modules/CDEPO/constructor.t.sol b/src/test/modules/CDEPO/constructor.t.sol new file mode 100644 index 00000000..e54797b9 --- /dev/null +++ b/src/test/modules/CDEPO/constructor.t.sol @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: Unlicensed +pragma solidity 0.8.15; + +import {CDEPOTest} from "./CDEPOTest.sol"; + +import {OlympusConvertibleDepository} from "src/modules/CDEPO/OlympusConvertibleDepository.sol"; + +contract ConstructorCDEPOTest is CDEPOTest { + // when the vault address is zero + // [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_vault_zeroAddress_reverts() public { + // Expect revert + vm.expectRevert(); + + // 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 new file mode 100644 index 00000000..0e686d5b --- /dev/null +++ b/src/test/modules/CDEPO/mint.t.sol @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: Unlicensed +pragma solidity 0.8.15; + +import {CDEPOTest} from "./CDEPOTest.sol"; + +import {CDEPOv1} from "src/modules/CDEPO/CDEPO.v1.sol"; + +contract MintCDEPOTest is CDEPOTest { + // 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 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("TRANSFER_FROM_FAILED"); + + // Call function + _mint(10e18); + } + + function test_insufficientBalance_reverts() + public + givenAddressHasReserveToken(recipient, 5e18) + givenReserveTokenSpendingIsApproved(address(recipient), address(CDEPO), 10e18) + { + // Expect revert + vm.expectRevert("TRANSFER_FROM_FAILED"); + + // 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, 0); + } +} diff --git a/src/test/modules/CDEPO/mintFor.t.sol b/src/test/modules/CDEPO/mintFor.t.sol new file mode 100644 index 00000000..3caf9b81 --- /dev/null +++ b/src/test/modules/CDEPO/mintFor.t.sol @@ -0,0 +1,80 @@ +// SPDX-License-Identifier: Unlicensed +pragma solidity 0.8.15; + +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/previewMint.t.sol b/src/test/modules/CDEPO/previewMint.t.sol new file mode 100644 index 00000000..ebf995e6 --- /dev/null +++ b/src/test/modules/CDEPO/previewMint.t.sol @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: Unlicensed +pragma solidity 0.8.15; + +import {CDEPOTest} from "./CDEPOTest.sol"; + +import {CDEPOv1} from "src/modules/CDEPO/CDEPO.v1.sol"; + +contract PreviewMintCDEPOTest is CDEPOTest { + // when the amount is zero + // [X] it reverts + // when the amount is greater than zero + // [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/previewReclaim.t.sol b/src/test/modules/CDEPO/previewReclaim.t.sol new file mode 100644 index 00000000..ba1970a4 --- /dev/null +++ b/src/test/modules/CDEPO/previewReclaim.t.sol @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: Unlicensed +pragma solidity 0.8.15; + +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 + // [X] it reverts + // when the amount is greater than zero + // [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/previewSweepYield.t.sol b/src/test/modules/CDEPO/previewSweepYield.t.sol new file mode 100644 index 00000000..bf4e7f0d --- /dev/null +++ b/src/test/modules/CDEPO/previewSweepYield.t.sol @@ -0,0 +1,92 @@ +// SPDX-License-Identifier: Unlicensed +pragma solidity 0.8.15; + +import {CDEPOTest} from "./CDEPOTest.sol"; + +contract PreviewSweepYieldCDEPOTest is CDEPOTest { + // when there are no deposits + // [X] it returns zero + // when there are deposits + // 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/reclaim.t.sol b/src/test/modules/CDEPO/reclaim.t.sol new file mode 100644 index 00000000..fecaa032 --- /dev/null +++ b/src/test/modules/CDEPO/reclaim.t.sol @@ -0,0 +1,114 @@ +// SPDX-License-Identifier: Unlicensed +pragma solidity 0.8.15; + +import {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"; + +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 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_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) + givenReserveTokenSpendingIsApproved(recipient, address(CDEPO), 5e18) + givenRecipientHasCDEPO(5e18) + { + // 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) + { + 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); + } + + 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/reclaimFor.t.sol b/src/test/modules/CDEPO/reclaimFor.t.sol new file mode 100644 index 00000000..ffdaf9cb --- /dev/null +++ b/src/test/modules/CDEPO/reclaimFor.t.sol @@ -0,0 +1,156 @@ +// SPDX-License-Identifier: Unlicensed +pragma solidity 0.8.15; + +import {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"; + +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/redeem.t.sol b/src/test/modules/CDEPO/redeem.t.sol new file mode 100644 index 00000000..ed6d92c2 --- /dev/null +++ b/src/test/modules/CDEPO/redeem.t.sol @@ -0,0 +1,111 @@ +// SPDX-License-Identifier: Unlicensed +pragma solidity 0.8.15; + +import {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"; + +contract RedeemCDEPOTest is CDEPOTest { + // when the amount is zero + // [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 + // [X] it reverts + // when the caller is permissioned + // [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); + } +} diff --git a/src/test/modules/CDEPO/redeemFor.t.sol b/src/test/modules/CDEPO/redeemFor.t.sol new file mode 100644 index 00000000..6c12bad1 --- /dev/null +++ b/src/test/modules/CDEPO/redeemFor.t.sol @@ -0,0 +1,173 @@ +// SPDX-License-Identifier: Unlicensed +pragma solidity 0.8.15; + +import {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"; + +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); + } +} diff --git a/src/test/modules/CDEPO/setReclaimRate.t.sol b/src/test/modules/CDEPO/setReclaimRate.t.sol new file mode 100644 index 00000000..ad722e79 --- /dev/null +++ b/src/test/modules/CDEPO/setReclaimRate.t.sol @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: Unlicensed +pragma solidity 0.8.15; + +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"); + } +} diff --git a/src/test/modules/CDEPO/sweepYield.t.sol b/src/test/modules/CDEPO/sweepYield.t.sol new file mode 100644 index 00000000..ff71ca22 --- /dev/null +++ b/src/test/modules/CDEPO/sweepYield.t.sol @@ -0,0 +1,226 @@ +// SPDX-License-Identifier: Unlicensed +pragma solidity 0.8.15; + +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 + // [X] it reverts + // when the recipient_ address is the zero address + // [X] it reverts + // when there are no deposits + // [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 + // [X] it returns zero + // when deposit tokens have been reclaimed + // [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))" + ); + } +} diff --git a/src/test/modules/CDPOS/CDPOSTest.sol b/src/test/modules/CDPOS/CDPOSTest.sol new file mode 100644 index 00000000..eb99d2fe --- /dev/null +++ b/src/test/modules/CDPOS/CDPOSTest.sol @@ -0,0 +1,216 @@ +// 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 {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"; +import {CDPOSv1} from "src/modules/CDPOS/CDPOS.v1.sol"; + +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; + uint48 public constant EXPIRY = uint48(INITIAL_BLOCK + EXPIRY_DELAY); + + Kernel public kernel; + OlympusConvertibleDepositPositions public CDPOS; + ERC721ReceiverMock public mockERC721Receiver; + address public godmode; + address public convertibleDepositToken; + uint8 public convertibleDepositTokenDecimals = 18; + + uint256[] public positions; + + function setUp() public { + vm.warp(INITIAL_BLOCK); + + 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(); + mockERC20.initialize("Convertible Deposit Token", "CDT", convertibleDepositTokenDecimals); + convertibleDepositToken = address(mockERC20); + + // 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; + } + + // ========== 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"); + } + + 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_) { + // 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_); + _; + } + + 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_); + } + + 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 new file mode 100644 index 00000000..bf7c99ad --- /dev/null +++ b/src/test/modules/CDPOS/create.t.sol @@ -0,0 +1,328 @@ +// SPDX-License-Identifier: Unlicensed +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 + // [X] it reverts + // when the owner is the zero address + // [X] it reverts + // when the convertible deposit token is the zero address + // [X] it reverts + // when the remaining deposit is 0 + // [X] it reverts + // when the conversion price is 0 + // [X] it reverts + // when the expiry is in the past or now + // [X] it reverts + // when multiple positions are created + // [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 + // [X] it sets the expiry + // 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 + // [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 + _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 + _assertPosition(0, address(this), REMAINING_DEPOSIT, CONVERSION_PRICE, EXPIRY, false); + + // Assert that the owner's list of positions is updated + _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); + 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 + _assertERC721PositionReceived(0, 1, true); + + // Assert that the ERC721 balances were updated + _assertERC721Balance(address(this), 1); + _assertERC721Owner(0, address(this), true); + + // Assert that the position is correct + _assertPosition(0, address(this), REMAINING_DEPOSIT, CONVERSION_PRICE, EXPIRY, true); + + // Assert that the owner's list of positions is updated + _assertUserPosition(address(this), 0, 1); + } + + 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 + _assertERC721Owner(i, address(this), false); + } + + // Assert that the ERC721 balance of the owner is not updated + _assertERC721Balance(address(this), 0); + + // 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(this); + address owner2 = address(mockERC721Receiver); + + // 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 + _assertERC721Balance(owner1, 0); + _assertERC721Balance(owner2, 0); + + // 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 + _assertPosition(0, address(this), REMAINING_DEPOSIT, CONVERSION_PRICE, expiry, false); + } +} diff --git a/src/test/modules/CDPOS/previewConvert.t.sol b/src/test/modules/CDPOS/previewConvert.t.sol new file mode 100644 index 00000000..76802351 --- /dev/null +++ b/src/test/modules/CDPOS/previewConvert.t.sol @@ -0,0 +1,188 @@ +// SPDX-License-Identifier: Unlicensed +pragma solidity 0.8.15; + +import {CDPOSTest} from "./CDPOSTest.sol"; + +import {CDPOSv1} from "src/modules/CDPOS/CDPOS.v1.sol"; + +contract PreviewConvertCDPOSTest is CDPOSTest { + // when the position does not exist + // [X] it reverts + // when the position is expired + // [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 new file mode 100644 index 00000000..a9048052 --- /dev/null +++ b/src/test/modules/CDPOS/setDisplayDecimals.t.sol @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: Unlicensed +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 + // [X] it reverts + // [X] 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/split.t.sol b/src/test/modules/CDPOS/split.t.sol new file mode 100644 index 00000000..81470d52 --- /dev/null +++ b/src/test/modules/CDPOS/split.t.sol @@ -0,0 +1,263 @@ +// SPDX-License-Identifier: Unlicensed +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 + // [X] it reverts + // when the caller is not the owner of the position + // [X] it reverts + // when the caller is a permissioned address + // [X] it reverts + // when the amount is 0 + // [X] it reverts + // when the amount is greater than the remaining deposit + // [X] it reverts + // when the to_ address is the zero address + // [X] it reverts + // when wrap is true + // [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/tokenURI.t.sol b/src/test/modules/CDPOS/tokenURI.t.sol new file mode 100644 index 00000000..05c4713a --- /dev/null +++ b/src/test/modules/CDPOS/tokenURI.t.sol @@ -0,0 +1,297 @@ +// SPDX-License-Identifier: Unlicensed +pragma solidity 0.8.15; + +import {CDPOSTest} from "./CDPOSTest.sol"; +import {CDPOSv1} from "src/modules/CDPOS/CDPOS.v1.sol"; +import {Base64} from "base64-1.1.0/base64.sol"; +import {Strings} from "@openzeppelin/contracts/utils/Strings.sol"; + +function substring( + string memory str, + uint256 startIndex, + uint256 endIndex +) pure returns (string memory) { + bytes memory strBytes = bytes(str); + bytes memory result = new bytes(endIndex - startIndex); + for (uint256 i = startIndex; i < endIndex; i++) { + result[i - startIndex] = strBytes[i]; + } + return string(result); +} + +function substringFrom(string memory str, uint256 startIndex) pure returns (string memory) { + return substring(str, startIndex, bytes(str).length); +} + +// solhint-disable quotes + +contract TokenURICDPOSTest is CDPOSTest { + uint48 public constant SAMPLE_DATE = 1737014593; + uint48 public constant SAMPLE_EXPIRY_DATE = 1737014593 + 1 days; + string public constant EXPIRY_DATE_STRING = "2025-01-17"; + + // when the position does not exist + // [X] it reverts + // when the conversion price has decimal places + // [X] it is displayed to 2 decimal places + // when the remaining deposit has decimal places + // [X] it is displayed to 2 decimal places + // when the remaining deposit is 0 + // [X] it is displayed as 0 + // [X] the value is Base64 encoded + // [X] the name value is the name of the contract + // [X] the symbol value is the symbol of the contract + // [X] the position ID attribute is the position ID + // [X] the convertible deposit token attribute is the convertible deposit token address + // [X] the expiry attribute is the expiry timestamp + // [X] the remaining deposit attribute is the remaining deposit + // [X] the conversion price attribute is the conversion price + // [X] the image value is set + + function test_positionDoesNotExist() public { + vm.expectRevert(abi.encodeWithSelector(CDPOSv1.CDPOS_InvalidPositionId.selector, 1)); + + CDPOS.tokenURI(1); + } + + function test_success() + public + givenPositionCreated( + address(this), + REMAINING_DEPOSIT, + CONVERSION_PRICE, + SAMPLE_EXPIRY_DATE, + false + ) + { + uint256[] memory ownerPositions = CDPOS.getUserPositionIds(address(this)); + uint256 positionId = ownerPositions[0]; + + // Call function + string memory tokenURI = CDPOS.tokenURI(positionId); + + // Check that the string begins with `data:application/json;base64,` + assertEq(substring(tokenURI, 0, 29), "data:application/json;base64,", "prefix"); + + // Strip the `data:application/json;base64,` prefix + string memory base64EncodedTokenURI = substringFrom(tokenURI, 29); + + // Decode the return value from Base64 + string memory decodedTokenURI = string(Base64.decode(base64EncodedTokenURI)); + + // Assert JSON structure + // Name + string memory tokenUriName = vm.parseJsonString(decodedTokenURI, ".name"); + assertEq(tokenUriName, "Olympus Convertible Deposit Position", "name"); + + // Symbol + string memory tokenUriSymbol = vm.parseJsonString(decodedTokenURI, ".symbol"); + assertEq(tokenUriSymbol, "OCDP", "symbol"); + + // Position ID + uint256 tokenUriPositionId = vm.parseJsonUint( + decodedTokenURI, + '.attributes[?(@.trait_type=="Position ID")].value' + ); + assertEq(tokenUriPositionId, positionId, "positionId"); + + // Convertible Deposit Token + string memory tokenUriConvertibleDepositToken = vm.parseJsonString( + decodedTokenURI, + '.attributes[?(@.trait_type=="Convertible Deposit Token")].value' + ); + assertEq( + tokenUriConvertibleDepositToken, + Strings.toHexString(convertibleDepositToken), + "convertibleDepositToken" + ); + + // Expiry + uint256 tokenUriExpiry = vm.parseJsonUint( + decodedTokenURI, + '.attributes[?(@.trait_type=="Expiry")].value' + ); + assertEq(tokenUriExpiry, SAMPLE_EXPIRY_DATE, "expiry"); + + // Remaining Deposit + string memory tokenUriRemainingDeposit = vm.parseJsonString( + decodedTokenURI, + '.attributes[?(@.trait_type=="Remaining Deposit")].value' + ); + assertEq(tokenUriRemainingDeposit, "25", "remainingDeposit"); + + // Conversion Price + string memory tokenUriConversionPrice = vm.parseJsonString( + decodedTokenURI, + '.attributes[?(@.trait_type=="Conversion Price")].value' + ); + assertEq(tokenUriConversionPrice, "2", "conversionPrice"); + + // Image + string memory tokenUriImage = vm.parseJsonString(decodedTokenURI, ".image"); + + // Check that the string begins with `data:image/svg+xml;base64,` + assertEq(substring(tokenUriImage, 0, 26), "data:image/svg+xml;base64,", "image prefix"); + + // Strip the `data:image/svg+xml;base64,` prefix + string memory base64EncodedImage = substringFrom(tokenUriImage, 26); + + // Decode the return value from Base64 + string memory decodedImage = string(Base64.decode(base64EncodedImage)); + + // Check that the image starts with the SVG element + assertEq(substring(decodedImage, 0, 4), "", + "image ends with SVG" + ); + } + + function test_remainingDepositHasDecimals() + public + givenPositionCreated( + address(this), + 25123456e14, + CONVERSION_PRICE, + SAMPLE_EXPIRY_DATE, + false + ) + { + uint256[] memory ownerPositions = CDPOS.getUserPositionIds(address(this)); + uint256 positionId = ownerPositions[0]; + + // Call function + string memory tokenURI = CDPOS.tokenURI(positionId); + + // Check that the string begins with `data:application/json;base64,` + assertEq(substring(tokenURI, 0, 29), "data:application/json;base64,", "prefix"); + + // Strip the `data:application/json;base64,` prefix + string memory base64EncodedTokenURI = substringFrom(tokenURI, 29); + + // Decode the return value from Base64 + string memory decodedTokenURI = string(Base64.decode(base64EncodedTokenURI)); + + // Assert JSON structure + // Remaining Deposit + string memory tokenUriRemainingDeposit = vm.parseJsonString( + decodedTokenURI, + '.attributes[?(@.trait_type=="Remaining Deposit")].value' + ); + assertEq(tokenUriRemainingDeposit, "2512.34", "remainingDeposit"); + } + + function test_remainingDepositIsZero() + public + givenPositionCreated( + address(this), + REMAINING_DEPOSIT, + CONVERSION_PRICE, + SAMPLE_EXPIRY_DATE, + false + ) + { + uint256[] memory ownerPositions = CDPOS.getUserPositionIds(address(this)); + uint256 positionId = ownerPositions[0]; + + // Update the position remaining deposit to 0 + _updatePosition(positionId, 0); + + // Call function + string memory tokenURI = CDPOS.tokenURI(positionId); + + // Check that the string begins with `data:application/json;base64,` + assertEq(substring(tokenURI, 0, 29), "data:application/json;base64,", "prefix"); + + // Strip the `data:application/json;base64,` prefix + string memory base64EncodedTokenURI = substringFrom(tokenURI, 29); + + // Decode the return value from Base64 + string memory decodedTokenURI = string(Base64.decode(base64EncodedTokenURI)); + + // Assert JSON structure + // Remaining Deposit + string memory tokenUriRemainingDeposit = vm.parseJsonString( + decodedTokenURI, + '.attributes[?(@.trait_type=="Remaining Deposit")].value' + ); + assertEq(tokenUriRemainingDeposit, "0", "remainingDeposit"); + } + + function test_conversionPriceHasDecimals() + public + givenPositionCreated( + address(this), + REMAINING_DEPOSIT, + 20123456e14, + SAMPLE_EXPIRY_DATE, + false + ) + { + uint256[] memory ownerPositions = CDPOS.getUserPositionIds(address(this)); + uint256 positionId = ownerPositions[0]; + + // Call function + string memory tokenURI = CDPOS.tokenURI(positionId); + + // Check that the string begins with `data:application/json;base64,` + assertEq(substring(tokenURI, 0, 29), "data:application/json;base64,", "prefix"); + + // Strip the `data:application/json;base64,` prefix + string memory base64EncodedTokenURI = substringFrom(tokenURI, 29); + + // Decode the return value from Base64 + string memory decodedTokenURI = string(Base64.decode(base64EncodedTokenURI)); + + // Assert JSON structure + // Conversion Price + string memory tokenUriConversionPrice = vm.parseJsonString( + decodedTokenURI, + '.attributes[?(@.trait_type=="Conversion Price")].value' + ); + assertEq(tokenUriConversionPrice, "2012.34", "conversionPrice"); + } + + function test_multiplePositions() + public + givenPositionCreated( + address(this), + REMAINING_DEPOSIT, + CONVERSION_PRICE, + SAMPLE_EXPIRY_DATE, + false + ) + givenPositionCreated( + address(this), + REMAINING_DEPOSIT, + CONVERSION_PRICE, + SAMPLE_EXPIRY_DATE, + false + ) + { + uint256[] memory ownerPositions = CDPOS.getUserPositionIds(address(this)); + uint256 positionId = ownerPositions[1]; + + // Call function + string memory tokenURI = CDPOS.tokenURI(positionId); + + // Check that the string begins with `data:application/json;base64,` + assertEq(substring(tokenURI, 0, 29), "data:application/json;base64,", "prefix"); + + // Strip the `data:application/json;base64,` prefix + string memory base64EncodedTokenURI = substringFrom(tokenURI, 29); + + // Decode the return value from Base64 + string memory decodedTokenURI = string(Base64.decode(base64EncodedTokenURI)); + + // Assert JSON structure + uint256 tokenUriPositionId = vm.parseJsonUint( + decodedTokenURI, + '.attributes[?(@.trait_type=="Position ID")].value' + ); + assertEq(tokenUriPositionId, 1, "positionId"); + } +} diff --git a/src/test/modules/CDPOS/transferFrom.t.sol b/src/test/modules/CDPOS/transferFrom.t.sol new file mode 100644 index 00000000..6f9d919a --- /dev/null +++ b/src/test/modules/CDPOS/transferFrom.t.sol @@ -0,0 +1,88 @@ +// SPDX-License-Identifier: Unlicensed +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 + // [X] it reverts + // when the ERC721 has not been minted + // [X] it reverts + // when the caller is not the owner of the position + // [X] it reverts + // when the caller is a permissioned address + // [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(abi.encodeWithSelector(CDPOSv1.CDPOS_NotWrapped.selector, 0)); + + // 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); + } +} diff --git a/src/test/modules/CDPOS/unwrap.t.sol b/src/test/modules/CDPOS/unwrap.t.sol new file mode 100644 index 00000000..03c02552 --- /dev/null +++ b/src/test/modules/CDPOS/unwrap.t.sol @@ -0,0 +1,136 @@ +// SPDX-License-Identifier: Unlicensed +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 + // [X] it reverts + // when the caller is not the owner of the position + // [X] it reverts + // when the caller is a permissioned address + // [X] it reverts + // when the position is not wrapped + // [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/update.t.sol b/src/test/modules/CDPOS/update.t.sol new file mode 100644 index 00000000..616bf30d --- /dev/null +++ b/src/test/modules/CDPOS/update.t.sol @@ -0,0 +1,87 @@ +// SPDX-License-Identifier: Unlicensed +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 + // [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 + // [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); + } +} diff --git a/src/test/modules/CDPOS/wrap.t.sol b/src/test/modules/CDPOS/wrap.t.sol new file mode 100644 index 00000000..2a8a48f2 --- /dev/null +++ b/src/test/modules/CDPOS/wrap.t.sol @@ -0,0 +1,123 @@ +// SPDX-License-Identifier: Unlicensed +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 + // [X] it reverts + // when the caller is not the owner of the position + // [X] it reverts + // when the caller is a permissioned address + // [X] it reverts + // when the position is already wrapped + // [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), 0, 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), 0, 2); + _assertUserPosition(address(this), 1, 2); + } +} diff --git a/src/test/policies/ConvertibleDepositAuctioneer/ConvertibleDepositAuctioneerTest.sol b/src/test/policies/ConvertibleDepositAuctioneer/ConvertibleDepositAuctioneerTest.sol new file mode 100644 index 00000000..378f69cc --- /dev/null +++ b/src/test/policies/ConvertibleDepositAuctioneer/ConvertibleDepositAuctioneerTest.sol @@ -0,0 +1,302 @@ +// 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 {CDAuctioneer} from "src/policies/CDAuctioneer.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"; +import {ROLESv1} from "src/modules/ROLES/ROLES.v1.sol"; +import {IConvertibleDepositAuctioneer} from "src/policies/interfaces/IConvertibleDepositAuctioneer.sol"; + +// solhint-disable max-states-count +contract ConvertibleDepositAuctioneerTest is Test { + Kernel public kernel; + CDFacility public facility; + CDAuctioneer public auctioneer; + 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 heart = address(0x3); + address public admin = address(0x4); + address public emergency = address(0x5); + + uint48 public constant INITIAL_BLOCK = 1_000_000; + + // @dev This should result in multiple ticks being filled + uint256 public constant BID_LARGE_AMOUNT = 3000e18; + + uint256 public constant TICK_SIZE = 10e9; + uint24 public constant TICK_STEP = 110e2; // 110% + uint256 public constant MIN_PRICE = 15e18; + uint256 public constant TARGET = 20e9; + uint48 public constant TIME_TO_EXPIRY = 1 days; + uint8 public constant AUCTION_TRACKING_PERIOD = 7; + + // Events + event Activated(); + event TickStepUpdated(uint24 newTickStep); + event TimeToExpiryUpdated(uint48 newTimeToExpiry); + event AuctionParametersUpdated(uint256 newTarget, uint256 newTickSize, uint256 newMinPrice); + event AuctionTrackingPeriodUpdated(uint8 newAuctionTrackingPeriod); + event AuctionResult(uint256 ohmConvertible, uint256 target, uint8 periodIndex); + + 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)); + auctioneer = new CDAuctioneer(address(kernel), address(facility)); + 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(auctioneer)); + kernel.executeAction(Actions.ActivatePolicy, address(rolesAdmin)); + + // Grant roles + rolesAdmin.grantRole(bytes32("heart"), heart); + rolesAdmin.grantRole(bytes32("cd_admin"), admin); + rolesAdmin.grantRole(bytes32("emergency_shutdown"), emergency); + rolesAdmin.grantRole(bytes32("cd_auctioneer"), address(auctioneer)); + + // Activate policy dependencies + vm.prank(emergency); + facility.activate(); + } + + // ========== HELPERS ========== // + + function _expectRoleRevert(bytes32 role_) internal { + vm.expectRevert(abi.encodeWithSelector(ROLESv1.ROLES_RequireRole.selector, role_)); + } + + function _assertAuctionParameters( + uint256 target_, + uint256 tickSize_, + uint256 minPrice_ + ) internal { + IConvertibleDepositAuctioneer.AuctionParameters memory auctionParameters = auctioneer + .getAuctionParameters(); + + assertEq(auctionParameters.target, target_, "target"); + assertEq(auctionParameters.tickSize, tickSize_, "tickSize"); + assertEq(auctionParameters.minPrice, minPrice_, "minPrice"); + } + + function _assertPreviousTick( + uint256 capacity_, + uint256 price_, + uint256 tickSize_, + uint48 lastUpdate_ + ) internal { + IConvertibleDepositAuctioneer.Tick memory tick = auctioneer.getPreviousTick(); + + assertEq(tick.capacity, capacity_, "previous tick capacity"); + assertEq(tick.price, price_, "previous tick price"); + assertEq(tick.tickSize, tickSize_, "previous tick size"); + assertEq(tick.lastUpdate, lastUpdate_, "previous tick lastUpdate"); + } + + function _assertDayState(uint256 deposits_, uint256 convertible_) internal { + IConvertibleDepositAuctioneer.Day memory day = auctioneer.getDayState(); + + assertEq(day.deposits, deposits_, "deposits"); + assertEq(day.convertible, convertible_, "convertible"); + } + + function _assertAuctionResults( + int256 resultOne_, + int256 resultTwo_, + int256 resultThree_, + int256 resultFour_, + int256 resultFive_, + int256 resultSix_, + int256 resultSeven_ + ) internal { + int256[] memory auctionResults = auctioneer.getAuctionResults(); + + assertEq(auctionResults.length, 7, "auction results length"); + assertEq(auctionResults[0], resultOne_, "result one"); + assertEq(auctionResults[1], resultTwo_, "result two"); + assertEq(auctionResults[2], resultThree_, "result three"); + assertEq(auctionResults[3], resultFour_, "result four"); + assertEq(auctionResults[4], resultFive_, "result five"); + assertEq(auctionResults[5], resultSix_, "result six"); + assertEq(auctionResults[6], resultSeven_, "result seven"); + } + + function _assertAuctionResultsEmpty(uint8 length_) internal { + int256[] memory auctionResults = auctioneer.getAuctionResults(); + + assertEq(auctionResults.length, length_, "auction results length"); + for (uint256 i = 0; i < auctionResults.length; i++) { + assertEq(auctionResults[i], 0, string.concat("result ", vm.toString(i))); + } + } + + function _assertAuctionResults(int256[] memory auctionResults_) internal { + int256[] memory auctionResults = auctioneer.getAuctionResults(); + + assertEq(auctionResults.length, auctionResults_.length, "auction results length"); + for (uint256 i = 0; i < auctionResults.length; i++) { + assertEq( + auctionResults[i], + auctionResults_[i], + string.concat("result ", vm.toString(i)) + ); + } + } + + function _assertAuctionResultsNextIndex(uint8 nextIndex_) internal { + assertEq(auctioneer.getAuctionResultsNextIndex(), nextIndex_, "next index"); + } + + // ========== MODIFIERS ========== // + + modifier givenContractActive() { + vm.prank(emergency); + auctioneer.activate(); + _; + } + + modifier givenContractInactive() { + vm.prank(emergency); + auctioneer.deactivate(); + _; + } + + function _mintReserveToken(address to_, uint256 amount_) internal { + reserveToken.mint(to_, amount_); + } + + modifier givenAddressHasReserveToken(address to_, uint256 amount_) { + _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_); + _; + } + + modifier givenConvertibleDepositTokenSpendingIsApproved( + address owner_, + address spender_, + uint256 amount_ + ) { + vm.prank(owner_); + convertibleDepository.approve(spender_, amount_); + _; + } + + modifier givenTimeToExpiry(uint48 timeToExpiry_) { + vm.prank(admin); + auctioneer.setTimeToExpiry(timeToExpiry_); + _; + } + + modifier givenTickStep(uint24 tickStep_) { + vm.prank(admin); + auctioneer.setTickStep(tickStep_); + _; + } + + function _setAuctionParameters(uint256 target_, uint256 tickSize_, uint256 minPrice_) internal { + vm.prank(heart); + auctioneer.setAuctionParameters(target_, tickSize_, minPrice_); + } + + modifier givenInitialized() { + vm.prank(admin); + auctioneer.initialize( + TARGET, + TICK_SIZE, + MIN_PRICE, + TICK_STEP, + TIME_TO_EXPIRY, + AUCTION_TRACKING_PERIOD + ); + _; + } + + modifier givenAuctionParametersStandard() { + _setAuctionParameters(TARGET, TICK_SIZE, MIN_PRICE); + _; + } + + modifier givenAuctionParameters( + uint256 target_, + uint256 tickSize_, + uint256 minPrice_ + ) { + _setAuctionParameters(target_, tickSize_, minPrice_); + _; + } + + function _bid(address owner_, uint256 deposit_) internal { + vm.prank(owner_); + auctioneer.bid(deposit_); + } + + function _mintAndBid(address owner_, uint256 deposit_) internal { + // Mint + _mintReserveToken(owner_, deposit_); + + // Approve spending + _approveReserveTokenSpending(owner_, address(convertibleDepository), deposit_); + + // Bid + _bid(owner_, deposit_); + } + + modifier givenRecipientHasBid(uint256 deposit_) { + _mintAndBid(recipient, deposit_); + _; + } +} diff --git a/src/test/policies/ConvertibleDepositAuctioneer/activate.t.sol b/src/test/policies/ConvertibleDepositAuctioneer/activate.t.sol new file mode 100644 index 00000000..329fa7b3 --- /dev/null +++ b/src/test/policies/ConvertibleDepositAuctioneer/activate.t.sol @@ -0,0 +1,87 @@ +// SPDX-License-Identifier: Unlicensed +pragma solidity 0.8.15; + +import {ConvertibleDepositAuctioneerTest} from "./ConvertibleDepositAuctioneerTest.sol"; +import {IConvertibleDepositAuctioneer} from "src/policies/interfaces/IConvertibleDepositAuctioneer.sol"; + +contract ConvertibleDepositAuctioneerActivateTest is ConvertibleDepositAuctioneerTest { + // when the caller does not have the "emergency_shutdown" role + // [X] it reverts + // given the contract is not initialized + // [X] it reverts + // when the contract is already activated + // [X] it reverts + // when the contract is not activated + // [X] it activates the contract + // [X] it emits an event + // [X] it sets the last update to the current block timestamp + // [X] it resets the day state + // [X] it resets the auction results history and index + + function test_callerDoesNotHaveEmergencyShutdownRole_reverts(address caller_) public { + // Ensure caller is not emergency address + vm.assume(caller_ != emergency); + + // Expect revert + _expectRoleRevert("emergency_shutdown"); + + // Call function + vm.prank(caller_); + auctioneer.activate(); + } + + function test_contractNotInitialized() public { + // Expect revert + vm.expectRevert( + abi.encodeWithSelector( + IConvertibleDepositAuctioneer.CDAuctioneer_NotInitialized.selector + ) + ); + + // Call function + vm.prank(emergency); + auctioneer.activate(); + } + + function test_contractActivated_reverts() public givenInitialized { + // Expect revert + vm.expectRevert( + abi.encodeWithSelector(IConvertibleDepositAuctioneer.CDAuctioneer_InvalidState.selector) + ); + + // Call function + vm.prank(emergency); + auctioneer.activate(); + } + + function test_contractInactive() + public + givenInitialized + givenRecipientHasBid(1e18) + givenContractInactive + { + uint48 lastUpdate = uint48(block.timestamp); + uint48 newBlock = lastUpdate + 1; + + // Warp to change the block timestamp + vm.warp(newBlock); + + // Expect event + vm.expectEmit(true, true, true, true); + emit Activated(); + + // Call function + vm.prank(emergency); + auctioneer.activate(); + + // Assert state + assertEq(auctioneer.locallyActive(), true); + // lastUpdate has changed + assertEq(auctioneer.getPreviousTick().lastUpdate, newBlock); + // Day state is reset + _assertDayState(0, 0); + // Auction results are reset + _assertAuctionResults(0, 0, 0, 0, 0, 0, 0); + _assertAuctionResultsNextIndex(0); + } +} diff --git a/src/test/policies/ConvertibleDepositAuctioneer/bid.t.sol b/src/test/policies/ConvertibleDepositAuctioneer/bid.t.sol new file mode 100644 index 00000000..3775a563 --- /dev/null +++ b/src/test/policies/ConvertibleDepositAuctioneer/bid.t.sol @@ -0,0 +1,887 @@ +// SPDX-License-Identifier: Unlicensed +pragma solidity 0.8.15; + +import {ConvertibleDepositAuctioneerTest} from "./ConvertibleDepositAuctioneerTest.sol"; + +import {FullMath} from "src/libraries/FullMath.sol"; + +import {IConvertibleDepositAuctioneer} from "src/policies/interfaces/IConvertibleDepositAuctioneer.sol"; +import {CDPOSv1} from "src/modules/CDPOS/CDPOS.v1.sol"; + +contract ConvertibleDepositAuctioneerBidTest is ConvertibleDepositAuctioneerTest { + function _assertConvertibleDepositPosition( + uint256 bidAmount_, + uint256 expectedConvertedAmount_, + uint256 expectedReserveTokenBalance_, + uint256 previousConvertibleDepositBalance_, + uint256 previousPositionCount_, + uint256 returnedOhmOut_, + uint256 returnedPositionId_ + ) internal { + // Assert that the converted amount is as expected + assertEq(returnedOhmOut_, expectedConvertedAmount_, "converted amount"); + + // Assert that the CD tokens were transferred to the recipient + assertEq( + convertibleDepository.balanceOf(recipient), + previousConvertibleDepositBalance_ + bidAmount_, + "CD token balance" + ); + + // Assert that the reserve tokens were transferred from the recipient + assertEq( + reserveToken.balanceOf(recipient), + expectedReserveTokenBalance_, + "reserve token balance" + ); + + // Assert that the CD position terms were created + uint256[] memory positionIds = convertibleDepositPositions.getUserPositionIds(recipient); + assertEq(positionIds.length, previousPositionCount_ + 1, "position count"); + assertEq(positionIds[positionIds.length - 1], returnedPositionId_, "position id"); + + uint256 conversionPrice = FullMath.mulDivUp(bidAmount_, 1e9, expectedConvertedAmount_); + + // Assert that the position terms are correct + CDPOSv1.Position memory position = convertibleDepositPositions.getPosition( + returnedPositionId_ + ); + assertEq(position.owner, recipient, "position owner"); + assertEq(position.remainingDeposit, bidAmount_, "position remaining deposit"); + assertEq(position.conversionPrice, conversionPrice, "position conversion price"); + assertEq(position.expiry, uint48(block.timestamp) + TIME_TO_EXPIRY, "position expiry"); + assertEq(position.wrapped, false, "position wrapped"); + } + + // when the contract is deactivated + // [X] it reverts + // when the contract has not been initialized + // [X] it reverts + // when the caller has not approved CDEPO to spend the bid token + // [X] it reverts + // when the "cd_auctioneer" role is not granted to the auctioneer contract + // [X] it reverts + // when the bid amount converted is 0 + // [X] it reverts + // when the bid is the first bid + // [X] it sets the day's deposit balance + // [X] it sets the day's converted balance + // [X] it sets the current tick size to the standard tick size + // [X] it sets the lastUpdate to the current block timestamp + // [X] it deducts the converted amount from the tick capacity + // [X] it sets the current tick size to the standard tick size + // [X] it does not update the tick price + // when the bid is the first bid of the day + // [X] the day state is not reset + // [X] it updates the day's deposit balance + // [X] it updates the day's converted balance + // [X] it sets the current tick size to the standard tick size + // [X] it sets the lastUpdate to the current block timestamp + // when the bid is not the first bid of the day + // [X] it does not reset the day's deposit and converted balances + // [X] it updates the day's deposit balance + // [X] it updates the day's converted balance + // [X] it sets the current tick size to the standard tick size + // [X] it sets the lastUpdate to the current block timestamp + // when the bid amount converted is less than the remaining tick capacity + // when the calculated converted amount is 0 + // [X] it reverts + // [X] it returns the amount of OHM that can be converted + // [X] it issues CD terms with the current tick price and time to expiry + // [X] it updates the day's deposit balance + // [X] it updates the day's converted balance + // [X] it deducts the converted amount from the tick capacity + // [X] it does not update the tick price + // [X] it sets the current tick size to the standard tick size + // [X] it sets the lastUpdate to the current block timestamp + // when the bid amount converted is equal to the remaining tick capacity + // when the tick step is > 100e2 + // [X] it returns the amount of OHM that can be converted using the current tick price + // [X] it issues CD terms with the current tick price and time to expiry + // [X] it updates the day's deposit balance + // [X] it updates the day's converted balance + // [X] it updates the tick capacity to the tick size + // [X] it updates the tick price to be higher than the current tick price + // [X] it sets the current tick size to the standard tick size + // [X] it sets the lastUpdate to the current block timestamp + // when the tick step is = 100e2 + // [X] it returns the amount of OHM that can be converted using the current tick price + // [X] it issues CD terms with the current tick price and time to expiry + // [X] it updates the day's deposit balance + // [X] it updates the day's converted balance + // [X] it updates the tick capacity to the tick size + // [X] the tick price is unchanged + // [X] it sets the current tick size to the standard tick size + // [X] it sets the lastUpdate to the current block timestamp + // when the bid amount converted is greater than the remaining tick capacity + // when the remaining deposit results in a converted amount of 0 + // [X] it returns the amount of the reserve token that can be converted + // when the convertible amount of OHM will exceed the day target + // [X] the next tick size is set to half of the standard tick size + // when the convertible amount of OHM will exceed multiples of the day target + // [X] the next tick size is set to half of the previous tick size + // when the tick step is > 100e2 + // [X] it returns the amount of OHM that can be converted at multiple prices + // [X] it issues CD terms with the average price and time to expiry + // [X] it updates the day's deposit balance + // [X] it updates the day's converted balance + // [X] it updates the tick capacity to the tick size minus the converted amount at the new tick price + // [X] it updates the new tick price to be higher than the current tick price + // [X] it sets the current tick size to the standard tick size + // [X] it sets the lastUpdate to the current block timestamp + // when the tick step is = 100e2 + // [X] it returns the amount of OHM that can be converted at multiple prices + // [X] it issues CD terms with the average price and time to expiry + // [X] it updates the day's deposit balance + // [X] it updates the day's converted balance + // [X] it updates the tick capacity to the tick size minus the converted amount at the new tick price + // [X] the tick price is unchanged + // [X] it sets the current tick size to the standard tick size + // [X] it sets the lastUpdate to the current block timestamp + + function test_givenContractNotInitialized_reverts() public { + // Expect revert + vm.expectRevert(IConvertibleDepositAuctioneer.CDAuctioneer_NotActive.selector); + + // Call function + auctioneer.bid(1e18); + } + + function test_givenContractInactive_reverts() public givenInitialized givenContractInactive { + // Expect revert + vm.expectRevert(IConvertibleDepositAuctioneer.CDAuctioneer_NotActive.selector); + + // Call function + auctioneer.bid(1e18); + } + + function test_givenSpendingNotApproved_reverts() + public + givenInitialized + givenAddressHasReserveToken(recipient, 1e18) + { + // Expect revert + vm.expectRevert("TRANSFER_FROM_FAILED"); + + // Call function + vm.prank(recipient); + auctioneer.bid(1e18); + } + + function test_givenAuctioneerRoleNotGranted_reverts() + public + givenInitialized + givenAddressHasReserveToken(recipient, 1e18) + givenReserveTokenSpendingIsApproved(recipient, address(convertibleDepository), 1e18) + { + // Revoke the auctioneer role + rolesAdmin.revokeRole("cd_auctioneer", address(auctioneer)); + + // Expect revert + _expectRoleRevert("cd_auctioneer"); + + // Call function + vm.prank(recipient); + auctioneer.bid(1e18); + } + + function test_givenBidAmountConvertedIsZero_reverts( + uint256 bidAmount_ + ) + public + givenInitialized + givenAddressHasReserveToken(recipient, 1e18) + givenReserveTokenSpendingIsApproved(recipient, address(convertibleDepository), 1e18) + { + // We want a bid amount that will result in a converted amount of 0 + // Given bid amount * 1e9 / 15e18 = converted amount + // When bid amount = 15e9, the converted amount = 1 + uint256 bidAmount = bound(bidAmount_, 0, 15e9 - 1); + + // Expect revert + vm.expectRevert( + abi.encodeWithSelector( + IConvertibleDepositAuctioneer.CDAuctioneer_InvalidParams.selector, + "converted amount" + ) + ); + + // Call function + vm.prank(recipient); + auctioneer.bid(bidAmount); + } + + function test_givenBidAmountConvertedIsAboveZero( + uint256 bidAmount_ + ) + public + givenInitialized + givenAddressHasReserveToken(recipient, 1e18) + givenReserveTokenSpendingIsApproved(recipient, address(convertibleDepository), 1e18) + { + // We want a bid amount that will result in a converted amount of 0 + // Given bid amount * 1e9 / 15e18 = converted amount + // When bid amount = 15e9, the converted amount = 1 + uint256 bidAmount = bound(bidAmount_, 15e9, 1e18); + + // Calculate the expected converted amount + uint256 expectedConvertedAmount = (bidAmount * 1e9) / 15e18; + + // Check preview + (uint256 previewOhmOut, ) = auctioneer.previewBid(bidAmount); + + // Assert that the preview is as expected + assertEq(previewOhmOut, expectedConvertedAmount, "preview converted amount"); + + // Call function + vm.prank(recipient); + (uint256 ohmOut, uint256 positionId) = auctioneer.bid(bidAmount); + + // Assert returned values + _assertConvertibleDepositPosition( + bidAmount, + expectedConvertedAmount, + 1e18 - bidAmount, + 0, + 0, + ohmOut, + positionId + ); + } + + function test_givenFirstBid() + public + givenInitialized + givenAddressHasReserveToken(recipient, 3e18) + givenReserveTokenSpendingIsApproved(recipient, address(convertibleDepository), 3e18) + { + // Expected converted amount + // 3e18 * 1e9 / 15e18 = 2e8 + uint256 bidAmount = 3e18; + uint256 expectedConvertedAmount = 2e8; + + // Check preview + (uint256 previewOhmOut, ) = auctioneer.previewBid(bidAmount); + + // Assert that the preview is as expected + assertEq(previewOhmOut, expectedConvertedAmount, "preview converted amount"); + + // Call function + vm.prank(recipient); + (uint256 ohmOut, uint256 positionId) = auctioneer.bid(bidAmount); + + // Assert returned values + _assertConvertibleDepositPosition( + bidAmount, + expectedConvertedAmount, + 0, + 0, + 0, + ohmOut, + positionId + ); + + // Assert the day state + assertEq(auctioneer.getDayState().deposits, bidAmount, "day deposits"); + assertEq(auctioneer.getDayState().convertible, expectedConvertedAmount, "day convertible"); + + // Assert the state + _assertAuctionParameters(TARGET, TICK_SIZE, MIN_PRICE); + + // Assert the tick + _assertPreviousTick( + TICK_SIZE - expectedConvertedAmount, + MIN_PRICE, + TICK_SIZE, + uint48(block.timestamp) + ); + } + + function test_givenFirstBidOfDay() + public + givenInitialized + givenRecipientHasBid(120e18) + givenAddressHasReserveToken(recipient, 6e18) + givenReserveTokenSpendingIsApproved(recipient, address(convertibleDepository), 6e18) + { + // Warp to the next day + uint48 nextDay = uint48(block.timestamp) + 1 days; + vm.warp(nextDay); + + // Mimic auction parameters being set + _setAuctionParameters(TARGET, TICK_SIZE, MIN_PRICE); + + // Get the current tick for the new day + IConvertibleDepositAuctioneer.Tick memory beforeTick = auctioneer.getCurrentTick(); + + // Expected converted amount + // 6e18 * 1e9 / 15e18 = 4e8 + uint256 bidAmount = 6e18; + uint256 expectedConvertedAmount = 4e8; + + // Check preview + (uint256 previewOhmOut, ) = auctioneer.previewBid(bidAmount); + + // Assert that the preview is as expected + assertEq(previewOhmOut, expectedConvertedAmount, "preview converted amount"); + + // Call function + vm.prank(recipient); + (uint256 ohmOut, uint256 positionId) = auctioneer.bid(bidAmount); + + // Assert returned values + _assertConvertibleDepositPosition( + bidAmount, + expectedConvertedAmount, + 0, + 120e18, + 1, + ohmOut, + positionId + ); + + // Assert the day state + // Not affected by the previous day's bid + assertEq(auctioneer.getDayState().deposits, bidAmount, "day deposits"); + assertEq(auctioneer.getDayState().convertible, expectedConvertedAmount, "day convertible"); + + // Assert the state + _assertAuctionParameters(TARGET, TICK_SIZE, MIN_PRICE); + + // Assert the tick + _assertPreviousTick( + beforeTick.capacity - expectedConvertedAmount, + beforeTick.price, + TICK_SIZE, + uint48(nextDay) + ); + } + + function test_secondBidUpdatesDayState() + public + givenInitialized + givenRecipientHasBid(3e18) + givenAddressHasReserveToken(recipient, 6e18) + givenReserveTokenSpendingIsApproved(recipient, address(convertibleDepository), 6e18) + { + // Previous converted amount + // 3e18 * 1e9 / 15e18 = 2e8 + uint256 previousBidAmount = 3e18; + uint256 previousConvertedAmount = 2e8; + + // Expected converted amount + // 6e18 * 1e9 / 15e18 = 4e8 + uint256 bidAmount = 6e18; + uint256 expectedConvertedAmount = 4e8; + + // Check preview + (uint256 previewOhmOut, ) = auctioneer.previewBid(bidAmount); + + // Assert that the preview is as expected + assertEq(previewOhmOut, expectedConvertedAmount, "preview converted amount"); + + // Call function + vm.prank(recipient); + (uint256 ohmOut, uint256 positionId) = auctioneer.bid(bidAmount); + + // Assert returned values + _assertConvertibleDepositPosition( + bidAmount, + expectedConvertedAmount, + 0, + 3e18, + 1, + ohmOut, + positionId + ); + + // Assert the day state + // Not affected by the previous day's bid + assertEq(auctioneer.getDayState().deposits, previousBidAmount + bidAmount, "day deposits"); + assertEq( + auctioneer.getDayState().convertible, + previousConvertedAmount + expectedConvertedAmount, + "day convertible" + ); + + // Assert the state + _assertAuctionParameters(TARGET, TICK_SIZE, MIN_PRICE); + + // Assert the tick + _assertPreviousTick( + TICK_SIZE - previousConvertedAmount - expectedConvertedAmount, + MIN_PRICE, + TICK_SIZE, + uint48(block.timestamp) + ); + } + + function test_convertedAmountLessThanTickCapacity( + uint256 bidAmount_ + ) + public + givenInitialized + givenAddressHasReserveToken(recipient, 150e18) + givenReserveTokenSpendingIsApproved(recipient, address(convertibleDepository), 150e18) + { + // We want the converted amount to be less than the tick capacity (10e9) + // Bid amount * 1e9 / 15e18 = 10e9 - 1 + // Bid amount = (10e9 - 1) * 15e18 / 1e9 + // Bid amount = 150e18 - 1 (it will round down) + + uint256 bidAmount = bound(bidAmount_, 1e18, 150e18 - 1); + uint256 expectedConvertedAmount = (bidAmount * 1e9) / 15e18; + + // Check preview + (uint256 previewOhmOut, ) = auctioneer.previewBid(bidAmount); + + // Assert that the preview is as expected + assertEq(previewOhmOut, expectedConvertedAmount, "preview converted amount"); + + // Call function + vm.prank(recipient); + (uint256 ohmOut, uint256 positionId) = auctioneer.bid(bidAmount); + + // Assert returned values + _assertConvertibleDepositPosition( + bidAmount, + expectedConvertedAmount, + 150e18 - bidAmount, + 0, + 0, + ohmOut, + positionId + ); + + // Assert the day state + assertEq(auctioneer.getDayState().deposits, bidAmount, "day deposits"); + assertEq(auctioneer.getDayState().convertible, expectedConvertedAmount, "day convertible"); + + // Assert the state + _assertAuctionParameters(TARGET, TICK_SIZE, MIN_PRICE); + + // Assert the tick + _assertPreviousTick( + TICK_SIZE - expectedConvertedAmount, + MIN_PRICE, + TICK_SIZE, + uint48(block.timestamp) + ); + } + + function test_convertedAmountEqualToTickCapacity( + uint256 bidAmount_ + ) + public + givenInitialized + givenAddressHasReserveToken(recipient, 151e18) + givenReserveTokenSpendingIsApproved(recipient, address(convertibleDepository), 151e18) + { + // We want the converted amount to be equal to the tick capacity (10e9) + // Bid amount * 1e9 / 15e18 = 10e9 + // Bid amount = 10e9 * 15e18 / 1e9 + // Bid amount = 150e18 (it will round down) + + // We expect the range of bid amounts when converted to round down to 10e9 + uint256 bidAmount = bound(bidAmount_, 150e18, 150e18 + 15e9 - 1); + uint256 expectedDepositIn = 150e18; + uint256 expectedConvertedAmount = 10e9; + + // Check preview + (uint256 previewOhmOut, ) = auctioneer.previewBid(bidAmount); + + // Assert that the preview is as expected + assertEq(previewOhmOut, expectedConvertedAmount, "preview converted amount"); + + // Call function + vm.prank(recipient); + (uint256 ohmOut, uint256 positionId) = auctioneer.bid(bidAmount); + + // Assert returned values + _assertConvertibleDepositPosition( + expectedDepositIn, + expectedConvertedAmount, + 151e18 - expectedDepositIn, + 0, + 0, + ohmOut, + positionId + ); + + // Assert the day state + assertEq(auctioneer.getDayState().deposits, expectedDepositIn, "day deposits"); + assertEq(auctioneer.getDayState().convertible, expectedConvertedAmount, "day convertible"); + + // Assert the state + _assertAuctionParameters(TARGET, TICK_SIZE, MIN_PRICE); + + // Assert the tick + // As the capacity was depleted exactly, it shifts to the next tick + uint256 nextTickPrice = FullMath.mulDivUp(MIN_PRICE, TICK_STEP, 100e2); + _assertPreviousTick(TICK_SIZE, nextTickPrice, TICK_SIZE, uint48(block.timestamp)); + } + + function test_convertedAmountEqualToTickCapacity_givenTickStepIsEqual( + uint256 bidAmount_ + ) + public + givenInitialized + givenTickStep(100e2) + givenAddressHasReserveToken(recipient, 151e18) + givenReserveTokenSpendingIsApproved(recipient, address(convertibleDepository), 151e18) + { + // We want the converted amount to be equal to the tick capacity (10e9) + // Bid amount * 1e9 / 15e18 = 10e9 + // Bid amount = 10e9 * 15e18 / 1e9 + // Bid amount = 150e18 (it will round down) + + // We expect the range of bid amounts when converted to round down to 10e9 + uint256 bidAmount = bound(bidAmount_, 150e18, 150e18 + 15e9 - 1); + uint256 expectedDepositIn = 150e18; + uint256 expectedConvertedAmount = 10e9; + + // Check preview + (uint256 previewOhmOut, ) = auctioneer.previewBid(bidAmount); + + // Assert that the preview is as expected + assertEq(previewOhmOut, expectedConvertedAmount, "preview converted amount"); + + // Call function + vm.prank(recipient); + (uint256 ohmOut, uint256 positionId) = auctioneer.bid(bidAmount); + + // Assert returned values + _assertConvertibleDepositPosition( + expectedDepositIn, + expectedConvertedAmount, + 151e18 - expectedDepositIn, + 0, + 0, + ohmOut, + positionId + ); + + // Assert the day state + assertEq(auctioneer.getDayState().deposits, expectedDepositIn, "day deposits"); + assertEq(auctioneer.getDayState().convertible, expectedConvertedAmount, "day convertible"); + + // Assert the state + _assertAuctionParameters(TARGET, TICK_SIZE, MIN_PRICE); + + // Assert the tick + // As the capacity was depleted exactly, it shifts to the next tick + // As the tick step is 100e2, the price is unchanged + _assertPreviousTick(TICK_SIZE, MIN_PRICE, TICK_SIZE, uint48(block.timestamp)); + } + + function test_convertedAmountGreaterThanTickCapacity( + uint256 bidAmount_ + ) + public + givenInitialized + givenAddressHasReserveToken(recipient, 300e18) + givenReserveTokenSpendingIsApproved(recipient, address(convertibleDepository), 300e18) + { + // We want the converted amount to be greater than the tick capacity (10e9) + // Bid amount * 1e9 / 15e18 >= 11e9 + // Bid amount = 11e9 * 15e18 / 1e9 + // Bid amount = 165e18 (it will round down) + // At most it should be 300e18 - 1 to stay within the tick capacity + + uint256 bidAmount = bound(bidAmount_, 165e18, 300e18 - 1); + uint256 tickTwoPrice = FullMath.mulDivUp(MIN_PRICE, TICK_STEP, 100e2); + + uint256 tickOneConvertedAmount = (150e18 * 1e9) / 15e18; + uint256 tickTwoConvertedAmount = ((bidAmount - 150e18) * 1e9) / tickTwoPrice; + uint256 expectedConvertedAmount = tickOneConvertedAmount + tickTwoConvertedAmount; + + // Check preview + (uint256 previewOhmOut, ) = auctioneer.previewBid(bidAmount); + + // Assert that the preview is as expected + assertEq(previewOhmOut, expectedConvertedAmount, "preview converted amount"); + + // Call function + vm.prank(recipient); + (uint256 ohmOut, uint256 positionId) = auctioneer.bid(bidAmount); + + // Assert returned values + _assertConvertibleDepositPosition( + bidAmount, + expectedConvertedAmount, + 300e18 - bidAmount, + 0, + 0, + ohmOut, + positionId + ); + + // Assert the day state + assertEq(auctioneer.getDayState().deposits, bidAmount, "day deposits"); + assertEq(auctioneer.getDayState().convertible, expectedConvertedAmount, "day convertible"); + + // Assert the state + _assertAuctionParameters(TARGET, TICK_SIZE, MIN_PRICE); + + // Assert the tick + _assertPreviousTick( + TICK_SIZE * 2 - expectedConvertedAmount, + tickTwoPrice, + TICK_SIZE, + uint48(block.timestamp) + ); + } + + function test_convertedAmountGreaterThanTickCapacity_givenTickStepIsEqual( + uint256 bidAmount_ + ) + public + givenInitialized + givenTickStep(100e2) + givenAddressHasReserveToken(recipient, 300e18) + givenReserveTokenSpendingIsApproved(recipient, address(convertibleDepository), 300e18) + { + // We want the converted amount to be greater than the tick capacity (10e9) + // Bid amount * 1e9 / 15e18 >= 11e9 + // Bid amount = 11e9 * 15e18 / 1e9 + // Bid amount = 165e18 (it will round down) + // At most it should be 300e18 - 1 to stay within the tick capacity + + uint256 bidAmount = bound(bidAmount_, 165e18, 300e18 - 1); + uint256 expectedConvertedAmount = (bidAmount * 1e9) / 15e18; + + // Check preview + (uint256 previewOhmOut, ) = auctioneer.previewBid(bidAmount); + + // Assert that the preview is as expected + assertEq(previewOhmOut, expectedConvertedAmount, "preview converted amount"); + + // Call function + vm.prank(recipient); + (uint256 ohmOut, uint256 positionId) = auctioneer.bid(bidAmount); + + // Assert returned values + _assertConvertibleDepositPosition( + bidAmount, + expectedConvertedAmount, + 300e18 - bidAmount, + 0, + 0, + ohmOut, + positionId + ); + + // Assert the day state + assertEq(auctioneer.getDayState().deposits, bidAmount, "day deposits"); + assertEq(auctioneer.getDayState().convertible, expectedConvertedAmount, "day convertible"); + + // Assert the state + _assertAuctionParameters(TARGET, TICK_SIZE, MIN_PRICE); + + // Assert the tick + _assertPreviousTick( + TICK_SIZE * 2 - expectedConvertedAmount, + MIN_PRICE, + TICK_SIZE, + uint48(block.timestamp) + ); + } + + function test_convertedAmountGreaterThanTickCapacity_reachesDayTarget( + uint256 bidAmount_ + ) + public + givenInitialized + givenAddressHasReserveToken(recipient, 40575e16) + givenReserveTokenSpendingIsApproved(recipient, address(convertibleDepository), 40575e16) + { + // We want the converted amount to be greater than the day target, 20e9, but within tick three + // Tick one: 10e9, price is 15e18, max bid amount is 150e18 + // Tick two: 10e9, price is 165e17, max bid amount is 165e18 + // Tick three: 5e9, price is 1815e16, max bid amount is 9075e16 + // Total bid amount = 150e18 + 165e18 + 9075e16 = 40575e16 + uint256 bidOneAmount = 150e18; + uint256 bidTwoAmount = 165e18; + uint256 bidThreeMaxAmount = 9075e16; + uint256 reserveTokenBalance = bidOneAmount + bidTwoAmount + bidThreeMaxAmount; + uint256 bidAmount = bound(bidAmount_, bidOneAmount + bidTwoAmount, reserveTokenBalance - 1); + uint256 tickThreePrice = 1815e16; + uint256 tickThreeBidAmount = bidAmount - bidOneAmount - bidTwoAmount; + + uint256 tickOneConvertedAmount = (bidOneAmount * 1e9) / 15e18; + uint256 tickTwoConvertedAmount = (bidTwoAmount * 1e9) / 165e17; + uint256 tickThreeConvertedAmount = (tickThreeBidAmount * 1e9) / tickThreePrice; + uint256 expectedConvertedAmount = tickOneConvertedAmount + + tickTwoConvertedAmount + + tickThreeConvertedAmount; + + // Recalculate the bid amount, in case tickThreeConvertedAmount is 0 + uint256 expectedDepositIn = bidOneAmount + + bidTwoAmount + + (tickThreeConvertedAmount == 0 ? 0 : tickThreeBidAmount); + + // Check preview + (uint256 previewOhmOut, ) = auctioneer.previewBid(bidAmount); + + // Assert that the preview is as expected + assertEq(previewOhmOut, expectedConvertedAmount, "preview converted amount"); + + // Call function + vm.prank(recipient); + (uint256 ohmOut, uint256 positionId) = auctioneer.bid(bidAmount); + + // Assert returned values + _assertConvertibleDepositPosition( + expectedDepositIn, + expectedConvertedAmount, + reserveTokenBalance - expectedDepositIn, + 0, + 0, + ohmOut, + positionId + ); + + // Assert the day state + assertEq(auctioneer.getDayState().deposits, expectedDepositIn, "day deposits"); + assertEq(auctioneer.getDayState().convertible, expectedConvertedAmount, "day convertible"); + + // Assert the state + _assertAuctionParameters(TARGET, TICK_SIZE, MIN_PRICE); + + // Assert the tick + _assertPreviousTick( + 10e9 + 10e9 + 5e9 - expectedConvertedAmount, + tickThreePrice, + 5e9, // The tick size is halved as the target is met or exceeded + uint48(block.timestamp) + ); + } + + function test_convertedAmountGreaterThanTickCapacity_multipleDayTargets( + uint256 bidAmount_ + ) + public + givenInitialized + givenAddressHasReserveToken(recipient, 796064875e12) + givenReserveTokenSpendingIsApproved(recipient, address(convertibleDepository), 796064875e12) + { + // We want the converted amount to be >= 2 * day target, 40e9 + // Tick one: 10e9, price is 15e18, max bid amount is 150e18 + // Tick two: 10e9, price is 165e17, max bid amount is 165e18 + // Tick three: 5e9, price is 1815e16, max bid amount is 9075e16 + // Tick four: 5e9, price is 19965e15, max bid amount is 99825e15 + // Tick five: 5e9, price is 219615e14, max bid amount is 1098075e14 + // Tick six: 5e9, price is 2415765e13, max bid amount is 12078825e13 + // Tick seven: 2.5e9, price is 26573415e12, max bid amount is 59894125e12 + // Max bid amount = 150e18 + 165e18 + 9075e16 + 99825e15 + 1098075e14 + 12078825e13 + 59894125e12 = 796064875e12 + // Ticks one to six bid amount = 150e18 + 165e18 + 9075e16 + 99825e15 + 1098075e14 + 12078825e13 = 73617075e13 + uint256 reserveTokenBalance = 796064875e12; + uint256 bidAmount = bound(bidAmount_, 73617075e13, reserveTokenBalance - 1); + + uint256 expectedConvertedAmount; + uint256 expectedDepositIn; + { + // Tick one: 150e18 * 1e9 / 15e18 = 10e9 + // Tick two: 165e18 * 1e9 / 165e17 = 10e9 + // Tick three: 9075e16 * 1e9 / 1815e16 = 5e9 + // Tick four: 99825e15 * 1e9 / 19965e15 = 5e9 + // Tick five: 1098075e14 * 1e9 / 219615e14 = 5e9 + // Tick six: 12078825e23 * 1e9 / 2395765e13 = 5e9 + uint256 ticksOneToSixConvertedAmount = 40e9; + uint256 tickSevenConvertedAmount = ((bidAmount - 73617075e13) * 1e9) / 26573415e12; + expectedConvertedAmount = ticksOneToSixConvertedAmount + tickSevenConvertedAmount; + + // Recalculate the bid amount, in case tickSevenConvertedAmount is 0 + expectedDepositIn = + 73617075e13 + + (tickSevenConvertedAmount == 0 ? 0 : (bidAmount - 73617075e13)); + } + + // Check preview + (uint256 previewOhmOut, ) = auctioneer.previewBid(bidAmount); + + // Assert that the preview is as expected + assertEq(previewOhmOut, expectedConvertedAmount, "preview converted amount"); + + // Call function + vm.prank(recipient); + (uint256 ohmOut, uint256 positionId) = auctioneer.bid(bidAmount); + + // Assert returned values + _assertConvertibleDepositPosition( + expectedDepositIn, + expectedConvertedAmount, + reserveTokenBalance - expectedDepositIn, + 0, + 0, + ohmOut, + positionId + ); + + // Assert the day state + assertEq(auctioneer.getDayState().deposits, expectedDepositIn, "day deposits"); + assertEq(auctioneer.getDayState().convertible, expectedConvertedAmount, "day convertible"); + + // Assert the state + _assertAuctionParameters(TARGET, TICK_SIZE, MIN_PRICE); + + // Assert the tick + _assertPreviousTick( + 10e9 + 10e9 + 5e9 + 5e9 + 5e9 + 5e9 + 25e8 - expectedConvertedAmount, + 26573415e12, + 25e8, // The tick size is halved twice as the target is met or exceeded twice + uint48(block.timestamp) + ); + } + + function test_convertedAmountGreaterThanTickCapacity_convertedAmountIsZero( + uint256 bidAmount_ + ) + public + givenInitialized + givenAddressHasReserveToken(recipient, 300e18) + givenReserveTokenSpendingIsApproved(recipient, address(convertibleDepository), 300e18) + { + // We want the converted amount to be greater than the tick capacity (10e9) + // But also for the remaining deposit to result in a converted amount of 0 + // Bid amount > 150e18 + // Bid amount < 150e18 + 165e8 + uint256 bidAmount = bound(bidAmount_, 150e18 + 1, 150e18 + 165e8 - 1); + uint256 expectedDepositIn = 150e18; + + // Only uses the first tick + uint256 expectedConvertedAmount = (150e18 * 1e9) / 15e18; + uint256 tickTwoPrice = FullMath.mulDivUp(MIN_PRICE, TICK_STEP, 100e2); + + // Check preview + (uint256 previewOhmOut, ) = auctioneer.previewBid(bidAmount); + + // Assert that the preview is as expected + assertEq(previewOhmOut, expectedConvertedAmount, "preview converted amount"); + + // Call function + vm.prank(recipient); + (uint256 ohmOut, uint256 positionId) = auctioneer.bid(bidAmount); + + // Assert returned values + _assertConvertibleDepositPosition( + expectedDepositIn, + expectedConvertedAmount, + 300e18 - expectedDepositIn, // Does not transfer excess deposit + 0, + 0, + ohmOut, + positionId + ); + + // Assert the day state + assertEq(auctioneer.getDayState().deposits, expectedDepositIn, "day deposits"); + assertEq(auctioneer.getDayState().convertible, expectedConvertedAmount, "day convertible"); + + // Assert the state + _assertAuctionParameters(TARGET, TICK_SIZE, MIN_PRICE); + + // Assert the tick + _assertPreviousTick(TICK_SIZE, tickTwoPrice, TICK_SIZE, uint48(block.timestamp)); + } +} diff --git a/src/test/policies/ConvertibleDepositAuctioneer/deactivate.t.sol b/src/test/policies/ConvertibleDepositAuctioneer/deactivate.t.sol new file mode 100644 index 00000000..b6100515 --- /dev/null +++ b/src/test/policies/ConvertibleDepositAuctioneer/deactivate.t.sol @@ -0,0 +1,78 @@ +// SPDX-License-Identifier: Unlicensed +pragma solidity 0.8.15; + +import {ConvertibleDepositAuctioneerTest} from "./ConvertibleDepositAuctioneerTest.sol"; +import {IConvertibleDepositAuctioneer} from "src/policies/interfaces/IConvertibleDepositAuctioneer.sol"; + +contract ConvertibleDepositAuctioneerDeactivateTest is ConvertibleDepositAuctioneerTest { + event Deactivated(); + + // when the caller does not have the "emergency_shutdown" role + // [X] it reverts + // when the contract is already deactivated + // [X] it reverts + // when the contract is active + // [X] it deactivates the contract + // [X] it emits an event + // [X] the day state is unchanged + // [X] the auction results history and index are unchanged + + function test_callerDoesNotHaveEmergencyShutdownRole_reverts(address caller_) public { + // Ensure caller is not emergency address + vm.assume(caller_ != emergency); + + // Expect revert + _expectRoleRevert("emergency_shutdown"); + + // Call function + vm.prank(caller_); + auctioneer.deactivate(); + } + + function test_contractInactive_reverts() public givenInitialized givenContractInactive { + // Expect revert + vm.expectRevert( + abi.encodeWithSelector(IConvertibleDepositAuctioneer.CDAuctioneer_InvalidState.selector) + ); + + // Call function + vm.prank(emergency); + auctioneer.deactivate(); + } + + function test_contractActive() public givenInitialized givenRecipientHasBid(1e18) { + // Cache auction results + int256[] memory auctionResults = auctioneer.getAuctionResults(); + uint8 nextIndex = auctioneer.getAuctionResultsNextIndex(); + + uint48 lastUpdate = uint48(block.timestamp); + + // Warp to change the block timestamp + vm.warp(lastUpdate + 1); + + // Expect event + vm.expectEmit(true, true, true, true); + emit Deactivated(); + + // Call function + vm.prank(emergency); + auctioneer.deactivate(); + + // Assert state + assertEq(auctioneer.locallyActive(), false); + // lastUpdate has not changed + assertEq(auctioneer.getPreviousTick().lastUpdate, lastUpdate); + // Auction results are unchanged + _assertAuctionResults( + auctionResults[0], + auctionResults[1], + auctionResults[2], + auctionResults[3], + auctionResults[4], + auctionResults[5], + auctionResults[6] + ); + // Auction results index is unchanged + _assertAuctionResultsNextIndex(nextIndex); + } +} diff --git a/src/test/policies/ConvertibleDepositAuctioneer/getCurrentTick.t.sol b/src/test/policies/ConvertibleDepositAuctioneer/getCurrentTick.t.sol new file mode 100644 index 00000000..f200470e --- /dev/null +++ b/src/test/policies/ConvertibleDepositAuctioneer/getCurrentTick.t.sol @@ -0,0 +1,521 @@ +// SPDX-License-Identifier: Unlicensed +pragma solidity 0.8.15; + +import {ConvertibleDepositAuctioneerTest} from "./ConvertibleDepositAuctioneerTest.sol"; +import {IConvertibleDepositAuctioneer} from "src/policies/interfaces/IConvertibleDepositAuctioneer.sol"; + +contract ConvertibleDepositAuctioneerCurrentTickTest is ConvertibleDepositAuctioneerTest { + // given the contract has not been initialized + // [X] it reverts + // given the contract is inactive + // [X] it reverts + // given a bid has never been received and the tick price is at the minimum price + // given no time has passed + // [X] the tick price remains at the min price + // [X] the tick capacity remains at the standard tick size + // [X] the tick price remains at the min price + // [X] the tick capacity remains at the standard tick size + // when the new capacity (current tick capacity + added capacity) is equal to the current tick size + // given the current tick size is 5e9 + // given the current timestamp is on a different day to the last bid + // [X] the tick capacity is set to the standard tick size + // [X] the tick size is set to the standard tick size + // [X] the tick price is unchanged + // [X] the tick capacity is set to the current tick size + // [X] the tick size does not change + // [X] the tick price is unchanged + // [X] the tick capacity is set to the standard tick size + // when the new capacity is less than the current tick size + // [X] the tick price is unchanged + // [X] the tick capacity is set to the new capacity + // when the new capacity is greater than the current tick size + // given the tick step is = 100e2 + // [X] the tick price is unchanged + // [X] the tick capacity is set to the new capacity + // given the tick step is > 100e2 + // when the new price is lower than the minimum price + // given the current tick size is 5e9 + // given the current timestamp is on a different day to the last bid + // [X] the tick capacity is set to the standard tick size + // [X] the tick size is set to the standard tick size + // [ ] the tick price is set to the minimum price + // [ ] the tick capacity is set to the current tick size + // [ ] the tick size does not change + // [X] the tick price is set to the minimum price + // [X] the capacity is set to the standard tick size + // given the current tick size is 5e9 + // given the current timestamp is on a different day to the last bid + // [X] the tick capacity is set to the standard tick size + // [X] the tick size is set to the standard tick size + // [X] it reduces the price by the tick step until the total capacity is less than the current tick size + // [X] the tick capacity is set to the remainder + // [X] the tick size does not change + // [X] it reduces the price by the tick step until the total capacity is less than the standard tick size + // [X] the tick capacity is set to the remainder + + function test_contractNotInitialized_reverts() public { + // Expect revert + vm.expectRevert(IConvertibleDepositAuctioneer.CDAuctioneer_NotActive.selector); + + // Call function + auctioneer.getCurrentTick(); + } + + function test_contractInactive_reverts() public givenInitialized givenContractInactive { + // Expect revert + vm.expectRevert(IConvertibleDepositAuctioneer.CDAuctioneer_NotActive.selector); + + // Call function + auctioneer.getCurrentTick(); + } + + function test_fullCapacity_sameTime(uint48 secondsPassed_) public givenInitialized { + uint48 secondsPassed = uint48(bound(secondsPassed_, 0, 86400 - 1)); + + // Warp to change the block timestamp + vm.warp(block.timestamp + secondsPassed); + + // Call function + IConvertibleDepositAuctioneer.Tick memory tick = auctioneer.getCurrentTick(); + + uint256 expectedTickPrice = 15e18; + uint256 expectedTickCapacity = 10e9; + + // Assert current tick + assertEq(tick.capacity, expectedTickCapacity, "capacity"); + assertEq(tick.price, expectedTickPrice, "price"); + assertEq(tick.tickSize, 10e9, "new tick size"); + } + + function test_fullCapacity(uint48 secondsPassed_) public givenInitialized { + uint48 secondsPassed = uint48(bound(secondsPassed_, 1, 7 days)); + + // Warp to change the block timestamp + vm.warp(block.timestamp + secondsPassed); + + // Expected values + // Tick size = 10e9 + // Tick step = 110e2 + // Current tick capacity = tick size = 10e9 + // Current tick price = min price = 15e18 + // New capacity added = target * days passed = 20e9 * 2 = 40e9 + // New capacity = 10e9 + 40e9 = 50e9 + // Iteration 1: + // New capacity = 50e9 - 10e9 = 40e9 + // Tick price = 15e18 * 100e2 / 110e2 = 13636363636363636364 + // + // Updated tick price is < min price, so it is set to the min price + uint256 expectedTickPrice = 15e18; + uint256 expectedTickCapacity = 10e9; + + // Call function + IConvertibleDepositAuctioneer.Tick memory tick = auctioneer.getCurrentTick(); + + // Assert current tick + assertEq(tick.capacity, expectedTickCapacity, "capacity"); + assertEq(tick.price, expectedTickPrice, "price"); + assertEq(tick.tickSize, 10e9, "new tick size"); + } + + function test_newCapacityEqualToTickSize() public givenInitialized givenRecipientHasBid(75e18) { + // Min price is 15e18 + // We need a bid and time to pass so that remaining capacity + new capacity = tick size + // Given a tick size of 10e9 + // We want to get to a remaining capacity of 5e9 + // 5e9 = bid size * 1e9 / 15e18 + // Bid size = 5e9 * 15e18 / 1e9 = 75e18 + // If there is capacity of 5e9, we need new capacity of 5e9 + // new capacity = 20e9 * time passed / 1 days + // time passed = 5e9 * 1 days / 20e9 = 21600 seconds + + // Assert that the convertible amount is correct + uint256[] memory positionIds = convertibleDepositPositions.getUserPositionIds(recipient); + assertEq( + convertibleDepositPositions.previewConvert(positionIds[0], 75e18), + 5e9, + "convertible amount" + ); + + // Assert tick capacity + assertEq(auctioneer.getCurrentTick().capacity, 5e9, "previous tick capacity"); + + // Assert that the time passed will result in the correct capacity + uint48 timePassed = 21600; + assertEq( + (auctioneer.getAuctionParameters().target * timePassed) / 1 days, + 5e9, + "expected new capacity" + ); + + // Warp forward + vm.warp(block.timestamp + timePassed); + + // Call function + IConvertibleDepositAuctioneer.Tick memory tick = auctioneer.getCurrentTick(); + + // Assert tick capacity + assertEq(tick.capacity, 10e9, "new tick capacity"); + assertEq(tick.price, 15e18, "new tick price"); + assertEq(tick.tickSize, 10e9, "new tick size"); + } + + function test_newCapacityEqualToTickSize_dayTargetMet() + public + givenInitialized + givenRecipientHasBid(360375e15) + { + // Bid size of 360375e15 results in: + // 1. 360375e15 * 1e9 / 15e18 = 24,025,000,000. Greater than tick size of 10e9. Bid amount becomes 150e18. New price is 15e18 * 110e2 / 100e2 = 165e17 + // 2. (360375e15 - 150e18) * 1e9 / 165e17 = 12,750,000,000. Greater than the tick size of 10e9. Bid amount becomes 165e18. New price is 165e17 * 110e2 / 100e2 = 1815e16. Day target met, so tick size becomes 5e9. + // 3. (360375e15 - 150e18 - 165e18) * 1e9 / 1815e16 = 25e8. Less than the tick size of 5e9. + + // Remaining capacity is 25e8 + // Added capacity will be 25e8 + // New capacity will be 25e8 + 25e8 = 5e9 + + // Calculate the expected tick price + // Tick price remains at 1815e16 + + // Warp forward + uint48 timePassed = 10800; + vm.warp(block.timestamp + timePassed); + + // Call function + IConvertibleDepositAuctioneer.Tick memory tick = auctioneer.getCurrentTick(); + + // Assert tick capacity + assertEq(tick.capacity, 5e9, "new tick capacity"); + assertEq(tick.price, 1815e16, "new tick price"); + assertEq(tick.tickSize, 5e9, "new tick size"); + } + + function test_newCapacityEqualToTickSize_dayTargetMet_nextDay() + public + givenInitialized + givenRecipientHasBid(378861111101700000000) + { + // 150e18 + 165e18 + 63861111101700000000 = 378861111101700000000 + // Bid size of 378861111101700000000 results in: + // 1. 378861111101700000000 * 1e9 / 15e18 = 24,025,000,000. Greater than tick size of 10e9. Bid amount becomes 150e18. New price is 15e18 * 110e2 / 100e2 = 165e17 + // 2. (378861111101700000000 - 150e18) * 1e9 / 165e17 = 12,750,000,000. Greater than the tick size of 10e9. Bid amount becomes 165e18. New price is 165e17 * 110e2 / 100e2 = 1815e16. Day target met, so tick size becomes 5e9. + // 3. (378861111101700000000 - 150e18 - 165e18) * 1e9 / 1815e16 = 3518518518. Less than the tick size of 5e9. + // Remaining capacity is 5e9 - 3518518518 = 1481481482 + + // 20e9*36800/(24*60*60) = 8518518518 added capacity + // New capacity will be 1481481482 + 8518518518 = 10e9 + + // Calculate the expected tick price + // As it is the next day, the tick size will reset to 10e9 + + // Warp forward + // This will result in the next day being reached + uint48 timePassed = 36800; + vm.warp(block.timestamp + timePassed); + + // Call function + IConvertibleDepositAuctioneer.Tick memory tick = auctioneer.getCurrentTick(); + + // Assert tick capacity + assertEq(tick.capacity, 10e9, "new tick capacity"); + assertEq(tick.price, 1815e16, "new tick price"); + assertEq(tick.tickSize, 10e9, "new tick size"); + } + + function test_newCapacityLessThanTickSize() + public + givenInitialized + givenRecipientHasBid(90e18) + { + // Bid size of 90e18 results in convertible amount of 6e9 + // Remaining capacity is 4e9 + + // Added capacity will be 5e9 + // New capacity will be 4e9 + 5e9 = 9e9 + + // Warp forward + uint48 timePassed = 21600; + vm.warp(block.timestamp + timePassed); + + // Call function + IConvertibleDepositAuctioneer.Tick memory tick = auctioneer.getCurrentTick(); + + // Assert tick capacity + assertEq(tick.capacity, 9e9, "new tick capacity"); + assertEq(tick.price, 15e18, "new tick price"); + assertEq(tick.tickSize, 10e9, "new tick size"); + } + + function test_newCapacityGreaterThanTickSize() + public + givenInitialized + givenRecipientHasBid(45e18) + { + // Bid size of 45e18 results in convertible amount of 3e9 + // Remaining capacity is 7e9 + + // Added capacity will be 5e9 + // New capacity will be 7e9 + 5e9 = 12e9 + // Excess capacity = 12e9 - 10e9 = 2e9 + // Tick price = 15e18 * 100e2 / 110e2 = 13636363636363636364 + // Because the tick price is below the minimum price, capacity is set to the tick size + + // Warp forward + uint48 timePassed = 21600; + vm.warp(block.timestamp + timePassed); + + // Call function + IConvertibleDepositAuctioneer.Tick memory tick = auctioneer.getCurrentTick(); + + // Assert tick capacity + assertEq(tick.capacity, 10e9, "new tick capacity"); + assertEq(tick.price, 15e18, "new tick price"); + assertEq(tick.tickSize, 10e9, "new tick size"); + } + + function test_tickStepSame_newCapacityGreaterThanTickSize() + public + givenInitialized + givenTickStep(100e2) + givenRecipientHasBid(45e18) + { + // Bid size of 45e18 results in convertible amount of 3e9 + // Remaining capacity is 7e9 + + // Added capacity will be 5e9 + // New capacity will be 7e9 + 5e9 = 12e9 + // Excess capacity = 12e9 - 10e9 = 2e9 + // Tick price = 15e18 * 100e2 / 100e2 = 15e18 + + // Warp forward + uint48 timePassed = 21600; + vm.warp(block.timestamp + timePassed); + + // Call function + IConvertibleDepositAuctioneer.Tick memory tick = auctioneer.getCurrentTick(); + + // Assert tick capacity + assertEq(tick.capacity, 2e9, "new tick capacity"); + assertEq(tick.price, 15e18, "new tick price"); + assertEq(tick.tickSize, 10e9, "new tick size"); + } + + function test_tickStepSame_newCapacityLessThanTickSize() + public + givenInitialized + givenTickStep(100e2) + givenRecipientHasBid(75e18) + { + // Bid size of 75e18 results in convertible amount of 5e9 + // Remaining capacity is 5e9 + + // Added capacity will be 2.5e9 + // New capacity will be 5e9 + 2.5e9 = 7.5e9 + // Not greater than tick size, so price remains unchanged + + // Warp forward + uint48 timePassed = 10800; + vm.warp(block.timestamp + timePassed); + + // Call function + IConvertibleDepositAuctioneer.Tick memory tick = auctioneer.getCurrentTick(); + + // Assert tick capacity + assertEq(tick.capacity, 75e8, "new tick capacity"); + assertEq(tick.price, 15e18, "new tick price"); + assertEq(tick.tickSize, 10e9, "new tick size"); + } + + function test_tickStepSame_newCapacityEqualToTickSize() + public + givenInitialized + givenTickStep(100e2) + givenRecipientHasBid(75e18) + { + // Bid size of 75e18 results in convertible amount of 5e9 + // Remaining capacity is 5e9 + + // Added capacity will be 5e9 + // New capacity will be 5e9 + 5e9 = 10e9 + // Not greater than tick size, so price remains unchanged + + // Warp forward + uint48 timePassed = 21600; + vm.warp(block.timestamp + timePassed); + + // Call function + IConvertibleDepositAuctioneer.Tick memory tick = auctioneer.getCurrentTick(); + + // Assert tick capacity + assertEq(tick.capacity, 10e9, "new tick capacity"); + assertEq(tick.price, 15e18, "new tick price"); + assertEq(tick.tickSize, 10e9, "new tick size"); + } + + function test_tickPriceAboveMinimum_newCapacityGreaterThanTickSize() + public + givenInitialized + givenRecipientHasBid(270e18) + { + // Bid size of 270e18 results in: + // 1. 270e18 * 1e9 / 15e18 = 18e9. Greater than tick size of 10e9. Bid amount becomes 150e18. New price is 15e18 * 110e2 / 100e2 = 165e17 + // 2. (270e18 - 150e18) * 1e9 / 165e17 = 7272727272. Less than the tick size of 10e9, so the tick price remains unchanged. + // Remaining capacity is 10e9 - 7272727272 = 2727272728 + + // 20e9*32400/86400 = 7,500,000,000 + // Added capacity will be 7,500,000,000 + // New capacity will be 2727272728 + 7,500,000,000 = 10227272728 + + // Calculate the expected tick price + // Excess capacity = 10227272728 - 10e9 = 227272728 + // Tick price = 165e17 * 100e2 / 110e2 = 15e18 + + // Warp forward + uint48 timePassed = 32400; + vm.warp(block.timestamp + timePassed); + + // Call function + IConvertibleDepositAuctioneer.Tick memory tick = auctioneer.getCurrentTick(); + + // Assert tick capacity + assertEq(tick.capacity, 227272728, "new tick capacity"); + assertEq(tick.price, 15e18, "new tick price"); + assertEq(tick.tickSize, 10e9, "new tick size"); + } + + function test_tickPriceAboveMinimum_newCapacityGreaterThanTickSize_dayTargetMet() + public + givenInitialized + givenRecipientHasBid(330e18) + { + // Bid size of 330e18 results in: + // 1. 330e18 * 1e9 / 15e18 = 22e9. Greater than tick size of 10e9. Bid amount becomes 150e18. New price is 15e18 * 110e2 / 100e2 = 165e17 + // 2. (330e18 - 150e18) * 1e9 / 165e17 = 10,909,090,909. Greater than tick size of 10e9. Bid amount becomes 165e18. New price is 165e17 * 110e2 / 100e2 = 1815e16 + // 3. (330e18 - 150e18 - 165e18) * 1e9 / 1815e16 = 826,446,280 + // Remaining capacity is 5e9 - 826,446,280 = 4,173,553,720 + + // Added capacity will be 5e9 + // New capacity will be 4,173,553,720 + 5e9 = 9,173,553,720 + + // Calculate the expected tick price + // Excess capacity = 9,173,553,720 - 5e9 = 4,173,553,720 + // Tick price = 165e17 + + // Warp forward + uint48 timePassed = 21600; + vm.warp(block.timestamp + timePassed); + + // Call function + IConvertibleDepositAuctioneer.Tick memory tick = auctioneer.getCurrentTick(); + + // Assert tick capacity + assertEq(tick.capacity, 4173553720, "new tick capacity"); + assertEq(tick.price, 165e17, "new tick price"); + assertEq(tick.tickSize, 5e9, "new tick size"); + } + + function test_tickPriceAboveMinimum_newCapacityGreaterThanTickSize_dayTargetMet_nextDay() + public + givenInitialized + givenRecipientHasBid(330e18) + { + // Bid size of 330e18 results in: + // 1. 330e18 * 1e9 / 15e18 = 22e9. Greater than tick size of 10e9. Bid amount becomes 150e18. New price is 15e18 * 110e2 / 100e2 = 165e17 + // 2. (330e18 - 150e18) * 1e9 / 165e17 = 10,909,090,909. Greater than tick size of 10e9. Bid amount becomes 165e18. New price is 165e17 * 110e2 / 100e2 = 1815e16 + // 3. (330e18 - 150e18 - 165e18) * 1e9 / 1815e16 = 826,446,280 + // Remaining capacity is 5e9 - 826,446,280 = 4173553720 + + // 20e9*36800/(24*60*60) = 8518518518 added capacity + // New capacity will be 8518518518 + 4173553720 = 12692072238 + + // Calculate the expected tick price + // As it is the next day, the tick size will reset to 10e9 + // Excess capacity = 12692072238 - 10e9 = 2692072238 + // Tick price = 165e17 + + // Warp forward + uint48 timePassed = 36800; + vm.warp(block.timestamp + timePassed); + + // Call function + IConvertibleDepositAuctioneer.Tick memory tick = auctioneer.getCurrentTick(); + + // Assert tick capacity + assertEq(tick.capacity, 2692072238, "new tick capacity"); + assertEq(tick.price, 165e17, "new tick price"); + assertEq(tick.tickSize, 10e9, "new tick size"); + } + + function test_tickPriceAboveMinimum_newPriceBelowMinimum() + public + givenInitialized + givenRecipientHasBid(270e18) + { + // Bid size of 270e18 results in: + // 1. 270e18 * 1e9 / 15e18 = 18e9. Greater than tick size of 10e9. Bid amount becomes 150e18. New price is 15e18 * 110e2 / 100e2 = 165e17 + // 2. (270e18 - 150e18) * 1e9 / 165e17 = 7,272,727,273. Less than tick size of 10e9. + // Remaining capacity is 10e9 - 7,272,727,273 = 2,727,272,727 + + // Added capacity will be 30e9 + // New capacity will be 2,727,272,727 + 30e9 = 32,727,272,727 + + // Calculate the expected tick price + // 1. Excess capacity = 32,727,272,727 - 10e9 = 22,727,272,727 + // Tick price = 165e17 + // 2. Excess capacity = 22,727,272,727 - 10e9 = 12,727,272,727 + // Tick price = 165e17 * 100e2 / 110e2 = 15e18 + // 3. Excess capacity = 12,727,272,727 - 10e9 = 2,727,272,727 + // Tick price = 15e18 * 100e2 / 110e2 = 13636363636363636364 + // Tick price is below the minimum price, so it is set to the minimum price + // New capacity = tick size = 10e9 + + // Warp forward + uint48 timePassed = 6 * 21600; + vm.warp(block.timestamp + timePassed); + + // Call function + IConvertibleDepositAuctioneer.Tick memory tick = auctioneer.getCurrentTick(); + + // Assert tick capacity + assertEq(tick.capacity, 10e9, "new tick capacity"); + assertEq(tick.price, 15e18, "new tick price"); + assertEq(tick.tickSize, 10e9, "new tick size"); + } + + function test_tickPriceAboveMinimum_newPriceBelowMinimum_dayTargetMet_nextDay() + public + givenInitialized + givenRecipientHasBid(330e18) + { + // Bid size of 330e18 results in: + // 1. 330e18 * 1e9 / 15e18 = 22e9. Greater than tick size of 10e9. Bid amount becomes 150e18. New price is 15e18 * 110e2 / 100e2 = 165e17 + // 2. (330e18 - 150e18) * 1e9 / 165e17 = 10,909,090,909. Greater than tick size of 10e9. Bid amount becomes 165e18. New price is 165e17 * 110e2 / 100e2 = 1815e16 + // 3. (330e18 - 150e18 - 165e18) * 1e9 / 1815e16 = 826,446,280 + // Remaining capacity is 5e9 - 826,446,280 = 4,173,553,720 + + // Added capacity will be 30e9 + // New capacity will be 4,173,553,720 + 30e9 = 34,173,553,720 + + // Calculate the expected tick price + // As it is the next day, the tick size will reset to 10e9 + // 1. Excess capacity = 34,173,553,720 - 10e9 = 24,173,553,720 + // Tick price = 165e17 + // 2. Excess capacity = 24,173,553,720 - 10e9 = 14,173,553,720 + // Tick price = 165e17 * 100e2 / 110e2 = 15e18 + // 3. Excess capacity = 14,173,553,720 - 10e9 = 4,173,553,720 + // Tick price = 15e18 * 100e2 / 110e2 = 13636363636363636364 + // Tick price is below the minimum price, so it is set to the minimum price + // New capacity = current tick size = 10e9 + + // Warp forward + uint48 timePassed = 6 * 21600; + vm.warp(block.timestamp + timePassed); + + // Call function + IConvertibleDepositAuctioneer.Tick memory tick = auctioneer.getCurrentTick(); + + // Assert tick capacity + assertEq(tick.capacity, 10e9, "new tick capacity"); + assertEq(tick.price, 15e18, "new tick price"); + assertEq(tick.tickSize, 10e9, "new tick size"); + } +} diff --git a/src/test/policies/ConvertibleDepositAuctioneer/initialize.t.sol b/src/test/policies/ConvertibleDepositAuctioneer/initialize.t.sol new file mode 100644 index 00000000..69a3075e --- /dev/null +++ b/src/test/policies/ConvertibleDepositAuctioneer/initialize.t.sol @@ -0,0 +1,284 @@ +// SPDX-License-Identifier: Unlicensed +pragma solidity 0.8.15; + +import {ConvertibleDepositAuctioneerTest} from "./ConvertibleDepositAuctioneerTest.sol"; +import {IConvertibleDepositAuctioneer} from "src/policies/interfaces/IConvertibleDepositAuctioneer.sol"; + +contract ConvertibleDepositAuctioneerInitializeTest is ConvertibleDepositAuctioneerTest { + // when the caller does not have the "admin" role + // [X] it reverts + // when the contract is already active + // [X] it reverts + // when the tick size is 0 + // [X] it reverts + // when the min price is 0 + // [X] it reverts + // when the tick step is < 100e2 + // [X] it reverts + // when the time to expiry is 0 + // [X] it reverts + // when the auction tracking period is 0 + // [X] it reverts + // given the contract is already initialized + // given the contract is disabled + // [X] it reverts + // [X] it reverts + // [X] it sets the auction parameters + // [X] it sets the tick step + // [X] it sets the time to expiry + // [X] it initializes the current tick + // [X] it activates the contract + // [X] it initializes the day state + // [X] it initializes the auction results history and index + + function test_callerNotAdmin_reverts(address caller_) public { + // Ensure caller is not admin + vm.assume(caller_ != admin); + + // Expect revert + _expectRoleRevert("cd_admin"); + + // Call function + vm.prank(caller_); + auctioneer.initialize( + TARGET, + TICK_SIZE, + MIN_PRICE, + TICK_STEP, + TIME_TO_EXPIRY, + AUCTION_TRACKING_PERIOD + ); + } + + function test_contractAlreadyActive_reverts() public givenInitialized { + // Expect revert + vm.expectRevert( + abi.encodeWithSelector(IConvertibleDepositAuctioneer.CDAuctioneer_InvalidState.selector) + ); + + // Call function + vm.prank(admin); + auctioneer.initialize( + TARGET, + TICK_SIZE, + MIN_PRICE, + TICK_STEP, + TIME_TO_EXPIRY, + AUCTION_TRACKING_PERIOD + ); + } + + function test_tickSizeZero_reverts() public { + // Expect revert + vm.expectRevert( + abi.encodeWithSelector( + IConvertibleDepositAuctioneer.CDAuctioneer_InvalidParams.selector, + "tick size" + ) + ); + + // Call function + vm.prank(admin); + auctioneer.initialize( + TARGET, + 0, + MIN_PRICE, + TICK_STEP, + TIME_TO_EXPIRY, + AUCTION_TRACKING_PERIOD + ); + } + + function test_minPriceZero_reverts() public { + // Expect revert + vm.expectRevert( + abi.encodeWithSelector( + IConvertibleDepositAuctioneer.CDAuctioneer_InvalidParams.selector, + "min price" + ) + ); + + // Call function + vm.prank(admin); + auctioneer.initialize( + TARGET, + TICK_SIZE, + 0, + TICK_STEP, + TIME_TO_EXPIRY, + AUCTION_TRACKING_PERIOD + ); + } + + function test_tickStepOutOfBounds_reverts(uint24 tickStep_) public { + uint24 tickStep = uint24(bound(tickStep_, 0, 100e2 - 1)); + + // Expect revert + vm.expectRevert( + abi.encodeWithSelector( + IConvertibleDepositAuctioneer.CDAuctioneer_InvalidParams.selector, + "tick step" + ) + ); + + // Call function + vm.prank(admin); + auctioneer.initialize( + TARGET, + TICK_SIZE, + MIN_PRICE, + tickStep, + TIME_TO_EXPIRY, + AUCTION_TRACKING_PERIOD + ); + } + + function test_timeToExpiryZero_reverts() public { + // Expect revert + vm.expectRevert( + abi.encodeWithSelector( + IConvertibleDepositAuctioneer.CDAuctioneer_InvalidParams.selector, + "time to expiry" + ) + ); + + // Call function + vm.prank(admin); + auctioneer.initialize(TARGET, TICK_SIZE, MIN_PRICE, TICK_STEP, 0, AUCTION_TRACKING_PERIOD); + } + + function test_auctionTrackingPeriodZero_reverts() public { + // Expect revert + vm.expectRevert( + abi.encodeWithSelector( + IConvertibleDepositAuctioneer.CDAuctioneer_InvalidParams.selector, + "auction tracking period" + ) + ); + + // Call function + vm.prank(admin); + auctioneer.initialize(TARGET, TICK_SIZE, MIN_PRICE, TICK_STEP, TIME_TO_EXPIRY, 0); + } + + function test_contractInitialized_reverts() public givenInitialized { + // Expect revert + vm.expectRevert( + abi.encodeWithSelector(IConvertibleDepositAuctioneer.CDAuctioneer_InvalidState.selector) + ); + + // Call function + vm.prank(admin); + auctioneer.initialize( + TARGET, + TICK_SIZE, + MIN_PRICE, + TICK_STEP, + TIME_TO_EXPIRY, + AUCTION_TRACKING_PERIOD + ); + } + + function test_contractInitialized_disabled_reverts() + public + givenInitialized + givenContractInactive + { + // Expect revert + vm.expectRevert( + abi.encodeWithSelector(IConvertibleDepositAuctioneer.CDAuctioneer_InvalidState.selector) + ); + + // Call function + vm.prank(admin); + auctioneer.initialize( + TARGET, + TICK_SIZE, + MIN_PRICE, + TICK_STEP, + TIME_TO_EXPIRY, + AUCTION_TRACKING_PERIOD + ); + } + + function test_success() public { + // Not yet initialized + assertEq(auctioneer.initialized(), false, "initialized"); + + // Expect events + vm.expectEmit(true, true, true, true); + emit AuctionParametersUpdated(TARGET, TICK_SIZE, MIN_PRICE); + + vm.expectEmit(true, true, true, true); + emit TickStepUpdated(TICK_STEP); + + vm.expectEmit(true, true, true, true); + emit TimeToExpiryUpdated(TIME_TO_EXPIRY); + + vm.expectEmit(true, true, true, true); + emit AuctionTrackingPeriodUpdated(AUCTION_TRACKING_PERIOD); + + vm.expectEmit(true, true, true, true); + emit Activated(); + + // Call function + vm.prank(admin); + auctioneer.initialize( + TARGET, + TICK_SIZE, + MIN_PRICE, + TICK_STEP, + TIME_TO_EXPIRY, + AUCTION_TRACKING_PERIOD + ); + + // Assert state + _assertAuctionParameters(TARGET, TICK_SIZE, MIN_PRICE); + + assertEq(auctioneer.getTickStep(), TICK_STEP, "tick step"); + assertEq(auctioneer.getTimeToExpiry(), TIME_TO_EXPIRY, "time to expiry"); + assertEq( + auctioneer.getAuctionTrackingPeriod(), + AUCTION_TRACKING_PERIOD, + "auction tracking period" + ); + assertEq(auctioneer.locallyActive(), true, "locally active"); + assertEq(auctioneer.initialized(), true, "initialized"); + + _assertPreviousTick(TICK_SIZE, MIN_PRICE, TICK_SIZE, INITIAL_BLOCK); + + _assertAuctionResults(0, 0, 0, 0, 0, 0, 0); + _assertAuctionResultsNextIndex(0); + } + + function test_auctionTrackingPeriodDifferent() public { + // Not yet initialized + assertEq(auctioneer.initialized(), false, "initialized"); + + vm.expectEmit(true, true, true, true); + emit AuctionTrackingPeriodUpdated(AUCTION_TRACKING_PERIOD + 1); + + // Call function + vm.prank(admin); + auctioneer.initialize( + TARGET, + TICK_SIZE, + MIN_PRICE, + TICK_STEP, + TIME_TO_EXPIRY, + AUCTION_TRACKING_PERIOD + 1 + ); + + // Assert state + assertEq( + auctioneer.getAuctionTrackingPeriod(), + AUCTION_TRACKING_PERIOD + 1, + "auction tracking period" + ); + assertEq( + auctioneer.getAuctionResults().length, + AUCTION_TRACKING_PERIOD + 1, + "auction results length" + ); + } +} diff --git a/src/test/policies/ConvertibleDepositAuctioneer/setAuctionParameters.t.sol b/src/test/policies/ConvertibleDepositAuctioneer/setAuctionParameters.t.sol new file mode 100644 index 00000000..11a1403c --- /dev/null +++ b/src/test/policies/ConvertibleDepositAuctioneer/setAuctionParameters.t.sol @@ -0,0 +1,610 @@ +// SPDX-License-Identifier: Unlicensed +pragma solidity 0.8.15; + +import {ConvertibleDepositAuctioneerTest} from "./ConvertibleDepositAuctioneerTest.sol"; +import {IConvertibleDepositAuctioneer} from "src/policies/interfaces/IConvertibleDepositAuctioneer.sol"; + +contract ConvertibleDepositAuctioneerAuctionParametersTest is ConvertibleDepositAuctioneerTest { + // when the caller does not have the "heart" role + // [X] it reverts + // given the contract is not initialized + // [X] it sets the parameters + // [X] it resets the day state + // when the new target is 0 + // [X] it reverts + // when the new tick size is 0 + // [X] it reverts + // when the new min price is 0 + // [X] it reverts + // when the contract is deactivated + // [X] it sets the parameters + // [X] it emits an event + // [X] it does not change the current tick capacity + // [X] it does not change the current tick price + // [X] it does not change the day state + // [X] it does not change the auction results + // [X] it does not change the auction results index + // given the tick price has never been set + // [X] it sets the parameters + // [X] it does not change the current tick capacity + // [X] it does not change the current tick price + // [X] it emits an event + // when the new tick size is less than the current tick capacity + // [X] the tick capacity is set to the new tick size + // when the new tick size is >= the current tick capacity + // [X] the tick capacity is unchanged + // when the new min price is > than the current tick price + // [X] the tick price is set to the new min price + // when the new min price is <= the current tick price + // [X] the tick price is unchanged + // given setAuctionParameters has been called on the same day + // [X] the day state is unchanged + // [X] the auction results history and index are unchanged + // given setAuctionParameters has not been called on the same day + // given this is the first day of the auction cycle + // [X] the day state is reset + // [X] it records the previous day's auction results + // [X] it resets the auction results index + // [X] the AuctionResult event is emitted + // given this is the second day of the auction cycle + // [X] the day state is reset + // [X] it resets the auction results history + // [X] it increments the auction results index + // [X] it records the previous day's auction results + // [X] the AuctionResult event is emitted + // [X] the day state is reset + // [X] it records the previous day's auction results + // [X] it increments the auction results index + // [X] the AuctionResult event is emitted + + function test_callerDoesNotHaveHeartRole_reverts(address caller_) public { + // Ensure caller is not heart + vm.assume(caller_ != heart); + + // Expect revert + _expectRoleRevert("heart"); + + // Call function + vm.prank(caller_); + auctioneer.setAuctionParameters(100, 100, 100); + } + + function test_contractNotInitialized() public { + uint256 newTarget = 21e9; + uint256 newTickSize = 11e9; + uint256 newMinPrice = 14e18; + + // Call function + vm.prank(heart); + auctioneer.setAuctionParameters(newTarget, newTickSize, newMinPrice); + + // Assert state + _assertAuctionParameters(newTarget, newTickSize, newMinPrice); + _assertPreviousTick( + 0, + newMinPrice, // Set to new min price. Will be overriden when initialized. + newTickSize, + 0 + ); + _assertAuctionResultsEmpty(0); + _assertAuctionResultsNextIndex(0); + } + + function test_targetZero_reverts() public givenInitialized { + uint256 newTickSize = 11e9; + uint256 newMinPrice = 14e18; + + // Expect revert + vm.expectRevert( + abi.encodeWithSelector( + IConvertibleDepositAuctioneer.CDAuctioneer_InvalidParams.selector, + "target" + ) + ); + + // Call function + vm.prank(heart); + auctioneer.setAuctionParameters(0, newTickSize, newMinPrice); + } + + function test_tickSizeZero_reverts() public givenInitialized { + // Expect revert + vm.expectRevert( + abi.encodeWithSelector( + IConvertibleDepositAuctioneer.CDAuctioneer_InvalidParams.selector, + "tick size" + ) + ); + + // Call function + vm.prank(heart); + auctioneer.setAuctionParameters(21e9, 0, 16e18); + } + + function test_minPriceZero_reverts() public givenInitialized { + // Expect revert + vm.expectRevert( + abi.encodeWithSelector( + IConvertibleDepositAuctioneer.CDAuctioneer_InvalidParams.selector, + "min price" + ) + ); + + // Call function + vm.prank(heart); + auctioneer.setAuctionParameters(21e9, 11e9, 0); + } + + function test_contractInactive() + public + givenInitialized + givenRecipientHasBid(1e18) + givenContractInactive + { + uint256 lastConvertible = auctioneer.getDayState().convertible; + uint256 lastDeposits = auctioneer.getDayState().deposits; + int256[] memory lastAuctionResults = auctioneer.getAuctionResults(); + uint8 lastAuctionResultsIndex = auctioneer.getAuctionResultsNextIndex(); + uint256 lastCapacity = auctioneer.getPreviousTick().capacity; + uint256 lastPrice = auctioneer.getPreviousTick().price; + uint48 lastUpdate = uint48(block.timestamp); + + // Warp to change the block timestamp to the next day + vm.warp(lastUpdate + 1 days); + + uint256 newTarget = 21e9; + uint256 newTickSize = 11e9; + uint256 newMinPrice = 14e18; + + // Expect event + vm.expectEmit(true, true, true, true); + emit AuctionParametersUpdated(newTarget, newTickSize, newMinPrice); + + // Call function + vm.prank(heart); + auctioneer.setAuctionParameters(newTarget, newTickSize, newMinPrice); + + // Assert state + _assertAuctionParameters(newTarget, newTickSize, newMinPrice); + + // Assert current tick + // Values are unchanged + _assertPreviousTick(lastCapacity, lastPrice, newTickSize, lastUpdate); + + // Assert day state + _assertDayState(lastDeposits, lastConvertible); + + // Assert auction results + // Values are unchanged + _assertAuctionResults( + lastAuctionResults[0], + lastAuctionResults[1], + lastAuctionResults[2], + lastAuctionResults[3], + lastAuctionResults[4], + lastAuctionResults[5], + lastAuctionResults[6] + ); + _assertAuctionResultsNextIndex(lastAuctionResultsIndex); + } + + function test_contractActive() public givenInitialized givenRecipientHasBid(1e18) { + uint256 lastConvertible = auctioneer.getDayState().convertible; + uint256 lastDeposits = auctioneer.getDayState().deposits; + int256[] memory lastAuctionResults = auctioneer.getAuctionResults(); + uint8 lastAuctionResultsIndex = auctioneer.getAuctionResultsNextIndex(); + uint256 lastCapacity = auctioneer.getPreviousTick().capacity; + uint256 lastPrice = auctioneer.getPreviousTick().price; + uint48 lastUpdate = uint48(block.timestamp); + + // Warp to change the block timestamp + vm.warp(lastUpdate + 1); + + uint256 newTarget = 21e9; + uint256 newTickSize = 11e9; + uint256 newMinPrice = 14e18; + + // Expect event + vm.expectEmit(true, true, true, true); + emit AuctionParametersUpdated(newTarget, newTickSize, newMinPrice); + + // Call function + vm.prank(heart); + auctioneer.setAuctionParameters(newTarget, newTickSize, newMinPrice); + + // Assert state + _assertAuctionParameters(newTarget, newTickSize, newMinPrice); + + // Assert current tick + // Values are unchanged + _assertPreviousTick(lastCapacity, lastPrice, newTickSize, lastUpdate); + + // Assert day state + // Values are unchanged + _assertDayState(lastDeposits, lastConvertible); + + // Assert auction results + // Values are unchanged + _assertAuctionResults( + lastAuctionResults[0], + lastAuctionResults[1], + lastAuctionResults[2], + lastAuctionResults[3], + lastAuctionResults[4], + lastAuctionResults[5], + lastAuctionResults[6] + ); + _assertAuctionResultsNextIndex(lastAuctionResultsIndex); + } + + function test_newTickSizeLessThanCurrentTickCapacity( + uint256 newTickSize_ + ) public givenInitialized { + uint48 lastUpdate = uint48(block.timestamp); + + // Warp to change the block timestamp + vm.warp(lastUpdate + 1); + + uint256 newTickSize = bound(newTickSize_, 1, TICK_SIZE); + + // Call function + vm.prank(heart); + auctioneer.setAuctionParameters(TARGET, newTickSize, MIN_PRICE); + + // Assert state + _assertAuctionParameters(TARGET, newTickSize, MIN_PRICE); + + // Assert current tick + // Tick capacity has been adjusted to the new tick size + _assertPreviousTick(newTickSize, MIN_PRICE, newTickSize, lastUpdate); + } + + function test_newTickSizeGreaterThanCurrentTickCapacity( + uint256 newTickSize_ + ) public givenInitialized { + uint48 lastUpdate = uint48(block.timestamp); + + // Warp to change the block timestamp + vm.warp(lastUpdate + 1); + + uint256 newTickSize = bound(newTickSize_, TICK_SIZE, 2 * TICK_SIZE); + + // Call function + vm.prank(heart); + auctioneer.setAuctionParameters(TARGET, newTickSize, MIN_PRICE); + + // Assert state + _assertAuctionParameters(TARGET, newTickSize, MIN_PRICE); + + // Assert current tick + // Tick capacity has been unchanged + _assertPreviousTick(TICK_SIZE, MIN_PRICE, newTickSize, lastUpdate); + } + + function test_newMinPriceGreaterThanCurrentTickPrice( + uint256 newMinPrice_ + ) public givenInitialized { + uint48 lastUpdate = uint48(block.timestamp); + + // Warp to change the block timestamp + vm.warp(lastUpdate + 1); + + uint256 newMinPrice = bound(newMinPrice_, MIN_PRICE + 1, 2 * MIN_PRICE); + + // Call function + vm.prank(heart); + auctioneer.setAuctionParameters(TARGET, TICK_SIZE, newMinPrice); + + // Assert state + _assertAuctionParameters(TARGET, TICK_SIZE, newMinPrice); + + // Assert current tick + // Tick price has been set to the new min price + _assertPreviousTick(TICK_SIZE, newMinPrice, TICK_SIZE, lastUpdate); + } + + function test_newMinPriceLessThanCurrentTickPrice( + uint256 newMinPrice_ + ) public givenInitialized { + uint48 lastUpdate = uint48(block.timestamp); + + // Warp to change the block timestamp + vm.warp(lastUpdate + 1); + + uint256 newMinPrice = bound(newMinPrice_, 1, MIN_PRICE); + + // Call function + vm.prank(heart); + auctioneer.setAuctionParameters(TARGET, TICK_SIZE, newMinPrice); + + // Assert state + _assertAuctionParameters(TARGET, TICK_SIZE, newMinPrice); + + // Assert current tick + // Tick price has been unchanged + _assertPreviousTick(TICK_SIZE, MIN_PRICE, TICK_SIZE, lastUpdate); + } + + function test_calledOnSameDay() + public + givenInitialized + givenRecipientHasBid(1e18) + givenAuctionParametersStandard + { + uint256 lastConvertible = auctioneer.getDayState().convertible; + uint256 lastDeposits = auctioneer.getDayState().deposits; + int256[] memory lastAuctionResults = auctioneer.getAuctionResults(); + uint8 lastAuctionResultsIndex = auctioneer.getAuctionResultsNextIndex(); + + // Warp to change the block timestamp within the same day + vm.warp(INITIAL_BLOCK + 1 hours); + + // Call function + vm.prank(heart); + auctioneer.setAuctionParameters(TARGET, TICK_SIZE, MIN_PRICE); + + // Assert day state + // Values are unchanged + _assertDayState(lastDeposits, lastConvertible); + + // Assert auction results + // Values are unchanged + _assertAuctionResults( + lastAuctionResults[0], + lastAuctionResults[1], + lastAuctionResults[2], + lastAuctionResults[3], + lastAuctionResults[4], + lastAuctionResults[5], + lastAuctionResults[6] + ); + _assertAuctionResultsNextIndex(lastAuctionResultsIndex); + } + + function test_calledOnDayTwo() + public + givenInitialized + givenRecipientHasBid(1e18) + givenAuctionParametersStandard + { + uint256 dayOneTarget = TARGET; + uint256 dayOneConvertible = auctioneer.getDayState().convertible; + + // Warp to day two + vm.warp(INITIAL_BLOCK + 1 days); + + // Expect event + vm.expectEmit(true, true, true, true); + emit AuctionResult(dayOneConvertible, dayOneTarget, 0); + + // Set parameters + uint256 dayTwoTarget = TARGET + 1; + vm.prank(heart); + auctioneer.setAuctionParameters(dayTwoTarget, TICK_SIZE, MIN_PRICE); + + // Bid + uint256 dayTwoDeposit = 2e18; + (uint256 dayTwoConvertible, ) = auctioneer.previewBid(dayTwoDeposit); + _mintAndBid(recipient, dayTwoDeposit); + + // Assert day state + // Values are updated for the current day + _assertDayState(dayTwoDeposit, dayTwoConvertible); + + // Assert auction results + // Values are updated for the previous day + _assertAuctionResults(int256(dayOneConvertible) - int256(dayOneTarget), 0, 0, 0, 0, 0, 0); + _assertAuctionResultsNextIndex(1); + } + + function test_calledOnDayEight() + public + givenInitialized + givenRecipientHasBid(1e18) + givenAuctionParametersStandard + { + int256[] memory expectedAuctionResults = new int256[](7); + { + uint256 dayOneTarget = TARGET; + uint256 dayOneConvertible = auctioneer.getDayState().convertible; + + expectedAuctionResults[0] = int256(dayOneConvertible) - int256(dayOneTarget); + } + + // Warp to day two + vm.warp(INITIAL_BLOCK + 1 days); + { + uint256 dayTwoDeposit = 2e18; + uint256 dayTwoTarget = TARGET + 1; + _setAuctionParameters(dayTwoTarget, TICK_SIZE, MIN_PRICE); + (uint256 dayTwoConvertible, ) = auctioneer.previewBid(dayTwoDeposit); + _mintAndBid(recipient, dayTwoDeposit); + + expectedAuctionResults[1] = int256(dayTwoConvertible) - int256(dayTwoTarget); + } + + // Warp to day three + vm.warp(INITIAL_BLOCK + 2 days); + { + uint256 dayThreeDeposit = 3e18; + uint256 dayThreeTarget = TARGET + 2; + _setAuctionParameters(dayThreeTarget, TICK_SIZE, MIN_PRICE); + (uint256 dayThreeConvertible, ) = auctioneer.previewBid(dayThreeDeposit); + _mintAndBid(recipient, dayThreeDeposit); + + expectedAuctionResults[2] = int256(dayThreeConvertible) - int256(dayThreeTarget); + } + + // Warp to day four + vm.warp(INITIAL_BLOCK + 3 days); + { + uint256 dayFourDeposit = 4e18; + uint256 dayFourTarget = TARGET + 3; + _setAuctionParameters(dayFourTarget, TICK_SIZE, MIN_PRICE); + (uint256 dayFourConvertible, ) = auctioneer.previewBid(dayFourDeposit); + _mintAndBid(recipient, dayFourDeposit); + + expectedAuctionResults[3] = int256(dayFourConvertible) - int256(dayFourTarget); + } + + // Warp to day five + vm.warp(INITIAL_BLOCK + 4 days); + { + uint256 dayFiveDeposit = 5e18; + uint256 dayFiveTarget = TARGET + 4; + _setAuctionParameters(dayFiveTarget, TICK_SIZE, MIN_PRICE); + (uint256 dayFiveConvertible, ) = auctioneer.previewBid(dayFiveDeposit); + _mintAndBid(recipient, dayFiveDeposit); + + expectedAuctionResults[4] = int256(dayFiveConvertible) - int256(dayFiveTarget); + } + + // Warp to day six + vm.warp(INITIAL_BLOCK + 5 days); + { + uint256 daySixDeposit = 6e18; + uint256 daySixTarget = TARGET + 5; + _setAuctionParameters(daySixTarget, TICK_SIZE, MIN_PRICE); + (uint256 daySixConvertible, ) = auctioneer.previewBid(daySixDeposit); + _mintAndBid(recipient, daySixDeposit); + + expectedAuctionResults[5] = int256(daySixConvertible) - int256(daySixTarget); + } + + // Warp to day seven + vm.warp(INITIAL_BLOCK + 6 days); + uint256 daySevenTarget = TARGET + 6; + uint256 daySevenConvertible; + { + uint256 daySevenDeposit = 7e18; + _setAuctionParameters(daySevenTarget, TICK_SIZE, MIN_PRICE); + (daySevenConvertible, ) = auctioneer.previewBid(daySevenDeposit); + _mintAndBid(recipient, daySevenDeposit); + + expectedAuctionResults[6] = int256(daySevenConvertible) - int256(daySevenTarget); + } + + // Warp to day eight + vm.warp(INITIAL_BLOCK + 7 days); + + // Expect event + vm.expectEmit(true, true, true, true); + emit AuctionResult(daySevenConvertible, daySevenTarget, 6); + + // Call function + uint256 dayEightTarget = TARGET + 7; + vm.prank(heart); + auctioneer.setAuctionParameters(dayEightTarget, TICK_SIZE, MIN_PRICE); + + // Bid + uint256 dayEightDeposit = 8e18; + (uint256 dayEightConvertible, ) = auctioneer.previewBid(dayEightDeposit); + _mintAndBid(recipient, dayEightDeposit); + + // Assert day state + // Values are updated for the current day + _assertDayState(dayEightDeposit, dayEightConvertible); + + // Assert auction results + // Values are updated for the previous day + _assertAuctionResults(expectedAuctionResults); + _assertAuctionResultsNextIndex(0); + } + + function test_calledOnDayNine() + public + givenInitialized + givenRecipientHasBid(1e18) + givenAuctionParametersStandard + { + // Warp to day two + vm.warp(INITIAL_BLOCK + 1 days); + uint256 dayTwoDeposit = 2e18; + uint256 dayTwoTarget = TARGET + 1; + _setAuctionParameters(dayTwoTarget, TICK_SIZE, MIN_PRICE); + auctioneer.previewBid(dayTwoDeposit); + _mintAndBid(recipient, dayTwoDeposit); + + // Warp to day three + vm.warp(INITIAL_BLOCK + 2 days); + uint256 dayThreeDeposit = 3e18; + uint256 dayThreeTarget = TARGET + 2; + _setAuctionParameters(dayThreeTarget, TICK_SIZE, MIN_PRICE); + auctioneer.previewBid(dayThreeDeposit); + _mintAndBid(recipient, dayThreeDeposit); + + // Warp to day four + vm.warp(INITIAL_BLOCK + 3 days); + uint256 dayFourDeposit = 4e18; + uint256 dayFourTarget = TARGET + 3; + _setAuctionParameters(dayFourTarget, TICK_SIZE, MIN_PRICE); + auctioneer.previewBid(dayFourDeposit); + _mintAndBid(recipient, dayFourDeposit); + + // Warp to day five + vm.warp(INITIAL_BLOCK + 4 days); + uint256 dayFiveDeposit = 5e18; + uint256 dayFiveTarget = TARGET + 4; + _setAuctionParameters(dayFiveTarget, TICK_SIZE, MIN_PRICE); + auctioneer.previewBid(dayFiveDeposit); + _mintAndBid(recipient, dayFiveDeposit); + + // Warp to day six + vm.warp(INITIAL_BLOCK + 5 days); + uint256 daySixDeposit = 6e18; + uint256 daySixTarget = TARGET + 5; + _setAuctionParameters(daySixTarget, TICK_SIZE, MIN_PRICE); + auctioneer.previewBid(daySixDeposit); + _mintAndBid(recipient, daySixDeposit); + + // Warp to day seven + vm.warp(INITIAL_BLOCK + 6 days); + uint256 daySevenDeposit = 7e18; + uint256 daySevenTarget = TARGET + 6; + _setAuctionParameters(daySevenTarget, TICK_SIZE, MIN_PRICE); + auctioneer.previewBid(daySevenDeposit); + _mintAndBid(recipient, daySevenDeposit); + + // Warp to day eight + vm.warp(INITIAL_BLOCK + 7 days); + uint256 dayEightDeposit = 8e18; + uint256 dayEightTarget = TARGET + 7; + _setAuctionParameters(dayEightTarget, TICK_SIZE, MIN_PRICE); + (uint256 dayEightConvertible, ) = auctioneer.previewBid(dayEightDeposit); + _mintAndBid(recipient, dayEightDeposit); + + // Warp to day nine + vm.warp(INITIAL_BLOCK + 8 days); + uint256 dayNineDeposit = 9e18; + uint256 dayNineTarget = TARGET + 8; + + // Expect event + vm.expectEmit(true, true, true, true); + emit AuctionResult(dayEightConvertible, dayEightTarget, 0); + + // Call function + vm.prank(heart); + auctioneer.setAuctionParameters(dayNineTarget, TICK_SIZE, MIN_PRICE); + + // Bid + (uint256 dayNineConvertible, ) = auctioneer.previewBid(dayNineDeposit); + _mintAndBid(recipient, dayNineDeposit); + + // Assert day state + // Values are updated for the current day + _assertDayState(dayNineDeposit, dayNineConvertible); + + // Assert auction results + // Values are updated for the previous day + _assertAuctionResults( + int256(dayEightConvertible) - int256(dayEightTarget), + 0, + 0, + 0, + 0, + 0, + 0 + ); + _assertAuctionResultsNextIndex(1); + } +} diff --git a/src/test/policies/ConvertibleDepositAuctioneer/setAuctionTrackingPeriod.t.sol b/src/test/policies/ConvertibleDepositAuctioneer/setAuctionTrackingPeriod.t.sol new file mode 100644 index 00000000..4dc43d5f --- /dev/null +++ b/src/test/policies/ConvertibleDepositAuctioneer/setAuctionTrackingPeriod.t.sol @@ -0,0 +1,198 @@ +// SPDX-License-Identifier: Unlicensed +pragma solidity 0.8.15; + +import {ConvertibleDepositAuctioneerTest} from "./ConvertibleDepositAuctioneerTest.sol"; +import {IConvertibleDepositAuctioneer} from "src/policies/interfaces/IConvertibleDepositAuctioneer.sol"; + +contract ConvertibleDepositAuctioneerSetAuctionTrackingPeriodTest is + ConvertibleDepositAuctioneerTest +{ + // given the caller does not have the admin role + // [X] it reverts + // when the auction tracking period is 0 + // [X] it reverts + // given the contract is not initialized + // [X] the array length is set to the tracking period + // [X] it sets the auction tracking period + // [X] it emits an event + // [X] it resets the auction results + // [X] it resets the auction results index + // given the contract is deactivated + // [X] the array length is set to the tracking period + // [X] it sets the auction tracking period + // [X] it emits an event + // [X] it resets the auction results + // [X] it resets the auction results index + // given the previous auction tracking period is less + // [X] the array length is increased to the tracking period + // [X] it sets the auction tracking period + // [X] it emits an event + // [X] it resets the auction results + // [X] it resets the auction results index + // given the previous auction tracking period is the same + // [X] the array length is unchanged + // [X] it sets the auction tracking period + // [X] it emits an event + // [X] it resets the auction results + // [X] it resets the auction results index + // given the previous auction tracking period is greater + // [X] the array length is reduced to the tracking period + // [X] it sets the auction tracking period + // [X] it emits an event + // [X] it resets the auction results + // [X] it resets the auction results index + // given there are previous auction results + // [X] it resets the auction results + + function test_callerDoesNotHaveAdminRole_reverts() public givenInitialized { + // Expect revert + _expectRoleRevert("cd_admin"); + + // Call function + vm.prank(recipient); + auctioneer.setAuctionTrackingPeriod(AUCTION_TRACKING_PERIOD); + } + + function test_auctionTrackingPeriodZero_reverts() public givenInitialized { + // Expect revert + vm.expectRevert( + abi.encodeWithSelector( + IConvertibleDepositAuctioneer.CDAuctioneer_InvalidParams.selector, + "auction tracking period" + ) + ); + + // Call function + vm.prank(admin); + auctioneer.setAuctionTrackingPeriod(0); + } + + function test_contractNotInitialized() public { + // Call function + vm.prank(admin); + auctioneer.setAuctionTrackingPeriod(AUCTION_TRACKING_PERIOD + 1); + + // Assert state + assertEq( + auctioneer.getAuctionTrackingPeriod(), + AUCTION_TRACKING_PERIOD + 1, + "auction tracking period" + ); + assertEq( + auctioneer.getAuctionResults().length, + AUCTION_TRACKING_PERIOD + 1, + "auction results length" + ); + _assertAuctionResultsEmpty(AUCTION_TRACKING_PERIOD + 1); + _assertAuctionResultsNextIndex(0); + } + + function test_contractDeactivated() public givenInitialized givenContractInactive { + // Call function + vm.prank(admin); + auctioneer.setAuctionTrackingPeriod(AUCTION_TRACKING_PERIOD + 1); + + // Assert state + assertEq( + auctioneer.getAuctionTrackingPeriod(), + AUCTION_TRACKING_PERIOD + 1, + "auction tracking period" + ); + assertEq( + auctioneer.getAuctionResults().length, + AUCTION_TRACKING_PERIOD + 1, + "auction results length" + ); + _assertAuctionResultsEmpty(AUCTION_TRACKING_PERIOD + 1); + _assertAuctionResultsNextIndex(0); + } + + function test_previousTrackingPeriodLess() public givenInitialized { + // Expect event + vm.expectEmit(true, true, true, true); + emit AuctionTrackingPeriodUpdated(AUCTION_TRACKING_PERIOD + 1); + + // Call function + vm.prank(admin); + auctioneer.setAuctionTrackingPeriod(AUCTION_TRACKING_PERIOD + 1); + + // Assert state + assertEq( + auctioneer.getAuctionTrackingPeriod(), + AUCTION_TRACKING_PERIOD + 1, + "auction tracking period" + ); + assertEq( + auctioneer.getAuctionResults().length, + AUCTION_TRACKING_PERIOD + 1, + "auction results length" + ); + _assertAuctionResultsEmpty(AUCTION_TRACKING_PERIOD + 1); + _assertAuctionResultsNextIndex(0); + } + + function test_previousTrackingPeriodSame() public givenInitialized { + // Call function + vm.prank(admin); + auctioneer.setAuctionTrackingPeriod(AUCTION_TRACKING_PERIOD); + + // Assert state + assertEq( + auctioneer.getAuctionTrackingPeriod(), + AUCTION_TRACKING_PERIOD, + "auction tracking period" + ); + assertEq( + auctioneer.getAuctionResults().length, + AUCTION_TRACKING_PERIOD, + "auction results length" + ); + _assertAuctionResultsEmpty(AUCTION_TRACKING_PERIOD); + _assertAuctionResultsNextIndex(0); + } + + function test_previousTrackingPeriodGreater() public givenInitialized { + // Call function + vm.prank(admin); + auctioneer.setAuctionTrackingPeriod(AUCTION_TRACKING_PERIOD - 1); + + // Assert state + assertEq( + auctioneer.getAuctionTrackingPeriod(), + AUCTION_TRACKING_PERIOD - 1, + "auction tracking period" + ); + assertEq( + auctioneer.getAuctionResults().length, + AUCTION_TRACKING_PERIOD - 1, + "auction results length" + ); + _assertAuctionResultsEmpty(AUCTION_TRACKING_PERIOD - 1); + _assertAuctionResultsNextIndex(0); + } + + function test_previousAuctionResults() public givenInitialized givenRecipientHasBid(1e18) { + // Warp to the next day and trigger storage of the previous day's results + vm.warp(block.timestamp + 1 days); + vm.prank(heart); + auctioneer.setAuctionParameters(TARGET, TICK_SIZE, MIN_PRICE); + + // Call function + vm.prank(admin); + auctioneer.setAuctionTrackingPeriod(AUCTION_TRACKING_PERIOD); + + // Assert state + assertEq( + auctioneer.getAuctionTrackingPeriod(), + AUCTION_TRACKING_PERIOD, + "auction tracking period" + ); + assertEq( + auctioneer.getAuctionResults().length, + AUCTION_TRACKING_PERIOD, + "auction results length" + ); + _assertAuctionResultsEmpty(AUCTION_TRACKING_PERIOD); + _assertAuctionResultsNextIndex(0); + } +} diff --git a/src/test/policies/ConvertibleDepositAuctioneer/setTickStep.t.sol b/src/test/policies/ConvertibleDepositAuctioneer/setTickStep.t.sol new file mode 100644 index 00000000..9014e3c2 --- /dev/null +++ b/src/test/policies/ConvertibleDepositAuctioneer/setTickStep.t.sol @@ -0,0 +1,95 @@ +// SPDX-License-Identifier: Unlicensed +pragma solidity 0.8.15; + +import {ConvertibleDepositAuctioneerTest} from "./ConvertibleDepositAuctioneerTest.sol"; +import {IConvertibleDepositAuctioneer} from "src/policies/interfaces/IConvertibleDepositAuctioneer.sol"; + +contract ConvertibleDepositAuctioneerTickStepTest is ConvertibleDepositAuctioneerTest { + // when the caller does not have the "cd_admin" role + // [X] it reverts + // given the contract is not initialized + // [X] it sets the tick step + // when the value is < 100e2 + // [X] it reverts + // when the contract is deactivated + // [X] it sets the tick step + // [X] it sets the tick step + // [X] it emits an event + + function test_callerDoesNotHaveCdAdminRole_reverts(address caller_) public { + // Ensure caller is not admin + vm.assume(caller_ != admin); + + // Expect revert + _expectRoleRevert("cd_admin"); + + // Call function + vm.prank(caller_); + auctioneer.setTickStep(100e2); + } + + function test_contractNotInitialized() public { + // Call function + vm.prank(admin); + auctioneer.setTickStep(100e2); + + // Assert state + assertEq(auctioneer.getTickStep(), 100e2, "tick step"); + } + + function test_valueIsOutOfBounds_reverts(uint24 tickStep_) public { + uint24 tickStep = uint24(bound(tickStep_, 0, 100e2 - 1)); + + // Expect revert + vm.expectRevert( + abi.encodeWithSelector( + IConvertibleDepositAuctioneer.CDAuctioneer_InvalidParams.selector, + "tick step" + ) + ); + + // Call function + vm.prank(admin); + auctioneer.setTickStep(tickStep); + } + + function test_contractInactive(uint24 tickStep_) public givenInitialized givenContractInactive { + uint24 tickStep = uint24(bound(tickStep_, 100e2, type(uint24).max)); + + uint48 lastUpdate = uint48(block.timestamp); + + // Warp to change the block timestamp + vm.warp(lastUpdate + 1); + + // Expect event + vm.expectEmit(true, true, true, true); + emit TickStepUpdated(tickStep); + + // Call function + vm.prank(admin); + auctioneer.setTickStep(tickStep); + + // Assert state + assertEq(auctioneer.getTickStep(), tickStep, "tick step"); + } + + function test_contractActive(uint24 tickStep_) public givenInitialized { + uint24 tickStep = uint24(bound(tickStep_, 100e2, type(uint24).max)); + + uint48 lastUpdate = uint48(block.timestamp); + + // Warp to change the block timestamp + vm.warp(lastUpdate + 1); + + // Expect event + vm.expectEmit(true, true, true, true); + emit TickStepUpdated(tickStep); + + // Call function + vm.prank(admin); + auctioneer.setTickStep(tickStep); + + // Assert state + assertEq(auctioneer.getTickStep(), tickStep, "tick step"); + } +} diff --git a/src/test/policies/ConvertibleDepositAuctioneer/setTimeToExpiry.t.sol b/src/test/policies/ConvertibleDepositAuctioneer/setTimeToExpiry.t.sol new file mode 100644 index 00000000..e98ef1c3 --- /dev/null +++ b/src/test/policies/ConvertibleDepositAuctioneer/setTimeToExpiry.t.sol @@ -0,0 +1,91 @@ +// SPDX-License-Identifier: Unlicensed +pragma solidity 0.8.15; + +import {ConvertibleDepositAuctioneerTest} from "./ConvertibleDepositAuctioneerTest.sol"; +import {IConvertibleDepositAuctioneer} from "src/policies/interfaces/IConvertibleDepositAuctioneer.sol"; + +contract ConvertibleDepositAuctioneerTimeToExpiryTest is ConvertibleDepositAuctioneerTest { + // when the caller does not have the "cd_admin" role + // [X] it reverts + // when the new time to expiry is 0 + // [X] it reverts + // given the contract is not initialized + // [X] it sets the time to expiry + // when the contract is deactivated + // [X] it sets the time to expiry + // [X] it sets the time to expiry + // [X] it emits an event + + function test_callerDoesNotHaveCdAdminRole_reverts(address caller_) public { + // Ensure caller is not admin + vm.assume(caller_ != admin); + + // Expect revert + _expectRoleRevert("cd_admin"); + + // Call function + vm.prank(caller_); + auctioneer.setTimeToExpiry(100); + } + + function test_contractNotInitialized() public { + // Call function + vm.prank(admin); + auctioneer.setTimeToExpiry(100); + + // Assert state + assertEq(auctioneer.getTimeToExpiry(), 100, "time to expiry"); + } + + function test_newTimeToExpiryZero_reverts() public { + // Expect revert + vm.expectRevert( + abi.encodeWithSelector( + IConvertibleDepositAuctioneer.CDAuctioneer_InvalidParams.selector, + "time to expiry" + ) + ); + + // Call function + vm.prank(admin); + auctioneer.setTimeToExpiry(0); + } + + function test_contractInactive() public givenInitialized givenContractInactive { + uint48 lastUpdate = uint48(block.timestamp); + + // Warp to change the block timestamp + vm.warp(lastUpdate + 1); + + // Expect event + vm.expectEmit(true, true, true, true); + emit TimeToExpiryUpdated(100); + + // Call function + vm.prank(admin); + auctioneer.setTimeToExpiry(100); + + // Assert state + assertEq(auctioneer.getTimeToExpiry(), 100, "time to expiry"); + } + + function test_contractActive(uint48 timeToExpiry_) public givenInitialized { + uint48 timeToExpiry = uint48(bound(timeToExpiry_, 1, 1 weeks)); + + uint48 lastUpdate = uint48(block.timestamp); + + // Warp to change the block timestamp + vm.warp(lastUpdate + 1); + + // Expect event + vm.expectEmit(true, true, true, true); + emit TimeToExpiryUpdated(timeToExpiry); + + // Call function + vm.prank(admin); + auctioneer.setTimeToExpiry(timeToExpiry); + + // Assert state + assertEq(auctioneer.getTimeToExpiry(), timeToExpiry, "time to expiry"); + } +} diff --git a/src/test/policies/ConvertibleDepositFacility/ConvertibleDepositFacilityTest.sol b/src/test/policies/ConvertibleDepositFacility/ConvertibleDepositFacilityTest.sol new file mode 100644 index 00000000..c1a46469 --- /dev/null +++ b/src/test/policies/ConvertibleDepositFacility/ConvertibleDepositFacilityTest.sol @@ -0,0 +1,133 @@ +// 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"; +import {ROLESv1} from "src/modules/ROLES/ROLES.v1.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); + address public recipientTwo = address(0x3); + address public emergency = address(0x4); + + 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); + + 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); + rolesAdmin.grantRole(bytes32("emergency_shutdown"), emergency); + } + + // ========== 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 returns (uint256 positionId) { + vm.prank(auctioneer); + positionId = facility.create(account_, amount_, conversionPrice_, expiry_, wrap_); + } + + modifier givenAddressHasPosition(address account_, uint256 amount_) { + _createPosition(account_, amount_, CONVERSION_PRICE, EXPIRY, false); + _; + } + + modifier givenConvertibleDepositTokenSpendingIsApproved( + address owner_, + address spender_, + uint256 amount_ + ) { + vm.prank(owner_); + convertibleDepository.approve(spender_, amount_); + _; + } + + modifier givenLocallyActive() { + vm.prank(emergency); + facility.activate(); + _; + } + + modifier givenLocallyInactive() { + vm.prank(emergency); + facility.deactivate(); + _; + } + + // ========== ASSERTIONS ========== // + + function _expectRoleRevert(bytes32 role_) internal { + vm.expectRevert(abi.encodeWithSelector(ROLESv1.ROLES_RequireRole.selector, role_)); + } +} diff --git a/src/test/policies/ConvertibleDepositFacility/activate.t.sol b/src/test/policies/ConvertibleDepositFacility/activate.t.sol new file mode 100644 index 00000000..d327521e --- /dev/null +++ b/src/test/policies/ConvertibleDepositFacility/activate.t.sol @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: Unlicensed +pragma solidity 0.8.15; + +import {ConvertibleDepositFacilityTest} from "./ConvertibleDepositFacilityTest.sol"; + +contract ActivateCDFTest is ConvertibleDepositFacilityTest { + event Activated(); + + // given the caller does not have the emergency_shutdown role + // [X] it reverts + // given the contract is already active + // [X] it does nothing + // [X] it sets the contract to active + // [X] it emits an Activated event + + function test_callerDoesNotHaveRole_reverts() public { + _expectRoleRevert("emergency_shutdown"); + + // Call function + facility.activate(); + } + + function test_contractActive() public givenLocallyActive { + // Call function + vm.prank(emergency); + facility.activate(); + + // Assert state + assertEq(facility.locallyActive(), true, "active"); + } + + function test_success() public { + // Emits event + vm.expectEmit(true, true, true, true); + emit Activated(); + + // Call function + vm.prank(emergency); + facility.activate(); + + // Assert state + assertEq(facility.locallyActive(), true, "active"); + } +} diff --git a/src/test/policies/ConvertibleDepositFacility/constructor.t.sol b/src/test/policies/ConvertibleDepositFacility/constructor.t.sol new file mode 100644 index 00000000..fe7ce9c4 --- /dev/null +++ b/src/test/policies/ConvertibleDepositFacility/constructor.t.sol @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: Unlicensed +pragma solidity 0.8.15; + +import {ConvertibleDepositFacilityTest} from "./ConvertibleDepositFacilityTest.sol"; + +import {CDFacility} from "src/policies/CDFacility.sol"; + +contract ConstructorCDFTest is ConvertibleDepositFacilityTest { + // [X] it sets the contract to inactive + + function test_success() public { + facility = new CDFacility(address(kernel)); + + // Assert state + assertEq(facility.locallyActive(), false, "inactive"); + } +} diff --git a/src/test/policies/ConvertibleDepositFacility/convert.t.sol b/src/test/policies/ConvertibleDepositFacility/convert.t.sol new file mode 100644 index 00000000..187df565 --- /dev/null +++ b/src/test/policies/ConvertibleDepositFacility/convert.t.sol @@ -0,0 +1,495 @@ +// SPDX-License-Identifier: Unlicensed +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"; +import {MINTRv1} from "src/modules/MINTR/MINTR.v1.sol"; + +contract ConvertCDFTest is ConvertibleDepositFacilityTest { + event ConvertedDeposit(address indexed user, uint256 depositAmount, uint256 convertedAmount); + + // given the contract is inactive + // [X] it reverts + // 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 an owner that is not the caller + // [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 + // 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 + // [X] it returns the total deposit amount and the converted amount + // [X] it emits a ConvertedDeposit event + + function test_contractInactive_reverts() public { + // Expect revert + vm.expectRevert(abi.encodeWithSelector(IConvertibleDepositFacility.CDF_NotActive.selector)); + + // Call function + facility.convert(new uint256[](0), new uint256[](0)); + } + + function test_arrayLengthMismatch_reverts() public givenLocallyActive { + 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 + givenLocallyActive + 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 + givenLocallyActive + 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_allPositionsHaveDifferentOwner_reverts() + public + givenLocallyActive + 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 + vm.prank(recipientTwo); + facility.convert(positionIds_, amounts_); + } + + function test_anyPositionHasExpired_reverts( + uint256 positionIndex_ + ) + public + givenLocallyActive + 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 + givenLocallyActive + 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 + givenLocallyActive + 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_convertedAmountIsZero_reverts() + public + givenLocallyActive + 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 + givenLocallyActive + 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 not 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)"); + } + + function test_success_fuzz( + uint256 amountOne_, + uint256 amountTwo_ + ) + public + givenLocallyActive + 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 originalMintApproval = minter.mintApproval(address(facility)); + 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)"); + + // Assert the remaining mint approval + assertEq( + minter.mintApproval(address(facility)), + originalMintApproval - expectedConvertedAmount, + "mintApproval" + ); + + // Assert the remaining deposit of each position + assertEq( + convertibleDepositPositions.getPosition(0).remainingDeposit, + 5e18 - amountOne, + "remainingDeposit[0]" + ); + assertEq( + convertibleDepositPositions.getPosition(1).remainingDeposit, + 5e18 - amountTwo, + "remainingDeposit[1]" + ); + + // 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 new file mode 100644 index 00000000..216e8c4d --- /dev/null +++ b/src/test/policies/ConvertibleDepositFacility/create.t.sol @@ -0,0 +1,164 @@ +// SPDX-License-Identifier: Unlicensed +pragma solidity 0.8.15; + +import {ConvertibleDepositFacilityTest} from "./ConvertibleDepositFacilityTest.sol"; + +import {ROLESv1} from "src/modules/ROLES/ROLES.v1.sol"; +import {IConvertibleDepositFacility} from "src/policies/interfaces/IConvertibleDepositFacility.sol"; + +contract CreateCDFTest is ConvertibleDepositFacilityTest { + event CreatedDeposit(address indexed user, uint256 indexed termId, uint256 amount); + + // given the contract is inactive + // [X] it reverts + // 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_contractInactive_reverts() public { + // Expect revert + vm.expectRevert(abi.encodeWithSelector(IConvertibleDepositFacility.CDF_NotActive.selector)); + + // Call function + vm.prank(auctioneer); + facility.create(recipient, RESERVE_TOKEN_AMOUNT, CONVERSION_PRICE, EXPIRY, false); + } + + function test_callerNotAuctioneer_reverts() + public + givenLocallyActive + 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 + givenLocallyActive + 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 + givenLocallyActive + givenAddressHasReserveToken(recipient, RESERVE_TOKEN_AMOUNT) + givenReserveTokenSpendingIsApproved( + recipient, + address(convertibleDepository), + 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); + + // Call function + uint256 positionId = _createPosition( + recipient, + RESERVE_TOKEN_AMOUNT, + CONVERSION_PRICE, + EXPIRY, + false + ); + + // Assert that the position ID is 0 + assertEq(positionId, 0, "positionId"); + + // Assert that the reserve token was transferred from the recipient + 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, + "convertibleDepository.balanceOf(recipient)" + ); + + // Assert that the recipient has a CDPOS position + uint256[] memory positionIds = convertibleDepositPositions.getUserPositionIds(recipient); + assertEq(positionIds.length, 1, "positionIds.length"); + assertEq(positionIds[0], 0, "positionIds[0]"); + + // Assert that the mint approval was increased + assertEq( + minter.mintApproval(address(facility)), + expectedOhmAmount, + "minter.mintApproval(address(facility))" + ); + } + + function test_success_multiple() + public + givenLocallyActive + givenAddressHasReserveToken(recipient, RESERVE_TOKEN_AMOUNT) + givenReserveTokenSpendingIsApproved( + recipient, + address(convertibleDepository), + 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); + + // 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, "positionId2"); + + // Assert that the reserve token was transferred from the recipient + 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, + "convertibleDepository.balanceOf(recipient)" + ); + + // Assert that the recipient has two CDPOS positions + uint256[] memory positionIds = convertibleDepositPositions.getUserPositionIds(recipient); + 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)), + expectedOhmAmount, + "minter.mintApproval(address(facility))" + ); + } +} diff --git a/src/test/policies/ConvertibleDepositFacility/deactivate.t.sol b/src/test/policies/ConvertibleDepositFacility/deactivate.t.sol new file mode 100644 index 00000000..cc7650d5 --- /dev/null +++ b/src/test/policies/ConvertibleDepositFacility/deactivate.t.sol @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: Unlicensed +pragma solidity 0.8.15; + +import {ConvertibleDepositFacilityTest} from "./ConvertibleDepositFacilityTest.sol"; + +contract DeactivateCDFTest is ConvertibleDepositFacilityTest { + event Deactivated(); + + // given the caller does not have the emergency_shutdown role + // [X] it reverts + // given the contract is already inactive + // [X] it does nothing + // [X] it sets the contract to inactive + // [X] it emits a Deactivated event + + function test_callerDoesNotHaveRole_reverts() public { + _expectRoleRevert("emergency_shutdown"); + + facility.deactivate(); + } + + function test_contractInactive() public { + vm.prank(emergency); + facility.deactivate(); + + assertEq(facility.locallyActive(), false, "inactive"); + } + + function test_success() public givenLocallyActive { + vm.prank(emergency); + facility.deactivate(); + + assertEq(facility.locallyActive(), false, "inactive"); + } +} diff --git a/src/test/policies/ConvertibleDepositFacility/previewConvert.t.sol b/src/test/policies/ConvertibleDepositFacility/previewConvert.t.sol new file mode 100644 index 00000000..8021f1a7 --- /dev/null +++ b/src/test/policies/ConvertibleDepositFacility/previewConvert.t.sol @@ -0,0 +1,339 @@ +// 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 { + // given the contract is inactive + // [X] it reverts + // 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 + // when the amount is 0 + // [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 + + function test_contractInactive_reverts() public { + // Expect revert + vm.expectRevert(abi.encodeWithSelector(IConvertibleDepositFacility.CDF_NotActive.selector)); + + // Call function + facility.previewConvert(recipient, new uint256[](0), new uint256[](0)); + } + + function test_arrayLengthMismatch_reverts() public givenLocallyActive { + 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(recipient, positionIds_, amounts_); + } + + function test_anyPositionIsNotValid_reverts( + uint256 positionIndex_ + ) + public + givenLocallyActive + 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, 2)); + + // Call function + facility.previewConvert(recipient, positionIds_, amounts_); + } + + function test_anyPositionHasDifferentOwner_reverts( + uint256 positionIndex_ + ) + public + givenLocallyActive + 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 + givenLocallyActive + 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( + uint256 positionIndex_ + ) + public + givenLocallyActive + 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 + facility.previewConvert(recipient, positionIds_, amounts_); + } + + function test_anyAmountIsGreaterThanRemainingDeposit_reverts( + uint256 positionIndex_ + ) + public + givenLocallyActive + 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(recipient, positionIds_, amounts_); + } + + function test_amountIsZero_reverts() + public + givenLocallyActive + 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(recipient, positionIds_, amounts_); + } + + function test_convertedAmountIsZero_reverts() + public + givenLocallyActive + 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(recipient, positionIds_, amounts_); + } + + function test_success( + uint256 amountOne_, + uint256 amountTwo_, + uint256 amountThree_ + ) + public + givenLocallyActive + 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( + recipient, + positionIds_, + amounts_ + ); + + // 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"); + } +} diff --git a/src/test/policies/ConvertibleDepositFacility/previewReclaim.t.sol b/src/test/policies/ConvertibleDepositFacility/previewReclaim.t.sol new file mode 100644 index 00000000..3f9061f6 --- /dev/null +++ b/src/test/policies/ConvertibleDepositFacility/previewReclaim.t.sol @@ -0,0 +1,316 @@ +// 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 PreviewReclaimCDFTest is ConvertibleDepositFacilityTest { + // given the contract is inactive + // [X] it reverts + // when the length of the positionIds_ array does not match the length of the amounts_ array + // [X] it reverts + // when the account_ is not the owner of all of the positions + // [X] it reverts + // when any position is not valid + // [X] it reverts + // when any position has not expired + // [X] it reverts + // when any position has an amount greater than the remaining deposit + // [X] it reverts + // when the reclaim amount is 0 + // [X] it reverts + // [X] it returns the total amount of deposit token that would be reclaimed + // [X] it returns the address that will spend the convertible deposit tokens + + function test_contractInactive_reverts() public { + // Expect revert + vm.expectRevert(abi.encodeWithSelector(IConvertibleDepositFacility.CDF_NotActive.selector)); + + // Call function + facility.previewReclaim(recipient, new uint256[](0), new uint256[](0)); + } + + function test_arrayLengthMismatch_reverts() public givenLocallyActive { + 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( + uint256 positionIndex_ + ) + public + givenLocallyActive + 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; + } + + // Warp to beyond the normal expiry + vm.warp(EXPIRY); + + // Expect revert + vm.expectRevert( + abi.encodeWithSelector(IConvertibleDepositFacility.CDF_NotOwner.selector, positionIndex) + ); + + // Call function + facility.previewReclaim(recipient, positionIds_, amounts_); + } + + function test_allPositionsHaveDifferentOwner_reverts() + public + givenLocallyActive + 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; + + // Warp to beyond the normal expiry + vm.warp(EXPIRY); + + // Expect revert + vm.expectRevert( + abi.encodeWithSelector(IConvertibleDepositFacility.CDF_NotOwner.selector, 0) + ); + + // Call function + facility.previewReclaim(recipientTwo, positionIds_, amounts_); + } + + function test_anyPositionIsNotValid_reverts( + uint256 positionIndex_ + ) + public + givenLocallyActive + 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; + } + } + + // Warp to beyond the normal expiry + vm.warp(EXPIRY); + + // Expect revert + vm.expectRevert(abi.encodeWithSelector(CDPOSv1.CDPOS_InvalidPositionId.selector, 2)); + + // Call function + facility.previewReclaim(recipient, positionIds_, amounts_); + } + + function test_anyPositionHasNotExpired_reverts( + uint256 positionIndex_ + ) + public + givenLocallyActive + 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 = EXPIRY; + if (positionIndex == i) { + expiry = EXPIRY + 1; + } + + // Create position + uint256 positionId = _createPosition(recipient, 3e18, CONVERSION_PRICE, expiry, false); + + positionIds_[i] = positionId; + amounts_[i] = 3e18; + } + + // Warp to beyond the normal expiry + vm.warp(EXPIRY); + + // Expect revert + vm.expectRevert( + abi.encodeWithSelector( + IConvertibleDepositFacility.CDF_PositionNotExpired.selector, + positionIndex + ) + ); + + // Call function + facility.previewReclaim(recipient, positionIds_, amounts_); + } + + function test_anyAmountIsGreaterThanRemainingDeposit_reverts( + uint256 positionIndex_ + ) + public + givenLocallyActive + 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; + } + } + + // Warp to beyond the normal expiry + vm.warp(EXPIRY); + + // Expect revert + vm.expectRevert( + abi.encodeWithSelector( + IConvertibleDepositFacility.CDF_InvalidAmount.selector, + positionIndex, + 4e18 + ) + ); + + // Call function + facility.previewReclaim(recipient, positionIds_, amounts_); + } + + function test_amountIsZero_reverts() + public + givenLocallyActive + 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; + + // Warp to the normal expiry + vm.warp(EXPIRY); + + // Expect revert + vm.expectRevert( + abi.encodeWithSelector(IConvertibleDepositFacility.CDF_InvalidArgs.selector, "amount") + ); + + // Call function + facility.previewReclaim(recipient, positionIds_, amounts_); + } + + function test_success( + uint256 amountOne_, + uint256 amountTwo_, + uint256 amountThree_ + ) + public + givenLocallyActive + 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; + + // Warp to the normal expiry + vm.warp(EXPIRY); + + // Call function + (uint256 reclaimed, address spender) = facility.previewReclaim( + recipient, + positionIds_, + amounts_ + ); + + // Assertion that the reclaimed amount is the sum of the amounts + assertEq(reclaimed, amountOne + amountTwo + amountThree, "reclaimed"); + + // Assertion that the spender is the convertible depository + assertEq(spender, address(convertibleDepository), "spender"); + } +} diff --git a/src/test/policies/ConvertibleDepositFacility/reclaim.t.sol b/src/test/policies/ConvertibleDepositFacility/reclaim.t.sol new file mode 100644 index 00000000..71165b0e --- /dev/null +++ b/src/test/policies/ConvertibleDepositFacility/reclaim.t.sol @@ -0,0 +1,504 @@ +// 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"; +import {CDEPOv1} from "src/modules/CDEPO/CDEPO.v1.sol"; + +contract ReclaimCDFTest is ConvertibleDepositFacilityTest { + event ReclaimedDeposit(address indexed user, uint256 reclaimedAmount); + + // given the contract is inactive + // [X] it reverts + // 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 an owner that is not the caller + // [X] it reverts + // when any position has not expired + // [X] it reverts + // when any position has an amount greater than the remaining deposit + // [X] it reverts + // when the caller has not approved CDEPO to spend the total amount of CD tokens + // [X] it reverts + // when the reclaim amount is 0 + // [X] it reverts + // [X] it updates the remaining deposit of each position + // [X] it transfers the redeemed reserve tokens to the owner + // [X] it decreases the OHM mint approval by the amount of OHM that would have been converted + // [X] it returns the reclaimed amount + // [X] it emits a ReclaimedDeposit event + + function test_contractInactive_reverts() public { + // Expect revert + vm.expectRevert(abi.encodeWithSelector(IConvertibleDepositFacility.CDF_NotActive.selector)); + + // Call function + facility.reclaim(new uint256[](0), new uint256[](0)); + } + + function test_arrayLengthMismatch_reverts() public givenLocallyActive { + 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.reclaim(positionIds_, amounts_); + } + + function test_anyPositionHasDifferentOwner_reverts( + uint256 positionIndex_ + ) + public + givenLocallyActive + 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; + } + + // Warp to beyond the normal expiry + vm.warp(EXPIRY); + + // Expect revert + vm.expectRevert( + abi.encodeWithSelector(IConvertibleDepositFacility.CDF_NotOwner.selector, positionIndex) + ); + + // Call function + vm.prank(recipient); + facility.reclaim(positionIds_, amounts_); + } + + function test_allPositionsHaveDifferentOwner_reverts() + public + givenLocallyActive + 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; + + // Warp to beyond the normal expiry + vm.warp(EXPIRY); + + // Expect revert + vm.expectRevert( + abi.encodeWithSelector(IConvertibleDepositFacility.CDF_NotOwner.selector, 0) + ); + + // Call function + vm.prank(recipientTwo); + facility.reclaim(positionIds_, amounts_); + } + + function test_anyPositionIsNotValid_reverts( + uint256 positionIndex_ + ) + public + givenLocallyActive + 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; + } + } + + // Warp to beyond the normal expiry + vm.warp(EXPIRY); + + // Expect revert + vm.expectRevert(abi.encodeWithSelector(CDPOSv1.CDPOS_InvalidPositionId.selector, 2)); + + // Call function + vm.prank(recipient); + facility.reclaim(positionIds_, amounts_); + } + + function test_anyPositionHasNotExpired_reverts( + uint256 positionIndex_ + ) + public + givenLocallyActive + 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 = EXPIRY; + if (positionIndex == i) { + expiry = EXPIRY + 1; + } + + // Create position + uint256 positionId = _createPosition(recipient, 3e18, CONVERSION_PRICE, expiry, false); + + positionIds_[i] = positionId; + amounts_[i] = 3e18; + } + + // Warp to beyond the normal expiry + vm.warp(EXPIRY); + + // Expect revert + vm.expectRevert( + abi.encodeWithSelector( + IConvertibleDepositFacility.CDF_PositionNotExpired.selector, + positionIndex + ) + ); + + // Call function + vm.prank(recipient); + facility.reclaim(positionIds_, amounts_); + } + + function test_anyAmountIsGreaterThanRemainingDeposit_reverts( + uint256 positionIndex_ + ) + public + givenLocallyActive + 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; + } + } + + // Warp to beyond the normal expiry + vm.warp(EXPIRY); + + // Expect revert + vm.expectRevert( + abi.encodeWithSelector( + IConvertibleDepositFacility.CDF_InvalidAmount.selector, + positionIndex, + 4e18 + ) + ); + + // Call function + vm.prank(recipient); + facility.reclaim(positionIds_, amounts_); + } + + function test_amountIsZero_reverts() + public + givenLocallyActive + 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; + + // Warp to the normal expiry + vm.warp(EXPIRY); + + // Expect revert + vm.expectRevert(abi.encodeWithSelector(CDEPOv1.CDEPO_InvalidArgs.selector, "amount")); + + // Call function + vm.prank(recipient); + facility.reclaim(positionIds_, amounts_); + } + + function test_spendingIsNotApproved_reverts() + public + givenLocallyActive + givenAddressHasReserveToken(recipient, RESERVE_TOKEN_AMOUNT) + givenReserveTokenSpendingIsApproved( + recipient, + address(convertibleDepository), + RESERVE_TOKEN_AMOUNT + ) + givenAddressHasPosition(recipient, RESERVE_TOKEN_AMOUNT) + { + uint256[] memory positionIds_ = new uint256[](1); + uint256[] memory amounts_ = new uint256[](1); + + positionIds_[0] = 0; + amounts_[0] = RESERVE_TOKEN_AMOUNT; + + // Warp to the normal expiry + vm.warp(EXPIRY); + + // Expect revert + vm.expectRevert(abi.encodeWithSelector(CDEPOv1.CDEPO_InvalidArgs.selector, "allowance")); + + // Call function + vm.prank(recipient); + facility.reclaim(positionIds_, amounts_); + } + + function test_success() + public + givenLocallyActive + 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] = RESERVE_TOKEN_AMOUNT / 2; + positionIds_[1] = 1; + amounts_[1] = RESERVE_TOKEN_AMOUNT / 2; + + // Warp to the normal expiry + vm.warp(EXPIRY); + + // Expect event + vm.expectEmit(true, true, true, true); + emit ReclaimedDeposit(recipient, RESERVE_TOKEN_AMOUNT); + + // Call function + vm.prank(recipient); + uint256 reclaimed = facility.reclaim(positionIds_, amounts_); + + // Assertion that the reclaimed amount is the sum of the amounts + assertEq(reclaimed, RESERVE_TOKEN_AMOUNT, "reclaimed"); + + // Assert convertible deposit tokens are transferred from the recipient + assertEq( + convertibleDepository.balanceOf(recipient), + 0, + "convertibleDepository.balanceOf(recipient)" + ); + + // Assert OHM not minted to the recipient + assertEq(ohm.balanceOf(recipient), 0, "ohm.balanceOf(recipient)"); + + // No dangling mint approval + assertEq( + minter.mintApproval(address(facility)), + 0, + "minter.mintApproval(address(facility))" + ); + + // Assertion that the remaining deposit of each position is updated + assertEq( + convertibleDepositPositions.getPosition(0).remainingDeposit, + 0, + "remainingDeposit[0]" + ); + assertEq( + convertibleDepositPositions.getPosition(1).remainingDeposit, + 0, + "remainingDeposit[1]" + ); + + // Deposit token is transferred to the recipient + assertEq( + reserveToken.balanceOf(address(treasury)), + 0, + "reserveToken.balanceOf(address(treasury))" + ); + assertEq( + reserveToken.balanceOf(address(facility)), + 0, + "reserveToken.balanceOf(address(facility))" + ); + assertEq( + reserveToken.balanceOf(recipient), + RESERVE_TOKEN_AMOUNT, + "reserveToken.balanceOf(recipient)" + ); + + // Vault shares are not transferred to the TRSRY + assertEq(vault.balanceOf(address(treasury)), 0, "vault.balanceOf(address(treasury))"); + 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 + givenLocallyActive + 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 originalMintApproval = minter.mintApproval(address(facility)); + uint256 expectedConvertedAmount = (amountOne * 1e18) / + CONVERSION_PRICE + + (amountTwo * 1e18) / + CONVERSION_PRICE; + + // Warp to the normal expiry + vm.warp(EXPIRY); + + // Call function + vm.prank(recipient); + uint256 reclaimed = facility.reclaim(positionIds_, amounts_); + + // Assert reclaimed amount + assertEq(reclaimed, amountOne + amountTwo, "reclaimed"); + + // Assert convertible deposit tokens are transferred from the recipient + assertEq( + convertibleDepository.balanceOf(recipient), + RESERVE_TOKEN_AMOUNT - amountOne - amountTwo, + "convertibleDepository.balanceOf(recipient)" + ); + + // Assert OHM not minted to the recipient + assertEq(ohm.balanceOf(recipient), 0, "ohm.balanceOf(recipient)"); + + // Assert the remaining mint approval + assertEq( + minter.mintApproval(address(facility)), + originalMintApproval - expectedConvertedAmount, + "mintApproval" + ); + + // Assertion that the remaining deposit of each position is updated + assertEq( + convertibleDepositPositions.getPosition(0).remainingDeposit, + 5e18 - amountOne, + "remainingDeposit[0]" + ); + assertEq( + convertibleDepositPositions.getPosition(1).remainingDeposit, + 5e18 - amountTwo, + "remainingDeposit[1]" + ); + + // Deposit token is transferred to the recipient + assertEq( + reserveToken.balanceOf(address(treasury)), + 0, + "reserveToken.balanceOf(address(treasury))" + ); + assertEq( + reserveToken.balanceOf(address(facility)), + 0, + "reserveToken.balanceOf(address(facility))" + ); + assertEq( + reserveToken.balanceOf(recipient), + amountOne + amountTwo, + "reserveToken.balanceOf(recipient)" + ); + + // Vault shares are not transferred to the TRSRY + assertEq(vault.balanceOf(address(treasury)), 0, "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/EmissionManager.t.sol b/src/test/policies/EmissionManager.t.sol index ec4bca17..f6c5acdd 100644 --- a/src/test/policies/EmissionManager.t.sol +++ b/src/test/policies/EmissionManager.t.sol @@ -2,7 +2,6 @@ pragma solidity >=0.8.0; import {Test} from "forge-std/Test.sol"; -import {console2} from "forge-std/console2.sol"; import {UserFactory} from "src/test/lib/UserFactory.sol"; import {BondFixedTermSDA} from "src/test/lib/bonds/BondFixedTermSDA.sol"; @@ -11,18 +10,16 @@ import {BondFixedTermTeller} from "src/test/lib/bonds/BondFixedTermTeller.sol"; import {RolesAuthority, Authority as SolmateAuthority} from "solmate/auth/authorities/RolesAuthority.sol"; import {MockERC20, ERC20} from "solmate/test/utils/mocks/MockERC20.sol"; -import {MockERC4626, ERC4626} from "solmate/test/utils/mocks/MockERC4626.sol"; +import {MockERC4626} from "solmate/test/utils/mocks/MockERC4626.sol"; 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 {IBondSDA} from "interfaces/IBondSDA.sol"; -import {IBondAggregator} from "interfaces/IBondAggregator.sol"; +import {MockConvertibleDepositAuctioneer} from "src/test/mocks/MockConvertibleDepositAuctioneer.sol"; import {FullMath} from "libraries/FullMath.sol"; -import "src/Kernel.sol"; +import {Actions, Kernel} from "src/Kernel.sol"; import {OlympusRange} from "modules/RANGE/OlympusRange.sol"; import {OlympusTreasury} from "modules/TRSRY/OlympusTreasury.sol"; import {OlympusMinter} from "modules/MINTR/OlympusMinter.sol"; @@ -44,7 +41,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 +56,7 @@ contract EmissionManagerTest is Test { OlympusClearinghouseRegistry internal CHREG; MockClearinghouse internal clearinghouse; + MockConvertibleDepositAuctioneer internal cdAuctioneer; RolesAdmin internal rolesAdmin; EmissionManager internal emissionManager; @@ -69,6 +67,11 @@ 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% + + uint256 internal DEFICIT = 1000e9; + uint256 internal SURPLUS = 1001e9; // test cases // @@ -84,14 +87,28 @@ contract EmissionManagerTest is Test { // [X] it returns without doing anything // [X] when beatCounter is incremented and == 0 // [X] when premium is greater than or equal to the minimum premium - // [X] sell amount is calculated as the base emissions rate * (1 + premium) / (1 + minimum premium) - // [X] it creates a new bond market with the sell amount + // [X] auction target is calculated as the base emissions rate * (1 + premium) / (1 + minimum premium) + // [X] when the sum of auction results is negative + // [X] it creates a new bond market with the deficit as the capacity + // [X] when the sum of auction results is positive + // [X] it does not create a new bond market // [X] when premium is less than the minimum premium - // [X] it does not create a new bond market + // [X] when the sum of auction results is negative + // [X] it creates a new bond market with the deficit as the capacity + // [X] when the sum of auction results is positive + // [X] it does not create a new bond market // [X] when there is a positive emissions adjustment // [X] it adjusts the emissions rate by the adjustment amount before calculating the sell amount + // [X] when the sum of auction results is negative + // [X] it creates a new bond market with the deficit as the capacity + // [X] when the sum of auction results is positive + // [X] it does not create a new bond market // [X] when there is a negative emissions adjustment // [X] it adjusts the emissions rate by the adjustment amount before calculating the sell amount + // [X] when the sum of auction results is negative + // [X] it creates a new bond market with the deficit as the capacity + // [X] when the sum of auction results is positive + // [X] it does not create a new bond market // // [X] callback unit tests // [X] when the sender is not the teller @@ -119,7 +136,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 @@ -132,8 +149,8 @@ contract EmissionManagerTest is Test { // [X] when the caller has emergency_shutdown role // [X] it sets locallyActive to false // [X] it sets the shutdown timestamp to the current block timestamp - // [ ] when the active market id is live - // [ ] it closes the market + // [X] when the active market id is live + // [X] it closes the market // // [X] restart // [X] when the caller doesn't have emergency_restart role @@ -205,11 +222,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 +244,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 +286,9 @@ contract EmissionManagerTest is Test { /// Deploy ROLES administrator rolesAdmin = new RolesAdmin(kernel); + // Deploy the mock CD auctioneer + cdAuctioneer = new MockConvertibleDepositAuctioneer(kernel); + // Deploy the emission manager emissionManager = new EmissionManager( kernel, @@ -276,7 +296,8 @@ contract EmissionManagerTest is Test { address(gohm), address(reserve), address(sReserve), - address(auctioneer), + address(bondAuctioneer), + address(cdAuctioneer), address(teller) ); } @@ -294,6 +315,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 +354,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 @@ -357,6 +386,33 @@ contract EmissionManagerTest is Test { emissionManager.execute(); emissionManager.execute(); vm.stopPrank(); + + // Warp to the next day + vm.warp(block.timestamp + 86400); + _; + } + + modifier givenCDAuctioneerHasDeficit() { + int256[] memory results = new int256[](2); + results[0] = -int256(DEFICIT) * 2; + results[1] = int256(DEFICIT); + cdAuctioneer.setAuctionResults(results); + _; + } + + modifier givenCDAuctioneerHasSurplus() { + int256[] memory results = new int256[](2); + results[0] = int256(SURPLUS) * 2; + results[1] = -int256(SURPLUS); + cdAuctioneer.setAuctionResults(results); + _; + } + + modifier givenCDAuctioneerHasZero() { + int256[] memory results = new int256[](2); + results[0] = 0; + results[1] = 0; + cdAuctioneer.setAuctionResults(results); _; } @@ -388,6 +444,10 @@ contract EmissionManagerTest is Test { vm.startPrank(heart); emissionManager.execute(); emissionManager.execute(); + + // Warp to the next day + vm.warp(block.timestamp + 86400); + emissionManager.execute(); vm.stopPrank(); } @@ -431,7 +491,11 @@ contract EmissionManagerTest is Test { assertEq(emissionManager.beatCounter(), 2, "Beat counter should be 2"); // Check that a bond market was not created - assertEq(aggregator.marketCounter(), nextBondMarketId); + assertEq( + aggregator.marketCounter(), + nextBondMarketId, + "Market counter should not increment" + ); // Check that the contract is locally active assertTrue(emissionManager.locallyActive(), "Contract should be locally active"); @@ -448,7 +512,11 @@ contract EmissionManagerTest is Test { emissionManager.execute(); // Check that a bond market was not created - assertEq(aggregator.marketCounter(), nextBondMarketId); + assertEq( + aggregator.marketCounter(), + nextBondMarketId, + "Market counter should not increment" + ); // Check that the beat counter did not increment assertEq(emissionManager.beatCounter(), 2, "Beat counter should be 2"); @@ -502,7 +570,11 @@ contract EmissionManagerTest is Test { emissionManager.execute(); // Check that a bond market was not created - assertEq(aggregator.marketCounter(), nextBondMarketId); + assertEq( + aggregator.marketCounter(), + nextBondMarketId, + "Market counter should not increment" + ); // Check the beat counter is 1 assertEq(emissionManager.beatCounter(), 1, "Beat counter should be 1"); @@ -512,7 +584,11 @@ contract EmissionManagerTest is Test { emissionManager.execute(); // Check that a bond market was not created - assertEq(aggregator.marketCounter(), nextBondMarketId); + assertEq( + aggregator.marketCounter(), + nextBondMarketId, + "Market counter should not increment" + ); // Check that the beat counter is now 2 assertEq(emissionManager.beatCounter(), 2, "Beat counter should be 2"); @@ -538,7 +614,125 @@ contract EmissionManagerTest is Test { emissionManager.execute(); // Check that a bond market was not created - assertEq(aggregator.marketCounter(), nextBondMarketId); + assertEq( + aggregator.marketCounter(), + nextBondMarketId, + "Market counter should not increment" + ); + + // Confirm that the token balances are still 0 + assertEq(ohm.balanceOf(address(emissionManager)), 0, "OHM balance should be 0"); + assertEq(reserve.balanceOf(address(emissionManager)), 0, "Reserve balance should be 0"); + + // Confirm that the beat counter is now 0 + assertEq(emissionManager.beatCounter(), 0, "Beat counter should be 0"); + } + + function test_execute_whenNextBeatIsZero_whenPremiumBelowMinimum_whenNoAdjustment_surplus() + public + givenNextBeatIsZero + givenPremiumBelowMinimum + givenCDAuctioneerHasSurplus + { + // Get the ID of the next bond market from the aggregator + uint256 nextBondMarketId = aggregator.marketCounter(); + + // Confirm that there are no tokens in the contract yet + assertEq(ohm.balanceOf(address(emissionManager)), 0, "OHM balance should be 0"); + assertEq(reserve.balanceOf(address(emissionManager)), 0, "Reserve balance should be 0"); + + // Check that the beat counter is 2 + assertEq(emissionManager.beatCounter(), 2, "Beat counter should be 2"); + + // Call execute + vm.prank(heart); + emissionManager.execute(); + + // Check that a bond market was not created + assertEq( + aggregator.marketCounter(), + nextBondMarketId, + "Market counter should not increment" + ); + + // Verify the auctioneer parameters + { + // Target == getNextEmission().emission + (, , uint256 emission) = emissionManager.getNextEmission(); + + assertEq(cdAuctioneer.target(), emission, "Target should be the emission"); + + // Tick size == getSizeFor(emission) + assertEq( + cdAuctioneer.tickSize(), + emissionManager.getSizeFor(emission), + "Tick size should be the emission" + ); + + // Min price == getMinPriceFor(emission) + assertEq( + cdAuctioneer.minPrice(), + emissionManager.getMinPriceFor(PRICE.getCurrentPrice()), + "Min price" + ); + } + + // Confirm that the token balances are still 0 + assertEq(ohm.balanceOf(address(emissionManager)), 0, "OHM balance should be 0"); + assertEq(reserve.balanceOf(address(emissionManager)), 0, "Reserve balance should be 0"); + + // Confirm that the beat counter is now 0 + assertEq(emissionManager.beatCounter(), 0, "Beat counter should be 0"); + } + + function test_execute_whenNextBeatIsZero_whenPremiumBelowMinimum_whenNoAdjustment_noSurplus() + public + givenNextBeatIsZero + givenPremiumBelowMinimum + givenCDAuctioneerHasZero + { + // Get the ID of the next bond market from the aggregator + uint256 nextBondMarketId = aggregator.marketCounter(); + + // Confirm that there are no tokens in the contract yet + assertEq(ohm.balanceOf(address(emissionManager)), 0, "OHM balance should be 0"); + assertEq(reserve.balanceOf(address(emissionManager)), 0, "Reserve balance should be 0"); + + // Check that the beat counter is 2 + assertEq(emissionManager.beatCounter(), 2, "Beat counter should be 2"); + + // Call execute + vm.prank(heart); + emissionManager.execute(); + + // Check that a bond market was not created + assertEq( + aggregator.marketCounter(), + nextBondMarketId, + "Market counter should not increment" + ); + + // Verify the auctioneer parameters + { + // Target == getNextEmission().emission + (, , uint256 emission) = emissionManager.getNextEmission(); + + assertEq(cdAuctioneer.target(), emission, "Target should be the emission"); + + // Tick size == getSizeFor(emission) + assertEq( + cdAuctioneer.tickSize(), + emissionManager.getSizeFor(emission), + "Tick size should be the emission" + ); + + // Min price == getMinPriceFor(emission) + assertEq( + cdAuctioneer.minPrice(), + emissionManager.getMinPriceFor(PRICE.getCurrentPrice()), + "Min price" + ); + } // Confirm that the token balances are still 0 assertEq(ohm.balanceOf(address(emissionManager)), 0, "OHM balance should be 0"); @@ -552,6 +746,7 @@ contract EmissionManagerTest is Test { public givenNextBeatIsZero givenPremiumEqualToMinimum + givenCDAuctioneerHasDeficit { // Get the ID of the next bond market from the aggregator uint256 nextBondMarketId = aggregator.marketCounter(); @@ -566,12 +761,19 @@ contract EmissionManagerTest is Test { // Check that the beat counter is 2 assertEq(emissionManager.beatCounter(), 2, "Beat counter should be 2"); + // Warp to the next day + vm.warp(block.timestamp + 86400); + // Call execute vm.prank(heart); emissionManager.execute(); // Check that a bond market was created - assertEq(aggregator.marketCounter(), nextBondMarketId + 1); + assertEq( + aggregator.marketCounter(), + nextBondMarketId + 1, + "Market counter should increment" + ); // Confirm that the beat counter is now 0 assertEq(emissionManager.beatCounter(), 0, "Beat counter should be 0"); @@ -579,7 +781,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 +795,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"); @@ -604,14 +806,7 @@ contract EmissionManagerTest is Test { "Callback address should be the emissions manager" ); assertEq(isCapacityInQuote, false, "Capacity should not be in quote token"); - assertEq( - capacity, - (((baseEmissionRate * PRICE.getLastPrice()) / - ((backing * (1e18 + minimumPremium)) / 1e18)) * - gohm.totalSupply() * - gohm.index()) / 1e27, - "Capacity" - ); + assertEq(capacity, DEFICIT, "Capacity"); assertEq(maxPayout, capacity / 6, "Max payout"); assertEq(scale, 10 ** uint8(36 + 9 - 18 + 0), "Scale"); @@ -638,12 +833,153 @@ contract EmissionManagerTest is Test { "Mint approval should be the capacity" ); } + + // Verify the auctioneer parameters + { + // Target == getNextEmission().emission + (, , uint256 emission) = emissionManager.getNextEmission(); + + assertEq(cdAuctioneer.target(), emission, "Target should be the emission"); + + // Tick size == getSizeFor(emission) + assertEq( + cdAuctioneer.tickSize(), + emissionManager.getSizeFor(emission), + "Tick size should be the emission" + ); + + // Min price == getMinPriceFor(emission) + assertEq( + cdAuctioneer.minPrice(), + emissionManager.getMinPriceFor(PRICE.getCurrentPrice()), + "Min price" + ); + } + } + + function test_execute_whenNextBeatIsZero_whenPremiumEqualMinimum_whenNoAdjustment_surplus() + public + givenNextBeatIsZero + givenPremiumEqualToMinimum + givenCDAuctioneerHasSurplus + { + // Get the ID of the next bond market from the aggregator + uint256 nextBondMarketId = aggregator.marketCounter(); + + // Confirm that there are no tokens in the contract yet + assertEq(ohm.balanceOf(address(emissionManager)), 0, "OHM balance should be 0"); + assertEq(reserve.balanceOf(address(emissionManager)), 0, "Reserve balance should be 0"); + + // Confirm that mint approval is originally zero + assertEq(MINTR.mintApproval(address(emissionManager)), 0, "Mint approval should be 0"); + + // Check that the beat counter is 2 + assertEq(emissionManager.beatCounter(), 2, "Beat counter should be 2"); + + // Warp to the next day + vm.warp(block.timestamp + 86400); + + // Call execute + vm.prank(heart); + emissionManager.execute(); + + // Check that a bond market was NOT created + assertEq( + aggregator.marketCounter(), + nextBondMarketId, + "Market counter should not increment" + ); + + // Confirm that the beat counter is now 0 + assertEq(emissionManager.beatCounter(), 0, "Beat counter should be 0"); + + // Verify the auctioneer parameters + { + // Target == getNextEmission().emission + (, , uint256 emission) = emissionManager.getNextEmission(); + + assertEq(cdAuctioneer.target(), emission, "Target should be the emission"); + + // Tick size == getSizeFor(emission) + assertEq( + cdAuctioneer.tickSize(), + emissionManager.getSizeFor(emission), + "Tick size should be the emission" + ); + + // Min price == getMinPriceFor(emission) + assertEq( + cdAuctioneer.minPrice(), + emissionManager.getMinPriceFor(PRICE.getCurrentPrice()), + "Min price" + ); + } + } + + function test_execute_whenNextBeatIsZero_whenPremiumEqualMinimum_whenNoAdjustment_noSurplus() + public + givenNextBeatIsZero + givenPremiumEqualToMinimum + givenCDAuctioneerHasZero + { + // Get the ID of the next bond market from the aggregator + uint256 nextBondMarketId = aggregator.marketCounter(); + + // Confirm that there are no tokens in the contract yet + assertEq(ohm.balanceOf(address(emissionManager)), 0, "OHM balance should be 0"); + assertEq(reserve.balanceOf(address(emissionManager)), 0, "Reserve balance should be 0"); + + // Confirm that mint approval is originally zero + assertEq(MINTR.mintApproval(address(emissionManager)), 0, "Mint approval should be 0"); + + // Check that the beat counter is 2 + assertEq(emissionManager.beatCounter(), 2, "Beat counter should be 2"); + + // Warp to the next day + vm.warp(block.timestamp + 86400); + + // Call execute + vm.prank(heart); + emissionManager.execute(); + + // Check that a bond market was NOT created + assertEq( + aggregator.marketCounter(), + nextBondMarketId, + "Market counter should not increment" + ); + + // Confirm that the beat counter is now 0 + assertEq(emissionManager.beatCounter(), 0, "Beat counter should be 0"); + + // Verify the auctioneer parameters + { + // Target == getNextEmission().emission + (, , uint256 emission) = emissionManager.getNextEmission(); + + assertEq(cdAuctioneer.target(), emission, "Target should be the emission"); + + // Tick size == getSizeFor(emission) + assertEq( + cdAuctioneer.tickSize(), + emissionManager.getSizeFor(emission), + "Tick size should be the emission" + ); + + // Min price == getMinPriceFor(emission) + assertEq( + cdAuctioneer.minPrice(), + emissionManager.getMinPriceFor(PRICE.getCurrentPrice()), + "Min price" + ); + } } function test_execute_whenNextBeatIsZero_givenPremiumAboveMinimum_whenNoAdjustment() public givenNextBeatIsZero givenPremiumAboveMinimum + givenCDAuctioneerHasDeficit { // Get the ID of the next bond market from the aggregator uint256 nextBondMarketId = aggregator.marketCounter(); @@ -658,20 +994,49 @@ contract EmissionManagerTest is Test { // Check that the beat counter is 2 assertEq(emissionManager.beatCounter(), 2, "Beat counter should be 2"); + // Warp to the next day + vm.warp(block.timestamp + 86400); + // Call execute vm.prank(heart); emissionManager.execute(); // Check that a bond market was created - assertEq(aggregator.marketCounter(), nextBondMarketId + 1); + assertEq( + aggregator.marketCounter(), + nextBondMarketId + 1, + "Market counter should increment" + ); // Confirm that the beat counter is now 0 assertEq(emissionManager.beatCounter(), 0, "Beat counter should be 0"); + // Verify the auctioneer parameters + { + // Target == getNextEmission().emission + (, , uint256 emission) = emissionManager.getNextEmission(); + + assertEq(cdAuctioneer.target(), emission, "Target should be the emission"); + + // Tick size == getSizeFor(emission) + assertEq( + cdAuctioneer.tickSize(), + emissionManager.getSizeFor(emission), + "Tick size should be the emission" + ); + + // Min price == getMinPriceFor(emission) + assertEq( + cdAuctioneer.minPrice(), + emissionManager.getMinPriceFor(PRICE.getCurrentPrice()), + "Min price" + ); + } + // 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 +1050,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"); @@ -696,14 +1061,7 @@ contract EmissionManagerTest is Test { "Callback address should be the emissions manager" ); assertEq(isCapacityInQuote, false, "Capacity should not be in quote token"); - assertEq( - capacity, - (((baseEmissionRate * PRICE.getLastPrice()) / - ((backing * (1e18 + minimumPremium)) / 1e18)) * - gohm.totalSupply() * - gohm.index()) / 1e27, - "Capacity" - ); + assertEq(capacity, DEFICIT, "Capacity"); assertEq(maxPayout, capacity / 6, "Max payout"); assertEq(scale, 10 ** uint8(36 + 9 - 18 + 0), "Scale"); @@ -732,45 +1090,183 @@ contract EmissionManagerTest is Test { } } - function test_execute_whenNextBeatIsZero_whenPositiveRateAdjustment() + function test_execute_whenNextBeatIsZero_givenPremiumAboveMinimum_whenNoAdjustment_surplus() public givenNextBeatIsZero - givenPositiveRateAdjustment + givenPremiumAboveMinimum + givenCDAuctioneerHasSurplus { - // Cache the current base rate - uint256 baseRate = emissionManager.baseEmissionRate(); - - // Calculate the expected base rate after the adjustment - uint256 expectedBaseRate = baseRate + changeBy; - // Get the ID of the next bond market from the aggregator uint256 nextBondMarketId = aggregator.marketCounter(); - // Calculate the expected capacity of the bond market - uint256 expectedCapacity = (((expectedBaseRate * PRICE.getLastPrice()) / - ((backing * (1e18 + minimumPremium)) / 1e18)) * - gohm.totalSupply() * - gohm.index()) / 1e27; + // Confirm that there are no tokens in the contract yet + assertEq(ohm.balanceOf(address(emissionManager)), 0, "OHM balance should be 0"); + assertEq(reserve.balanceOf(address(emissionManager)), 0, "Reserve balance should be 0"); - // Execute to trigger the rate adjustment + // Confirm that mint approval is originally zero + assertEq(MINTR.mintApproval(address(emissionManager)), 0, "Mint approval should be 0"); + + // Check that the beat counter is 2 + assertEq(emissionManager.beatCounter(), 2, "Beat counter should be 2"); + + // Warp to the next day + vm.warp(block.timestamp + 86400); + + // Call execute vm.prank(heart); emissionManager.execute(); - // Confirm the base rate has been updated + // Check that a bond market was not created assertEq( - emissionManager.baseEmissionRate(), - expectedBaseRate, - "Base rate should be updated" + aggregator.marketCounter(), + nextBondMarketId, + "Market counter should not increment" ); - // Confirm that the capacity of the bond market uses the new base rate - assertEq( - auctioneer.currentCapacity(nextBondMarketId), - expectedCapacity, - "Capacity should be updated" - ); + // Confirm that the beat counter is now 0 + assertEq(emissionManager.beatCounter(), 0, "Beat counter should be 0"); - // Calculate the expected base rate after the next adjustment + // Verify the auctioneer parameters + { + // Target == getNextEmission().emission + (, , uint256 emission) = emissionManager.getNextEmission(); + + assertEq(cdAuctioneer.target(), emission, "Target should be the emission"); + + // Tick size == getSizeFor(emission) + assertEq( + cdAuctioneer.tickSize(), + emissionManager.getSizeFor(emission), + "Tick size should be the emission" + ); + + // Min price == getMinPriceFor(emission) + assertEq( + cdAuctioneer.minPrice(), + emissionManager.getMinPriceFor(PRICE.getCurrentPrice()), + "Min price" + ); + } + } + + function test_execute_whenNextBeatIsZero_givenPremiumAboveMinimum_whenNoAdjustment_noSurplus() + public + givenNextBeatIsZero + givenPremiumAboveMinimum + givenCDAuctioneerHasZero + { + // Get the ID of the next bond market from the aggregator + uint256 nextBondMarketId = aggregator.marketCounter(); + + // Confirm that there are no tokens in the contract yet + assertEq(ohm.balanceOf(address(emissionManager)), 0, "OHM balance should be 0"); + assertEq(reserve.balanceOf(address(emissionManager)), 0, "Reserve balance should be 0"); + + // Confirm that mint approval is originally zero + assertEq(MINTR.mintApproval(address(emissionManager)), 0, "Mint approval should be 0"); + + // Check that the beat counter is 2 + assertEq(emissionManager.beatCounter(), 2, "Beat counter should be 2"); + + // Warp to the next day + vm.warp(block.timestamp + 86400); + + // Call execute + vm.prank(heart); + emissionManager.execute(); + + // Check that a bond market was not created + assertEq( + aggregator.marketCounter(), + nextBondMarketId, + "Market counter should not increment" + ); + + // Confirm that the beat counter is now 0 + assertEq(emissionManager.beatCounter(), 0, "Beat counter should be 0"); + + // Verify the auctioneer parameters + { + // Target == getNextEmission().emission + (, , uint256 emission) = emissionManager.getNextEmission(); + + assertEq(cdAuctioneer.target(), emission, "Target should be the emission"); + + // Tick size == getSizeFor(emission) + assertEq( + cdAuctioneer.tickSize(), + emissionManager.getSizeFor(emission), + "Tick size should be the emission" + ); + + // Min price == getMinPriceFor(emission) + assertEq( + cdAuctioneer.minPrice(), + emissionManager.getMinPriceFor(PRICE.getCurrentPrice()), + "Min price" + ); + } + } + + function test_execute_whenNextBeatIsZero_whenPositiveRateAdjustment() + public + givenNextBeatIsZero + givenPositiveRateAdjustment + givenCDAuctioneerHasDeficit + { + // Cache the current base rate + uint256 baseRate = emissionManager.baseEmissionRate(); + + // Calculate the expected base rate after the adjustment + uint256 expectedBaseRate = baseRate + changeBy; + + // Get the ID of the next bond market from the aggregator + uint256 nextBondMarketId = aggregator.marketCounter(); + + // Warp to the next day + vm.warp(block.timestamp + 86400); + + // Execute to trigger the rate adjustment + vm.prank(heart); + emissionManager.execute(); + + // Confirm the base rate has been updated + assertEq( + emissionManager.baseEmissionRate(), + expectedBaseRate, + "Base rate should be updated" + ); + + // Confirm that the capacity of the bond market uses the new base rate + assertEq( + bondAuctioneer.currentCapacity(nextBondMarketId), + DEFICIT, + "Capacity should be updated" + ); + + // Verify the auctioneer parameters + { + // Target == getNextEmission().emission + (, , uint256 emission) = emissionManager.getNextEmission(); + + assertEq(cdAuctioneer.target(), emission, "Target should be the emission"); + + // Tick size == getSizeFor(emission) + assertEq( + cdAuctioneer.tickSize(), + emissionManager.getSizeFor(emission), + "Tick size should be the emission" + ); + + // Min price == getMinPriceFor(emission) + assertEq( + cdAuctioneer.minPrice(), + emissionManager.getMinPriceFor(PRICE.getCurrentPrice()), + "Min price" + ); + } + + // Calculate the expected base rate after the next adjustment expectedBaseRate += changeBy; // Trigger a full cycle to make the next adjustment @@ -783,6 +1279,434 @@ contract EmissionManagerTest is Test { "Base rate should be updated" ); + // Verify the auctioneer parameters + { + // Target == getNextEmission().emission + (, , uint256 emission) = emissionManager.getNextEmission(); + + assertEq(cdAuctioneer.target(), emission, "Target should be the emission"); + + // Tick size == getSizeFor(emission) + assertEq( + cdAuctioneer.tickSize(), + emissionManager.getSizeFor(emission), + "Tick size should be the emission" + ); + + // Min price == getMinPriceFor(emission) + assertEq( + cdAuctioneer.minPrice(), + emissionManager.getMinPriceFor(PRICE.getCurrentPrice()), + "Min price" + ); + } + + // Trigger a full cycle again. There should be no adjustment this time since it uses a duration of 2 + triggerFullCycle(); + + // Confirm that the base rate has not been updated + assertEq( + emissionManager.baseEmissionRate(), + expectedBaseRate, + "Base rate should not be updated" + ); + + // Verify the auctioneer parameters + { + // Target == getNextEmission().emission + (, , uint256 emission) = emissionManager.getNextEmission(); + + assertEq(cdAuctioneer.target(), emission, "Target should be the emission"); + + // Tick size == getSizeFor(emission) + assertEq( + cdAuctioneer.tickSize(), + emissionManager.getSizeFor(emission), + "Tick size should be the emission" + ); + + // Min price == getMinPriceFor(emission) + assertEq( + cdAuctioneer.minPrice(), + emissionManager.getMinPriceFor(PRICE.getCurrentPrice()), + "Min price" + ); + } + } + + function test_execute_whenNextBeatIsZero_whenPositiveRateAdjustment_surplus() + public + givenNextBeatIsZero + givenPositiveRateAdjustment + givenCDAuctioneerHasSurplus + { + // Cache the current base rate + uint256 baseRate = emissionManager.baseEmissionRate(); + + // Calculate the expected base rate after the adjustment + uint256 expectedBaseRate = baseRate + changeBy; + + // Get the ID of the next bond market from the aggregator + uint256 nextBondMarketId = aggregator.marketCounter(); + + // Warp to the next day + vm.warp(block.timestamp + 86400); + + // Execute to trigger the rate adjustment + vm.prank(heart); + emissionManager.execute(); + + // Confirm the base rate has been updated + assertEq( + emissionManager.baseEmissionRate(), + expectedBaseRate, + "Base rate should be updated" + ); + + // Confirm that a bond market was not created + assertEq( + aggregator.marketCounter(), + nextBondMarketId, + "Market counter should not increment" + ); + + // Verify the auctioneer parameters + { + // Target == getNextEmission().emission + (, , uint256 emission) = emissionManager.getNextEmission(); + + assertEq(cdAuctioneer.target(), emission, "Target should be the emission"); + + // Tick size == getSizeFor(emission) + assertEq( + cdAuctioneer.tickSize(), + emissionManager.getSizeFor(emission), + "Tick size should be the emission" + ); + + // Min price == getMinPriceFor(emission) + assertEq( + cdAuctioneer.minPrice(), + emissionManager.getMinPriceFor(PRICE.getCurrentPrice()), + "Min price" + ); + } + + // Calculate the expected base rate after the next adjustment + expectedBaseRate += changeBy; + + // Trigger a full cycle to make the next adjustment + triggerFullCycle(); + + // Confirm that the base rate has been updated + assertEq( + emissionManager.baseEmissionRate(), + expectedBaseRate, + "Base rate should be updated" + ); + + // Confirm that a bond market was not created + assertEq( + aggregator.marketCounter(), + nextBondMarketId, + "Market counter should not increment" + ); + + // Verify the auctioneer parameters + { + // Target == getNextEmission().emission + (, , uint256 emission) = emissionManager.getNextEmission(); + + assertEq(cdAuctioneer.target(), emission, "Target should be the emission"); + + // Tick size == getSizeFor(emission) + assertEq( + cdAuctioneer.tickSize(), + emissionManager.getSizeFor(emission), + "Tick size should be the emission" + ); + + // Min price == getMinPriceFor(emission) + assertEq( + cdAuctioneer.minPrice(), + emissionManager.getMinPriceFor(PRICE.getCurrentPrice()), + "Min price" + ); + } + + // Trigger a full cycle again. There should be no adjustment this time since it uses a duration of 2 + triggerFullCycle(); + + // Confirm that the base rate has not been updated + assertEq( + emissionManager.baseEmissionRate(), + expectedBaseRate, + "Base rate should not be updated" + ); + + // Confirm that a bond market was not created + assertEq( + aggregator.marketCounter(), + nextBondMarketId, + "Market counter should not increment" + ); + + // Verify the auctioneer parameters + { + // Target == getNextEmission().emission + (, , uint256 emission) = emissionManager.getNextEmission(); + + assertEq(cdAuctioneer.target(), emission, "Target should be the emission"); + + // Tick size == getSizeFor(emission) + assertEq( + cdAuctioneer.tickSize(), + emissionManager.getSizeFor(emission), + "Tick size should be the emission" + ); + + // Min price == getMinPriceFor(emission) + assertEq( + cdAuctioneer.minPrice(), + emissionManager.getMinPriceFor(PRICE.getCurrentPrice()), + "Min price" + ); + } + } + + function test_execute_whenNextBeatIsZero_whenPositiveRateAdjustment_noSurplus() + public + givenNextBeatIsZero + givenPositiveRateAdjustment + givenCDAuctioneerHasZero + { + // Cache the current base rate + uint256 baseRate = emissionManager.baseEmissionRate(); + + // Calculate the expected base rate after the adjustment + uint256 expectedBaseRate = baseRate + changeBy; + + // Get the ID of the next bond market from the aggregator + uint256 nextBondMarketId = aggregator.marketCounter(); + + // Warp to the next day + vm.warp(block.timestamp + 86400); + + // Execute to trigger the rate adjustment + vm.prank(heart); + emissionManager.execute(); + + // Confirm the base rate has been updated + assertEq( + emissionManager.baseEmissionRate(), + expectedBaseRate, + "Base rate should be updated" + ); + + // Confirm that a bond market was not created + assertEq( + aggregator.marketCounter(), + nextBondMarketId, + "Market counter should not increment" + ); + + // Verify the auctioneer parameters + { + // Target == getNextEmission().emission + (, , uint256 emission) = emissionManager.getNextEmission(); + + assertEq(cdAuctioneer.target(), emission, "Target should be the emission"); + + // Tick size == getSizeFor(emission) + assertEq( + cdAuctioneer.tickSize(), + emissionManager.getSizeFor(emission), + "Tick size should be the emission" + ); + + // Min price == getMinPriceFor(emission) + assertEq( + cdAuctioneer.minPrice(), + emissionManager.getMinPriceFor(PRICE.getCurrentPrice()), + "Min price" + ); + } + + // Calculate the expected base rate after the next adjustment + expectedBaseRate += changeBy; + + // Trigger a full cycle to make the next adjustment + triggerFullCycle(); + + // Confirm that the base rate has been updated + assertEq( + emissionManager.baseEmissionRate(), + expectedBaseRate, + "Base rate should be updated" + ); + + // Confirm that a bond market was not created + assertEq( + aggregator.marketCounter(), + nextBondMarketId, + "Market counter should not increment" + ); + + // Verify the auctioneer parameters + { + // Target == getNextEmission().emission + (, , uint256 emission) = emissionManager.getNextEmission(); + + assertEq(cdAuctioneer.target(), emission, "Target should be the emission"); + + // Tick size == getSizeFor(emission) + assertEq( + cdAuctioneer.tickSize(), + emissionManager.getSizeFor(emission), + "Tick size should be the emission" + ); + + // Min price == getMinPriceFor(emission) + assertEq( + cdAuctioneer.minPrice(), + emissionManager.getMinPriceFor(PRICE.getCurrentPrice()), + "Min price" + ); + } + + // Trigger a full cycle again. There should be no adjustment this time since it uses a duration of 2 + triggerFullCycle(); + + // Confirm that the base rate has not been updated + assertEq( + emissionManager.baseEmissionRate(), + expectedBaseRate, + "Base rate should not be updated" + ); + + // Confirm that a bond market was not created + assertEq( + aggregator.marketCounter(), + nextBondMarketId, + "Market counter should not increment" + ); + + // Verify the auctioneer parameters + { + // Target == getNextEmission().emission + (, , uint256 emission) = emissionManager.getNextEmission(); + + assertEq(cdAuctioneer.target(), emission, "Target should be the emission"); + + // Tick size == getSizeFor(emission) + assertEq( + cdAuctioneer.tickSize(), + emissionManager.getSizeFor(emission), + "Tick size should be the emission" + ); + + // Min price == getMinPriceFor(emission) + assertEq( + cdAuctioneer.minPrice(), + emissionManager.getMinPriceFor(PRICE.getCurrentPrice()), + "Min price" + ); + } + } + + function test_execute_whenNextBeatIsZero_whenNegativeRateAdjustment() + public + givenNextBeatIsZero + givenNegativeRateAdjustment + givenCDAuctioneerHasDeficit + { + // Cache the current base rate + uint256 baseRate = emissionManager.baseEmissionRate(); + + // Calculate the expected base rate after the adjustment + uint256 expectedBaseRate = baseRate - changeBy; + + // Get the ID of the next bond market from the aggregator + uint256 nextBondMarketId = aggregator.marketCounter(); + + // Warp to the next day + vm.warp(block.timestamp + 86400); + + // Execute to trigger the rate adjustment + vm.prank(heart); + emissionManager.execute(); + + // Confirm the base rate has been updated + assertEq( + emissionManager.baseEmissionRate(), + expectedBaseRate, + "Base rate should be updated" + ); + + // Verify the auctioneer parameters + { + // Target == getNextEmission().emission + (, , uint256 emission) = emissionManager.getNextEmission(); + + assertEq(cdAuctioneer.target(), emission, "Target should be the emission"); + + // Tick size == getSizeFor(emission) + assertEq( + cdAuctioneer.tickSize(), + emissionManager.getSizeFor(emission), + "Tick size should be the emission" + ); + + // Min price == getMinPriceFor(emission) + assertEq( + cdAuctioneer.minPrice(), + emissionManager.getMinPriceFor(PRICE.getCurrentPrice()), + "Min price" + ); + } + + // Confirm that the capacity of the bond market uses the new base rate + assertEq( + bondAuctioneer.currentCapacity(nextBondMarketId), + DEFICIT, + "Capacity should be updated" + ); + + // Calculate the expected base rate after the next adjustment + expectedBaseRate -= changeBy; + + // Trigger a full cycle to make the next adjustment + triggerFullCycle(); + + // Confirm that the base rate has been updated + assertEq( + emissionManager.baseEmissionRate(), + expectedBaseRate, + "Base rate should be updated" + ); + + // Verify the auctioneer parameters + { + // Target == getNextEmission().emission + (, , uint256 emission) = emissionManager.getNextEmission(); + + assertEq(cdAuctioneer.target(), emission, "Target should be the emission"); + + // Tick size == getSizeFor(emission) + assertEq( + cdAuctioneer.tickSize(), + emissionManager.getSizeFor(emission), + "Tick size should be the emission" + ); + + // Min price == getMinPriceFor(emission) + assertEq( + cdAuctioneer.minPrice(), + emissionManager.getMinPriceFor(PRICE.getCurrentPrice()), + "Min price" + ); + } + // Trigger a full cycle again. There should be no adjustment this time since it uses a duration of 2 triggerFullCycle(); @@ -792,12 +1716,35 @@ contract EmissionManagerTest is Test { expectedBaseRate, "Base rate should not be updated" ); + + // Verify the auctioneer parameters + { + // Target == getNextEmission().emission + (, , uint256 emission) = emissionManager.getNextEmission(); + + assertEq(cdAuctioneer.target(), emission, "Target should be the emission"); + + // Tick size == getSizeFor(emission) + assertEq( + cdAuctioneer.tickSize(), + emissionManager.getSizeFor(emission), + "Tick size should be the emission" + ); + + // Min price == getMinPriceFor(emission) + assertEq( + cdAuctioneer.minPrice(), + emissionManager.getMinPriceFor(PRICE.getCurrentPrice()), + "Min price" + ); + } } - function test_execute_whenNextBeatIsZero_whenNegativeRateAdjustment() + function test_execute_whenNextBeatIsZero_whenNegativeRateAdjustment_surplus() public givenNextBeatIsZero givenNegativeRateAdjustment + givenCDAuctioneerHasSurplus { // Cache the current base rate uint256 baseRate = emissionManager.baseEmissionRate(); @@ -808,11 +1755,8 @@ contract EmissionManagerTest is Test { // Get the ID of the next bond market from the aggregator uint256 nextBondMarketId = aggregator.marketCounter(); - // Calculate the expected capacity of the bond market - uint256 expectedCapacity = (((expectedBaseRate * PRICE.getLastPrice()) / - ((backing * (1e18 + minimumPremium)) / 1e18)) * - gohm.totalSupply() * - gohm.index()) / 1e27; + // Warp to the next day + vm.warp(block.timestamp + 86400); // Execute to trigger the rate adjustment vm.prank(heart); @@ -825,13 +1769,175 @@ contract EmissionManagerTest is Test { "Base rate should be updated" ); - // Confirm that the capacity of the bond market uses the new base rate + // Confirm that a bond market was not created assertEq( - auctioneer.currentCapacity(nextBondMarketId), - expectedCapacity, - "Capacity should be updated" + aggregator.marketCounter(), + nextBondMarketId, + "Market counter should not increment" + ); + + // Verify the auctioneer parameters + { + // Target == getNextEmission().emission + (, , uint256 emission) = emissionManager.getNextEmission(); + + assertEq(cdAuctioneer.target(), emission, "Target should be the emission"); + + // Tick size == getSizeFor(emission) + assertEq( + cdAuctioneer.tickSize(), + emissionManager.getSizeFor(emission), + "Tick size should be the emission" + ); + + // Min price == getMinPriceFor(emission) + assertEq( + cdAuctioneer.minPrice(), + emissionManager.getMinPriceFor(PRICE.getCurrentPrice()), + "Min price" + ); + } + + // Calculate the expected base rate after the next adjustment + expectedBaseRate -= changeBy; + + // Trigger a full cycle to make the next adjustment + triggerFullCycle(); + + // Confirm that the base rate has been updated + assertEq( + emissionManager.baseEmissionRate(), + expectedBaseRate, + "Base rate should be updated" + ); + + // Confirm that a bond market was not created + assertEq( + aggregator.marketCounter(), + nextBondMarketId, + "Market counter should not increment" + ); + + // Verify the auctioneer parameters + { + // Target == getNextEmission().emission + (, , uint256 emission) = emissionManager.getNextEmission(); + + assertEq(cdAuctioneer.target(), emission, "Target should be the emission"); + + // Tick size == getSizeFor(emission) + assertEq( + cdAuctioneer.tickSize(), + emissionManager.getSizeFor(emission), + "Tick size should be the emission" + ); + + // Min price == getMinPriceFor(emission) + assertEq( + cdAuctioneer.minPrice(), + emissionManager.getMinPriceFor(PRICE.getCurrentPrice()), + "Min price" + ); + } + + // Trigger a full cycle again. There should be no adjustment this time since it uses a duration of 2 + triggerFullCycle(); + + // Confirm that the base rate has not been updated + assertEq( + emissionManager.baseEmissionRate(), + expectedBaseRate, + "Base rate should not be updated" + ); + + // Confirm that a bond market was not created + assertEq( + aggregator.marketCounter(), + nextBondMarketId, + "Market counter should not increment" + ); + + // Verify the auctioneer parameters + { + // Target == getNextEmission().emission + (, , uint256 emission) = emissionManager.getNextEmission(); + + assertEq(cdAuctioneer.target(), emission, "Target should be the emission"); + + // Tick size == getSizeFor(emission) + assertEq( + cdAuctioneer.tickSize(), + emissionManager.getSizeFor(emission), + "Tick size should be the emission" + ); + + // Min price == getMinPriceFor(emission) + assertEq( + cdAuctioneer.minPrice(), + emissionManager.getMinPriceFor(PRICE.getCurrentPrice()), + "Min price" + ); + } + } + + function test_execute_whenNextBeatIsZero_whenNegativeRateAdjustment_noSurplus() + public + givenNextBeatIsZero + givenNegativeRateAdjustment + givenCDAuctioneerHasZero + { + // Cache the current base rate + uint256 baseRate = emissionManager.baseEmissionRate(); + + // Calculate the expected base rate after the adjustment + uint256 expectedBaseRate = baseRate - changeBy; + + // Get the ID of the next bond market from the aggregator + uint256 nextBondMarketId = aggregator.marketCounter(); + + // Warp to the next day + vm.warp(block.timestamp + 86400); + + // Execute to trigger the rate adjustment + vm.prank(heart); + emissionManager.execute(); + + // Confirm the base rate has been updated + assertEq( + emissionManager.baseEmissionRate(), + expectedBaseRate, + "Base rate should be updated" + ); + + // Confirm that a bond market was not created + assertEq( + aggregator.marketCounter(), + nextBondMarketId, + "Market counter should not increment" ); + // Verify the auctioneer parameters + { + // Target == getNextEmission().emission + (, , uint256 emission) = emissionManager.getNextEmission(); + + assertEq(cdAuctioneer.target(), emission, "Target should be the emission"); + + // Tick size == getSizeFor(emission) + assertEq( + cdAuctioneer.tickSize(), + emissionManager.getSizeFor(emission), + "Tick size should be the emission" + ); + + // Min price == getMinPriceFor(emission) + assertEq( + cdAuctioneer.minPrice(), + emissionManager.getMinPriceFor(PRICE.getCurrentPrice()), + "Min price" + ); + } + // Calculate the expected base rate after the next adjustment expectedBaseRate -= changeBy; @@ -845,6 +1951,35 @@ contract EmissionManagerTest is Test { "Base rate should be updated" ); + // Confirm that a bond market was not created + assertEq( + aggregator.marketCounter(), + nextBondMarketId, + "Market counter should not increment" + ); + + // Verify the auctioneer parameters + { + // Target == getNextEmission().emission + (, , uint256 emission) = emissionManager.getNextEmission(); + + assertEq(cdAuctioneer.target(), emission, "Target should be the emission"); + + // Tick size == getSizeFor(emission) + assertEq( + cdAuctioneer.tickSize(), + emissionManager.getSizeFor(emission), + "Tick size should be the emission" + ); + + // Min price == getMinPriceFor(emission) + assertEq( + cdAuctioneer.minPrice(), + emissionManager.getMinPriceFor(PRICE.getCurrentPrice()), + "Min price" + ); + } + // Trigger a full cycle again. There should be no adjustment this time since it uses a duration of 2 triggerFullCycle(); @@ -854,6 +1989,35 @@ contract EmissionManagerTest is Test { expectedBaseRate, "Base rate should not be updated" ); + + // Confirm that a bond market was not created + assertEq( + aggregator.marketCounter(), + nextBondMarketId, + "Market counter should not increment" + ); + + // Verify the auctioneer parameters + { + // Target == getNextEmission().emission + (, , uint256 emission) = emissionManager.getNextEmission(); + + assertEq(cdAuctioneer.target(), emission, "Target should be the emission"); + + // Tick size == getSizeFor(emission) + assertEq( + cdAuctioneer.tickSize(), + emissionManager.getSizeFor(emission), + "Tick size should be the emission" + ); + + // Min price == getMinPriceFor(emission) + assertEq( + cdAuctioneer.minPrice(), + emissionManager.getMinPriceFor(PRICE.getCurrentPrice()), + "Min price" + ); + } } // callback test cases @@ -881,7 +2045,7 @@ contract EmissionManagerTest is Test { function test_callback_whenActiveMarketIdNotZero_whenIdNotActiveMarket_reverts( uint256 id_ - ) public { + ) public givenCDAuctioneerHasDeficit { // Trigger two sales so that the active market ID is 1 triggerFullCycle(); triggerFullCycle(); @@ -978,13 +2142,16 @@ contract EmissionManagerTest is Test { // execute -> callback (full cycle bond purchase) tests - function test_executeCallback_success() public givenNextBeatIsZero { + function test_executeCallback_success() public givenNextBeatIsZero givenCDAuctioneerHasDeficit { // Change the price to 20 reserve per OHM for easier math PRICE.setLastPrice(20 * 1e18); // Cache the next bond market id uint256 nextBondMarketId = aggregator.marketCounter(); + // Warp to the next day + vm.warp(block.timestamp + 86400); + // Call execute to create the bond market vm.prank(heart); emissionManager.execute(); @@ -997,7 +2164,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(); @@ -1081,11 +2248,12 @@ contract EmissionManagerTest is Test { function test_shutdown_whenMarketIsActive_closesMarket() public givenPremiumEqualToMinimum + givenCDAuctioneerHasDeficit givenThereIsPreviousSale { // We created a market, confirm it is active uint256 id = emissionManager.activeMarketId(); - assertTrue(auctioneer.isLive(id)); + assertTrue(bondAuctioneer.isLive(id), "Market should be active"); // Check that the contract is locally active assertTrue(emissionManager.locallyActive(), "Contract should be locally active"); @@ -1111,7 +2279,7 @@ contract EmissionManagerTest is Test { ); // Check that the market is no longer active - assertFalse(auctioneer.isLive(id)); + assertFalse(bondAuctioneer.isLive(id), "Market should be closed"); } // restart tests @@ -1193,7 +2361,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 +2376,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 +2408,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 +2429,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 +2450,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 +2471,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 +2492,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 +2513,8 @@ contract EmissionManagerTest is Test { baseEmissionRate + 1, minimumPremium + 1, backing + 1, + tickSizeScalar, + minPriceScalar, restartTimeframe + 1 ); @@ -1537,8 +2756,8 @@ contract EmissionManagerTest is Test { } function test_setBondContracts_whenBondAuctioneerZero_reverts() public { - // Try to set bond auctioneer to 0, expect revert - bytes memory err = abi.encodeWithSignature("InvalidParam(string)", "auctioneer"); + // Try to set bondAuctioneer to 0, expect revert + bytes memory err = abi.encodeWithSignature("InvalidParam(string)", "bondAuctioneer"); vm.expectRevert(err); vm.prank(guardian); emissionManager.setBondContracts(address(0), address(1)); @@ -1559,9 +2778,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 +2859,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 +2875,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 +2889,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)