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(
+ '"
+ );
+ }
+
+ // 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), "