From f3febbb01608524893095c60ec9f60d4c316f52f Mon Sep 17 00:00:00 2001 From: Alex Date: Mon, 15 Dec 2025 14:09:23 +0100 Subject: [PATCH 01/24] init (lockup) specs --- specs/SIPs/SIP-01-share-lockup-redemption.md | 186 +++++++++++++++++++ 1 file changed, 186 insertions(+) create mode 100644 specs/SIPs/SIP-01-share-lockup-redemption.md diff --git a/specs/SIPs/SIP-01-share-lockup-redemption.md b/specs/SIPs/SIP-01-share-lockup-redemption.md new file mode 100644 index 0000000..79188ec --- /dev/null +++ b/specs/SIPs/SIP-01-share-lockup-redemption.md @@ -0,0 +1,186 @@ +--- +SIP: 01 +Title: Share Lock-Up Redemption Mechanism for Tranche Vaults +Author: Strata Protocol Contributors +Status: Draft +Type: Protocol +Created: 2025-12-15 +--- + +# Tranche Vault — Share Lock-Up Redemption Specification + +## 1. Overview + +This specification defines a **share lock-up redemption mechanism** for Tranche Vaults (Junior and Senior tranches) that introduces a cooldown period on shares rather than on assets. + +Under this mechanism: + +* Users initiate a redemption request by transferring shares into a Silo Contract. +* Shares remain locked for a predefined lock-up period. +* After the lock-up period elapses, the user may finalize the redemption. +* The final exchange rate is determined at the time of finalization. +* Asset transfer occurs directly from the Tranche Vault at finalization. + +This design preserves ERC-4626 compatibility while enabling delayed liquidity and controlled redemption flows. + +--- + +## 2. Actors + +* **User**: An externally owned account (EOA) or contract initiating a redemption. +* **Receiver**: An account that receives the assets upon redemption. Per the ERC-4626 specification, the receiver MAY be different from the User. +* **Tranche Vault**: An ERC-4626-compatible vault managing Junior or Senior tranche shares. +* **Silo Contract**: A custody contract responsible for holding locked shares and executing final redemption on behalf of users. +* **Strategy**: The underlying asset manager (may require unstaking prior to asset withdrawal, e.g., USDe). + +--- + +## 3. Current Redemption Flow (Reference) + +The current (non lock-up) redemption flow is as follows: + +1. User calls `redeem` or `withdraw` +2. Vault calculates redeemable assets (excluding fees) +3. Vault burns the corresponding shares +4. Vault transfers assets: + + * Directly to the receiver, or + * To an unstaking / cooldown contract used by the Strategy + +--- + +## 4. Lock-Up Redemption Flow (When Enabled) + +### 4.1 Redemption Request Phase + +1. User initiates a redemption request via `redeem` or `withdraw` +2. Instead of burning shares: + + * The Tranche Vault transfers the specified shares to the Silo Contract +3. The Silo Contract records: + + * User address + * Receiver address + * Share amount + * Lock-up end timestamp + +At this stage: + +* No assets are transferred +* No shares are burned +* The user cannot finalize the redemption before the lock-up period expires +* The user may cancel the lock-up; in this case, the request is dismissed and the shares are transferred back to the user. +* [PROPOSAL] Early-Exit: The user may pay additional finalization fee to unlock and withdraw the shares before lock-up period ends. + +--- + +### 4.2 Finalization Phase + +After the lock-up period has elapsed: + +1. The user triggers finalization via the Silo Contract +2. The Silo Contract calls `redeem` on the Tranche Vault **on behalf of the user** +3. The Tranche Vault: + + * Detects that the caller is the Silo Contract + * Skips any lock-up or cooldown logic + * Skips fee accrual (if fees are active at the time of finalization) + * Processes the redemption as a direct ERC4626 redemption request +4. The Tranche Vault: + + * Burns the locked shares + * Calculates the redeemable assets using the **current exchange rate** +5. Asset handling: + + * If the asset is directly withdrawable, assets are transferred to the user + * If unstaking is required (e.g. USDe), the unstaking process is executed before transfer +6. During finalization, the user MAY specify the desired output asset (e.g. USDe or sUSDe) + +--- + +## 5. Exchange Rate Determination + +* The exchange rate used for redemption is determined **at the time of finalization** +* No price or asset amount is snapshotted at request time +* Users remain fully exposed to vault performance (positive or negative) during the lock-up period + +--- + +## 6. Fees + +* No additional withdrawal or redemption fees are charged specifically due to the lock-up mechanism + + +--- + +## 7. Redemption Pause Handling (`PAUSER_ROLE`) + +* If redemptions are fully paused at the Tranche Vault: + + * Finalization requests from the Silo Contract MUST revert + * Locked shares remain held in the Silo Contract until redemptions are unpaused + +--- + +## 8. Junior Tranche Redemption Limits + +The protocol enforces a maximum redeemable amount to preserve the MINIMUM Junior / Senior ratio. + +With the new lock-up mechanism, shares that are already locked in the Silo contract are not included in the ratio formula, meaning the Net Asset Value for a Tranche is changed to: + +$$ +\text{JuniorNAV} = \text{JuniorTVL} - \text{JuniorTVLInSilo} +$$ + +$$ +\text{SeniorNAV} = \text{SeniorTVL} - \text{SeniorTVLInSilo} +$$ + +--- + +## 9. Multiple Withdrawal Requests + +### 9.1 Request Granularity + +* Each redemption request is treated as a distinct lock-up entry +* Each entry has its own: + + * Share amount + * Lock-up end timestamp + +--- + +### 9.2 Aggregated Finalization + +* The Silo Contract MAY aggregate multiple completed lock-up entries +* Finalization may redeem the sum of all eligible completed requests in a single transaction + +--- + +### 9.3 Request Spam Prevention + +To prevent request spamming: + +* A maximum of **N** simultaneous lock-up requests per user is enforced (currently **50**) +* Once the limit is reached: + + * Any new request: + + * Increases the share amount of the last request + * Extends the lock-up end timestamp of that request + +--- + +### 9.4 Request Finalizer Account + +* A redemption request represents a **public intent** to exit the vault +* The receiver address is **recorded at request time** and **cannot be modified** +* The request has **no impact on the share exchange rate** + +Based on these properties: + +* The **finalization of a redemption request is permissionless**; any account MAY finalize an eligible redemption request. + +--- + +🏁 From 3bf6d3556f05190998e65d1c4a110ca86a1250b5 Mon Sep 17 00:00:00 2001 From: Alex Date: Mon, 5 Jan 2026 23:07:50 +0100 Subject: [PATCH 02/24] init (lockup + exit mode) implementation --- .olympix-ignore.json | 17 + .vscode/settings.json | 3 +- contracts/tranches/Accounting.sol | 9 + contracts/tranches/StrataCDO.sol | 116 +++++- contracts/tranches/Tranche.sol | 176 +++++---- contracts/tranches/TwoStepConfigManager.sol | 103 +++++- .../tranches/base/cooldown/SharesCooldown.sol | 299 +++++++++++++++ contracts/tranches/interfaces/IAccounting.sol | 1 + contracts/tranches/interfaces/IStrataCDO.sol | 16 +- contracts/tranches/interfaces/IStrategy.sol | 1 + contracts/tranches/interfaces/ITranche.sol | 12 + .../interfaces/cooldown/ISharesCooldown.sol | 62 ++++ .../strategies/ethena/sUSDeStrategy.sol | 10 +- package.json | 1 + specs/SIPs/SIP-01-share-lockup-redemption.md | 63 +++- specs/SIPs/SIP-02-unified-exit-modes.md | 158 ++++++++ src/deployments/TranchesDeployments.ts | 42 ++- src/utils/$exitMode.ts | 55 +++ test/tranches/accounting/Apr.spec.ts | 2 +- test/tranches/cooldowns/$testCooldown.ts | 8 +- test/tranches/cooldowns/ERC20Cooldown.spec.ts | 2 +- test/tranches/cooldowns/Exit.spec.ts | 340 ++++++++++++++++++ .../tranches/cooldowns/SharesCooldown.spec.ts | 270 ++++++++++++++ test/tranches/e2e/v1_1_0/fees.spec.ts | 32 +- test/tranches/utils/$ethena.ts | 6 + 25 files changed, 1686 insertions(+), 118 deletions(-) create mode 100644 .olympix-ignore.json create mode 100644 contracts/tranches/base/cooldown/SharesCooldown.sol create mode 100644 contracts/tranches/interfaces/cooldown/ISharesCooldown.sol create mode 100644 specs/SIPs/SIP-02-unified-exit-modes.md create mode 100644 src/utils/$exitMode.ts create mode 100644 test/tranches/cooldowns/Exit.spec.ts create mode 100644 test/tranches/cooldowns/SharesCooldown.spec.ts diff --git a/.olympix-ignore.json b/.olympix-ignore.json new file mode 100644 index 0000000..99b1275 --- /dev/null +++ b/.olympix-ignore.json @@ -0,0 +1,17 @@ +{ + "IgnoredVulnerabilities" : { + "missing-revert-reason-tests" : { + "contracts" : [] + }, + "reentrancy" : { + "contracts/tranches/Accounting.sol" : [330, 393], + "contracts/tranches/StrataCDO.sol" : [392], + "contracts/tranches/base/cooldown/UnstakeCooldown.sol": [113] + } + }, + "IgnoredPaths": [ + "contracts/test", + "contracts/oz", + "contracts/lens" + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json index 36da3eb..28aae35 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -72,5 +72,6 @@ "security.olympix.detectors.possibleDivisionByZero": true, "security.olympix.detectors.zeroAsParameter": true, "security.olympix.detectors.oracleManipulation": true, - "security.olympix.project.useAiToPruneFindings": true + "security.olympix.project.useAiToPruneFindings": true, + "security.olympix.detectors.missingEventsAssertion": false } diff --git a/contracts/tranches/Accounting.sol b/contracts/tranches/Accounting.sol index 0beba29..59881fe 100644 --- a/contracts/tranches/Accounting.sol +++ b/contracts/tranches/Accounting.sol @@ -166,6 +166,15 @@ contract Accounting is IAccounting, CDOComponent { } function maxWithdraw(bool isJrt) external view returns (uint256) { + return maxWithdrawInner(isJrt, false); + } + function maxWithdraw(bool isJrt, bool ownerIsSharesCooldown) external view returns (uint256) { + return maxWithdrawInner(isJrt, ownerIsSharesCooldown); + } + function maxWithdrawInner(bool isJrt, bool ownerIsSharesCooldown) internal view returns (uint256) { + if (ownerIsSharesCooldown) { + return isJrt ? jrtNav : srtNav; + } if (isJrt) { uint256 minJrt = srtNav * minimumJrtSrtRatio / 1e18; return Math.saturatingSub(jrtNav, minJrt); diff --git a/contracts/tranches/StrataCDO.sol b/contracts/tranches/StrataCDO.sol index 1d593de..6f6b525 100644 --- a/contracts/tranches/StrataCDO.sol +++ b/contracts/tranches/StrataCDO.sol @@ -17,6 +17,8 @@ import { IStrategy } from "./interfaces/IStrategy.sol"; import { IStrataCDO, IStrataCDOSetters } from "./interfaces/IStrataCDO.sol"; import { TActionState } from "./structs/TActionState.sol"; import { IAccounting } from "./interfaces/IAccounting.sol"; +import { ISharesCooldown } from "./interfaces/cooldown/ISharesCooldown.sol"; + /// @notice Core CDO contract that orchestrates Tranches, Accounting, and Strategy /// @dev Manages deposits, withdrawals, and asset distribution between tranches @@ -59,6 +61,8 @@ contract StrataCDO is IErrors, IStrataCDO, IStrataCDOSetters, AccessControlled { /// @dev Withdrawal fees for the Senior Tranche uint256 public exitFeeSrt; + ISharesCooldown public sharesCooldown; + event DepositsStateChanged(address indexed tranche, bool enabled); event WithdrawalsStateChanged(address indexed tranche, bool enabled); event ReserveReduced(address token, uint256 amount); @@ -67,6 +71,7 @@ contract StrataCDO is IErrors, IStrataCDO, IStrataCDOSetters, AccessControlled { event ShortfallPaused(); event JrtShortfallPausePriceSet(uint256 pricePerShare); event ExitFeesSet(uint256 jrt, uint256 srt); + event SharesCooldownSet(address sharesCooldown); /// @notice Restricts function access to only the junior (JRT) or senior (SRT) tranche contracts @@ -126,27 +131,49 @@ contract StrataCDO is IErrors, IStrataCDO, IStrataCDOSetters, AccessControlled { return accounting.maxDeposit(isJrt_); } function maxWithdraw(address tranche) external view returns (uint256) { + return maxWithdraw(tranche, address(0)); + } + + function maxWithdraw(address tranche, address owner) public view returns (uint256) { bool isJrt_ = isJrt(tranche); bool isWithdrawEnabled = isJrt_ ? actionsJrt.isWithdrawEnabled : actionsSrt.isWithdrawEnabled; if (isWithdrawEnabled == false) { return 0; } - return accounting.maxWithdraw(isJrt_); + bool ownerIsSharesCooldown = owner != address(0) && owner == address(sharesCooldown); + return accounting.maxWithdraw(isJrt_, ownerIsSharesCooldown); } - /// @notice Calculates the exit fee for a withdrawal from a specific tranche. - /// @dev The calculation can be based on either the gross withdrawal amount (before fees) - /// or the net amount a user wishes to receive (after fees). + /// @notice Determines the exit mode and associated parameters for a withdrawal from a specific tranche. + /// @dev Checks if shares cooldown is configured and calculates exit parameters based on coverage ratio. + /// If the owner is the shares cooldown contract, returns ERC4626 mode with no fees. + /// Otherwise, returns either SharesLock mode (if cooldown required) or Fee mode with applicable fees. /// @param tranche The address of the tranche (junior or senior). - /// @param amount The amount to calculate the fee on. - /// @param isGross If true, `amount` is the gross withdrawal amount. - /// If false, `amount` is the net amount to be received. - /// @return The calculated exit fee amount. - function calculateExitFee (address tranche, uint256 amount, bool isGross) external view returns (uint256) { - uint256 fee = isJrt(tranche) ? exitFeeJrt : exitFeeSrt; - return isGross - ? Math.mulDiv(amount, fee, 1e18, Math.Rounding.Floor) - : Math.mulDiv(amount, fee, 1e18 - fee, Math.Rounding.Floor); + /// @param owner The shares owner. No fee or cooldown is applied when the shares cooldown contract redeems. + /// @return mode The exit mode (ERC4626, SharesLock, or Fee). + /// @return fee The exit fee in 18 decimals (0 if no fee applies). + /// @return cooldownSeconds The cooldown period in seconds (0 if no cooldown applies). + function calculateExitMode (address tranche, address owner) external view returns (TExitMode mode, uint256 fee, uint32 cooldownSeconds) { + if (address(sharesCooldown) != address(0)) { + if (owner == address(sharesCooldown)) { + return (TExitMode.ERC4626, 0, 0); + } + uint32 cov = coverage(); + ISharesCooldown.TExitParams memory exit = sharesCooldown.calculateExitParams(tranche, cov); + if (exit.feePpm > 0) { + // Convert to 18 decimals + fee = uint256(exit.feePpm) * 1e18 / 1e6; + } + if (exit.sharesLock > 0) { + return (TExitMode.SharesLock, fee, exit.sharesLock); + } + } + if (fee == 0) { + // default + bool isJrt_ = isJrt(tranche); + fee = isJrt_ ? exitFeeJrt : exitFeeSrt; + } + return (TExitMode.Fee, fee, 0); } /// @notice On behalf of a tranche, moves accrued fees from the tranche's TVL to the reserve. @@ -156,6 +183,26 @@ contract StrataCDO is IErrors, IStrataCDO, IStrataCDOSetters, AccessControlled { accounting.accrueFee(isJrt(tranche), assets); } + function totalAssetsUnlocked() public view returns (uint256 jrtNav, uint256 srtNav) { + (jrtNav, srtNav, ) = accounting.totalAssetsT0(); + + uint256 jrtNavLocked = jrtVault.convertToAssets(jrtVault.balanceOf(address(sharesCooldown))); + uint256 srtNavLocked = srtVault.convertToAssets(srtVault.balanceOf(address(sharesCooldown))); + + jrtNav = jrtNav > jrtNavLocked ? jrtNav - jrtNavLocked : 0; + srtNav = srtNav > srtNavLocked ? srtNav - srtNavLocked : 0; + return (jrtNav, srtNav); + } + + function coverage () public view returns (uint32 coverage) { + (uint256 jrtNav, uint256 srtNav) = totalAssetsUnlocked(); + if (srtNav == 0) { + return type(uint32).max; + } + uint256 coverage = jrtNav * 1e6 / srtNav; + return coverage > type(uint32).max ? type(uint32).max : uint32(coverage); + } + function updateAccounting () external onlyTranche { uint256 totalAssetsOverall = strategy.totalAssets(); accounting.updateAccounting(totalAssetsOverall); @@ -181,24 +228,50 @@ contract StrataCDO is IErrors, IStrataCDO, IStrataCDOSetters, AccessControlled { } function withdraw(address tranche, address token, uint256 tokenAmount, uint256 baseAssets, address sender, address receiver) external onlyTranche nonReentrant { + if (tokenAmount == 0 || baseAssets == 0) { + revert ZeroAmount(); + } bool isJrt_ = isJrt(tranche); bool enabled = isJrt_ ? actionsJrt.isWithdrawEnabled : actionsSrt.isWithdrawEnabled; if (!enabled) { revert WithdrawalsDisabled(tranche); } - if (baseAssets > accounting.maxWithdraw(isJrt_)) { + bool isSharesLockup = sender == address(sharesCooldown); + if (baseAssets > accounting.maxWithdraw(isJrt_, isSharesLockup)) { revert WithdrawalCapReached(tranche); } - if (tokenAmount == 0 || baseAssets == 0) { - revert ZeroAmount(); - } - strategy.withdraw(tranche, token, tokenAmount, baseAssets, sender, receiver); + // When the sender is the shares lockup contract, we should skip any cooldown on our side, + // unless the underlying protocol has some cooldown/unstake process. + bool shouldSkipCooldown = isSharesLockup == true; + strategy.withdraw(tranche, token, tokenAmount, baseAssets, sender, receiver, shouldSkipCooldown); uint256 jrtAssetsOut = isJrt_ ? baseAssets : 0; uint256 srtAssetsOut = isJrt_ ? 0 : baseAssets; accounting.updateBalanceFlow(0, jrtAssetsOut, 0, srtAssetsOut); shortfallPauser(); } + /// @notice Initiates a cooldown period for share redemption by transferring shares to the cooldown contract. + /// @dev Validates withdrawal permissions and delegates to the sharesCooldown contract to handle the lock-up. + /// The shares are held in escrow during the cooldown period before they can be redeemed for assets. + /// The caller MUST transfer the required shares to the shares cooldown contract before calling this function. + /// @param tranche The address of the tranche (junior or senior). + /// @param shares The amount of shares to lock for cooldown. + /// @param sender The address initiating the cooldown (original share owner). + /// @param receiver The address that will receive the assets after cooldown completes. + /// @param fee The exit fee to be applied when redeeming (in 18 decimals). + /// @param cooldownSeconds The duration of the cooldown period in seconds. + function cooldownShares(address tranche, uint256 shares, address sender, address receiver, uint256 fee, uint32 cooldownSeconds) external onlyTranche nonReentrant { + if (shares == 0) { + revert ZeroAmount(); + } + bool isJrt_ = isJrt(tranche); + bool enabled = isJrt_ ? actionsJrt.isWithdrawEnabled : actionsSrt.isWithdrawEnabled; + if (!enabled) { + revert WithdrawalsDisabled(tranche); + } + sharesCooldown.requestRedeem(ITranche(tranche), sender, receiver, shares, fee, cooldownSeconds); + } + /// @notice Determines if the given address is the Junior (BB) Tranche /// @dev Used to differentiate between Junior and Senior Tranches /// @param tranche The address to check @@ -309,6 +382,13 @@ contract StrataCDO is IErrors, IStrataCDO, IStrataCDOSetters, AccessControlled { emit JrtShortfallPausePriceSet(jrtShortfallPausePrice_); } + /// @notice Sets the shares cooldown contract address + /// @param sharesCooldown_ The new shares cooldown contract address + function setSharesCooldown (ISharesCooldown sharesCooldown_) external onlyOwner { + sharesCooldown = sharesCooldown_; + emit SharesCooldownSet(address(sharesCooldown_)); + } + function shortfallPauser () internal { (uint256 jrtNav,,) = accounting.totalAssetsT0(); uint256 jrtPrice = calculatePricePerShare(jrtNav, jrtVault.totalSupply()); diff --git a/contracts/tranches/Tranche.sol b/contracts/tranches/Tranche.sol index e0791de..3cbdc6d 100644 --- a/contracts/tranches/Tranche.sol +++ b/contracts/tranches/Tranche.sol @@ -3,6 +3,7 @@ pragma solidity ^0.8.28; import { Math } from "@openzeppelin/contracts/utils/math/Math.sol"; import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { IERC20Metadata } from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; import { IERC4626 } from "@openzeppelin/contracts/interfaces/IERC4626.sol"; import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import { ERC20Upgradeable } from "@openzeppelin/contracts-upgradeable/token/ERC20/ERC20Upgradeable.sol"; @@ -10,9 +11,10 @@ import { ERC4626Upgradeable } from "@openzeppelin/contracts-upgradeable/token/ER import { ERC20PermitUpgradeable } from "@openzeppelin/contracts-upgradeable/token/ERC20/extensions/ERC20PermitUpgradeable.sol"; import { IStrataCDO } from "./interfaces/IStrataCDO.sol"; import { IStrategy } from "./interfaces/IStrategy.sol"; +import { ITranche } from "./interfaces/ITranche.sol"; import { CDOComponent } from "./base/CDOComponent.sol"; -contract Tranche is CDOComponent, ERC4626Upgradeable, ERC20PermitUpgradeable { +contract Tranche is ITranche, CDOComponent, ERC4626Upgradeable, ERC20PermitUpgradeable { /// @notice Minimum non-zero shares amount to prevent donation attack uint256 private constant MIN_SHARES = 0.1 ether; @@ -38,11 +40,11 @@ contract Tranche is CDOComponent, ERC4626Upgradeable, ERC20PermitUpgradeable { /// @return uint256 The total assets for this tranche - function totalAssets() public view override returns (uint256) { + function totalAssets() public view override(ERC4626Upgradeable, IERC4626) returns (uint256) { return cdo.totalAssets(address(this)); } - function decimals() public view override(ERC20Upgradeable, ERC4626Upgradeable) returns (uint8) { + function decimals() public view override(ERC20Upgradeable, ERC4626Upgradeable, IERC20Metadata) returns (uint8) { return super.decimals(); } @@ -53,12 +55,12 @@ contract Tranche is CDOComponent, ERC4626Upgradeable, ERC20PermitUpgradeable { */ /** @dev Extends {IERC4626-maxDeposit} to handle the paused state and the TVL ratio */ - function maxDeposit(address owner) public view override returns (uint256) { + function maxDeposit(address owner) public view override(ERC4626Upgradeable, IERC4626) returns (uint256) { return cdo.maxDeposit(address(this)); } /** @dev Extends {IERC4626-maxMint} to handle the paused state and the TVL ratio */ - function maxMint(address owner) public view override returns (uint256) { + function maxMint(address owner) public view override(ERC4626Upgradeable, IERC4626) returns (uint256) { uint256 assets = cdo.maxDeposit(address(this)); if (assets == type(uint256).max) { // No mint-cap @@ -68,29 +70,38 @@ contract Tranche is CDOComponent, ERC4626Upgradeable, ERC20PermitUpgradeable { } /** @dev Extends {IERC4626-maxWithdraw} to handle the paused state and the TVL ratio */ - function maxWithdraw(address owner) public view override returns (uint256 assetsNet) { + function maxWithdraw(address owner) public view override(ERC4626Upgradeable, IERC4626) returns (uint256 assetsNet) { uint256 sharesGross = balanceOf(owner); - assetsNet = Math.min(previewRedeem(sharesGross), cdo.maxWithdraw(address(this))); + assetsNet = Math.min(previewRedeem(sharesGross), cdo.maxWithdraw(address(this), owner)); } /** @dev Extends {IERC4626-maxRedeem} to handle the paused state and the TVL ratio */ - function maxRedeem(address owner) public view override returns (uint256 sharesGross) { - uint256 assetsProtocolMax = cdo.maxWithdraw(address(this)); + function maxRedeem(address owner) public view override(ERC4626Upgradeable, IERC4626) returns (uint256 sharesGross) { + uint256 assetsProtocolMax = cdo.maxWithdraw(address(this), owner); uint256 sharesProtocolMax = convertToShares(assetsProtocolMax); sharesGross = Math.min(super.maxRedeem(owner), sharesProtocolMax); } /** @dev Extends {IERC4626-previewRedeem} to handle fee calculation */ - function previewRedeem(uint256 sharesGross) public view override returns (uint256 assetsNet) { - uint256 fee = cdo.calculateExitFee(address(this), sharesGross, true); - assetsNet = super.previewRedeem(sharesGross - fee); + function previewRedeem(uint256 sharesGross) public view override(ERC4626Upgradeable, IERC4626) returns (uint256 assetsNet) { + (IStrataCDO.TExitMode mode, uint256 fee, ) = cdo.calculateExitMode(address(this), address(0)); + assetsNet = quoteRedeem(sharesGross, fee); + } + function quoteRedeem(uint256 sharesGross, uint256 fee) public view returns (uint256 assetsNet) { + uint256 sharesFee = fee > 0 ? calculateExitFee(sharesGross, fee, /*isGross*/true) : 0; + assetsNet = super.previewRedeem(sharesGross - sharesFee); } /** @dev Extends {IERC4626-previewWithdraw} to handle fee calculation */ - function previewWithdraw(uint256 assetsNet) public view override returns (uint256 sharesGross) { + function previewWithdraw(uint256 assetsNet) public view override(ERC4626Upgradeable, IERC4626) returns (uint256 sharesGross) { + (IStrataCDO.TExitMode mode, uint256 exitFee, ) = cdo.calculateExitMode(address(this), address(0)); + sharesGross = quoteWithdraw(assetsNet, exitFee); + } + + function quoteWithdraw(uint256 assetsNet, uint256 fee) public view returns (uint256 sharesGross) { uint256 sharesNet = super.previewWithdraw(assetsNet); - uint256 fee = cdo.calculateExitFee(address(this), sharesNet, false); - sharesGross = sharesNet + fee; + uint256 sharesFee = fee > 0 ? calculateExitFee(sharesNet, fee, /*isGross*/false) : 0; + sharesGross = sharesNet + sharesFee; } /** @@ -148,7 +159,7 @@ contract Tranche is CDOComponent, ERC4626Upgradeable, ERC20PermitUpgradeable { */ /** @dev See {IERC4626-deposit}. */ - function deposit(uint256 tokenAssets, address receiver) public override returns (uint256) { + function deposit(uint256 tokenAssets, address receiver) public override(ERC4626Upgradeable, IERC4626) returns (uint256) { cdo.updateAccounting(); uint256 shares = super.deposit(tokenAssets, receiver); return shares; @@ -165,7 +176,7 @@ contract Tranche is CDOComponent, ERC4626Upgradeable, ERC20PermitUpgradeable { return shares; } /** @dev See {IERC4626-mint}. */ - function mint(uint256 shares, address receiver) public override returns (uint256) { + function mint(uint256 shares, address receiver) public override(ERC4626Upgradeable, IERC4626) returns (uint256) { cdo.updateAccounting(); uint256 assets = super.mint(shares, receiver); return assets; @@ -214,78 +225,54 @@ contract Tranche is CDOComponent, ERC4626Upgradeable, ERC20PermitUpgradeable { */ /** @dev See {IERC4626-withdraw}. */ - function withdraw(uint256 assets, address receiver, address owner) public override returns (uint256) { - cdo.updateAccounting(); - uint256 shares = super.withdraw(assets, receiver, owner); - return shares; + function withdraw(uint256 assets, address receiver, address owner) public override(ERC4626Upgradeable, IERC4626) returns (uint256) { + return withdraw(asset(), assets, receiver, owner); } function withdraw(address token, uint256 tokenAmount, address receiver, address owner) public virtual returns (uint256) { - if (token == asset()) { - return withdraw(tokenAmount, receiver, owner); - } + return withdraw(token, tokenAmount, receiver, owner, TRedemptionParams(IStrataCDO.TExitMode.Dynamic, 0, 0)); + } + function withdraw(address token, uint256 tokenAmount, address receiver, address owner, TRedemptionParams memory params) public virtual returns (uint256) { cdo.updateAccounting(); + (IStrataCDO.TExitMode exitMode, uint256 exitFee, uint32 cooldownSec) = cdo.calculateExitMode(address(this), owner); + validateRedemptionParams(params, exitMode, exitFee, cooldownSec); + // {Optimistic path} Reverts if token is not supported uint256 baseAssets = cdo.strategy().convertToAssets(token, tokenAmount, Math.Rounding.Floor); uint256 maxAssets = maxWithdraw(owner); if (baseAssets > maxAssets) { revert ERC4626ExceededMaxWithdraw(owner, baseAssets, maxAssets); } - uint256 shares = previewWithdraw(baseAssets); - _withdraw(token, _msgSender(), receiver, owner, baseAssets, tokenAmount, shares); + uint256 shares = quoteWithdraw(baseAssets, exitFee); + _withdraw(token, _msgSender(), receiver, owner, baseAssets, tokenAmount, shares, exitMode, exitFee, cooldownSec); return shares; } /** @dev See {IERC4626-redeem}. */ - function redeem(uint256 shares, address receiver, address owner) public override returns (uint256) { - cdo.updateAccounting(); - uint256 assets = super.redeem(shares, receiver, owner); - return assets; + function redeem(uint256 shares, address receiver, address owner) public override(ERC4626Upgradeable, IERC4626) returns (uint256) { + return redeem(asset(), shares, receiver, owner); } function redeem(address token, uint256 shares, address receiver, address owner) public virtual returns (uint256) { - if (token == asset()) { - return redeem(shares, receiver, owner); - } + return redeem(token, shares, receiver, owner, TRedemptionParams(IStrataCDO.TExitMode.Dynamic, 0, 0)); + } + function redeem(address token, uint256 shares, address receiver, address owner, TRedemptionParams memory params) public virtual returns (uint256) { cdo.updateAccounting(); uint256 maxShares = maxRedeem(owner); if (shares > maxShares) { revert ERC4626ExceededMaxRedeem(owner, shares, maxShares); } - uint256 baseAssets = previewRedeem(shares); + (IStrataCDO.TExitMode exitMode, uint256 exitFee, uint32 cooldownSec) = cdo.calculateExitMode(address(this), owner); + validateRedemptionParams(params, exitMode, exitFee, cooldownSec); + + uint256 baseAssets = quoteRedeem(shares, exitFee); // {Optimistic path} Reverts if token is not supported uint256 tokenAssets = cdo.strategy().convertToTokens(token, baseAssets, Math.Rounding.Ceil); - _withdraw(token, _msgSender(), receiver, owner, baseAssets, tokenAssets, shares); + _withdraw(token, _msgSender(), receiver, owner, baseAssets, tokenAssets, shares, exitMode, exitFee, cooldownSec); return tokenAssets; } /** - * @dev Withdraw/redeem common workflow for base token - * assets ~ net - * shares ~ gross - */ - function _withdraw( - address caller, - address receiver, - address owner, - uint256 assetsNet, - uint256 sharesGross - ) internal override { - if (caller != owner) { - _spendAllowance(owner, caller, sharesGross); - } - uint256 assetsGross = convertToAssets(sharesGross); - uint256 fee = Math.saturatingSub(assetsGross, assetsNet); - - _burn(owner, sharesGross); - cdo.accrueFee(address(this), fee); - cdo.withdraw(address(this), asset(), assetsNet, assetsNet, owner, receiver); - - _onAfterWithdrawalChecks(); - emit Withdraw(caller, receiver, owner, assetsNet, sharesGross); - } - - /** - * @dev Withdraw/redeem common workflow for meta token + * @dev Withdraw/redeem common workflow for base and meta tokens */ function _withdraw( address token, @@ -294,11 +281,21 @@ contract Tranche is CDOComponent, ERC4626Upgradeable, ERC20PermitUpgradeable { address owner, uint256 baseAssets, uint256 tokenAssets, - uint256 sharesGross + uint256 sharesGross, + IStrataCDO.TExitMode exitMode, + uint256 exitFee, + uint32 cooldownSec ) internal virtual { if (caller != owner) { _spendAllowance(owner, caller, sharesGross); } + + if (exitMode == IStrataCDO.TExitMode.SharesLock) { + _transfer(owner, address(cdo.sharesCooldown()), sharesGross); + cdo.cooldownShares(address(this), sharesGross, owner, receiver, exitFee, cooldownSec); + return; + } + uint256 baseAssetsGross = convertToAssets(sharesGross); uint256 fee = Math.saturatingSub(baseAssetsGross, baseAssets); @@ -310,6 +307,32 @@ contract Tranche is CDOComponent, ERC4626Upgradeable, ERC20PermitUpgradeable { emit OnMetaWithdraw(receiver, token, tokenAssets, sharesGross); } + /** + * ============================================ + * Fee methods + * ============================================ + */ + + /// @notice Burns shares as fee without withdrawing assets. Permissionless but typically called by SharesCooldown + /// to accrue fees on the redeemable portion during cooldown process. + /// @param shares The amount of shares to burn as fee + /// @param owner The owner of the shares to burn + /// @return assets The base assets accounted as fee + function burnSharesAsFee(uint256 shares, address owner) external returns (uint256 assets) { + address caller = _msgSender(); + if (caller != owner) { + _spendAllowance(owner, caller, shares); + } + uint256 maxShares = maxRedeem(owner); + if (shares > maxShares) { + revert ERC4626ExceededMaxRedeem(owner, shares, maxShares); + } + + assets = convertToAssets(shares); + _burn(owner, shares); + cdo.accrueFee(address(this), assets); + } + /** * ============================================ * Configuration @@ -338,4 +361,31 @@ contract Tranche is CDOComponent, ERC4626Upgradeable, ERC20PermitUpgradeable { revert MinSharesViolation(); } } + + /// @dev The calculation can be based on either the gross withdrawal amount (before fees) + /// or the net amount a user wishes to receive (after fees). + /// @param amount The amount to calculate the fee on. + /// @param isGross If true, `amount` is the gross withdrawal amount. + /// If false, `amount` is the net amount to be received. + /// @return The calculated exit fee amount + function calculateExitFee (uint256 amount, uint256 fee, bool isGross) internal pure returns (uint256) { + return isGross + ? Math.mulDiv(amount, fee, 1e18, Math.Rounding.Floor) + : Math.mulDiv(amount, fee, 1e18 - fee, Math.Rounding.Floor); + } + + + function validateRedemptionParams(TRedemptionParams memory params, IStrataCDO.TExitMode exitMode, uint256 exitFee, uint32 cooldownSec) internal pure { + if (params.exitMode == IStrataCDO.TExitMode.Dynamic) { + return; + } + if (params.exitMode != exitMode || params.exitFee != exitFee || params.cooldownSeconds != cooldownSec) { + return; + } + revert RedemptionParamsMismatch(params, TRedemptionParams({ + exitMode: exitMode, + exitFee: exitFee, + cooldownSeconds: cooldownSec + })); + } } diff --git a/contracts/tranches/TwoStepConfigManager.sol b/contracts/tranches/TwoStepConfigManager.sol index 7b21c32..747fe90 100644 --- a/contracts/tranches/TwoStepConfigManager.sol +++ b/contracts/tranches/TwoStepConfigManager.sol @@ -3,6 +3,7 @@ pragma solidity ^0.8.28; import { AccessControlled } from "../governance/AccessControlled.sol"; import { IStrataCDO, IStrataCDOSetters } from "./interfaces/IStrataCDO.sol"; +import { ISharesCooldown } from "./interfaces/cooldown/ISharesCooldown.sol"; interface IStrataCDOFull is IStrataCDO, IStrataCDOSetters { function acm () external view returns (address); @@ -32,6 +33,40 @@ abstract contract PendingFeesTypes { event ExitFeeChangeCancelled(); } +/// @notice Shared types and events for pending exit mode configuration. +abstract contract PendingExitModeTypes { + /// @notice Pending exit mode change that becomes executable after a delay. + struct TPendingExitModeBoundsChange { + ISharesCooldown.TExitUpperBounds bounds; + uint64 executeAfter; + } + /// @notice Emitted when a new exit fee change is scheduled. + event ExitModeBoundsChangeScheduled( + ISharesCooldown.TExitUpperBounds boundsJrt, + ISharesCooldown.TExitUpperBounds boundsSrt, + uint64 executeAfter + ); + /// @notice Emitted when a pending exit-fee change is executed on the CDO. + event ExitModeBoundsChangeExecuted( + ISharesCooldown.TExitUpperBounds boundsJrt, + ISharesCooldown.TExitUpperBounds boundsSrt + ); + /// @notice Emitted when a pending exit fee change is cancelled. + event ExitModeBoundsChangeCancelled(); + + function validateBounds (ISharesCooldown.TExitUpperBounds calldata bounds) internal pure { + require(bounds.p0 <= bounds.p1, 'P1>P2'); + + require(bounds.r0.feePpm <= 0.05e6, "InvalidFee"); + require(bounds.r1.feePpm <= 0.05e6, "InvalidFee"); + require(bounds.r2.feePpm <= 0.05e6, "InvalidFee"); + + require(bounds.r0.sharesLock <= 30 days, "InvalidCooldown"); + require(bounds.r1.sharesLock <= 30 days, "InvalidCooldown"); + require(bounds.r2.sharesLock <= 30 days, "InvalidCooldown"); + } +} + /** * @title TwoStepConfigManager @@ -45,12 +80,14 @@ abstract contract PendingFeesTypes { * This contract is intended to be extended later with additional * two-step configuration methods (e.g. other risk params). */ -contract TwoStepConfigManager is AccessControlled, PendingFeesTypes { +contract TwoStepConfigManager is AccessControlled, PendingFeesTypes, PendingExitModeTypes{ uint256 public constant MIN_DELAY = 1 days; IStrataCDOFull public immutable cdo; TPendingExitFeeChange public pendingExitFeeChange; + TPendingExitModeBoundsChange public pendingExitModeBoundsJrt; + TPendingExitModeBoundsChange public pendingExitModeBoundsSrt; constructor (IStrataCDOFull _cdo) { cdo = _cdo; @@ -122,4 +159,68 @@ contract TwoStepConfigManager is AccessControlled, PendingFeesTypes { delete pendingExitFeeChange; emit ExitFeeChangeCancelled(); } + + /** + * ============================================ + * Exit mode upper bounds configuration + * ============================================ + */ + + /** + * @notice Step 1: Schedule a new exit mode bounds configuration. + * @param boundsJrt Exit mode upper bounds for the junior tranche (JRT), defining coverage ratio thresholds and corresponding fee/lock parameters. + * @param boundsSrt Exit mode upper bounds for the senior tranche (SRT), defining coverage ratio thresholds and corresponding fee/lock parameters. + * @param delay Delay in seconds until the change can be executed. + * Must be greater or equal than MIN_DELAY. + */ + function scheduleExitModeBoundsChange( + ISharesCooldown.TExitUpperBounds calldata boundsJrt, + ISharesCooldown.TExitUpperBounds calldata boundsSrt, + uint256 delay + ) external onlyRole(PROPOSER_CONFIG_ROLE) { + + validateBounds(boundsJrt); + validateBounds(boundsSrt); + require(delay >= MIN_DELAY, "InvalidDelay"); + + uint64 executeAfter = uint64(block.timestamp + delay); + pendingExitModeBoundsJrt = TPendingExitModeBoundsChange({ + bounds: boundsJrt, + executeAfter: executeAfter + }); + pendingExitModeBoundsSrt = TPendingExitModeBoundsChange({ + bounds: boundsSrt, + executeAfter: executeAfter + }); + emit ExitModeBoundsChangeScheduled(boundsJrt, boundsSrt, executeAfter); + } + + /** + * @notice Step 2: execute the pending exit mode bounds change on the underlying CDO. + */ + function executeExitModeBoundsChange() external onlyRole(UPDATER_STRAT_CONFIG_ROLE) { + TPendingExitModeBoundsChange memory pendingJrt = pendingExitModeBoundsJrt; + TPendingExitModeBoundsChange memory pendingSrt = pendingExitModeBoundsSrt; + require(pendingJrt.executeAfter != 0, "NoPendingChange"); + require(block.timestamp >= pendingJrt.executeAfter, "TooEarly"); + // Clear pending state + delete pendingExitModeBoundsJrt; + delete pendingExitModeBoundsSrt; + // External call to the underlying contract. + + cdo.sharesCooldown().setVaultExitBounds(address(cdo.jrtVault()), pendingJrt.bounds); + cdo.sharesCooldown().setVaultExitBounds(address(cdo.srtVault()), pendingSrt.bounds); + + emit ExitModeBoundsChangeExecuted(pendingJrt.bounds, pendingSrt.bounds); + } + + /** + * @notice Cancel the currently pending exit mode bounds change, if any. + */ + function cancelExitModeBoundsChange() external onlyRole(UPDATER_STRAT_CONFIG_ROLE) { + require(pendingExitModeBoundsJrt.executeAfter != 0, "NoPendingChange"); + delete pendingExitModeBoundsJrt; + delete pendingExitModeBoundsSrt; + emit ExitModeBoundsChangeCancelled(); + } } diff --git a/contracts/tranches/base/cooldown/SharesCooldown.sol b/contracts/tranches/base/cooldown/SharesCooldown.sol new file mode 100644 index 0000000..d417a70 --- /dev/null +++ b/contracts/tranches/base/cooldown/SharesCooldown.sol @@ -0,0 +1,299 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.28; + +import { Math } from "@openzeppelin/contracts/utils/math/Math.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {IERC4626} from "@openzeppelin/contracts/interfaces/IERC4626.sol"; +import {ISharesCooldown} from "../../interfaces/cooldown/ISharesCooldown.sol"; +import {ICooldown} from "../../interfaces/cooldown/ICooldown.sol"; +import {ITranche} from "../../interfaces/ITranche.sol"; +import {CooldownBase} from "./CooldownBase.sol"; + +/** + * @title Strata Shares Cooldown Manager + * @notice Manages cooldown periods for vault share redemptions with configurable lock times and fees. + * @dev This contract acts as a silo that holds vault shares during the cooldown period before redemption. + * Key features: + * - Supports multiple concurrent redemption requests per user (up to 70 active requests) + * - Configurable cooldown periods based on vault coverage ratios + * - Optional early exit with proportional fees (fee = vaultEarlyExitFeePerDay * daysRemaining) + * - Users can cancel pending requests and recover their shares + * - Supports redemptions to external receivers (limited to 40 requests per receiver) + * + * Workflow: + * 1. Tranche Vault initiates redemption via requestRedeem() - shares are locked in this contract + * 2. After cooldown period expires, user calls finalize() to redeem underlying assets + * 3. Alternatively, user can call finalizeWithFee() for early exit with fee + * 4. User can cancel() pending requests to recover locked shares + */ +contract SharesCooldown is ISharesCooldown, CooldownBase { + + modifier onlyUser (address user) { + require(msg.sender == user, "OnlyOwner"); + _; + } + + mapping(address vault => mapping(address account => TRequest[] requests)) public activeRequests; + mapping(address vault => uint256 fee) public vaultEarlyExitFeePerDay; + mapping(address vault => TExitUpperBounds bounds) public vaultExitBounds; + + + /// @notice Initiates a share redemption request with optional cooldown period and fee. + /// @dev Called by COOLDOWN_WORKER_ROLE (typically StrataCDO) when vault coverage requires lockup or fee. + /// Lockup and fee values are determined using calculateExitParams() based on vault coverage. + /// Request handling follows the same pattern as ERC20Cooldown and UnstakeCooldown transfer methods. + /// @param vault The tranche vault holding the shares + /// @param initialFrom The original owner initiating the redemption + /// @param to The recipient who will receive the redeemed assets (can differ from initialFrom) + /// @param shares Amount of vault shares to redeem + /// @param fee Fee in basis points (1e18 = 100%) to burn from shares before locking + /// @param cooldownSeconds Lock duration in seconds; 0 for immediate redemption + function requestRedeem(ITranche vault, address initialFrom, address to, uint256 shares, uint256 fee, uint32 cooldownSeconds) external onlyRole(COOLDOWN_WORKER_ROLE) { + if (shares == 0) { + return; + } + if (fee > 0) { + (uint256 sharesUser, ) = accrueFee(vault, shares, fee); + shares = sharesUser; + } + if (cooldownSeconds == 0) { + vault.redeem(shares, to, address(this)); + emit Finalized(IERC20(address(vault)), to, shares); + return; + } + + TRequest[] storage requests = activeRequests[address(vault)][to]; + + uint256 requestsCount = requests.length; + if (initialFrom != to && requestsCount >= PUBLIC_REQUEST_SLOTS_CAP) { + revert ExternalReceiverRequestLimitReached( + vault, + initialFrom, + to, + shares + ); + } + + uint64 unlockAt = uint64(block.timestamp + cooldownSeconds); + if (requestsCount < MAX_ACTIVE_REQUEST_SLOTS) { + if ( + requestsCount > 0 && + requests[requestsCount - 1].unlockAt == unlockAt + ) { + // is requested within current block + TRequest storage last = requests[requestsCount - 1]; + last.shares += uint192(shares); + } else { + requests.push(TRequest(unlockAt, uint192(shares))); + } + } else { + TRequest storage last = requests[requestsCount - 1]; + last.shares += uint192(shares); + if (last.unlockAt < unlockAt) { + last.unlockAt = unlockAt; + } + } + + emit TransferRequested(vault, initialFrom, to, shares, unlockAt); + } + + function finalize(ITranche vault, address token, address user) external returns (uint256 claimed) { + return finalize(vault, token, user, block.timestamp); + } + function finalize(ITranche vault, address token, address user, uint256 at) public returns (uint256 claimed) { + claimed = extractClaimableInner(address(vault), user, at); + vault.redeem(token, claimed, user, address(this)); + emit Finalized(vault, user, claimed); + return claimed; + } + function finalize(IERC20 vault, address user) external returns (uint256 claimed) { + return finalize(vault, user, block.timestamp); + } + function finalize(IERC20 vault, address user, uint256 at) public returns (uint256 claimed) { + claimed = extractClaimableInner(address(vault), user, at); + IERC4626(address(vault)).redeem(claimed, user, address(this)); + emit Finalized(vault, user, claimed); + return claimed; + } + + /// @notice Finalizes a redemption request before the unlock time by paying an early exit fee. + /// @dev Allows users to bypass the cooldown period by paying a fee proportional to the remaining lock time. + /// The fee is calculated as: fee = vaultEarlyExitFeePerDay * daysLeft + /// The fee is burned from the shares, and the remaining shares are redeemed to the user. + /// Only the recipient (user) can finalize their own requests early. + /// Scenario: If Alice redeems shares to Bob with a 7-day lock, Bob can call this function + /// after 3 days to receive the shares immediately by paying a fee for the remaining 4 days. + /// @param vault The vault/tranche token address + /// @param user The recipient address of the redemption request (must be msg.sender) + /// @param i The index of the request in the user's active requests array + /// @return claimed The amount of shares claimed after deducting the early exit fee + function finalizeWithFee(ITranche vault, address user, uint256 i) external onlyUser(user) returns (uint256 claimed) { + TRequest[] storage requests = activeRequests[address(vault)][user]; + uint256 len = requests.length; + require(i < len, "OutOfRange"); + TRequest memory req = requests[i]; + if (i < len - 1) { + requests[i] = requests[len - 1]; + } + requests.pop(); + + require(req.unlockAt > block.timestamp, "RequestReady"); + + uint256 shares = req.shares; + uint256 daysLeft = (req.unlockAt - block.timestamp) / (24 * 60 * 60) + 1; // includes current day in the count + uint256 fee = vaultEarlyExitFeePerDay[address(vault)]; + + (uint256 sharesUser, uint256 sharesFee) = accrueFee(vault, shares, fee * daysLeft); + + vault.redeem(sharesUser, user, address(this)); + emit ExitFeeAccrued(address(this), user, sharesFee, sharesUser); + + claimed = sharesUser; + } + + /// @notice Cancels an active redemption request and returns the shares to the user. + /// @dev The shares are transferred back to the user who is the recipient of the redemption request. + /// Only the recipient (user) can cancel their own requests. + /// Scenario: If Alice redeems shares to Bob, the shares remain locked in this contract. + /// Only Bob can cancel the request and receive the shares back to his account. + /// @param vault The vault/tranche token address + /// @param user The recipient address of the redemption request (must be msg.sender) + /// @param i The index of the request in the user's active requests array + function cancel(IERC20 vault, address user, uint256 i) external onlyUser(user) { + + TRequest[] storage requests = activeRequests[address(vault)][user]; + uint256 len = requests.length; + require(i < len, "OutOfRange"); + TRequest memory req = requests[i]; + if (i < len - 1) { + requests[i] = requests[len - 1]; + } + requests.pop(); + vault.transfer(user, req.shares); + emit RequestCanceled(address(vault), user, req.shares); + } + + function balanceOf(IERC20 vault, address user) external view returns (ICooldown.TBalanceState memory) { + return balanceOf(vault, user, block.timestamp); + } + function balanceOf(IERC20 vault, address user, uint256 at) public view returns (ICooldown.TBalanceState memory) { + TRequest[] storage requests = activeRequests[address(vault)][user]; + + bool isCooldownActive = isCooldownActiveInner(address(vault)); + + uint256 l = requests.length; + uint256 pending; + uint256 claimable; + uint256 nextUnlockAt; + uint256 nextUnlockAmount; + + for (uint256 i; i < l; i++) { + TRequest memory req = requests[i]; + if (isCooldownActive && req.unlockAt > at) { + pending += req.shares; + if (nextUnlockAt == 0 || req.unlockAt < nextUnlockAt) { + nextUnlockAt = req.unlockAt; + nextUnlockAmount = req.shares; + continue; + } + if (req.unlockAt == nextUnlockAt) { + nextUnlockAmount += req.shares; + } + continue; + } + claimable += req.shares; + } + return + TBalanceState({ + pending: pending, + claimable: claimable, + nextUnlockAt: nextUnlockAt, + nextUnlockAmount: nextUnlockAmount, + totalRequests: l + }); + } + + /// @notice Configures exit parameters (cooldown periods and fees) for a specific vault based on coverage thresholds. + /// @dev Only callable by TwoStepConfigManager to ensure governance-controlled configuration changes. + /// Defines three coverage ranges with corresponding exit parameters: + /// - Range 0: coverage <= p0 (most restrictive, typically longest lock/highest fee) + /// - Range 1: p0 < coverage <= p1 (moderate restrictions) + /// - Range 2: coverage > p1 (least restrictive, typically no lock/minimal fee) + /// The bounds.p0 and bounds.p1 values are in parts per million (ppm), where 1e6 = 100%. + /// Example: p0=5000 (0.5%), p1=23000 (2.3%) creates three ranges for different coverage levels. + /// @param vault The tranche vault address to configure + /// @param bounds The exit bounds configuration containing coverage thresholds (p0, p1) and corresponding exit parameters (r0, r1, r2) + function setVaultExitBounds(address vault, TExitUpperBounds calldata bounds) external onlyTwoStepConfigManager { + require(bounds.p0 <= bounds.p1, 'P1>P2'); + + vaultExitBounds[vault] = bounds; + emit VaultCooldownBoundsUpdated(vault, bounds); + } + + /// @notice Sets the daily early exit fee rate for a vault, capped at 1% per day. + function setVaultEarlyExitFee(address vault, uint256 fee) external onlyOwner { + require(fee <= 0.01e18, "InvalidFee"); + vaultEarlyExitFeePerDay[vault] = fee; + + emit VaultEarlyExitFeeSet(vault, fee); + } + + /// @notice Calculates exit parameters (cooldown period and fee) for a vault based on current coverage ratio. + function calculateExitParams (address vault, uint32 coveragePpm) public view returns (TExitParams memory) { + TExitUpperBounds memory bounds = vaultExitBounds[vault]; + if (coveragePpm <= bounds.p0) return bounds.r0; + if (coveragePpm <= bounds.p1) return bounds.r1; + return bounds.r2; + } + + /// @dev Returns false if cooldown is disabled (p1=0 and r2.sharesLock=0), indicating immediate finalizations are allowed. + function isCooldownActiveInner (address vault) internal view returns (bool) { + return vaultExitBounds[vault].p1 > 0 || vaultExitBounds[vault].r2.sharesLock > 0; + } + + /// @dev Accrues exit fees by burning a portion of shares; + /// called either before lockup (immediate fee) or during early exit (proportional to remaining lock time). + function accrueFee (ITranche vault, uint256 shares, uint256 feeBps) internal returns (uint256 sharesUser, uint256 sharesFee) { + sharesFee = Math.mulDiv(shares, feeBps, 1e18, Math.Rounding.Floor); + sharesUser = shares > sharesFee ? shares - sharesFee : 0; + + require(sharesUser > 0 && sharesFee > 0, "EmptyFee"); + vault.burnSharesAsFee(sharesFee, address(this)); + } + + /// @dev Extracts and removes all claimable requests (unlocked or cooldown disabled) from user's active requests array. + function extractClaimableInner(address vault, address user, uint256 at) internal returns (uint256 claimable) { + if (at > block.timestamp) { + revert InvalidTime(); + } + TRequest[] storage requests = activeRequests[address(vault)][user]; + bool isCooldownActive = isCooldownActiveInner(vault); + + uint256 len = requests.length; + for (uint256 i; i < len; ) { + TRequest memory req = requests[i]; + if (isCooldownActive && req.unlockAt > at) { + // still pending + unchecked { + i++; + } + continue; + } + claimable += req.shares; + + if (i < len - 1) { + requests[i] = requests[len - 1]; + } + requests.pop(); + unchecked { + len--; + } + } + if (claimable == 0) { + revert NothingToFinalize(); + } + return claimable; + } + +} diff --git a/contracts/tranches/interfaces/IAccounting.sol b/contracts/tranches/interfaces/IAccounting.sol index 6723c6d..51f83f0 100644 --- a/contracts/tranches/interfaces/IAccounting.sol +++ b/contracts/tranches/interfaces/IAccounting.sol @@ -21,6 +21,7 @@ interface IAccounting is ICDOComponent, IAprPairFeedListener { function accrueFee(bool isJrt, uint256 amount) external; function maxWithdraw(bool isJrt) external view returns (uint256); + function maxWithdraw(bool isJrt, bool ownerIsSharesCooldown) external view returns (uint256); function maxDeposit(bool isJrt) external view returns (uint256); } diff --git a/contracts/tranches/interfaces/IStrataCDO.sol b/contracts/tranches/interfaces/IStrataCDO.sol index 87c82ed..934356c 100644 --- a/contracts/tranches/interfaces/IStrataCDO.sol +++ b/contracts/tranches/interfaces/IStrataCDO.sol @@ -3,10 +3,21 @@ pragma solidity ^0.8.28; import { IStrategy } from "./IStrategy.sol"; import { ITranche } from "./ITranche.sol"; +import { ISharesCooldown } from "./cooldown/ISharesCooldown.sol"; interface IStrataCDO { + enum TExitMode { + Dynamic, + Fee, + AssetsLock, + SharesLock, + ERC4626 + } + + function strategy() external view returns (IStrategy); + function sharesCooldown() external view returns (ISharesCooldown); function totalAssets (address tranche) external view returns (uint256); function totalStrategyAssets () external view returns (uint256); @@ -14,8 +25,10 @@ interface IStrataCDO { function deposit (address tranche, address token, uint256 tokenAmount, uint256 baseAssets) external; function withdraw (address tranche, address token, uint256 tokenAmount, uint256 baseAssets, address owner, address receiver) external; + function cooldownShares(address tranche, uint256 shares, address sender, address receiver, uint256 fee, uint32 cooldownSeconds) external; function maxWithdraw(address tranche) external view returns (uint256); + function maxWithdraw(address tranche, address owner) external view returns (uint256); function maxDeposit(address tranche) external view returns (uint256); // reverts if neither Jrt nor Srt @@ -24,10 +37,11 @@ interface IStrataCDO { function jrtVault() external view returns (ITranche); function srtVault() external view returns (ITranche); - function calculateExitFee(address tranche, uint256 amount, bool isGross) external view returns (uint256); function accrueFee(address tranche, uint256 assetsFee) external; function exitFeeJrt () external view returns (uint256); function exitFeeSrt () external view returns (uint256); + + function calculateExitMode (address tranche, address owner) external view returns (TExitMode mode, uint256 feeBps, uint32 coverage); } interface IStrataCDOSetters { diff --git a/contracts/tranches/interfaces/IStrategy.sol b/contracts/tranches/interfaces/IStrategy.sol index 5ea74d4..0d0ad6b 100644 --- a/contracts/tranches/interfaces/IStrategy.sol +++ b/contracts/tranches/interfaces/IStrategy.sol @@ -9,6 +9,7 @@ interface IStrategy is ICDOComponent { function deposit (address tranche, address token, uint256 tokenAmount, uint256 baseAssets, address owner) external returns (uint256); function withdraw (address tranche, address token, uint256 tokenAmount, uint256 baseAssets, address sender, address receiver) external returns (uint256); + function withdraw (address tranche, address token, uint256 tokenAmount, uint256 baseAssets, address sender, address receiver, bool shouldSkipCooldown) external returns (uint256); function totalAssets () external view returns (uint256); function reduceReserve (address token, uint256 tokenAmount, address receiver) external; diff --git a/contracts/tranches/interfaces/ITranche.sol b/contracts/tranches/interfaces/ITranche.sol index 8038a24..d515fec 100644 --- a/contracts/tranches/interfaces/ITranche.sol +++ b/contracts/tranches/interfaces/ITranche.sol @@ -3,8 +3,20 @@ pragma solidity ^0.8.28; import { ICDOComponent } from "./ICDOComponent.sol"; import { IMetaVault } from "./IMetaVault.sol"; +import { IStrataCDO } from "./IStrataCDO.sol"; interface ITranche is ICDOComponent, IMetaVault { + /// @dev Reverts when user-specified redemption parameters don't match current exit mode settings, + /// protecting against mode slippage before transaction execution + struct TRedemptionParams { + IStrataCDO.TExitMode exitMode; + uint256 exitFee; + uint32 cooldownSeconds; + } + + error RedemptionParamsMismatch(TRedemptionParams requested, TRedemptionParams current); + function configure () external; + function burnSharesAsFee(uint256 shares, address owner) external returns (uint256); } diff --git a/contracts/tranches/interfaces/cooldown/ISharesCooldown.sol b/contracts/tranches/interfaces/cooldown/ISharesCooldown.sol new file mode 100644 index 0000000..9b28b94 --- /dev/null +++ b/contracts/tranches/interfaces/cooldown/ISharesCooldown.sol @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { IERC4626 } from "@openzeppelin/contracts/interfaces/IERC4626.sol"; +import { ITranche } from "../ITranche.sol"; +import { ICooldown } from "./ICooldown.sol"; +import { IStrataCDO } from "../IStrataCDO.sol"; + +interface ISharesCooldown is ICooldown { + + struct TRequest { + uint64 unlockAt; + uint192 shares; + } + + struct TExitParams { + uint32 feePpm; + uint32 sharesLock; + } + + /// @notice Defines exit mode upper bounds for coverage-based fee and lockup parameters. + /// @dev Structure is gas and storage optimized by packing all fields into a single 256-bit slot. + /// Coverage ratios (p0, p1) and exit parameters (r0, r1, r2) are stored as uint32 values. + /// The coverage ratio determines which range applies: + /// - coverage < p0: use r0 (typically highest fees/longest locks) + /// - p0 <= coverage < p1: use r1 (typically medium fees/locks) + /// - coverage >= p1: use r2 (lowest fees/shortest locks, typically zero) + /// @param p0 First breakpoint in parts per million (upper bound for range0) + /// @param p1 Second breakpoint in parts per million (upper bound for range1) + /// @param r0 Exit parameters (fee in ppm, shares lock in seconds) for coverage < p0 + /// @param r1 Exit parameters for p0 <= coverage < p1 + /// @param r2 Exit parameters for coverage >= p1 (default) + struct TExitUpperBounds { + uint32 p0; + uint32 p1; + TExitParams r0; + TExitParams r1; + TExitParams r2; + } + + event RedeemRequested(IERC4626 indexed vault, address indexed from, address indexed to, uint256 shares, uint256 unlockAt); + event VaultCooldownUpdated(address indexed vault, uint256 cooldownSeconds); + event RequestCanceled(address indexed vault, address user, uint256 shares); + event VaultCooldownBoundsUpdated(address indexed vault,TExitUpperBounds bounds); + event VaultEarlyExitFeeSet(address indexed vault, uint256 earlyExitFee); + event ExitFeeAccrued(address indexed vault, address user, uint256 sharesFee, uint256 sharesUser); + + function finalize(ITranche vault, address token, address user) external returns (uint256 claimed); + function finalize(ITranche vault, address token, address user, uint256 at) external returns (uint256 claimed); + function requestRedeem( + ITranche vault, + address initialFrom, + address to, + uint256 shares, + uint256 exitFee, + uint32 exitSharesLock + ) external; + + function setVaultExitBounds(address vault, TExitUpperBounds calldata bounds)external; + function calculateExitParams (address vault, uint32 coveragePpm) external view returns (TExitParams memory); +} diff --git a/contracts/tranches/strategies/ethena/sUSDeStrategy.sol b/contracts/tranches/strategies/ethena/sUSDeStrategy.sol index be6c074..bf11617 100644 --- a/contracts/tranches/strategies/ethena/sUSDeStrategy.sol +++ b/contracts/tranches/strategies/ethena/sUSDeStrategy.sol @@ -90,9 +90,17 @@ contract sUSDeStrategy is Strategy { * @return The amount of tokens withdrawn (shares for sUSDe, baseAssets for USDe) */ function withdraw (address tranche, address token, uint256 tokenAmount, uint256 baseAssets, address sender, address receiver) external onlyCDO returns (uint256) { + return withdrawInner(tranche, token, tokenAmount, baseAssets, sender, receiver, false); + } + + function withdraw (address tranche, address token, uint256 tokenAmount, uint256 baseAssets, address sender, address receiver, bool shouldSkipCooldown) external onlyCDO returns (uint256) { + return withdrawInner(tranche, token, tokenAmount, baseAssets, sender, receiver, shouldSkipCooldown); + } + + function withdrawInner (address tranche, address token, uint256 tokenAmount, uint256 baseAssets, address sender, address receiver, bool shouldSkipCooldown) internal returns (uint256) { uint256 shares = sUSDe.previewWithdraw(baseAssets); if (token == address(sUSDe)) { - uint256 cooldownSeconds = cdo.isJrt (tranche) ? sUSDeCooldownJrt : sUSDeCooldownSrt; + uint256 cooldownSeconds = shouldSkipCooldown ? 0 : (cdo.isJrt (tranche) ? sUSDeCooldownJrt : sUSDeCooldownSrt); erc20Cooldown.transfer(sUSDe, sender, receiver, shares, cooldownSeconds); return shares; } diff --git a/package.json b/package.json index 71809ff..54edabf 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "compile": "hardhat compile", "coverage": "hardhat clean && atma --max-old-space-size=12000 test --coverage", "coverage-nocompile": "atma --max-old-space-size=12000 test --coverage --no-compile", + "coverage-server": "cd coverage/report && npx atma server", "deploy-local": "atma act tasks/deploy.act.ts -q \"deploy and configure\" --chain hardhat", "fork-eth": "hardhat node --verbose --show-stack-traces --fork %MAINNET_RPC_URL%" }, diff --git a/specs/SIPs/SIP-01-share-lockup-redemption.md b/specs/SIPs/SIP-01-share-lockup-redemption.md index 79188ec..bd34f55 100644 --- a/specs/SIPs/SIP-01-share-lockup-redemption.md +++ b/specs/SIPs/SIP-01-share-lockup-redemption.md @@ -2,7 +2,7 @@ SIP: 01 Title: Share Lock-Up Redemption Mechanism for Tranche Vaults Author: Strata Protocol Contributors -Status: Draft +Status: Final Type: Protocol Created: 2025-12-15 --- @@ -30,7 +30,7 @@ This design preserves ERC-4626 compatibility while enabling delayed liquidity an * **User**: An externally owned account (EOA) or contract initiating a redemption. * **Receiver**: An account that receives the assets upon redemption. Per the ERC-4626 specification, the receiver MAY be different from the User. * **Tranche Vault**: An ERC-4626-compatible vault managing Junior or Senior tranche shares. -* **Silo Contract**: A custody contract responsible for holding locked shares and executing final redemption on behalf of users. +* **Silo Contract**: - A contract responsible for holding locked shares and executing final redemption on behalf of users. * **Strategy**: The underlying asset manager (may require unstaking prior to asset withdrawal, e.g., USDe). --- @@ -57,6 +57,7 @@ The current (non lock-up) redemption flow is as follows: 2. Instead of burning shares: * The Tranche Vault transfers the specified shares to the Silo Contract + 3. The Silo Contract records: * User address @@ -67,10 +68,9 @@ The current (non lock-up) redemption flow is as follows: At this stage: * No assets are transferred -* No shares are burned -* The user cannot finalize the redemption before the lock-up period expires +* Only fee shares are burned to accrue fees in accounting +* The user may finalize the redemption before the lock-up period expires only if an ExitFee is specified for the Vault by the Protocol * The user may cancel the lock-up; in this case, the request is dismissed and the shares are transferred back to the user. -* [PROPOSAL] Early-Exit: The user may pay additional finalization fee to unlock and withdraw the shares before lock-up period ends. --- @@ -106,10 +106,59 @@ After the lock-up period has elapsed: --- -## 6. Fees +## 6. Lock-Up Duration and Fees + +The protocol MAY apply a **coverage-dependent lock-up period** and associated fees to share redemptions when the Share Lock-Up mechanism is enabled. + +All parameters described in this section are **evaluated at redemption request time** and remain immutable for the lifetime of the corresponding lock-up entry. + +### 6.1 Lock-Up Duration + +The protocol MAY define up to **two coverage thresholds** (`C₀`, `C₁`) that partition the coverage space into **three mutually exclusive ranges**, each associated with a predefined lock-up duration. + +Let `Coverage` be the current coverage ratio at redemption request time. + +| Coverage Range | Applicable Lock-Up Duration | +| -------------------- | --------------------------- | +| `Coverage ≤ C₀` | `lockupSeconds[0]` | +| `C₀ < Coverage ≤ C₁` | `lockupSeconds[1]` | +| `Coverage > C₁` | `lockupSeconds[2]` | + +Properties: + +* Coverage thresholds MUST be strictly increasing (`C₀ < C₁`) or equal (`C₀ == C₁`) - this effectively disables the `C₁` range +* Each range maps to exactly one lock-up duration +* A lock-up duration of `0` seconds represents **immediate finalization eligibility** +* The selected lock-up duration is recorded in the Silo Contract at request time and MUST NOT change afterwards + +--- + +### 6.2 Redemption Fee (Request-Time Fee) + +The protocol MAY apply a **redemption fee at request time**, expressed as a percentage of shares being redeemed. + +Characteristics: + +* The applicable fee tier is determined using the **same coverage range selection** as defined in Section 6.1 +* The fee is accrued **immediately at redemption request** +* Fee collection is implemented via burning and assets distribution according to the accounting rules +* The fee is independent of whether the redemption is later finalized, cancelled, or early-exited + +--- + +### 6.3 Early-Exit Fee + +If enabled by the protocol, a user MAY finalize a redemption **before the lock-up period has elapsed** by paying an **early-exit fee**. + +Early-exit fee rules: + +* The fee is calculated based on the **remaining lock-up time** at the moment of finalization +* The fee rate is defined as a **per-day penalty** +* Early-exit fees are applied **in addition to** any redemption fee already accrued at request time -* No additional withdrawal or redemption fees are charged specifically due to the lock-up mechanism +If early-exit is disabled: +* Finalization attempts prior to lock-up expiry MUST revert --- diff --git a/specs/SIPs/SIP-02-unified-exit-modes.md b/specs/SIPs/SIP-02-unified-exit-modes.md new file mode 100644 index 0000000..9446833 --- /dev/null +++ b/specs/SIPs/SIP-02-unified-exit-modes.md @@ -0,0 +1,158 @@ +--- +SIP: 02 +Title: Unified Exit Modes +Author: Strata Protocol Contributors +Status: Final +Type: Protocol +Created: 2026-12-20 +Requires: SIP-01 +--- + +# SIP-02: Unified Exit Modes and Coverage-Aware Redemption Flow + +## 1. Abstract + +This specification defines a **unified exit-mode process** for Tranche Vault redemptions, formalizing three distinct exit modes: + +1. **SharesLock** +2. **Fee** +3. **AssetsLock** + +The SIP standardizes how exit modes are selected, how **coverage-dependent parameters** are applied, and how share-level and asset-level cooldowns interact during the withdrawal and redemption lifecycle. + +--- + +## 2. Motivation + +Historically, the protocol supported two exit behaviors: + +* **Fee-based exits**, applying a static redemption fee +* **Asset-level cooldowns**, delaying asset delivery after shares were burned + +With the introduction of **SharesLock**, the protocol gains the ability to: + +* Delay redemptions *before* shares are burned +* Apply **dynamic, coverage-aware lock-ups and fees** +* Preserve ERC-4626 invariants while improving risk management + +This SIP provides a single, coherent process model governing **all exit paths**, ensuring predictable behavior and extensibility. + +--- + +## 3. Actors + +* **User**: Initiates a `withdraw` or `redeem` +* **Tranche Vault**: An ERC-4626-compliant vault (Junior or Senior) +* **SharesCooldown Contract**: Calculates share lock-up duration and fees based on coverage +* **Strategy**: Underlying asset manager (may require unstaking) + +--- + +## 4. Coverage Input + +During every `withdraw` or `redeem` call, the Tranche Vault MUST compute the **current coverage ratio** and supply it to the SharesCooldown Contract. + +Coverage is defined as: + +``` +Coverage = JuniorTVL / SeniorTVL +``` + +* Coverage is evaluated **at execution time** + +--- + +## 5. Unified Exit Flow + +### 5.1 SharesLock Exit Mode + +When **SharesLock** is active: + +1. The Vault passes the current coverage to the SharesCooldown Contract +2. The SharesCooldown Contract computes: + + * Share lock-up duration + * Redemption fee (if configured) +3. If `cooldownSeconds > 0`: + + * Shares MUST NOT be burned + * Shares are transferred to the SharesCooldown Contract + * Lock-up metadata is recorded +4. If `cooldownSeconds == 0`: + + * Shares MAY be redeemed immediately + * Fee logic is applied if configured + +At this stage: + +* No assets are transferred + +--- + +### 5.2 Fee Exit Mode + +When **SharesLock** is not active for a redemption, the protocol MAY select the **Fee** exit mode (as defined in earlier protocol versions) by checking global fee parameters configured in the CDO contract. + +Under the Fee exit mode: + +1. Shares are burned immediately +2. A redemption fee is accrued +3. The fee portion: + + * Either increases Junior or Senior TVL (retention), or + * Is transferred to the protocol reserve +4. Net assets proceed to asset delivery + +The fee applied in this mode is **not coverage-dependent**. + +--- + +### 5.3 Fee Accrual Rules + +Fee accrual MAY occur in the following scenarios: + +* Immediate redemption under SharesLock when a fee is configured +* Fee-only exit mode + +Fee application rules: + +* Fees are expressed as a percentage of redeemed shares +* Fee logic MUST be applied **before asset transfer** + +--- + +### 5.4 Asset Delivery and Asset Lock + +After shares are burned and net assets are determined: + +1. The Strategy is instructed to release assets +2. If an asset-level cooldown is enabled: + + * Assets are transferred into the ERC-20 AssetCooldown contract +3. Otherwise: + + * Assets are transferred directly to the receiver + +Asset-level cooldowns are **independent of SharesLock** and MAY be applied in combination when configured. + +--- + +### 5.5 Mandatory Unstaking + +If the Strategy MUST return an unstaked asset (e.g., USDe): + +1. The Strategy initiates the unstaking process +2. The protocol enforces the mandatory unstaking cooldown +3. Assets are transferred only after unstaking finalization + +This process is orthogonal to both SharesLock and AssetsLock and MUST always be honored. + +--- + +## 6. Backward Compatibility + +* Existing Fee and AssetsLock flows remain valid +* SharesLock is opt-in via protocol configuration +* No changes are required for ERC-4626 integrators + +--- diff --git a/src/deployments/TranchesDeployments.ts b/src/deployments/TranchesDeployments.ts index f2b1d7d..e1ae56b 100644 --- a/src/deployments/TranchesDeployments.ts +++ b/src/deployments/TranchesDeployments.ts @@ -35,6 +35,7 @@ import { CDOLens } from '@0xc/hardhat/CDOLens/CDOLens'; import { TwoStepConfigManager } from '@0xc/hardhat/TwoStepConfigManager/TwoStepConfigManager'; import { ContractBase } from 'dequanto/contracts/ContractBase'; import { Constructor } from 'dequanto/utils/types'; +import { SharesCooldown } from '@0xc/hardhat/SharesCooldown/SharesCooldown'; export class TranchesDeployments { @@ -220,8 +221,8 @@ export class TranchesDeployments { } } - async addRoles() { - let roles = { + async addRoles(roles?: Record) { + roles ??= { [this.owner.address]: [ $contract.keccak256('PAUSER_ROLE'), $contract.keccak256('UPDATER_STRAT_CONFIG_ROLE'), @@ -271,7 +272,7 @@ export class TranchesDeployments { } async ensureConfigManager() { - let { cdo, acm } = await this.ensureEthenaCDO(); + let { cdo, acm, sharesCooldown } = await this.ensureEthenaCDO(); let owner = this.accounts.timelock.admin; let { contract: configManager } = await this.ds.ensureWithProxy(TwoStepConfigManager, { id: 'USDeConfigManager', @@ -280,15 +281,25 @@ export class TranchesDeployments { }); await this.ds.configure(cdo, { - title: `Set Two-Step Config Manager`, + title: `Set CDO Two-Step Config Manager`, async shouldUpdate() { - return $address.eq(await cdo.twoStepConfigManager(), configManager.address) === false + return $address.eq(await cdo.twoStepConfigManager(), configManager.address) === false }, async updater() { await cdo.$receipt().setTwoStepConfigManager(owner, configManager.address); } }); + await this.ds.configure(sharesCooldown, { + title: `Set SharesCooldown Two-Step Config Manager`, + async shouldUpdate() { + return $address.eq(await sharesCooldown.twoStepConfigManager(), configManager.address) === false + }, + async updater() { + await sharesCooldown.$receipt().setTwoStepConfigManager(owner, configManager.address); + } + }); + await this.ensureRoles({ 'PROPOSER_CONFIG_ROLE': { [this.accounts.safe.admin.address]: true @@ -302,7 +313,7 @@ export class TranchesDeployments { } @memd.deco.memoize() - async ensureCooldowns() { + async ensureCooldowns(cdo?: StrataCDO) { const acm = await this.ensureACM(); const { contract: erc20Cooldown } = await this.ds.ensureWithProxy(ERC20Cooldown, { initialize: [ @@ -316,6 +327,12 @@ export class TranchesDeployments { acm.address ] }); + const { contract: sharesCooldown } = await this.ds.ensureWithProxy(SharesCooldown, { + initialize: [ + this.owner.address, + acm.address + ] + }); let { sUSDe } = await this.ensureEthena(); const { contractBeaconProxy: sUSDeCooldownRequestImpl } = await this.ds.ensureWithBeacon(SUSDeCooldownRequestImpl, { @@ -335,9 +352,19 @@ export class TranchesDeployments { } }); + if (cdo != null) { + await this.ds.configure(cdo, { + shouldUpdate: $address.eq(await cdo.sharesCooldown(), sharesCooldown.address) === false, + updater: async () => { + await cdo.$receipt().setSharesCooldown(this.owner, sharesCooldown.address); + } + }); + } + return { erc20Cooldown, unstakeCooldown, + sharesCooldown, acm, }; } @@ -418,7 +445,7 @@ export class TranchesDeployments { }); // Strategy - const { erc20Cooldown, unstakeCooldown } = await this.ensureCooldowns(); + const { erc20Cooldown, unstakeCooldown, sharesCooldown } = await this.ensureCooldowns(cdo); const { contract: strategy } = await this.ds.ensureWithProxy(SUSDeStrategy, { arguments: [ @@ -466,6 +493,7 @@ export class TranchesDeployments { accounting, erc20Cooldown, unstakeCooldown, + sharesCooldown, feed, USDe, sUSDe, diff --git a/src/utils/$exitMode.ts b/src/utils/$exitMode.ts new file mode 100644 index 0000000..4aa6d3c --- /dev/null +++ b/src/utils/$exitMode.ts @@ -0,0 +1,55 @@ +import { SharesCooldown } from '@0xc/hardhat/SharesCooldown/SharesCooldown'; +import { TwoStepConfigManager } from '@0xc/hardhat/TwoStepConfigManager/TwoStepConfigManager'; +import { TEth } from 'dequanto/models/TEth'; +import { $bigint } from 'dequanto/utils/$bigint'; +import { $require } from 'dequanto/utils/$require'; + +export namespace $exitMode { + + interface IExitMode { + // Percentage + covPct: number + // Duration (seconds) + lock?: number + // Exit fee percentage + feePct?: number + } + + export async function propose (proposer: TEth.IAccount, twoStepConfig: TwoStepConfigManager, jrt: IExitMode[], srt: IExitMode[], delay?: number) { + await twoStepConfig.$receipt().scheduleExitModeBoundsChange(proposer, map(jrt), map(srt), BigInt(delay ?? 24 * 60 * 60)); + } + export function map (modes: IExitMode[]) { + while (modes.length < 3) { + modes.unshift({ covPct: 0 }); + } + + const arr = modes.map(mode => { + return { + coverage: Number($bigint.toWei(mode.covPct / 100, 6)), + sharesLock: mode.lock ?? 0, + fee: Number($bigint.toWei((mode.feePct ?? 0) / 100, 6)), + assetsLock: 0 + }; + }); + return { + p0: arr[0].coverage, + p1: arr[1]?.coverage ?? 0, + r0: { feePpm: arr[0].fee, sharesLock: arr[0].sharesLock }, + r1: { feePpm: arr[1]?.fee ?? 0, sharesLock: arr[1]?.sharesLock ?? 0 }, + r2: { feePpm: arr[2]?.fee ?? 0, sharesLock: arr[2]?.sharesLock ?? 0 }, + } + } + + export async function set (sharesCooldown: SharesCooldown, twoStepConfig: TEth.IAccount, vault: TEth.Address, modes: IExitMode[]): Promise { + $require.eq(sharesCooldown.client.platform, 'hardhat') + await sharesCooldown.client.debug.setBalance(twoStepConfig.address, BigInt(1e18)); + await sharesCooldown.$receipt().setVaultExitBounds({ + address: twoStepConfig.address, + type: 'impersonated', + }, vault, map(modes)); + } + + export async function execute (executor: TEth.IAccount, twoStepConfig: TwoStepConfigManager) { + await twoStepConfig.$receipt().executeExitModeBoundsChange(executor); + } +} diff --git a/test/tranches/accounting/Apr.spec.ts b/test/tranches/accounting/Apr.spec.ts index 2cd79f9..fc02556 100644 --- a/test/tranches/accounting/Apr.spec.ts +++ b/test/tranches/accounting/Apr.spec.ts @@ -64,7 +64,7 @@ UTest.create({ ] }); }, - async '!drastically change the APR due to TVL ratio' () { + async 'drastically change the APR due to TVL ratio' () { let exec = new ProtocolExecutor($hh.test); await exec.run({ diff --git a/test/tranches/cooldowns/$testCooldown.ts b/test/tranches/cooldowns/$testCooldown.ts index 43740e7..895df1e 100644 --- a/test/tranches/cooldowns/$testCooldown.ts +++ b/test/tranches/cooldowns/$testCooldown.ts @@ -4,16 +4,18 @@ import { $acc } from '../utils/$acc'; import { $date } from 'dequanto/utils/$date'; import { $require } from 'dequanto/utils/$require'; import { $hh } from '../utils/$hh'; +import { ISharesCooldown } from '@0xc/hardhat/ISharesCooldown/ISharesCooldown'; export namespace $testCooldown { - export async function finalize (cooldown: ICooldown, token: ERC20 | any, to: $acc.Address) { - await cooldown.$receipt().finalize($hh.test.deployer, token.address, $acc.toAddress(to)); + export async function finalize (cooldown: Pick, token: ERC20 | any, to: $acc.Address) { + let tx = await cooldown.finalize($hh.test.deployer, token.address, $acc.toAddress(to)); + return await tx.wait(); } - export async function eqBalanceOf(cooldown: ICooldown, token: ERC20 | any, account: $acc.Address, amounts: { + export async function eqBalanceOf(cooldown: ICooldown | ISharesCooldown, token: ERC20 | any, account: $acc.Address, amounts: { pending?: bigint, claimable?: bigint nextUnlockAmount?: bigint diff --git a/test/tranches/cooldowns/ERC20Cooldown.spec.ts b/test/tranches/cooldowns/ERC20Cooldown.spec.ts index ba8b34e..512b6ac 100644 --- a/test/tranches/cooldowns/ERC20Cooldown.spec.ts +++ b/test/tranches/cooldowns/ERC20Cooldown.spec.ts @@ -82,7 +82,7 @@ UAction.create({ await transfer(erc20Cooldown, USDe, alice, bob.address, 21n, '7days'); - let result = await $promise.caught(() => $testCooldown.finalize(erc20Cooldown, USDe, bob.address)); + let result = await $promise.caught($testCooldown.finalize(erc20Cooldown, USDe, bob.address)); $require.match(/NothingToFinalize/, result.error?.message); await erc20Cooldown.$receipt().setCooldownDisabled(alice, USDe.address, true); diff --git a/test/tranches/cooldowns/Exit.spec.ts b/test/tranches/cooldowns/Exit.spec.ts new file mode 100644 index 0000000..010ead0 --- /dev/null +++ b/test/tranches/cooldowns/Exit.spec.ts @@ -0,0 +1,340 @@ +import { HardhatProvider } from 'dequanto/hardhat/HardhatProvider'; +import { UTest } from 'atma-utest' +import { $usde } from '../utils/$usde'; +import { $erc20 } from '../utils/$erc20'; +import { $tranche } from '../utils/$tranche'; +import { $hh } from '../utils/$hh'; +import { $require } from 'dequanto/utils/$require'; +import { $bigint } from 'dequanto/utils/$bigint'; +import { $erc4626 } from '../utils/$erc4626'; +import { $exitMode } from '@s/utils/$exitMode'; +import { $date } from 'dequanto/utils/$date'; +import { $ethena } from '../utils/$ethena'; +import { $promise } from 'dequanto/utils/$promise'; + + +let hh = new HardhatProvider(); +let alice = await hh.deployer(1); + +let { + jrtVault, + srtVault, + strategy, + erc20Cooldown, + unstakeCooldown, + sharesCooldown, + accounting, + cdo, + USDe, + sUSDe, +} = await $hh.test.deploy(); + +let { + configManager +} = await $hh.test.factory.ensureConfigManager(); + + +let { + client, + deployer +} = $hh.test; + +await $erc20.mint(USDe, deployer, alice, 1e6); + +//await strategy.$receipt().setCooldowns($hh.test.factory.accounts.timelock.admin, 0n, 0n); +await $ethena.setCooldownDuration(sUSDe, deployer, 0); +await accounting.$receipt().setMinimumJrtSrtRatio(deployer, $bigint.toWei(0.01)) +await accounting.$receipt().setMinimumJrtSrtRatioBuffer(deployer, $bigint.toWei(0.01)) + + +await $hh.test.factory.addRoles({ + [cdo.address]: [ + await sharesCooldown.COOLDOWN_WORKER_ROLE() + ] +}); + +await $hh.test.snapshot('exit'); + +UTest.create({ + async $after () { + await $hh.test.reset(); + }, + async $teardown () { + await $hh.test.reset('exit'); + }, + async 'coverage checks' () { + + // Alice deposits into jrt and srt vaults + await $usde.mint(USDe, alice, 1000.0); + await $tranche.deposit(jrtVault, alice, USDe, 300.0); + + // Senior === 0, the coverage must be uint32.max + let coverage = await cdo.coverage(); + $require.eq(coverage, 2 ** 32 - 1); + + // Senior === Junior, the coverage must be 100% + await $tranche.deposit(srtVault, alice, USDe, 300.0); + coverage = await cdo.coverage(); + $require.eq(coverage, 1e6); + }, + async 'junior redemption' () { + + await $tranche.deposit(jrtVault, alice, USDe, 100.0); + await $tranche.deposit(srtVault, alice, USDe, 100.0); + + await $exitMode.set(sharesCooldown, configManager, jrtVault.address, [ + { covPct: 5, feePct: 10, lock: $date.parseTimespan('1day', { get: 's' }) }, + { covPct: 10, feePct: 5, lock: $date.parseTimespan('8hours', { get: 's' }) }, + { covPct: 0, feePct: 1, lock: 0 }, + ]); + + await $hh.test.snapshot('exit-jrt'); + return UTest.create({ + async $teardown () { + await $hh.test.reset('exit-jrt'); + }, + async 'should exit with 1% fee' () { + await $util.ensureCoverage(20, { jrt: 1000.0 }); + let shares = BigInt(10e18); + let assets = await jrtVault.convertToAssets(shares); + let assetsFact = await $erc4626.redeem(jrtVault, alice, shares); + let fee = $bigint.toEther(assets) * .01; + $require.eq(fee, 10 * 0.01, `Should accrue 1% fee`); + $require.eq($bigint.toEther(assetsFact) + fee, $bigint.toEther(assets)); + }, + async 'should exit with 5% fee and 8hours shares-lock' () { + await $util.ensureCoverage(8, { jrt: 1000.0 }); + let shares = BigInt(10e18); + let assets = await jrtVault.convertToAssets(shares); + let assetsFact = await $erc4626.redeem(jrtVault, alice, shares); + $require.eq(assetsFact, 0n); + let fee = $bigint.toEther(assets) * .05; + + await client.debug.mine('1hour'); + let { error } = await $promise.caught(sharesCooldown.$receipt().finalize(alice, jrtVault.address, alice.address)); + $require.match(/NothingToFinalize/, error?.message); + + await client.debug.mine('8hours'); + let assetsBefore = await USDe.balanceOf(alice.address); + await sharesCooldown.$receipt().finalize(alice, jrtVault.address, alice.address); + assetsFact = await USDe.balanceOf(alice.address) - assetsBefore; + + $require.eq($bigint.toEther(assetsFact) + fee, $bigint.toEther(assets)); + }, + async 'should exit with 10% fee and 1day shares-lock' () { + await $util.ensureCoverage(4, { jrt: 1000.0 }); + let shares = BigInt(10e18); + let assets = await jrtVault.convertToAssets(shares); + let assetsFact = await $erc4626.redeem(jrtVault, alice, shares); + $require.eq(assetsFact, 0n); + let fee = $bigint.toEther(assets) * .10; + + await client.debug.mine('1hour'); + let { error } = await $promise.caught(sharesCooldown.$receipt().finalize(alice, jrtVault.address, alice.address)); + $require.match(/NothingToFinalize/, error?.message); + + await client.debug.mine('1day'); + let assetsBefore = await USDe.balanceOf(alice.address); + await sharesCooldown.$receipt().finalize(alice, jrtVault.address, alice.address); + assetsFact = await USDe.balanceOf(alice.address) - assetsBefore; + + $require.eq($bigint.toEther(assetsFact) + fee, $bigint.toEther(assets)); + } + }) + }, + async 'senior redemption' () { + + await $tranche.deposit(jrtVault, alice, USDe, 100.0); + await $tranche.deposit(srtVault, alice, USDe, 100.0); + + await $exitMode.set(sharesCooldown, configManager, srtVault.address, [ + { covPct: 5, feePct: 10, lock: $date.parseTimespan('1day', { get: 's' }) }, + { covPct: 10, feePct: 5, lock: $date.parseTimespan('8hours', { get: 's' }) }, + { covPct: 0, feePct: 1, lock: 0 }, + ]); + + await $hh.test.snapshot('exit-srt'); + return UTest.create({ + async $teardown () { + await $hh.test.reset('exit-srt'); + }, + async 'should exit with 1% fee' () { + await $util.ensureCoverage(20, { jrt: 1000.0 }); + let shares = BigInt(10e18); + let assets = await srtVault.convertToAssets(shares); + let assetsFact = await $erc4626.redeem(srtVault, alice, shares); + let fee = $bigint.toEther(assets) * .01; + $require.eq(fee, 10 * 0.01, `Should accrue 1% fee`); + $require.eq($bigint.toEther(assetsFact) + fee, $bigint.toEther(assets)); + }, + async 'should exit with 5% fee and 8hours shares-lock' () { + await $util.ensureCoverage(8, { jrt: 1000.0 }); + let shares = BigInt(10e18); + let assets = await srtVault.convertToAssets(shares); + let assetsFact = await $erc4626.redeem(srtVault, alice, shares); + $require.eq(assetsFact, 0n); + let fee = $bigint.toEther(assets) * .05; + + await client.debug.mine('1hour'); + let { error } = await $promise.caught(sharesCooldown.$receipt().finalize(alice, srtVault.address, alice.address)); + $require.match(/NothingToFinalize/, error?.message); + + await client.debug.mine('8hours'); + let assetsBefore = await USDe.balanceOf(alice.address); + await sharesCooldown.$receipt().finalize(alice, srtVault.address, alice.address); + assetsFact = await USDe.balanceOf(alice.address) - assetsBefore; + + $require.eq($bigint.toEther(assetsFact) + fee, $bigint.toEther(assets)); + }, + async 'should exit with 10% fee and 1day shares-lock' () { + await $util.ensureCoverage(4, { jrt: 1000.0 }); + let shares = BigInt(10e18); + let assets = await srtVault.convertToAssets(shares); + let assetsFact = await $erc4626.redeem(srtVault, alice, shares); + $require.eq(assetsFact, 0n); + let fee = $bigint.toEther(assets) * .10; + + await client.debug.mine('1hour'); + let { error } = await $promise.caught(sharesCooldown.$receipt().finalize(alice, srtVault.address, alice.address)); + $require.match(/NothingToFinalize/, error?.message); + + await client.debug.mine('1day'); + let assetsBefore = await USDe.balanceOf(alice.address); + await sharesCooldown.$receipt().finalize(alice, srtVault.address, alice.address); + assetsFact = await USDe.balanceOf(alice.address) - assetsBefore; + + $require.eq($bigint.toEther(assetsFact) + fee, $bigint.toEther(assets)); + } + }) + }, + async 'should use old exit fee flow' () { + await $tranche.deposit(jrtVault, alice, USDe, 100.0); + await $tranche.deposit(srtVault, alice, USDe, 100.0); + + await $hh.test.snapshot('exit-old-fee'); + return UTest.create({ + async $teardown () { + await $hh.test.reset('exit-old-fee'); + }, + async 'when no lockup bounds' () { + await $exitMode.set(sharesCooldown, configManager, srtVault.address, [ + { covPct: 0, feePct: 0, lock: 0 }, + { covPct: 0, feePct: 0, lock: 0 }, + { covPct: 0, feePct: 0, lock: 0 }, + ]); + await cdo.$receipt().setExitFees({ + address: configManager.address, + type: 'impersonated' + }, BigInt(0.003e18), BigInt(0.003e18)); + + await $util.ensureCoverage(20, { jrt: 1000.0 }); + let shares = BigInt(10e18); + let assets = await srtVault.convertToAssets(shares); + let assetsFact = await $erc4626.redeem(srtVault, alice, shares); + let fee = $bigint.toEther(assets) * .003; + $require.eq($bigint.toEther(assetsFact) + fee, $bigint.toEther(assets)); + }, + async 'when empty default bound' () { + await $exitMode.set(sharesCooldown, configManager, srtVault.address, [ + { covPct: 10, feePct: 2, lock: 60 }, + { covPct: 30, feePct: 0, lock: 120 }, + { covPct: 0, feePct: 0, lock: 0 }, + ]); + await cdo.$receipt().setExitFees({ + address: configManager.address, + type: 'impersonated' + }, BigInt(0.0025e18), BigInt(0.0025e18)); + + await $util.ensureCoverage(60, { jrt: 1000.0 }); + let shares = BigInt(10e18); + let assets = await srtVault.convertToAssets(shares); + let assetsFact = await $erc4626.redeem(srtVault, alice, shares); + let fee = $bigint.toEther(assets) * .0025; + $require.eq($bigint.toEther(assetsFact) + fee, $bigint.toEther(assets)); + } + }) + }, + async 'should early exit the shares cooldown' () { + await $tranche.deposit(jrtVault, alice, USDe, 100.0); + await $tranche.deposit(srtVault, alice, USDe, 100.0); + + await $exitMode.set(sharesCooldown, configManager, jrtVault.address, [ + { covPct: 70, feePct: 0, lock: $date.parseTimespan('8days', { get: 's' }) }, + ]); + await sharesCooldown.$receipt().setVaultEarlyExitFee(deployer, jrtVault.address, BigInt(0.01e18)); + + let amount = BigInt(10e18); + let assets = await jrtVault.convertToAssets(amount); + let assetsFact = await $erc4626.redeem(jrtVault, alice, 10.0); + $require.eq(assetsFact, 0n); + + + let balanceBefore = await USDe.balanceOf(alice.address); + await sharesCooldown.$receipt().finalizeWithFee(alice, jrtVault.address, alice.address, 0n); + + assetsFact = await USDe.balanceOf(alice.address) - balanceBefore; + + let fee = $bigint.toEther(assets) * 0.01 * 8; + $require.eq($bigint.toEther(assetsFact) + fee, $bigint.toEther(assets)); + }, + + async 'should revert on exit when coverage is below minimumJrtSrtRatio' () { + await $tranche.deposit(jrtVault, alice, USDe, 100.0); + await $tranche.deposit(srtVault, alice, USDe, 100.0); + + await $exitMode.set(sharesCooldown, configManager, jrtVault.address, [ + { covPct: 1, feePct: 2 , lock: $date.parseTimespan('8days', { get: 's' }) }, + { covPct: 70, feePct: 1.5, lock: $date.parseTimespan('8days', { get: 's' }) }, + ]); + + let assetsFact = await $erc4626.redeem(jrtVault, alice, 10.0); + $require.eq(assetsFact, 0n); + + await accounting.$receipt().setMinimumJrtSrtRatioBuffer(deployer, BigInt(1.10e18)); + await accounting.$receipt().setMinimumJrtSrtRatio(deployer, BigInt(1.10e18)); + await client.debug.mine('10days'); + + // new redemption should fail + let { error } = await $promise.caught($erc4626.redeem(jrtVault, alice, 10.0)); + $require.match(/ERC4626ExceededMaxRedeem/, error.message); + + // finalization works + let before = await USDe.balanceOf(alice.address); + await sharesCooldown.$receipt().finalize(alice, jrtVault.address, alice.address); + let received = $bigint.toEther(await USDe.balanceOf(alice.address) - before); + + $require.eq(received, 10 - 10 * 0.015); + } +}) + + +namespace $util { + export async function ensureCoverage (cov: number, minimums?: { jrt?: number, srt?: number }) { + let { jrtNav, srtNav } = await cdo.totalAssetsUnlocked(); + let jrtNavEth = $bigint.toEther(jrtNav); + let srtNavEth = $bigint.toEther(srtNav); + + let current = jrtNavEth / srtNavEth * 100; + if (current < cov) { + let amount = cov / 100 * srtNavEth - jrtNavEth; + await $erc4626.deposit(jrtVault, alice, $bigint.toWei(amount)); + jrtNavEth += amount; + } else if (current > cov) { + let amount = jrtNavEth * 100 / cov - srtNavEth; + await $erc4626.deposit(srtVault, alice, $bigint.toWei(amount)); + srtNavEth += amount; + } + + let scale = 1; + if (minimums?.jrt > jrtNavEth) { + scale = minimums.jrt / jrtNavEth; + } + if (minimums?.srt > srtNavEth) { + scale = Math.max(scale, minimums.srt / srtNavEth); + } + if (scale > 1) { + await $erc4626.deposit(jrtVault, alice, $bigint.toWei(jrtNavEth * scale - jrtNavEth)); + await $erc4626.deposit(srtVault, alice, $bigint.toWei(srtNavEth * scale - srtNavEth)); + } + } +} diff --git a/test/tranches/cooldowns/SharesCooldown.spec.ts b/test/tranches/cooldowns/SharesCooldown.spec.ts new file mode 100644 index 0000000..85e02a1 --- /dev/null +++ b/test/tranches/cooldowns/SharesCooldown.spec.ts @@ -0,0 +1,270 @@ +import { HardhatProvider } from 'dequanto/hardhat/HardhatProvider'; +import { UAction } from 'atma-utest' +import { $erc20 } from '../utils/$erc20'; +import { $acc } from '../utils/$acc'; +import { TEth } from 'dequanto/models/TEth'; +import { $date } from 'dequanto/utils/$date'; +import { $require } from 'dequanto/utils/$require'; +import { $hh } from '../utils/$hh'; +import { $testCooldown } from './$testCooldown'; +import { $promise } from 'dequanto/utils/$promise'; +import { l } from 'dequanto/utils/$logger'; +import { $erc4626 } from '../utils/$erc4626'; +import { SharesCooldown } from '@0xc/hardhat/SharesCooldown/SharesCooldown'; +import { ERC4626 } from 'dequanto/prebuilt/openzeppelin/ERC4626'; +import { MockERC4626 } from '@0xc/hardhat/MockERC4626/MockERC4626'; +import { $exitMode } from '@s/utils/$exitMode'; + + +const _7DAYS = BigInt(7 * 24 * 60 * 60); + +let hh = new HardhatProvider(); +let alice = await hh.deployer(1); +let bob = await hh.deployer(2); + +let { + jrtVault, + srtVault, +} = await $hh.test.deploy(); + +let { USDe } = await $hh.test.factory.ensureEthena(); +let { contract: Vault } = await $hh.test.factory.ds.ensure(MockERC4626, { + arguments: [ USDe.address ] +}); +let { sharesCooldown, acm } = await $hh.test.factory.ensureCooldowns(); +let { strategy } = $hh.test.tranches; + +UAction.create({ + async $before () { + await $hh.test.factory.addRoles({ + [alice.address]: [ + await sharesCooldown.COOLDOWN_WORKER_ROLE(), + ] + }); + await sharesCooldown.$receipt().setTwoStepConfigManager($hh.test.deployer, alice.address); + await $erc4626.deposit(Vault, alice, 1000.0); + await $exitMode.set(sharesCooldown, alice, Vault.address, [ + { covPct: 100, lock: 60 } + ]); + await $hh.test.snapshot('sharesCooldown'); + }, + async $teardown() { + await $hh.test.client.debug.setAutomine(true); + await $hh.test.reset('sharesCooldown'); + }, + async $after() { + await $hh.test.reset(); + }, + async 'requestRedeem'() { + + await requestRedeem(sharesCooldown, Vault, alice, bob, BigInt(2e18), '60s'); + await $erc20.eqBalance(Vault, alice, 998.0); + await requestRedeem(sharesCooldown, Vault, alice, bob, BigInt(3e18), '120s'); + await $erc20.eqBalance(Vault, alice, 995.0); + + await $hh.test.mine(`30s`); + + // no transfers yet + await $erc20.eqBalance(Vault, bob, 0n); + await $testCooldown.eqBalanceOf(sharesCooldown, Vault, bob, { + pending: BigInt(5e18), + nextUnlockAmount: BigInt(2e18) + }); + + // fail to withdraw + const { error } = await $promise.caught($testCooldown.finalize(sharesCooldown, Vault, bob)); + $require.match(/NothingToFinalize/, error.message); + + // #1: 60s passed, withdraw 1. portion + await $hh.test.mine(`51s`); + await $testCooldown.eqBalanceOf(sharesCooldown, Vault, bob, { + pending: BigInt(3e18), + claimable: BigInt(2e18), + nextUnlockAmount: BigInt(3e18) + }); + + await $testCooldown.finalize(sharesCooldown, Vault, bob); + await $erc20.eqBalance(USDe, bob, BigInt(2e18)); + + // No balance yet + await $testCooldown.eqBalanceOf(sharesCooldown, Vault, bob, { pending: BigInt(3e18), claimable: 0n, nextUnlockAmount: BigInt(3e18) }); + + // #2: 120s passed, withdraw 2. portion + await $hh.test.mine(`61s`); + await $testCooldown.eqBalanceOf(sharesCooldown, Vault, bob, { pending: 0n, claimable: BigInt(3e18), nextUnlockAmount: 0n }); + await $testCooldown.finalize(sharesCooldown, Vault, bob); + await $erc20.eqBalance(USDe, bob, BigInt(5e18)); + await $testCooldown.eqBalanceOf(sharesCooldown, Vault, bob, { pending: 0n, claimable: 0n, nextUnlockAmount: 0n }); + }, + async 'emergency disable'() { + await requestRedeem(sharesCooldown, Vault, alice, bob.address, 21n, '7days'); + let result = await $promise.caught($testCooldown.finalize(sharesCooldown, Vault, bob.address)); + $require.match(/NothingToFinalize/, result.error?.message); + + + await $exitMode.set(sharesCooldown, alice, Vault.address, []); + await $testCooldown.eqBalanceOf(sharesCooldown, Vault, bob, { pending: 0n, claimable: 21n, nextUnlockAmount: 0n, nextUnlockAt: 0n }); + await $testCooldown.finalize(sharesCooldown, Vault, bob.address); + await $erc20.eqBalance(USDe, bob, 21n); + }, + async 'ensure unstake request is reused within single block'() { + + await Vault.$receipt().transfer(alice, sharesCooldown.address, 50n); + + await $hh.test.client.debug.setAutomine(false); + await $promise.wait(200); + let tx1 = await sharesCooldown.requestRedeem(alice, Vault.address, alice.address, bob.address, 23n, 0n, 60); + let tx2 = await sharesCooldown.requestRedeem(alice, Vault.address, alice.address, bob.address, 27n, 0n, 60); + await $promise.wait(200); + await $hh.test.client.debug.setAutomine(true); + await $hh.test.client.debug.mine(1); + + let [ r1, r2 ] = await Promise.all([ + tx1.wait(), + tx2.wait() + ]); + + + l`1 request only, as 2 requests were made in the same block`; + $require.eq(r1.blockNumber, r2.blockNumber, 'different blocks'); + + await $testCooldown.eqBalanceOf(sharesCooldown, Vault, bob, { + pending: 50n, + nextUnlockAmount: 50n, + totalRequests: 1n + }); + + await $hh.test.mine(`7days`); + await $testCooldown.finalize(sharesCooldown, Vault, bob.address); + await $erc20.eqBalance(USDe, bob, 50n); + }, + async 'max active requests'() { + await Vault.$receipt().transfer(alice, sharesCooldown.address, 80n); + + const MAX = $hh.isCoverage() ? 2 : 71; + for (let i = 0; i < MAX; i++) { + await sharesCooldown.$receipt().requestRedeem(alice, Vault.address, bob.address, bob.address, 1n, 0n, 7 * 24 * 60 * 60); + } + await $testCooldown.eqBalanceOf(sharesCooldown, Vault, bob, { + pending: BigInt(MAX), + nextUnlockAmount: 1n, + totalRequests: BigInt(Math.min(MAX, 70)) + }); + await $hh.test.mine(`8days`); + await $testCooldown.finalize(sharesCooldown, Vault, bob.address); + await $erc20.eqBalance(USDe, bob, BigInt(MAX)); + }, + async 'max external requests'() { + await Vault.$receipt().transfer(alice, sharesCooldown.address, 80n); + + const MAX = $hh.isCoverage() ? 2 : 40; + const _7d = 7 * 24 * 60 * 60; + for (let i = 0; i < MAX; i++) { + await sharesCooldown.$receipt().requestRedeem(alice, Vault.address, alice.address, bob.address, 1n, 0n, _7d ); + } + await $testCooldown.eqBalanceOf(sharesCooldown, Vault, bob, { + pending: 40n, + nextUnlockAmount: 1n, + totalRequests: 40n + }); + + let result = await $promise.caught(() => { + return sharesCooldown.$receipt().requestRedeem(alice, Vault.address, alice.address, bob.address, 1n, 0n, _7d); + }); + $require.match(/ExternalReceiverRequestLimitReached/, result.error?.message); + + await sharesCooldown.$receipt().requestRedeem(alice, Vault.address, bob.address, bob.address, 5n, 0n, _7d); + await $testCooldown.eqBalanceOf(sharesCooldown, Vault, bob, { + pending: 45n, + nextUnlockAmount: 1n, + totalRequests: 41n + }); + + await $hh.test.mine(`8days`); + await $testCooldown.finalize(sharesCooldown, Vault, bob.address); + await $erc20.eqBalance(USDe, bob, 45n); + }, + async 'cancel request' () { + await requestRedeem(sharesCooldown, Vault, alice, bob, BigInt(2e18), '60s'); + + let { error } = await $promise.caught(sharesCooldown.$receipt().cancel(alice, Vault.address, bob.address, 0n)); + $require.match(/OnlyOwner/, error.message); + + await $erc20.eqBalance(Vault, bob, 0); + + await sharesCooldown.$receipt().cancel(bob, Vault.address, bob.address, 0n); + await $erc20.eqBalance(Vault, bob, 2.0); + + await $testCooldown.eqBalanceOf(sharesCooldown, Vault, bob, { + totalRequests: 0n + }); + }, + async 'configure via TwoStepConfigManager' () { + let { configManager } = await $hh.test.factory.ensureConfigManager(); + let { client, deployer } = $hh.test; + + await sharesCooldown.$receipt().setTwoStepConfigManager(deployer, configManager.address); + + await $exitMode.propose(deployer, configManager, + [ + { covPct: .5, feePct: 1, lock: $date.parseTimespan('2days', { get: 's' }) }, + { covPct: 2.3, feePct: 0.5, lock: $date.parseTimespan('8hours', { get: 's' }) }, + { covPct: 0, feePct: 0.03, lock: 0 }, + ], + [ + { covPct: 2, feePct: 1, lock: $date.parseTimespan('3day', { get: 's' }) }, + { covPct: 40, feePct: 0.7, lock: $date.parseTimespan('7hours', { get: 's' }) }, + { covPct: 0, feePct: 0.15, lock: 0 }, + ] + ); + await client.debug.mine('2days'); + await $exitMode.execute(deployer, configManager); + + let jrt = await sharesCooldown.vaultExitBounds(jrtVault.address); + $require.eq(jrt.p0, 0.005e6); + $require.eq(jrt.p1, 0.023e6); + $require.eq(jrt.r0.feePpm, 0.01e6); + $require.eq(jrt.r0.sharesLock, 2 * 24 * 60 * 60); + $require.eq(jrt.r1.feePpm, 0.005e6); + $require.eq(jrt.r1.sharesLock, 8 * 60 * 60); + $require.eq(jrt.r2.feePpm, 0.0003e6); + + let srt = await sharesCooldown.vaultExitBounds(srtVault.address); + $require.eq(srt.p0, 0.02e6); + $require.eq(srt.p1, 0.4e6); + $require.eq(srt.r0.feePpm, 0.01e6); + $require.eq(srt.r0.sharesLock, 3 * 24 * 60 * 60); + $require.eq(srt.r1.feePpm, 0.007e6); + $require.eq(srt.r1.sharesLock, 7 * 60 * 60); + $require.eq(srt.r2.feePpm, 0.0015e6); + + + l`should cancel`; + await $exitMode.propose(deployer, configManager, + [ + { covPct: .5, feePct: 1.1, lock: $date.parseTimespan('2days', { get: 's' }) }, + { covPct: 2.3, feePct: 3, lock: $date.parseTimespan('8hours', { get: 's' }) }, + { covPct: 0, feePct: 0.3, lock: 0 }, + ], + [ + { covPct: 2, feePct: 1, lock: $date.parseTimespan('3day', { get: 's' }) }, + { covPct: 40, feePct: 3, lock: $date.parseTimespan('7hours', { get: 's' }) }, + { covPct: 0, feePct: 1.5, lock: 0 }, + ] + ); + + let pending = await configManager.pendingExitModeBoundsJrt(); + $require.eq(pending.bounds.p0, 0.005e6); + + await configManager.$receipt().cancelExitModeBoundsChange(deployer); + pending = await configManager.pendingExitModeBoundsJrt(); + $require.eq(pending.bounds.p0, 0); + } +}) + + +async function requestRedeem(cooldown: SharesCooldown, token: ERC4626 | any, from: TEth.IAccount, to: $acc.Address, amount: bigint, timespan: string) { + let cooldownSeconds = Math.floor($date.parseTimespan(timespan) / 1000); + await token.$receipt().transfer(from, cooldown.address, amount); + await cooldown.$receipt().requestRedeem(from, token.address, from.address, $acc.toAddress(to), amount, 0n, cooldownSeconds); +} diff --git a/test/tranches/e2e/v1_1_0/fees.spec.ts b/test/tranches/e2e/v1_1_0/fees.spec.ts index 857a706..9f69731 100644 --- a/test/tranches/e2e/v1_1_0/fees.spec.ts +++ b/test/tranches/e2e/v1_1_0/fees.spec.ts @@ -18,7 +18,6 @@ import { Addresses } from '@s/constants'; import { l } from 'dequanto/utils/$logger'; import { AprPairFeed } from '@0xc/hardhat/AprPairFeed/AprPairFeed'; import { $strata } from '../../utils/$strata'; -import { $promise } from 'dequanto/utils/$promise'; await $hh.test.init(); @@ -598,25 +597,30 @@ UTest.create({ } }, async 'final TVLs should be equal' () { - $require.eq(TVLs_v110_fees.jrtBefore, TVLs_v100_noFee.jrtBefore, '(before) JRT TVLs should be same'); - $require.eq(TVLs_v110_fees.srtBefore, TVLs_v100_noFee.srtBefore, '(before) SRT TVLs should be same'); - $require.eq(TVLs_v110_noFee.srtBefore, TVLs_v100_noFee.srtBefore, 'v110 vs v100 - (before) SRT TVLs should be same'); + function eqRounded(a: bigint, b: bigint, hint: string) { + $require.lte($bigint.abs(a - b), 1n, hint); + } + + eqRounded(TVLs_v110_fees.jrtBefore, TVLs_v100_noFee.jrtBefore, '(before) JRT TVLs should be same'); + eqRounded(TVLs_v110_fees.srtBefore, TVLs_v100_noFee.srtBefore, '(before) SRT TVLs should be same'); + + eqRounded(TVLs_v110_noFee.srtBefore, TVLs_v100_noFee.srtBefore, 'v110 vs v100 - (before) SRT TVLs should be same'); - $require.eq(TVLs_v110_fees.jrtWithdrawnGross, TVLs_v100_noFee.jrtWithdrawnGross, 'JRT Withdrawn Gross final TVLs should be same'); - $require.eq(TVLs_v110_fees.srtWithdrawnGross, TVLs_v100_noFee.srtWithdrawnGross, 'SRT Withdrawn Gross final TVLs should be same'); + eqRounded(TVLs_v110_fees.jrtWithdrawnGross, TVLs_v100_noFee.jrtWithdrawnGross, 'JRT Withdrawn Gross final TVLs should be same'); + eqRounded(TVLs_v110_fees.srtWithdrawnGross, TVLs_v100_noFee.srtWithdrawnGross, 'SRT Withdrawn Gross final TVLs should be same'); - $require.eq(TVLs_v110_noFee.jrtWithdrawnGross, TVLs_v100_noFee.jrtWithdrawnGross, 'JRT Withdrawn Gross final TVLs should be same'); - $require.eq(TVLs_v110_noFee.srtWithdrawnGross, TVLs_v100_noFee.srtWithdrawnGross, 'SRT Withdrawn Gross final TVLs should be same'); + eqRounded(TVLs_v110_noFee.jrtWithdrawnGross, TVLs_v100_noFee.jrtWithdrawnGross, 'JRT Withdrawn Gross final TVLs should be same'); + eqRounded(TVLs_v110_noFee.srtWithdrawnGross, TVLs_v100_noFee.srtWithdrawnGross, 'SRT Withdrawn Gross final TVLs should be same'); - $require.eq(TVLs_v110_fees.srtAfter - TVLs_v110_fees.srtFees, TVLs_v100_noFee.srtAfter, '(after) SRT TVLs should be same'); - $require.eq(TVLs_v110_fees.jrtAfter - TVLs_v110_fees.jrtFees, TVLs_v100_noFee.jrtAfter, '(after) JRT TVLs should be same'); + eqRounded(TVLs_v110_fees.srtAfter - TVLs_v110_fees.srtFees, TVLs_v100_noFee.srtAfter, '(after) SRT TVLs should be same'); + eqRounded(TVLs_v110_fees.jrtAfter - TVLs_v110_fees.jrtFees, TVLs_v100_noFee.jrtAfter, '(after) JRT TVLs should be same'); - $require.eq(TVLs_v110_noFee.srtAfter, TVLs_v100_noFee.srtAfter, '(after) SRT TVLs should be same'); - $require.eq(TVLs_v110_noFee.jrtAfter, TVLs_v100_noFee.jrtAfter, '(after) JRT TVLs should be same'); + eqRounded(TVLs_v110_noFee.srtAfter, TVLs_v100_noFee.srtAfter, '(after) SRT TVLs should be same'); + eqRounded(TVLs_v110_noFee.jrtAfter, TVLs_v100_noFee.jrtAfter, '(after) JRT TVLs should be same'); - $require.eq(TVLs_v110_fees.jrtWithdrawnNet + TVLs_v110_fees.jrtFees, TVLs_v100_noFee.jrtWithdrawnGross, 'JRT'); - $require.eq(TVLs_v110_fees.srtWithdrawnNet + TVLs_v110_fees.srtFees, TVLs_v100_noFee.srtWithdrawnGross, 'SRT'); + eqRounded(TVLs_v110_fees.jrtWithdrawnNet + TVLs_v110_fees.jrtFees, TVLs_v100_noFee.jrtWithdrawnGross, 'JRT'); + eqRounded(TVLs_v110_fees.srtWithdrawnNet + TVLs_v110_fees.srtFees, TVLs_v100_noFee.srtWithdrawnGross, 'SRT'); } }) diff --git a/test/tranches/utils/$ethena.ts b/test/tranches/utils/$ethena.ts index 17324f9..f03d199 100644 --- a/test/tranches/utils/$ethena.ts +++ b/test/tranches/utils/$ethena.ts @@ -4,6 +4,7 @@ import { TEth } from 'dequanto/models/TEth'; import { ERC20 } from 'dequanto/prebuilt/openzeppelin/ERC20'; import { $bigint } from 'dequanto/utils/$bigint'; import { l } from 'dequanto/utils/$logger'; +import { $require } from 'dequanto/utils/$require'; export namespace $ethena { export async function distribute (sUSDe: MockStakedUSDe | any, USDe: ERC20 | any, distributor: TEth.IAccount, amount: number | bigint) { @@ -20,4 +21,9 @@ export namespace $ethena { l`Distributing yellow<${amount}>`; await sUSDe.$receipt().transferInRewards(distributor, amountWei); } + + export async function setCooldownDuration (sUSDe: MockStakedUSDe, sender: TEth.IAccount, seconds: number) { + $require.eq(sUSDe.client.platform, 'hardhat'); + await sUSDe.$receipt().setCooldownDuration(sender, seconds); + } } From e157b7af1fb64e441c51c1e62885a76e6ab81119 Mon Sep 17 00:00:00 2001 From: Alex Date: Wed, 7 Jan 2026 23:46:43 +0100 Subject: [PATCH 03/24] add (Tranche) Exit event to track exitModes, fees and cooldowns --- .github/workflows/olympix.yml | 13 +++++++++++++ .olympix-ignore.json | 3 ++- contracts/tranches/Tranche.sol | 19 +++++++++++++++---- 3 files changed, 30 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/olympix.yml diff --git a/.github/workflows/olympix.yml b/.github/workflows/olympix.yml new file mode 100644 index 0000000..3edd943 --- /dev/null +++ b/.github/workflows/olympix.yml @@ -0,0 +1,13 @@ +name: Integrated Security Workflow +on: push +jobs: + security: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Olympix Integrated Security + uses: olympix/integrated-security@main + env: + OLYMPIX_API_TOKEN: ${{ secrets.OLYMPIX_API_TOKEN }} + with: + args: -f json --no-uninitialized-state-variable --no-default-visibility --no-missing-revert-reason-tests diff --git a/.olympix-ignore.json b/.olympix-ignore.json index 99b1275..d402e7a 100644 --- a/.olympix-ignore.json +++ b/.olympix-ignore.json @@ -12,6 +12,7 @@ "IgnoredPaths": [ "contracts/test", "contracts/oz", - "contracts/lens" + "contracts/lens", + "test/PoC" ] } diff --git a/contracts/tranches/Tranche.sol b/contracts/tranches/Tranche.sol index 3cbdc6d..0c8fdda 100644 --- a/contracts/tranches/Tranche.sol +++ b/contracts/tranches/Tranche.sol @@ -21,6 +21,15 @@ contract Tranche is ITranche, CDOComponent, ERC4626Upgradeable, ERC20PermitUpgra event OnMetaDeposit(address indexed owner, address indexed token, uint256 tokenAssets, uint256 shares); event OnMetaWithdraw(address indexed owner, address indexed token, uint256 tokenAssets, uint256 shares); + event OnExit( + address indexed owner, + address indexed token, + uint256 tokenAssets, + uint256 shares, + IStrataCDO.TExitMode exitMode, + uint256 exitFee, + uint32 cooldownSeconds + ); function initialize( address owner_, @@ -55,12 +64,12 @@ contract Tranche is ITranche, CDOComponent, ERC4626Upgradeable, ERC20PermitUpgra */ /** @dev Extends {IERC4626-maxDeposit} to handle the paused state and the TVL ratio */ - function maxDeposit(address owner) public view override(ERC4626Upgradeable, IERC4626) returns (uint256) { + function maxDeposit(address) public view override(ERC4626Upgradeable, IERC4626) returns (uint256) { return cdo.maxDeposit(address(this)); } /** @dev Extends {IERC4626-maxMint} to handle the paused state and the TVL ratio */ - function maxMint(address owner) public view override(ERC4626Upgradeable, IERC4626) returns (uint256) { + function maxMint(address) public view override(ERC4626Upgradeable, IERC4626) returns (uint256) { uint256 assets = cdo.maxDeposit(address(this)); if (assets == type(uint256).max) { // No mint-cap @@ -84,7 +93,7 @@ contract Tranche is ITranche, CDOComponent, ERC4626Upgradeable, ERC20PermitUpgra /** @dev Extends {IERC4626-previewRedeem} to handle fee calculation */ function previewRedeem(uint256 sharesGross) public view override(ERC4626Upgradeable, IERC4626) returns (uint256 assetsNet) { - (IStrataCDO.TExitMode mode, uint256 fee, ) = cdo.calculateExitMode(address(this), address(0)); + (, uint256 fee, ) = cdo.calculateExitMode(address(this), address(0)); assetsNet = quoteRedeem(sharesGross, fee); } function quoteRedeem(uint256 sharesGross, uint256 fee) public view returns (uint256 assetsNet) { @@ -94,7 +103,7 @@ contract Tranche is ITranche, CDOComponent, ERC4626Upgradeable, ERC20PermitUpgra /** @dev Extends {IERC4626-previewWithdraw} to handle fee calculation */ function previewWithdraw(uint256 assetsNet) public view override(ERC4626Upgradeable, IERC4626) returns (uint256 sharesGross) { - (IStrataCDO.TExitMode mode, uint256 exitFee, ) = cdo.calculateExitMode(address(this), address(0)); + (, uint256 exitFee, ) = cdo.calculateExitMode(address(this), address(0)); sharesGross = quoteWithdraw(assetsNet, exitFee); } @@ -290,6 +299,8 @@ contract Tranche is ITranche, CDOComponent, ERC4626Upgradeable, ERC20PermitUpgra _spendAllowance(owner, caller, sharesGross); } + emit OnExit(receiver, token, tokenAssets, sharesGross, exitMode, exitFee, cooldownSec); + if (exitMode == IStrataCDO.TExitMode.SharesLock) { _transfer(owner, address(cdo.sharesCooldown()), sharesGross); cdo.cooldownShares(address(this), sharesGross, owner, receiver, exitFee, cooldownSec); From 652a5c14f5a446147bb5cd82c084e42703eb03c7 Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 9 Jan 2026 14:29:15 +0100 Subject: [PATCH 04/24] fix (Tranche) validate users exit params --- contracts/tranches/Tranche.sol | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/contracts/tranches/Tranche.sol b/contracts/tranches/Tranche.sol index 0c8fdda..69cdca0 100644 --- a/contracts/tranches/Tranche.sol +++ b/contracts/tranches/Tranche.sol @@ -391,12 +391,11 @@ contract Tranche is ITranche, CDOComponent, ERC4626Upgradeable, ERC20PermitUpgra return; } if (params.exitMode != exitMode || params.exitFee != exitFee || params.cooldownSeconds != cooldownSec) { - return; + revert RedemptionParamsMismatch(params, TRedemptionParams({ + exitMode: exitMode, + exitFee: exitFee, + cooldownSeconds: cooldownSec + })); } - revert RedemptionParamsMismatch(params, TRedemptionParams({ - exitMode: exitMode, - exitFee: exitFee, - cooldownSeconds: cooldownSec - })); } } From 219880e7a6d43a88d91b1ff3c1f94cea1082bbc9 Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 9 Jan 2026 14:34:06 +0100 Subject: [PATCH 05/24] docs (StrataCDO) revisit public functions --- contracts/tranches/StrataCDO.sol | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/contracts/tranches/StrataCDO.sol b/contracts/tranches/StrataCDO.sol index 6f6b525..74e5608 100644 --- a/contracts/tranches/StrataCDO.sol +++ b/contracts/tranches/StrataCDO.sol @@ -183,6 +183,8 @@ contract StrataCDO is IErrors, IStrataCDO, IStrataCDOSetters, AccessControlled { accounting.accrueFee(isJrt(tranche), assets); } + /// @notice Returns tranche NAVs excluding assets locked in the shares cooldown (silo). + /// @dev Reads accounting totals and subtracts locked assets to compute available TVLs and coverage. function totalAssetsUnlocked() public view returns (uint256 jrtNav, uint256 srtNav) { (jrtNav, srtNav, ) = accounting.totalAssetsT0(); @@ -194,15 +196,20 @@ contract StrataCDO is IErrors, IStrataCDO, IStrataCDOSetters, AccessControlled { return (jrtNav, srtNav); } - function coverage () public view returns (uint32 coverage) { + /// @notice Returns the coverage ratio using available TVLs (assets not locked in the silo). + /// @dev Uses totals from totalAssetsUnlocked() so locked assets do not affect coverage. + function coverage () public view returns (uint32) { (uint256 jrtNav, uint256 srtNav) = totalAssetsUnlocked(); if (srtNav == 0) { return type(uint32).max; } - uint256 coverage = jrtNav * 1e6 / srtNav; - return coverage > type(uint32).max ? type(uint32).max : uint32(coverage); + uint256 coverage_ = jrtNav * 1e6 / srtNav; + return coverage_ > type(uint32).max ? type(uint32).max : uint32(coverage_); } + + /// @notice Refreshes accounting state before tranche deposit or redemption flows. + /// @dev Called by tranche contracts to sync balances with current strategy TVL. function updateAccounting () external onlyTranche { uint256 totalAssetsOverall = strategy.totalAssets(); accounting.updateAccounting(totalAssetsOverall); From ad26a5eeb865bf01caf564484d4ff222ee8d7228 Mon Sep 17 00:00:00 2001 From: Alex Date: Mon, 12 Jan 2026 10:29:15 +0100 Subject: [PATCH 06/24] fix (#5 burnSharesAsFee) ensure min shares --- contracts/tranches/Tranche.sol | 1 + 1 file changed, 1 insertion(+) diff --git a/contracts/tranches/Tranche.sol b/contracts/tranches/Tranche.sol index 69cdca0..43aed71 100644 --- a/contracts/tranches/Tranche.sol +++ b/contracts/tranches/Tranche.sol @@ -342,6 +342,7 @@ contract Tranche is ITranche, CDOComponent, ERC4626Upgradeable, ERC20PermitUpgra assets = convertToAssets(shares); _burn(owner, shares); cdo.accrueFee(address(this), assets); + _onAfterWithdrawalChecks(); } /** From 7ce9e32f72b4633fd4edc3e04092523265f1a14e Mon Sep 17 00:00:00 2001 From: vivekascoder Date: Wed, 7 Jan 2026 01:53:52 +0530 Subject: [PATCH 07/24] feat: WIP SteakhouseUSDC --- .../strategies/morpho/SteakhouseUSDC.sol | 225 +++ src/deployments/TimelockHandler.ts | 134 +- src/deployments/TranchesDeployments.ts | 1392 +++++++++-------- test/Swap.t.sol | 99 +- 4 files changed, 1071 insertions(+), 779 deletions(-) create mode 100644 contracts/tranches/strategies/morpho/SteakhouseUSDC.sol diff --git a/contracts/tranches/strategies/morpho/SteakhouseUSDC.sol b/contracts/tranches/strategies/morpho/SteakhouseUSDC.sol new file mode 100644 index 0000000..4204f9f --- /dev/null +++ b/contracts/tranches/strategies/morpho/SteakhouseUSDC.sol @@ -0,0 +1,225 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.28; + +import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import {IERC4626} from "@openzeppelin/contracts/interfaces/IERC4626.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {IErrors} from "../../interfaces/IErrors.sol"; +import {IStrataCDO} from "../../interfaces/IStrataCDO.sol"; +import {IERC20Cooldown} from "../../interfaces/cooldown/ICooldown.sol"; +import {Strategy} from "../../Strategy.sol"; + +contract SteakhouseUSDC is Strategy { + IERC4626 public immutable steakhouseUSD; + IERC20 public immutable USDC; + + IERC20Cooldown public erc20Cooldown; + + /** + * configuration + */ + uint256 public steakhouseUSDCooldownJrt; + uint256 public steakhouseUSDCooldownSrt; + + event CooldownsChanged(uint256 jrt, uint256 srt); + + constructor(IERC4626 steakhouseUSD_) { + steakhouseUSD = steakhouseUSD_; + USDC = IERC20(steakhouseUSD_.asset()); + } + + function initialize(address owner_, address acm_, IStrataCDO cdo_, IERC20Cooldown erc20Cooldown_) + public + virtual + initializer + { + AccessControlled_init(owner_, acm_); + + cdo = cdo_; + erc20Cooldown = erc20Cooldown_; + + SafeERC20.forceApprove(steakhouseUSD, address(erc20Cooldown), type(uint256).max); + } + + /** + * @notice Processes asset deposits for the CDO contract. + * @dev This method is called by the CDO contract to handle asset deposits. + * If the deposited token is USDC, it will be deposited to receive steakhouseUSD shares. + * If the deposited token is already steakhouseUSD shares, it will be accepted as is. + * @param tranche The address of the tranche depositing assets (not used in this strategy) + * @param token The address of the token being deposited + * @param tokenAmount The amount of tokens being deposited + * @param baseAssets The amount of base assets represented by the deposit (used for steakhouseUSD deposits) + * @param owner The address of the asset owner from whom to transfer tokens + * @return The amount of base assets received after deposit + */ + function deposit(address tranche, address token, uint256 tokenAmount, uint256 baseAssets, address owner) + external + onlyCDO + returns (uint256) + { + SafeERC20.safeTransferFrom(IERC20(token), owner, address(this), tokenAmount); + + if (token == address(USDC)) { + SafeERC20.forceApprove(USDC, address(steakhouseUSD), tokenAmount); + steakhouseUSD.deposit(tokenAmount, address(this)); + return tokenAmount; + } + if (token == address(steakhouseUSD)) { + // already transferred in ↑ + return baseAssets; + } + revert UnsupportedToken(token); + } + + /** + * @notice Processes asset withdrawals for the CDO contract. + * @dev This method is called by the CDO contract to handle asset withdrawals. + * If withdrawing steakhouseUSD shares, a cooldown period is applied based on the tranche type. + * If withdrawing USDC, the steakhouseUSD shares are redeemed directly. + * @param tranche The address of the tranche withdrawing assets + * @param token The address of the token to be withdrawn + * @param tokenAmount The amount of tokens to be withdrawn (not used in this implementation) + * @param baseAssets The amount of base assets to be withdrawn + * @param receiver The address that will receive the withdrawn assets + * @param sender The account that initiated the withdrawal + * @return The amount of tokens withdrawn (shares for steakhouseUSD, baseAssets for USDC) + */ + function withdraw( + address tranche, + address token, + uint256 tokenAmount, + uint256 baseAssets, + address sender, + address receiver + ) external onlyCDO returns (uint256) { + uint256 shares = steakhouseUSD.previewWithdraw(baseAssets); + if (token == address(steakhouseUSD)) { + uint256 cooldownSeconds = cdo.isJrt(tranche) ? steakhouseUSDCooldownJrt : steakhouseUSDCooldownSrt; + erc20Cooldown.transfer(steakhouseUSD, sender, receiver, shares, cooldownSeconds); + return shares; + } + if (token == address(USDC)) { + // Morpho allows direct withdrawal - no unstaking needed + steakhouseUSD.withdraw(baseAssets, receiver, address(this)); + return baseAssets; + } + revert UnsupportedToken(token); + } + + /** + * @notice Allows the CDO to withdraw tokens from the strategy's reserve + * @dev This function is part of the reserve reduction process and can only be called by the CDO. + * It handles both steakhouseUSD shares and USDC tokens, applying different transfer mechanisms for each. + * For steakhouseUSD shares, it uses erc20Cooldown with no cooldown period. + * For USDC, it withdraws directly from the Morpho vault. + * @param token The address of the token to be withdrawn (either steakhouseUSD or USDC) + * @param tokenAmount The amount of tokens to be withdrawn + * @param receiver The address that will receive the withdrawn tokens + */ + function reduceReserve(address token, uint256 tokenAmount, address receiver) external onlyCDO { + if (token == address(steakhouseUSD)) { + erc20Cooldown.transfer(steakhouseUSD, receiver, receiver, tokenAmount, 0); + return; + } + if (token == address(USDC)) { + // Direct withdrawal from Morpho vault + steakhouseUSD.withdraw(tokenAmount, receiver, address(this)); + return; + } + revert UnsupportedToken(token); + } + + /** + * @notice Calculates the total assets managed by this strategy + * @dev This function returns the current value of the strategy's assets in USDC. + * @return baseAssets The total amount of USDC managed by this strategy + */ + function totalAssets() external view returns (uint256 baseAssets) { + uint256 shares = steakhouseUSD.balanceOf(address(this)); + baseAssets = steakhouseUSD.previewRedeem(shares); + return baseAssets; + } + + /** + * @notice Converts a given amount of supported tokens to their equivalent in USDC + * @dev This function handles conversion for both steakhouseUSD shares and USDC tokens. + * For steakhouseUSD shares, it uses the vault's exchange rate, considering the rounding direction. + * For USDC, it returns the input amount as is. + * @param token The address of the token to convert (either steakhouseUSD or USDC) + * @param tokenAmount The amount of tokens to convert + * @param rounding The rounding direction to use for the conversion (floor or ceiling) + * @return The equivalent amount in USDC + */ + function convertToAssets(address token, uint256 tokenAmount, Math.Rounding rounding) + external + view + returns (uint256) + { + if (token == address(steakhouseUSD)) { + return rounding == Math.Rounding.Floor + ? steakhouseUSD.previewRedeem(tokenAmount) // aka convertToAssets(tokenAmount) + : steakhouseUSD.previewMint(tokenAmount); + } + if (token == address(USDC)) { + return tokenAmount; + } + revert UnsupportedToken(token); + } + + /** + * @notice Converts a given amount of base assets (USDC) to the equivalent amount of supported tokens + * @dev This function handles conversion for both steakhouseUSD shares and USDC tokens. + * For steakhouseUSD shares, it uses the vault's exchange rate, considering the rounding direction. + * For USDC, it returns the input amount as is. + * @param token The address of the token to convert to (either steakhouseUSD or USDC) + * @param baseAssets The amount of base assets (USDC) to convert + * @param rounding The rounding direction to use for the conversion (floor or ceiling) + * @return The equivalent amount in the requested token (steakhouseUSD shares or USDC) + */ + function convertToTokens(address token, uint256 baseAssets, Math.Rounding rounding) + external + view + returns (uint256) + { + if (token == address(steakhouseUSD)) { + return rounding == Math.Rounding.Floor + ? steakhouseUSD.previewDeposit(baseAssets) // aka convertToShares(baseAssets) + : steakhouseUSD.previewWithdraw(baseAssets); + } + if (token == address(USDC)) { + return baseAssets; + } + revert UnsupportedToken(token); + } + + /** + * @notice Returns an array of supported tokens: steakhouseUSD shares and USDC + */ + function getSupportedTokens() external view returns (IERC20[] memory) { + IERC20[] memory tokens = new IERC20[](2); + tokens[0] = IERC20(address(steakhouseUSD)); + tokens[1] = USDC; + return tokens; + } + + /** + * @notice Updates the cooldown periods for steakhouseUSD share withdrawals + */ + function setCooldowns(uint256 steakhouseUSDCooldownJrt_, uint256 steakhouseUSDCooldownSrt_) + external + onlyRole(UPDATER_STRAT_CONFIG_ROLE) + { + uint256 WEEK = 7 days; + if (steakhouseUSDCooldownJrt_ > WEEK || steakhouseUSDCooldownSrt_ > WEEK) { + revert InvalidConfigCooldown(); + } + steakhouseUSDCooldownJrt = steakhouseUSDCooldownJrt_; + steakhouseUSDCooldownSrt = steakhouseUSDCooldownSrt_; + + bool isDisabled = steakhouseUSDCooldownJrt_ == 0 && steakhouseUSDCooldownSrt_ == 0; + erc20Cooldown.setCooldownDisabled(steakhouseUSD, isDisabled); + emit CooldownsChanged(steakhouseUSDCooldownJrt_, steakhouseUSDCooldownSrt_); + } +} diff --git a/src/deployments/TimelockHandler.ts b/src/deployments/TimelockHandler.ts index 30eee91..760a9d8 100644 --- a/src/deployments/TimelockHandler.ts +++ b/src/deployments/TimelockHandler.ts @@ -1,77 +1,87 @@ -import { TAddress } from 'dequanto/models/TAddress'; -import { TEth } from 'dequanto/models/TEth'; -import { ETimelockTxStatus } from 'dequanto/services/TimelockService/ITimelockService'; -import { TimelockService } from 'dequanto/services/TimelockService/TimelockService'; - -import { $address } from 'dequanto/utils/$address'; -import { l } from 'dequanto/utils/$logger'; -import { TranchesDeployments } from './TranchesDeployments'; -import { StrataMasterChef } from '@0xc/hardhat/StrataMasterChef/StrataMasterChef'; -import { ChainAccountService } from 'dequanto/ChainAccountService'; - +import { TAddress } from "dequanto/models/TAddress"; +import { TEth } from "dequanto/models/TEth"; +import { ETimelockTxStatus } from "dequanto/services/TimelockService/ITimelockService"; +import { TimelockService } from "dequanto/services/TimelockService/TimelockService"; +import { $address } from "dequanto/utils/$address"; +import { l } from "dequanto/utils/$logger"; +import { TranchesDeployments } from "./TranchesDeployments"; +import { StrataMasterChef } from "@0xc/hardhat/StrataMasterChef/StrataMasterChef"; +import { ChainAccountService } from "dequanto/ChainAccountService"; export class TimelockHandler { + constructor(public ds: TranchesDeployments, public owner: TEth.IAccount) {} - constructor (public ds: TranchesDeployments, public owner: TEth.IAccount) { - } + async get() { + let timelockAcc = await ChainAccountService.get("timelock/eth/strata"); + return new StrataMasterChef(timelockAcc.address, this.ds.client); + } - async get () { - let timelockAcc = await ChainAccountService.get('timelock/eth/strata'); - return new StrataMasterChef(timelockAcc.address, this.ds.client); + async isOwner(contract: { owner: () => Promise }) { + let timelock = await this.get(); + if ($address.isEmpty(timelock?.address)) { + // No timelock for chain + return false; } + let owner = await contract.owner(); + return $address.eq(owner, timelock.address); + } - async isOwner (contract: { owner: () => Promise }) { - let timelock = await this.get(); - if ($address.isEmpty(timelock?.address)) { - // No timelock for chain - return false; - } - let owner = await contract.owner(); - return $address.eq(owner, timelock.address); - } + async isProposer(address: TAddress) { + let timelock = await this.get(); + return await timelock.hasRole(await timelock.PROPOSER_ROLE(), address); + } - async isProposer (address: TAddress) { - let timelock = await this.get(); - return await timelock.hasRole(await timelock.PROPOSER_ROLE(), address); + async process( + pendingKey: string, + actions: TEth.DataLike[], + options?: { simulate?: boolean; execute?: boolean } + ) { + let timelock = await this.get(); + let service = new TimelockService(timelock, options); + let { status, schedule } = await service.getPendingByTitle(pendingKey); + if (schedule == null) { + schedule = await service.scheduleCallBatch( + pendingKey, + this.owner, + actions + ); + l`✅ Scheduled green<'${pendingKey}'> ${schedule.txSchedule}`; + return { status: ETimelockTxStatus.Pending }; } - - async process (pendingKey: string, actions: TEth.DataLike[], options?: { simulate?: boolean, execute?: boolean }) { - let timelock = await this.get(); - let service = new TimelockService(timelock, options); - let { status, schedule } = await service.getPendingByTitle(pendingKey); - if (schedule == null) { - schedule = await service.scheduleCallBatch(pendingKey, this.owner, actions); - l`✅ Scheduled green<'${ pendingKey }'> ${schedule.txSchedule}`; - return { status: ETimelockTxStatus.Pending }; - } - if (status === ETimelockTxStatus.Pending) { - l`⏳ Schedule green<'${ pendingKey }'> is still pending`; - return { status }; - } - - let result = await service.executePendingByTitle(this.owner, pendingKey); - l`✅✅ Executed ${result.schedule.txExecute}`; - return { status: ETimelockTxStatus.Executed }; + if (status === ETimelockTxStatus.Pending) { + l`⏳ Schedule green<'${pendingKey}'> is still pending`; + return { status }; } - async execute (sender, txParams) { - let timelock = await this.get(); + let result = await service.executePendingByTitle(this.owner, pendingKey); + l`✅✅ Executed ${result.schedule.txExecute}`; + return { status: ETimelockTxStatus.Executed }; + } - await timelock.$receipt().executeBatch( - sender, - [txParams.to as TAddress], - [txParams.value as bigint], - [txParams.data as TEth.Hex], - txParams.predecessor, - txParams.salt - ) - } + async execute(sender, txParams) { + let timelock = await this.get(); + + await timelock + .$receipt() + .executeBatch( + sender, + [txParams.to as TAddress], + [txParams.value as bigint], + [txParams.data as TEth.Hex], + txParams.predecessor, + txParams.salt + ); + } - async processOrExitWait (pendingKey: string, actions: TEth.DataLike[], options?: { simulate?: boolean, execute?: boolean }) { - let { status } = await this.process(pendingKey, actions, options); - if (status === ETimelockTxStatus.Pending) { - throw new Error(`Okay. Wait for ${pendingKey} to complete`); - } + async processOrExitWait( + pendingKey: string, + actions: TEth.DataLike[], + options?: { simulate?: boolean; execute?: boolean } + ) { + let { status } = await this.process(pendingKey, actions, options); + if (status === ETimelockTxStatus.Pending) { + throw new Error(`Okay. Wait for ${pendingKey} to complete`); } + } } diff --git a/src/deployments/TranchesDeployments.ts b/src/deployments/TranchesDeployments.ts index e1ae56b..27cb13c 100644 --- a/src/deployments/TranchesDeployments.ts +++ b/src/deployments/TranchesDeployments.ts @@ -1,719 +1,747 @@ -import memd from 'memd'; -import { AccessControlManager } from '@0xc/hardhat/AccessControlManager/AccessControlManager' -import { MockStakedUSDe } from '@0xc/hardhat/MockStakedUSDe/MockStakedUSDe' -import { MockUSDe } from '@0xc/hardhat/MockUSDe/MockUSDe' -import { Tranche } from '@0xc/hardhat/Tranche/Tranche' -import { Web3Client } from 'dequanto/clients/Web3Client' -import { Deployments } from 'dequanto/contracts/deploy/Deployments' -import { TEth } from 'dequanto/models/TEth' -import { Platforms } from '../platforms/Platforms' -import { IPlatform, IPlatformAccounts } from '../platforms/IPlatform' -import { $require } from 'dequanto/utils/$require' -import { SUSDeStrategy } from '@0xc/hardhat/sUSDeStrategy/sUSDeStrategy' -import { StrataCDO } from '@0xc/hardhat/StrataCDO/StrataCDO' -import { ERC20Cooldown } from '@0xc/hardhat/ERC20Cooldown/ERC20Cooldown' -import { UnstakeCooldown } from '@0xc/hardhat/UnstakeCooldown/UnstakeCooldown' -import { Accounting } from '@0xc/hardhat/Accounting/Accounting' -import { Tranches } from '../platforms/Tranches' -import { $address } from 'dequanto/utils/$address' -import { IERC4626 } from 'dequanto/prebuilt/openzeppelin/IERC4626' -import { $contract } from 'dequanto/utils/$contract' -import { SUSDeCooldownRequestImpl } from '@0xc/hardhat/sUSDeCooldownRequestImpl/sUSDeCooldownRequestImpl'; -import { $date } from 'dequanto/utils/$date'; -import { AprPairFeed } from '@0xc/hardhat/AprPairFeed/AprPairFeed'; -import { SUSDeAprPairProvider } from '@0xc/hardhat/sUSDeAprPairProvider/sUSDeAprPairProvider'; -import { MockStakedUSDS } from '@0xc/hardhat/MockStakedUSDS/MockStakedUSDS'; -import { $erc4626 } from '../../test/tranches/utils/$erc4626'; -import { TrancheDepositor } from '@0xc/hardhat/TrancheDepositor/TrancheDepositor'; -import { SafeTx } from 'dequanto/safe/SafeTx'; -import { InMemoryServiceTransport } from 'dequanto/safe/transport/InMemoryServiceTransport'; -import { l } from 'dequanto/utils/$logger'; -import { Addresses } from '@s/constants'; -import { MockERC4626 } from '@0xc/hardhat/MockERC4626/MockERC4626'; -import { AaveAprPairProvider } from '@0xc/hardhat/AaveAprPairProvider/AaveAprPairProvider'; -import { CDOLens } from '@0xc/hardhat/CDOLens/CDOLens'; -import { TwoStepConfigManager } from '@0xc/hardhat/TwoStepConfigManager/TwoStepConfigManager'; -import { ContractBase } from 'dequanto/contracts/ContractBase'; -import { Constructor } from 'dequanto/utils/types'; -import { SharesCooldown } from '@0xc/hardhat/SharesCooldown/SharesCooldown'; - +import memd from "memd"; +import { AccessControlManager } from "@0xc/hardhat/AccessControlManager/AccessControlManager"; +import { MockStakedUSDe } from "@0xc/hardhat/MockStakedUSDe/MockStakedUSDe"; +import { MockUSDe } from "@0xc/hardhat/MockUSDe/MockUSDe"; +import { Tranche } from "@0xc/hardhat/Tranche/Tranche"; +import { Web3Client } from "dequanto/clients/Web3Client"; +import { Deployments } from "dequanto/contracts/deploy/Deployments"; +import { TEth } from "dequanto/models/TEth"; +import { Platforms } from "../platforms/Platforms"; +import { IPlatform, IPlatformAccounts } from "../platforms/IPlatform"; +import { $require } from "dequanto/utils/$require"; +import { SUSDeStrategy } from "@0xc/hardhat/sUSDeStrategy/sUSDeStrategy"; +import { StrataCDO } from "@0xc/hardhat/StrataCDO/StrataCDO"; +import { ERC20Cooldown } from "@0xc/hardhat/ERC20Cooldown/ERC20Cooldown"; +import { UnstakeCooldown } from "@0xc/hardhat/UnstakeCooldown/UnstakeCooldown"; +import { Accounting } from "@0xc/hardhat/Accounting/Accounting"; +import { Tranches } from "../platforms/Tranches"; +import { $address } from "dequanto/utils/$address"; +import { IERC4626 } from "dequanto/prebuilt/openzeppelin/IERC4626"; +import { $contract } from "dequanto/utils/$contract"; +import { SUSDeCooldownRequestImpl } from "@0xc/hardhat/sUSDeCooldownRequestImpl/sUSDeCooldownRequestImpl"; +import { $date } from "dequanto/utils/$date"; +import { AprPairFeed } from "@0xc/hardhat/AprPairFeed/AprPairFeed"; +import { SUSDeAprPairProvider } from "@0xc/hardhat/sUSDeAprPairProvider/sUSDeAprPairProvider"; +import { MockStakedUSDS } from "@0xc/hardhat/MockStakedUSDS/MockStakedUSDS"; +import { $erc4626 } from "../../test/tranches/utils/$erc4626"; +import { TrancheDepositor } from "@0xc/hardhat/TrancheDepositor/TrancheDepositor"; +import { SafeTx } from "dequanto/safe/SafeTx"; +import { InMemoryServiceTransport } from "dequanto/safe/transport/InMemoryServiceTransport"; +import { l } from "dequanto/utils/$logger"; +import { Addresses } from "@s/constants"; +import { MockERC4626 } from "@0xc/hardhat/MockERC4626/MockERC4626"; +import { AaveAprPairProvider } from "@0xc/hardhat/AaveAprPairProvider/AaveAprPairProvider"; +import { CDOLens } from "@0xc/hardhat/CDOLens/CDOLens"; +import { TwoStepConfigManager } from "@0xc/hardhat/TwoStepConfigManager/TwoStepConfigManager"; +import { ContractBase } from "dequanto/contracts/ContractBase"; +import { Constructor } from "dequanto/utils/types"; export class TranchesDeployments { - - ds: Deployments - platform: IPlatform - owner: TEth.IAccount - deployer: TEth.EoAccount - client: Web3Client - ethenaInfo: typeof Tranches.ethena; - - accounts: IPlatformAccounts - - constructor(params: { - client: Web3Client - deployer: TEth.EoAccount - owner?: TEth.IAccount - accounts?: IPlatformAccounts - deployments?: 'throw' | 'redeploy' - }) { - this.deployer = params.deployer; - this.owner = params.owner ?? params.deployer; - this.client = params.client; - this.platform = Platforms[params.client.network]; - this.accounts = params.accounts; - - this.ds = new Deployments(params.client, params.deployer, { - directory: './deployments/', - whenBytecodeChanged: params.deployments ?? (this.isTestnet() ? null : 'throw'), - fork: params.client.forked?.platform - }); - - let info = JSON.parse(JSON.stringify(Tranches.ethena)) as typeof Tranches.ethena; - - if (this.platform.Tranches?.ethena) { - info.jrt = { ...info.jrt, ...(this.platform.Tranches.ethena.jrt ?? {}) } as any; - info.srt = { ...info.srt, ...(this.platform.Tranches.ethena.srt ?? {}) } as any; - } - this.ethenaInfo = info; + ds: Deployments; + platform: IPlatform; + owner: TEth.IAccount; + deployer: TEth.EoAccount; + client: Web3Client; + ethenaInfo: typeof Tranches.ethena; + + accounts: IPlatformAccounts; + + constructor(params: { + client: Web3Client; + deployer: TEth.EoAccount; + owner?: TEth.IAccount; + accounts?: IPlatformAccounts; + deployments?: "throw" | "redeploy"; + }) { + this.deployer = params.deployer; + this.owner = params.owner ?? params.deployer; + this.client = params.client; + this.platform = Platforms[params.client.network]; + this.accounts = params.accounts; + + this.ds = new Deployments(params.client, params.deployer, { + directory: "./deployments/", + whenBytecodeChanged: + params.deployments ?? (this.isTestnet() ? null : "throw"), + fork: params.client.forked?.platform, + }); + + let info = JSON.parse( + JSON.stringify(Tranches.ethena) + ) as typeof Tranches.ethena; + + if (this.platform.Tranches?.ethena) { + info.jrt = { + ...info.jrt, + ...(this.platform.Tranches.ethena.jrt ?? {}), + } as any; + info.srt = { + ...info.srt, + ...(this.platform.Tranches.ethena.srt ?? {}), + } as any; } - - async get(Ctor: Constructor, params?: { id?: 'jrUSDe' | 'srUSDe', cdo?: 'USDe' }) { - if (params?.id === 'jrUSDe') { - return await this.ds.get(Ctor, { id: 'USDeJrt' }) - } - if (params?.id === 'srUSDe') { - return await this.ds.get(Ctor, { id: 'USDeSrt' }) - } - let all = await this.ds.store.getDeployments(); - let byName = all.filter(d => d.name === Ctor.name); - $require.gt(byName.length, 0, `${Ctor.name} not found in deployments`); - - if (byName.length === 1) { - return await this.ds.get(Ctor, { id: byName[0].id }); - } - let cdo = params?.cdo ?? 'USDe'; - let byCdo = byName.filter(x => x.id.toLowerCase().includes(cdo.toLowerCase())); - if (byCdo.length === 1) { - return await this.ds.get(Ctor, { id: byCdo[0].id }); - } - - if (byCdo.length === 0) { - throw new Error(`No ${Ctor.name} found for CDO ${cdo} in deployments`); - } - if (byCdo.length > 1) { - throw new Error(`Multiple ${Ctor.name} found for CDO ${cdo}: ${byCdo.map(x => x.id).join(', ')}`); - } + this.ethenaInfo = info; + } + + async get( + Ctor: Constructor, + params?: { id?: "jrUSDe" | "srUSDe"; cdo?: "USDe" } + ) { + if (params?.id === "jrUSDe") { + return await this.ds.get(Ctor, { id: "USDeJrt" }); } - - @memd.deco.memoize() - async ensureEthena() { - let network = this.ds.client.network; - if (network === 'hardhat') { - let USDe = await this.ds.ensureContract(MockUSDe); - let sUSDe = await this.ds.ensureContract(MockStakedUSDe, { - arguments: [ - USDe.address, - this.owner.address, - this.owner.address - ] - }); - let sUSDS = await this.ds.ensureContract(MockStakedUSDS, { - arguments: [ - USDe.address, - ] - }); - let pUSDe = await this.ds.ensureContract(MockERC4626, { - id: 'pUSDeMock', - arguments: [USDe.address] - }); - await sUSDe.$receipt().setCooldownDuration(this.owner, $date.parseTimespan('1week', { get: 's' })); - - return { USDe, sUSDe, sUSDS, pUSDe, }; - } - if (network === 'hoodi') { - let USDeAddress = $require.Address(this.platform.Tokens['USDe'].address); - let sUSDeAddress = $require.Address(this.platform.Tokens['sUSDe'].address); - let pUSDeAddress = $require.Address(this.platform.Tokens['pUSDe'].address); - let sUSDS = await this.ds.ensureContract(MockStakedUSDS, { - arguments: [ - USDeAddress, - ] - }); - return { - USDe: new MockUSDe(USDeAddress, this.ds.client), - sUSDe: new MockStakedUSDe(sUSDeAddress, this.ds.client), - sUSDS: sUSDS, - pUSDe: new MockERC4626(pUSDeAddress, this.ds.client) - }; - } - - let USDeAddress = $require.Address(this.platform.Tokens['USDe'].address); - let sUSDeAddress = $require.Address(this.platform.Tokens['sUSDe'].address); - let sUSDSAddress = $require.Address(this.platform.Tokens['sUSDS'].address); - let pUSDeAddress = $require.Address(this.platform.Tokens['pUSDe'].address); - return { - USDe: new MockUSDe(USDeAddress, this.ds.client), - sUSDe: new MockStakedUSDe(sUSDeAddress, this.ds.client), - sUSDS: new MockStakedUSDS(sUSDSAddress, this.ds.client), - pUSDe: new MockERC4626(pUSDeAddress, this.ds.client) - }; + if (params?.id === "srUSDe") { + return await this.ds.get(Ctor, { id: "USDeSrt" }); } + let all = await this.ds.store.getDeployments(); + let byName = all.filter((d) => d.name === Ctor.name); + $require.gt(byName.length, 0, `${Ctor.name} not found in deployments`); - @memd.deco.memoize() - async ensureACM() { - const owner = this.owner!; - const acm = await this.ds.ensureContract(AccessControlManager, { - arguments: [owner.address] - }); - - if (this.isTestnet() === false) { - let ownerIsAdmin = await acm.hasRole('0x', owner.address); - let deployer = this.deployer; - await this.ds.configure(acm, { - title: `Grant Owner the AccessControlManager Admin Role`, - shouldUpdate: ownerIsAdmin === false, - async updater() { - await acm.$receipt().grantRole(deployer, '0x', owner.address); - await acm.$receipt().revokeRole(owner, '0x', deployer.address); - } - }); - let deployerIsAdmin = await acm.hasRole('0x', deployer.address); - await this.ds.configure(acm, { - title: `Revoke Deployer the AccessControlManager Admin Role`, - shouldUpdate: deployerIsAdmin && $address.eq(deployer.address, owner.address) === false, - async updater() { - await acm.$receipt().revokeRole(owner, '0x', deployer.address); - } - }); - } - return acm; + if (byName.length === 1) { + return await this.ds.get(Ctor, { id: byName[0].id }); } - - async ensureRole(role: TEth.Hex, account: TEth.Address) { - let acm = await this.ensureACM(); - let has = await acm.hasRole(role, account); - if (has === false) { - await acm.$receipt().grantRole(this.owner, role, account); - } - } - async ensureRoles(roles: Record>) { - const acm = await this.ensureACM(); - const admin = this.owner; - for (let role in roles) { - for (let address in roles[role]) { - await ensure(role, address as TEth.Address, roles[role][address]); - } - } - - async function ensure(role: string, address: TEth.Address, has: boolean) { - let roleHash = role.startsWith('0x') - ? role as TEth.Hex - : $contract.keccak256(role); - let hasCurrent = await acm.hasRole(roleHash, address); - if (hasCurrent !== has) { - l`gray: cyan<${role}> ${has ? '🟢' : '🔴'} cyan<${address}> ⌛`; - if (has) { - await acm.$receipt().grantRole(admin, roleHash, address) - } else { - await acm.$receipt().revokeRole(admin, roleHash, address) - } - } - } + let cdo = params?.cdo ?? "USDe"; + let byCdo = byName.filter((x) => + x.id.toLowerCase().includes(cdo.toLowerCase()) + ); + if (byCdo.length === 1) { + return await this.ds.get(Ctor, { id: byCdo[0].id }); } - async addRoles(roles?: Record) { - roles ??= { - [this.owner.address]: [ - $contract.keccak256('PAUSER_ROLE'), - $contract.keccak256('UPDATER_STRAT_CONFIG_ROLE'), - $contract.keccak256('UPDATER_FEED_ROLE'), - $contract.keccak256('RESERVE_MANAGER_ROLE'), - ] - }; - for (let account in roles) { - for (let role of roles[account]) { - await this.ensureRole(role, account as TEth.Address); - } - } + if (byCdo.length === 0) { + throw new Error(`No ${Ctor.name} found for CDO ${cdo} in deployments`); } - - async ensureEthenaTranches(cdo: StrataCDO) { - const { USDe, sUSDe } = await this.ensureEthena(); - - const acm = await this.ensureACM(); - const info = this.ethenaInfo; - let { contract: jrtVault } = await this.ds.ensureWithProxy(Tranche, { - id: 'USDeJrt', - initialize: [ - this.owner.address, - acm.address, - info.jrt.name, - info.jrt.symbol, - USDe.address, - cdo.address, - ] - }); - let { contract: srtVault } = await this.ds.ensureWithProxy(Tranche, { - id: 'USDeSrt', - initialize: [ - this.owner.address, - acm.address, - info.srt.name, - info.srt.symbol, - USDe.address, - cdo.address, - ] - }); - - return { - jrtVault: jrtVault as Tranche & IERC4626, - srtVault: srtVault as Tranche & IERC4626, - }; + if (byCdo.length > 1) { + throw new Error( + `Multiple ${Ctor.name} found for CDO ${cdo}: ${byCdo + .map((x) => x.id) + .join(", ")}` + ); } + } + + @memd.deco.memoize() + async ensureEthena() { + let network = this.ds.client.network; + if (network === "hardhat") { + let USDe = await this.ds.ensureContract(MockUSDe); + let sUSDe = await this.ds.ensureContract(MockStakedUSDe, { + arguments: [USDe.address, this.owner.address, this.owner.address], + }); + let sUSDS = await this.ds.ensureContract(MockStakedUSDS, { + arguments: [USDe.address], + }); + let pUSDe = await this.ds.ensureContract(MockERC4626, { + id: "pUSDeMock", + arguments: [USDe.address], + }); + await sUSDe + .$receipt() + .setCooldownDuration( + this.owner, + $date.parseTimespan("1week", { get: "s" }) + ); - async ensureConfigManager() { - let { cdo, acm, sharesCooldown } = await this.ensureEthenaCDO(); - let owner = this.accounts.timelock.admin; - let { contract: configManager } = await this.ds.ensureWithProxy(TwoStepConfigManager, { - id: 'USDeConfigManager', - arguments: [cdo.address], - initialize: [] - }); - - await this.ds.configure(cdo, { - title: `Set CDO Two-Step Config Manager`, - async shouldUpdate() { - return $address.eq(await cdo.twoStepConfigManager(), configManager.address) === false - }, - async updater() { - await cdo.$receipt().setTwoStepConfigManager(owner, configManager.address); - } - }); - - await this.ds.configure(sharesCooldown, { - title: `Set SharesCooldown Two-Step Config Manager`, - async shouldUpdate() { - return $address.eq(await sharesCooldown.twoStepConfigManager(), configManager.address) === false - }, - async updater() { - await sharesCooldown.$receipt().setTwoStepConfigManager(owner, configManager.address); - } - }); - - await this.ensureRoles({ - 'PROPOSER_CONFIG_ROLE': { - [this.accounts.safe.admin.address]: true - }, - 'UPDATER_STRAT_CONFIG_ROLE': { - [this.accounts.timelock.config.address]: true - } - }); - - return { configManager }; + return { USDe, sUSDe, sUSDS, pUSDe }; } - - @memd.deco.memoize() - async ensureCooldowns(cdo?: StrataCDO) { - const acm = await this.ensureACM(); - const { contract: erc20Cooldown } = await this.ds.ensureWithProxy(ERC20Cooldown, { - initialize: [ - this.owner.address, - acm.address - ] - }); - const { contract: unstakeCooldown } = await this.ds.ensureWithProxy(UnstakeCooldown, { - initialize: [ - this.owner.address, - acm.address - ] - }); - const { contract: sharesCooldown } = await this.ds.ensureWithProxy(SharesCooldown, { - initialize: [ - this.owner.address, - acm.address - ] - }); - - let { sUSDe } = await this.ensureEthena(); - const { contractBeaconProxy: sUSDeCooldownRequestImpl } = await this.ds.ensureWithBeacon(SUSDeCooldownRequestImpl, { - //const { contract: sUSDeCooldownRequestImpl } = await this.ds.ensureWithProxy(SUSDeCooldownRequestImpl, { - id: 'SUSDeCooldownRequestBeacon', - arguments: [sUSDe.address], - initialize: [$address.ZERO, $address.ZERO] - }); - - await this.ds.configure(unstakeCooldown, { - shouldUpdate: async () => { - let impl = await unstakeCooldown.implementations(sUSDe.address); - return $address.eq(impl, sUSDeCooldownRequestImpl.address) === false - }, - updater: async () => { - await unstakeCooldown.$receipt().setImplementations(this.owner, [sUSDe.address], [sUSDeCooldownRequestImpl.address]); - } - }); - - if (cdo != null) { - await this.ds.configure(cdo, { - shouldUpdate: $address.eq(await cdo.sharesCooldown(), sharesCooldown.address) === false, - updater: async () => { - await cdo.$receipt().setSharesCooldown(this.owner, sharesCooldown.address); - } - }); - } - - return { - erc20Cooldown, - unstakeCooldown, - sharesCooldown, - acm, - }; + if (network === "hoodi") { + let USDeAddress = $require.Address(this.platform.Tokens["USDe"].address); + let sUSDeAddress = $require.Address( + this.platform.Tokens["sUSDe"].address + ); + let pUSDeAddress = $require.Address( + this.platform.Tokens["pUSDe"].address + ); + let sUSDS = await this.ds.ensureContract(MockStakedUSDS, { + arguments: [USDeAddress], + }); + return { + USDe: new MockUSDe(USDeAddress, this.ds.client), + sUSDe: new MockStakedUSDe(sUSDeAddress, this.ds.client), + sUSDS: sUSDS, + pUSDe: new MockERC4626(pUSDeAddress, this.ds.client), + }; } - async ensureFeeds() { - const acm = await this.ensureACM(); - const { sUSDe, USDe, sUSDS } = await this.ensureEthena(); - const { contract: sUSDeAprPairProvider } = await this.ds.ensure(SUSDeAprPairProvider, { - arguments: [ - sUSDS.address, - sUSDe.address, - ] - }); - let CURRENT_PROVIDER = sUSDeAprPairProvider.address; - let aaveAprPairProvider: AaveAprPairProvider; - - const network = this.client.network; - const aavePool = Addresses[network]?.AavePool; - if (aavePool) { - const { contract } = await this.ds.ensure(AaveAprPairProvider, { - arguments: [ - $require.Address(Addresses[network].AavePool), - [ - $require.Address(Addresses[network].USDC), - $require.Address(Addresses[network].USDT), - ], - sUSDe.address - ] - }); - aaveAprPairProvider = contract; - CURRENT_PROVIDER = aaveAprPairProvider.address; - } - - const stalePeriodAfter = $date.parseTimespan(this.platform.Feed.stalePeriodAfter, { get: 's' }); - const { contract: feed } = await this.ds.ensureWithProxy(AprPairFeed, { - id: 'sUSDeAprFeeds', - initialize: [ - this.owner.address, - acm.address, - sUSDeAprPairProvider.address, - BigInt(stalePeriodAfter), - "Ethena CDO APR Pair" - ] - }); - - await this.ds.configure(feed, { - title: 'Update AprPair Feed Provider', - shouldUpdate: async () => { - let providerAddress = await feed.provider(); - return !$address.eq(providerAddress, CURRENT_PROVIDER); - }, - updater: async () => { - await feed.$receipt().setProvider(this.owner, CURRENT_PROVIDER) - } - }); - - return { - feed, - sUSDeAprPairProvider, - aaveAprPairProvider, - }; + let USDeAddress = $require.Address(this.platform.Tokens["USDe"].address); + let sUSDeAddress = $require.Address(this.platform.Tokens["sUSDe"].address); + let sUSDSAddress = $require.Address(this.platform.Tokens["sUSDS"].address); + let pUSDeAddress = $require.Address(this.platform.Tokens["pUSDe"].address); + return { + USDe: new MockUSDe(USDeAddress, this.ds.client), + sUSDe: new MockStakedUSDe(sUSDeAddress, this.ds.client), + sUSDS: new MockStakedUSDS(sUSDSAddress, this.ds.client), + pUSDe: new MockERC4626(pUSDeAddress, this.ds.client), + }; + } + + @memd.deco.memoize() + async ensureACM() { + const owner = this.owner!; + const acm = await this.ds.ensureContract(AccessControlManager, { + arguments: [owner.address], + }); + + if (this.isTestnet() === false) { + let ownerIsAdmin = await acm.hasRole("0x", owner.address); + let deployer = this.deployer; + await this.ds.configure(acm, { + title: `Grant Owner the AccessControlManager Admin Role`, + shouldUpdate: ownerIsAdmin === false, + async updater() { + await acm.$receipt().grantRole(deployer, "0x", owner.address); + await acm.$receipt().revokeRole(owner, "0x", deployer.address); + }, + }); + let deployerIsAdmin = await acm.hasRole("0x", deployer.address); + await this.ds.configure(acm, { + title: `Revoke Deployer the AccessControlManager Admin Role`, + shouldUpdate: + deployerIsAdmin && + $address.eq(deployer.address, owner.address) === false, + async updater() { + await acm.$receipt().revokeRole(owner, "0x", deployer.address); + }, + }); } - - @memd.deco.memoize() - async ensureEthenaCDO() { - const { USDe, sUSDe, pUSDe, } = await this.ensureEthena(); - - const acm = await this.ensureACM(); - const info = this.ethenaInfo; - - const { contract: cdo } = await this.ds.ensureWithProxy(StrataCDO, { - id: 'USDeCDO', - arguments: [], - initialize: [ - this.owner.address, - acm.address, - ] - }); - - // Strategy - const { erc20Cooldown, unstakeCooldown, sharesCooldown } = await this.ensureCooldowns(cdo); - - const { contract: strategy } = await this.ds.ensureWithProxy(SUSDeStrategy, { - arguments: [ - sUSDe.address - ], - initialize: [ - this.owner.address, - acm.address, - cdo.address, - erc20Cooldown.address, - unstakeCooldown.address - ] - }); - await this.ensureRole(await erc20Cooldown.COOLDOWN_WORKER_ROLE(), strategy.address) - - - // Accounting - const accounting = await this.ensureAccounting(cdo.address); - - // Oracle - const { feed, sUSDeAprPairProvider } = await this.ensureFeeds(); - - - const { jrtVault, srtVault } = await this.ensureEthenaTranches(cdo); - - await this.ds.configure(cdo, { - shouldUpdate: async () => $address.eq(await cdo.strategy(), $address.ZERO), - updater: async (x, value) => { - await cdo.$receipt().configure( - this.owner, - accounting.address, - strategy.address, - jrtVault.address, - srtVault.address - ); - }, - }); - - const output = { - acm, - jrtVault, - srtVault, - cdo, - strategy, - accounting, - erc20Cooldown, - unstakeCooldown, - sharesCooldown, - feed, - USDe, - sUSDe, - sUSDeAprPairProvider, - pUSDe, - }; - - await this.configure(info, output); - return output; + return acm; + } + + async ensureRole(role: TEth.Hex, account: TEth.Address) { + let acm = await this.ensureACM(); + let has = await acm.hasRole(role, account); + if (has === false) { + await acm.$receipt().grantRole(this.owner, role, account); } - - async ensureAccounting(cdo: TEth.Address) { - const acm = await this.ensureACM(); - const { feed } = await this.ensureFeeds(); - const { contract: accounting } = await this.ds.ensureWithProxy(Accounting, { - id: `USDeAccounting`, - initialize: [ - this.owner.address, - acm.address, - cdo, - feed.address, - ] - }); - return accounting; + } + async ensureRoles(roles: Record>) { + const acm = await this.ensureACM(); + const admin = this.owner; + for (let role in roles) { + for (let address in roles[role]) { + await ensure(role, address as TEth.Address, roles[role][address]); + } } - async configure(info: typeof Tranches['ethena'], contracts: { - acm: AccessControlManager, - jrtVault: Tranche, - srtVault: Tranche, - cdo: StrataCDO, - strategy: SUSDeStrategy, - accounting: Accounting, - feed: AprPairFeed - }) { - - let { - acm, - jrtVault, - srtVault, - cdo, - strategy, - accounting, - feed, - } = contracts; - - await this.addRoles(); - - await this.ensureRole($contract.keccak256('UPDATER_CDO_APR_ROLE'), feed.address); - await this.setCooldown(strategy, info); - - if (await jrtVault.totalSupply() === 0n) { - if (this.client.network === 'eth') { - throw new Error(`Already deployed`); - await this.initialDepositAtomic({ jrtVault, srtVault, cdo }); - } else { - //await this.initialDeposit({ jrtVault, srtVault, cdo }); - } - } - - await this.setTrancheActions(cdo, jrtVault, info, 'jrt'); - await this.setTrancheActions(cdo, srtVault, info, 'srt'); - } - - async setTrancheActions(cdo: StrataCDO, tranche: Tranche, info: typeof Tranches['ethena'], type: 'srt' | 'jrt') { - let actions = type === 'jrt' - ? await cdo.actionsJrt() - : await cdo.actionsSrt(); - - let current = type === 'jrt' - ? info.jrt - : info.srt; - - if (actions.isDepositEnabled !== current.depositsEnabled || actions.isWithdrawEnabled !== current.withdrawalsEnabled) { - await cdo.$receipt().setActionStates( - this.owner, - tranche.address, - current.depositsEnabled, - current.withdrawalsEnabled, - ); + async function ensure(role: string, address: TEth.Address, has: boolean) { + let roleHash = role.startsWith("0x") + ? (role as TEth.Hex) + : $contract.keccak256(role); + let hasCurrent = await acm.hasRole(roleHash, address); + if (hasCurrent !== has) { + l`gray: cyan<${role}> ${ + has ? "🟢" : "🔴" + } cyan<${address}> ⌛`; + if (has) { + await acm.$receipt().grantRole(admin, roleHash, address); + } else { + await acm.$receipt().revokeRole(admin, roleHash, address); } + } } - - async setCooldown(strategy: SUSDeStrategy, info: typeof Tranches['ethena']) { - let cooldowns = [info.jrt.sUSDeCooldown, info.srt.sUSDeCooldown] - .map(mix => { - if (typeof mix === 'string') { - return $date.parseTimespan(mix, { get: 's' }); - } - return mix; - }) - .map(BigInt); - - - let current = await Promise.all([ - await strategy.sUSDeCooldownJrt(), - await strategy.sUSDeCooldownSrt(), - ]); - await this.ds.configure(strategy, { - shouldUpdate: () => { - return cooldowns[0] !== current[0] || cooldowns[1] !== current[1] - }, - updater: async () => { - await strategy.$receipt().setCooldowns( - this.owner, - cooldowns[0], - cooldowns[1] - ); - } - }); + } + + async addRoles() { + let roles = { + [this.owner.address]: [ + $contract.keccak256("PAUSER_ROLE"), + $contract.keccak256("UPDATER_STRAT_CONFIG_ROLE"), + $contract.keccak256("UPDATER_FEED_ROLE"), + $contract.keccak256("RESERVE_MANAGER_ROLE"), + ], + }; + for (let account in roles) { + for (let role of roles[account]) { + await this.ensureRole(role, account as TEth.Address); + } } - - private async initialDeposit(tranches: { jrtVault: Tranche, srtVault: Tranche, cdo: StrataCDO }) { - let { USDe } = await this.ensureEthena(); - let { jrtVault, srtVault, cdo } = tranches; - - if (this.owner.type === 'safe') { - throw new Error(`Mainnet deployment not ready`); - } - const AMOUNT = 20n * 10n ** 18n; - let balance = await USDe.balanceOf(this.owner.address); - if (balance < AMOUNT) { - if (this.client.network === 'hardhat' || this.client.network === 'hoodi') { - await USDe.$receipt().mint(this.owner, this.owner.address, AMOUNT * 100n); - } else { - throw new Error(`Not enough balance for initial deposit.`); - } - } - - await cdo.$receipt().setActionStates( - this.owner, - $address.ZERO, - true, - false, + } + + async ensureEthenaTranches(cdo: StrataCDO) { + const { USDe, sUSDe } = await this.ensureEthena(); + + const acm = await this.ensureACM(); + const info = this.ethenaInfo; + let { contract: jrtVault } = await this.ds.ensureWithProxy(Tranche, { + id: "USDeJrt", + initialize: [ + this.owner.address, + acm.address, + info.jrt.name, + info.jrt.symbol, + USDe.address, + cdo.address, + ], + }); + let { contract: srtVault } = await this.ds.ensureWithProxy(Tranche, { + id: "USDeSrt", + initialize: [ + this.owner.address, + acm.address, + info.srt.name, + info.srt.symbol, + USDe.address, + cdo.address, + ], + }); + + return { + jrtVault: jrtVault as Tranche & IERC4626, + srtVault: srtVault as Tranche & IERC4626, + }; + } + + async ensureConfigManager() { + let { cdo, acm } = await this.ensureEthenaCDO(); + let owner = this.accounts.timelock.admin; + let { contract: configManager } = await this.ds.ensureWithProxy( + TwoStepConfigManager, + { + id: "USDeConfigManager", + arguments: [cdo.address], + initialize: [], + } + ); + + await this.ds.configure(cdo, { + title: `Set Two-Step Config Manager`, + async shouldUpdate() { + return ( + $address.eq( + await cdo.twoStepConfigManager(), + configManager.address + ) === false ); - await $erc4626.deposit(jrtVault as any, this.owner, AMOUNT / 2n); - await $erc4626.deposit(srtVault as any, this.owner, AMOUNT / 2n); - await cdo.$receipt().setActionStates( + }, + async updater() { + await cdo + .$receipt() + .setTwoStepConfigManager(owner, configManager.address); + }, + }); + + await this.ensureRoles({ + PROPOSER_CONFIG_ROLE: { + [this.accounts.safe.admin.address]: true, + }, + UPDATER_STRAT_CONFIG_ROLE: { + [this.accounts.timelock.config.address]: true, + }, + }); + + return { configManager }; + } + + @memd.deco.memoize() + async ensureCooldowns() { + const acm = await this.ensureACM(); + const { contract: erc20Cooldown } = await this.ds.ensureWithProxy( + ERC20Cooldown, + { + initialize: [this.owner.address, acm.address], + } + ); + const { contract: unstakeCooldown } = await this.ds.ensureWithProxy( + UnstakeCooldown, + { + initialize: [this.owner.address, acm.address], + } + ); + + let { sUSDe } = await this.ensureEthena(); + const { contractBeaconProxy: sUSDeCooldownRequestImpl } = + await this.ds.ensureWithBeacon(SUSDeCooldownRequestImpl, { + //const { contract: sUSDeCooldownRequestImpl } = await this.ds.ensureWithProxy(SUSDeCooldownRequestImpl, { + id: "SUSDeCooldownRequestBeacon", + arguments: [sUSDe.address], + initialize: [$address.ZERO, $address.ZERO], + }); + + await this.ds.configure(unstakeCooldown, { + shouldUpdate: async () => { + let impl = await unstakeCooldown.implementations(sUSDe.address); + return $address.eq(impl, sUSDeCooldownRequestImpl.address) === false; + }, + updater: async () => { + await unstakeCooldown + .$receipt() + .setImplementations( this.owner, - $address.ZERO, - false, - false, - ); + [sUSDe.address], + [sUSDeCooldownRequestImpl.address] + ); + }, + }); + + return { + erc20Cooldown, + unstakeCooldown, + acm, + }; + } + + async ensureFeeds() { + const acm = await this.ensureACM(); + const { sUSDe, USDe, sUSDS } = await this.ensureEthena(); + const { contract: sUSDeAprPairProvider } = await this.ds.ensure( + SUSDeAprPairProvider, + { + arguments: [sUSDS.address, sUSDe.address], + } + ); + let CURRENT_PROVIDER = sUSDeAprPairProvider.address; + let aaveAprPairProvider: AaveAprPairProvider; + + const network = this.client.network; + const aavePool = Addresses[network]?.AavePool; + if (aavePool) { + const { contract } = await this.ds.ensure(AaveAprPairProvider, { + arguments: [ + $require.Address(Addresses[network].AavePool), + [ + $require.Address(Addresses[network].USDC), + $require.Address(Addresses[network].USDT), + ], + sUSDe.address, + ], + }); + aaveAprPairProvider = contract; + CURRENT_PROVIDER = aaveAprPairProvider.address; } - private async initialDepositAtomic(tranches: { jrtVault: Tranche, srtVault: Tranche, cdo: StrataCDO }) { - let { USDe } = await this.ensureEthena(); - let { jrtVault, srtVault, cdo } = tranches; - - - const AMOUNT = 40n * 10n ** 18n; - let balance = await USDe.balanceOf(this.owner.address); - if (balance < AMOUNT) { - throw new Error(`Not enough balance for initial deposit.`); - } - - const AMOUNT_SRT = AMOUNT / 2n; - const AMOUNT_JRT = AMOUNT / 2n; - let actions = [ - await cdo.$data().setActionStates(this.owner, $address.ZERO, true, false), - - await USDe.$data().approve(this.owner, jrtVault.address, AMOUNT_JRT), - await jrtVault.$data().deposit(this.owner, AMOUNT_JRT, this.owner.address), - - await USDe.$data().approve(this.owner, srtVault.address, AMOUNT_SRT), - await srtVault.$data().deposit(this.owner, AMOUNT_SRT, this.owner.address), - - await cdo.$data().setActionStates(this.owner, $address.ZERO, false, false), - - ]; - - let safeTx = new SafeTx(this.owner, this.client, { - safeTransport: new InMemoryServiceTransport(this.client, this.deployer) - }); + const stalePeriodAfter = $date.parseTimespan( + this.platform.Feed.stalePeriodAfter, + { get: "s" } + ); + const { contract: feed } = await this.ds.ensureWithProxy(AprPairFeed, { + id: "sUSDeAprFeeds", + initialize: [ + this.owner.address, + acm.address, + sUSDeAprPairProvider.address, + BigInt(stalePeriodAfter), + "Ethena CDO APR Pair", + ], + }); + + await this.ds.configure(feed, { + title: "Update AprPair Feed Provider", + shouldUpdate: async () => { + let providerAddress = await feed.provider(); + return !$address.eq(providerAddress, CURRENT_PROVIDER); + }, + updater: async () => { + await feed.$receipt().setProvider(this.owner, CURRENT_PROVIDER); + }, + }); + + return { + feed, + sUSDeAprPairProvider, + aaveAprPairProvider, + }; + } + + @memd.deco.memoize() + async ensureEthenaCDO() { + const { USDe, sUSDe, pUSDe } = await this.ensureEthena(); + + const acm = await this.ensureACM(); + const info = this.ethenaInfo; + + const { contract: cdo } = await this.ds.ensureWithProxy(StrataCDO, { + id: "USDeCDO", + arguments: [], + initialize: [this.owner.address, acm.address], + }); + + // Strategy + const { erc20Cooldown, unstakeCooldown } = await this.ensureCooldowns(); + + const { contract: strategy } = await this.ds.ensureWithProxy( + SUSDeStrategy, + { + arguments: [sUSDe.address], + initialize: [ + this.owner.address, + acm.address, + cdo.address, + erc20Cooldown.address, + unstakeCooldown.address, + ], + } + ); + await this.ensureRole( + await erc20Cooldown.COOLDOWN_WORKER_ROLE(), + strategy.address + ); + + // Accounting + const accounting = await this.ensureAccounting(cdo.address); + + // Oracle + const { feed, sUSDeAprPairProvider } = await this.ensureFeeds(); + + const { jrtVault, srtVault } = await this.ensureEthenaTranches(cdo); + + await this.ds.configure(cdo, { + shouldUpdate: async () => + $address.eq(await cdo.strategy(), $address.ZERO), + updater: async (x, value) => { + await cdo + .$receipt() + .configure( + this.owner, + accounting.address, + strategy.address, + jrtVault.address, + srtVault.address + ); + }, + }); + + const output = { + acm, + jrtVault, + srtVault, + cdo, + strategy, + accounting, + erc20Cooldown, + unstakeCooldown, + feed, + USDe, + sUSDe, + sUSDeAprPairProvider, + pUSDe, + }; + + await this.configure(info, output); + return output; + } + + async ensureAccounting(cdo: TEth.Address) { + const acm = await this.ensureACM(); + const { feed } = await this.ensureFeeds(); + const { contract: accounting } = await this.ds.ensureWithProxy(Accounting, { + id: `USDeAccounting`, + initialize: [this.owner.address, acm.address, cdo, feed.address], + }); + return accounting; + } + + async configure( + info: (typeof Tranches)["ethena"], + contracts: { + acm: AccessControlManager; + jrtVault: Tranche; + srtVault: Tranche; + cdo: StrataCDO; + strategy: SUSDeStrategy; + accounting: Accounting; + feed: AprPairFeed; + } + ) { + let { acm, jrtVault, srtVault, cdo, strategy, accounting, feed } = + contracts; + + await this.addRoles(); + + await this.ensureRole( + $contract.keccak256("UPDATER_CDO_APR_ROLE"), + feed.address + ); + await this.setCooldown(strategy, info); + + if ((await jrtVault.totalSupply()) === 0n) { + if (this.client.network === "eth") { + throw new Error(`Already deployed`); + await this.initialDepositAtomic({ jrtVault, srtVault, cdo }); + } else { + //await this.initialDeposit({ jrtVault, srtVault, cdo }); + } + } - l`yellow` - let tx = await safeTx.executeBatch( - ...actions + await this.setTrancheActions(cdo, jrtVault, info, "jrt"); + await this.setTrancheActions(cdo, srtVault, info, "srt"); + } + + async setTrancheActions( + cdo: StrataCDO, + tranche: Tranche, + info: (typeof Tranches)["ethena"], + type: "srt" | "jrt" + ) { + let actions = + type === "jrt" ? await cdo.actionsJrt() : await cdo.actionsSrt(); + + let current = type === "jrt" ? info.jrt : info.srt; + + if ( + actions.isDepositEnabled !== current.depositsEnabled || + actions.isWithdrawEnabled !== current.withdrawalsEnabled + ) { + await cdo + .$receipt() + .setActionStates( + this.owner, + tranche.address, + current.depositsEnabled, + current.withdrawalsEnabled ); - await tx.wait(); } - - async ensureDepositor() { - let acm = await this.ensureACM(); - let { pUSDe } = await this.ensureEthena(); - let { cdo, jrtVault, USDe } = await this.ensureEthenaCDO(); - let { contract: depositor } = await this.ds.ensureWithProxy(TrancheDepositor, { - id: 'TrancheDepositorV2', - initialize: [ - this.owner.address, - acm.address - ] - }); - - - await this.ensureRole($contract.keccak256('DEPOSITOR_CONFIG_ROLE'), this.owner.address); - - let status = await depositor.tranches(jrtVault.address, USDe.address); - if (status == false) { - await depositor.$receipt().addCdo(this.owner, cdo.address); + } + + async setCooldown( + strategy: SUSDeStrategy, + info: (typeof Tranches)["ethena"] + ) { + let cooldowns = [info.jrt.sUSDeCooldown, info.srt.sUSDeCooldown] + .map((mix) => { + if (typeof mix === "string") { + return $date.parseTimespan(mix, { get: "s" }); } + return mix; + }) + .map(BigInt); + + let current = await Promise.all([ + await strategy.sUSDeCooldownJrt(), + await strategy.sUSDeCooldownSrt(), + ]); + await this.ds.configure(strategy, { + shouldUpdate: () => { + return cooldowns[0] !== current[0] || cooldowns[1] !== current[1]; + }, + updater: async () => { + await strategy + .$receipt() + .setCooldowns(this.owner, cooldowns[0], cooldowns[1]); + }, + }); + } + + private async initialDeposit(tranches: { + jrtVault: Tranche; + srtVault: Tranche; + cdo: StrataCDO; + }) { + let { USDe } = await this.ensureEthena(); + let { jrtVault, srtVault, cdo } = tranches; + + if (this.owner.type === "safe") { + throw new Error(`Mainnet deployment not ready`); + } + const AMOUNT = 20n * 10n ** 18n; + let balance = await USDe.balanceOf(this.owner.address); + if (balance < AMOUNT) { + if ( + this.client.network === "hardhat" || + this.client.network === "hoodi" + ) { + await USDe.$receipt().mint( + this.owner, + this.owner.address, + AMOUNT * 100n + ); + } else { + throw new Error(`Not enough balance for initial deposit.`); + } + } - if (pUSDe) { - let status = await depositor.autoWithdrawals(pUSDe.address); - if (status === false) { - await depositor.$receipt().addAutoWithdrawals(this.owner, [pUSDe.address], [true]); - } - } - return depositor; + await cdo + .$receipt() + .setActionStates(this.owner, $address.ZERO, true, false); + await $erc4626.deposit(jrtVault as any, this.owner, AMOUNT / 2n); + await $erc4626.deposit(srtVault as any, this.owner, AMOUNT / 2n); + await cdo + .$receipt() + .setActionStates(this.owner, $address.ZERO, false, false); + } + private async initialDepositAtomic(tranches: { + jrtVault: Tranche; + srtVault: Tranche; + cdo: StrataCDO; + }) { + let { USDe } = await this.ensureEthena(); + let { jrtVault, srtVault, cdo } = tranches; + + const AMOUNT = 40n * 10n ** 18n; + let balance = await USDe.balanceOf(this.owner.address); + if (balance < AMOUNT) { + throw new Error(`Not enough balance for initial deposit.`); } - public isTestnet() { - return this.client.platform !== 'eth'; + const AMOUNT_SRT = AMOUNT / 2n; + const AMOUNT_JRT = AMOUNT / 2n; + + let actions = [ + await cdo.$data().setActionStates(this.owner, $address.ZERO, true, false), + + await USDe.$data().approve(this.owner, jrtVault.address, AMOUNT_JRT), + await jrtVault + .$data() + .deposit(this.owner, AMOUNT_JRT, this.owner.address), + + await USDe.$data().approve(this.owner, srtVault.address, AMOUNT_SRT), + await srtVault + .$data() + .deposit(this.owner, AMOUNT_SRT, this.owner.address), + + await cdo + .$data() + .setActionStates(this.owner, $address.ZERO, false, false), + ]; + + let safeTx = new SafeTx(this.owner, this.client, { + safeTransport: new InMemoryServiceTransport(this.client, this.deployer), + }); + + l`yellow`; + let tx = await safeTx.executeBatch(...actions); + await tx.wait(); + } + + async ensureDepositor() { + let acm = await this.ensureACM(); + let { pUSDe } = await this.ensureEthena(); + let { cdo, jrtVault, USDe } = await this.ensureEthenaCDO(); + let { contract: depositor } = await this.ds.ensureWithProxy( + TrancheDepositor, + { + id: "TrancheDepositorV2", + initialize: [this.owner.address, acm.address], + } + ); + + await this.ensureRole( + $contract.keccak256("DEPOSITOR_CONFIG_ROLE"), + this.owner.address + ); + + let status = await depositor.tranches(jrtVault.address, USDe.address); + if (status == false) { + await depositor.$receipt().addCdo(this.owner, cdo.address); } - async ensureLenses() { - let { contract: cdoLens } = await this.ds.ensureWithProxy(CDOLens, { - initialize: [ - this.owner.address - ] - }); + if (pUSDe) { + let status = await depositor.autoWithdrawals(pUSDe.address); + if (status === false) { + await depositor + .$receipt() + .addAutoWithdrawals(this.owner, [pUSDe.address], [true]); + } } + return depositor; + } + + public isTestnet() { + return this.client.platform !== "eth"; + } + + async ensureLenses() { + let { contract: cdoLens } = await this.ds.ensureWithProxy(CDOLens, { + initialize: [this.owner.address], + }); + } } diff --git a/test/Swap.t.sol b/test/Swap.t.sol index 3db70f4..405b27b 100644 --- a/test/Swap.t.sol +++ b/test/Swap.t.sol @@ -5,7 +5,7 @@ import "forge-std/console2.sol"; import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; import {IERC4626} from "@openzeppelin/contracts/interfaces/IERC4626.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; -import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import {MockUSDe} from "../contracts/test/MockUSDe.sol"; import {MockStakedUSDe} from "../contracts/test/MockStakedUSDe.sol"; @@ -16,33 +16,34 @@ import {Tranche} from "../contracts/tranches/Tranche.sol"; import {Accounting} from "../contracts/tranches/Accounting.sol"; import {TrancheDepositor} from "../contracts/tranches/TrancheDepositor.sol"; +import {sUSDeCooldownRequestImpl as SUSDeCooldownRequestImpl} from + "../contracts/tranches/strategies/ethena/sUSDeCooldownRequestImpl.sol"; +import { + sUSDeAprPairProvider as SUSDeAprPairProvider, + IsUSDS +} from "../contracts/tranches/strategies/ethena/sUSDeAprPairProvider.sol"; +import {sUSDeStrategy as SUSDeStrategy} from "../contracts/tranches/strategies/ethena/sUSDeStrategy.sol"; -import { sUSDeCooldownRequestImpl as SUSDeCooldownRequestImpl } from "../contracts/tranches/strategies/ethena/sUSDeCooldownRequestImpl.sol"; -import { sUSDeAprPairProvider as SUSDeAprPairProvider, IsUSDS } from "../contracts/tranches/strategies/ethena/sUSDeAprPairProvider.sol"; -import { sUSDeStrategy as SUSDeStrategy} from "../contracts/tranches/strategies/ethena/sUSDeStrategy.sol"; +import {AccessControlManager} from "../contracts/governance/AccessControlManager.sol"; -import { AccessControlManager } from "../contracts/governance/AccessControlManager.sol"; +import {AprPairFeed} from "../contracts/tranches/oracles/AprPairFeed.sol"; -import { AprPairFeed } from "../contracts/tranches/oracles/AprPairFeed.sol"; +import {console2} from "forge-std/console2.sol"; -import { console2} from "forge-std/console2.sol"; +import {StrataCDO} from "../contracts/tranches/StrataCDO.sol"; -import { StrataCDO} from "../contracts/tranches/StrataCDO.sol"; +import {ERC20Cooldown} from "../contracts/tranches/base/cooldown/ERC20Cooldown.sol"; +import {UnstakeCooldown} from "../contracts/tranches/base/cooldown/UnstakeCooldown.sol"; +import {CooldownBase} from "../contracts/tranches/base/cooldown/CooldownBase.sol"; -import { ERC20Cooldown } from "../contracts/tranches/base/cooldown/ERC20Cooldown.sol"; -import { UnstakeCooldown } from "../contracts/tranches/base/cooldown/UnstakeCooldown.sol"; -import { CooldownBase } from "../contracts/tranches/base/cooldown/CooldownBase.sol"; - - -import { IsUSDe } from "../contracts/tranches/strategies/ethena/IsUSDe.sol"; -import { IUnstakeHandler } from "../contracts/tranches/interfaces/cooldown/IUnstakeHandler.sol"; -import { ITranche } from "../contracts/tranches/interfaces/ITranche.sol"; -import { IStrategy } from "../contracts/tranches/interfaces/IStrategy.sol"; -import { IAccounting } from "../contracts/tranches/interfaces/IAccounting.sol"; -import { IMetaVault } from "../contracts/tranches/interfaces/IMetaVault.sol"; +import {IsUSDe} from "../contracts/tranches/strategies/ethena/IsUSDe.sol"; +import {IUnstakeHandler} from "../contracts/tranches/interfaces/cooldown/IUnstakeHandler.sol"; +import {ITranche} from "../contracts/tranches/interfaces/ITranche.sol"; +import {IStrategy} from "../contracts/tranches/interfaces/IStrategy.sol"; +import {IAccounting} from "../contracts/tranches/interfaces/IAccounting.sol"; +import {IMetaVault} from "../contracts/tranches/interfaces/IMetaVault.sol"; contract CDOTest is Test { - // External protocols MockUSDe public USDe; MockStakedUSDe public sUSDe; @@ -73,7 +74,6 @@ contract CDOTest is Test { ERC20Cooldown public erc20Cooldown; SUSDeCooldownRequestImpl public sUSDeCooldownRequestImpl; - address account; function setUp() public { @@ -97,8 +97,7 @@ contract CDOTest is Test { cdo = StrataCDO( address( new ERC1967Proxy( - address(new StrataCDO()), - abi.encodeWithSelector(StrataCDO.initialize.selector, owner, address(acm)) + address(new StrataCDO()), abi.encodeWithSelector(StrataCDO.initialize.selector, owner, address(acm)) ) ) ); @@ -108,7 +107,15 @@ contract CDOTest is Test { address( new ERC1967Proxy( address(new Tranche()), - abi.encodeWithSelector(Tranche.initialize.selector, owner, address(acm), "jrtVault", "jrtUSDe", IERC20(address(USDe)), address(cdo)) + abi.encodeWithSelector( + Tranche.initialize.selector, + owner, + address(acm), + "jrtVault", + "jrtUSDe", + IERC20(address(USDe)), + address(cdo) + ) ) ) ); @@ -116,7 +123,15 @@ contract CDOTest is Test { address( new ERC1967Proxy( address(new Tranche()), - abi.encodeWithSelector(Tranche.initialize.selector, owner, address(acm), "srtVault", "srtUSDe", IERC20(address(USDe)), address(cdo)) + abi.encodeWithSelector( + Tranche.initialize.selector, + owner, + address(acm), + "srtVault", + "srtUSDe", + IERC20(address(USDe)), + address(cdo) + ) ) ) ); @@ -132,8 +147,10 @@ contract CDOTest is Test { ) ); sUSDeCooldownRequestImpl = new SUSDeCooldownRequestImpl(IsUSDe(address(sUSDe))); - address[] memory unstakeAddrs = new address[](1); unstakeAddrs[0] = address(sUSDe); - IUnstakeHandler[] memory unstakeImpls = new IUnstakeHandler[](1); unstakeImpls[0] = IUnstakeHandler(address(sUSDeCooldownRequestImpl)); + address[] memory unstakeAddrs = new address[](1); + unstakeAddrs[0] = address(sUSDe); + IUnstakeHandler[] memory unstakeImpls = new IUnstakeHandler[](1); + unstakeImpls[0] = IUnstakeHandler(address(sUSDeCooldownRequestImpl)); unstakeCooldown.setImplementations(unstakeAddrs, unstakeImpls); @@ -142,7 +159,14 @@ contract CDOTest is Test { address( new ERC1967Proxy( address(new SUSDeStrategy(IERC4626(address(sUSDe)))), - abi.encodeWithSelector(SUSDeStrategy.initialize.selector, owner, address(acm), address(cdo), address(erc20Cooldown), address(unstakeCooldown)) + abi.encodeWithSelector( + SUSDeStrategy.initialize.selector, + owner, + address(acm), + address(cdo), + address(erc20Cooldown), + address(unstakeCooldown) + ) ) ) ); @@ -154,7 +178,14 @@ contract CDOTest is Test { address( new ERC1967Proxy( address(new AprPairFeed()), - abi.encodeWithSelector(AprPairFeed.initialize.selector, owner, address(acm), address(sUSDeAprPairProvider), 4 hours, "Ethena CDO APR Pair") + abi.encodeWithSelector( + AprPairFeed.initialize.selector, + owner, + address(acm), + address(sUSDeAprPairProvider), + 4 hours, + "Ethena CDO APR Pair" + ) ) ) ); @@ -164,7 +195,9 @@ contract CDOTest is Test { address( new ERC1967Proxy( address(new Accounting()), - abi.encodeWithSelector(Accounting.initialize.selector, owner, address(acm), address(cdo), address(feed)) + abi.encodeWithSelector( + Accounting.initialize.selector, owner, address(acm), address(cdo), address(feed) + ) ) ) ); @@ -179,7 +212,6 @@ contract CDOTest is Test { acm.grantRole(cdo.PAUSER_ROLE(), owner); cdo.setActionStates(address(0), true, true); - trancheDepositor = TrancheDepositor( address( new ERC1967Proxy( @@ -193,18 +225,16 @@ contract CDOTest is Test { trancheDepositor.addCdo(cdo); } - function test_Flow() public { address router = address(0xE592427A0AEce92De3Edee1F18E0157C05861564); IERC20 usdt = IERC20(address(0xdAC17F958D2ee523a2206206994597C13D831ec7)); address usde = address(0x4c9EDD5852cd905f086C759E8383e09bff1E68B3); - trancheDepositor.addSwapInfo(usdt, TrancheDepositor.TAutoSwap(router, 100, 900)); + trancheDepositor.addSwapInfo(address(usdt), TrancheDepositor.TAutoSwap(router, 100, 900)); address someUsdtHolder = address(0x6AC38D1b2f0c0c3b9E816342b1CA14d91D5Ff60B); vm.startPrank(someUsdtHolder); - SafeERC20.forceApprove(usdt, address(trancheDepositor), type(uint256).max); TrancheDepositor.TDepositParams memory params = TrancheDepositor.TDepositParams({ @@ -219,5 +249,4 @@ contract CDOTest is Test { console2.log("Supports", trancheDepositor.tranches(address(jrtVault), address(usde))); trancheDepositor.deposit(IMetaVault(address(jrtVault)), usdt, 10e6, someUsdtHolder, params); } - } From 6b4de6502e87765adec952d67088210e0a81bf36 Mon Sep 17 00:00:00 2001 From: vivekascoder Date: Thu, 8 Jan 2026 01:47:10 +0530 Subject: [PATCH 08/24] forge install: v4-core v4.0.0 --- .gitmodules | 3 +++ lib/v4-core | 1 + 2 files changed, 4 insertions(+) create mode 160000 lib/v4-core diff --git a/.gitmodules b/.gitmodules index 0034708..b54dacb 100644 --- a/.gitmodules +++ b/.gitmodules @@ -10,3 +10,6 @@ [submodule "lib/chimera"] path = lib/chimera url = https://github.com/recon-fuzz/chimera +[submodule "lib/v4-core"] + path = lib/v4-core + url = https://github.com/uniswap/v4-core diff --git a/lib/v4-core b/lib/v4-core new file mode 160000 index 0000000..e50237c --- /dev/null +++ b/lib/v4-core @@ -0,0 +1 @@ +Subproject commit e50237c43811bd9b526eff40f26772152a42daba From 3235d1df60d13e47224954935735289614c1ee51 Mon Sep 17 00:00:00 2001 From: vivekascoder Date: Thu, 8 Jan 2026 01:47:58 +0530 Subject: [PATCH 09/24] forge install: v4-periphery --- .gitmodules | 3 +++ lib/v4-periphery | 1 + 2 files changed, 4 insertions(+) create mode 160000 lib/v4-periphery diff --git a/.gitmodules b/.gitmodules index b54dacb..b302c6f 100644 --- a/.gitmodules +++ b/.gitmodules @@ -13,3 +13,6 @@ [submodule "lib/v4-core"] path = lib/v4-core url = https://github.com/uniswap/v4-core +[submodule "lib/v4-periphery"] + path = lib/v4-periphery + url = https://github.com/uniswap/v4-periphery diff --git a/lib/v4-periphery b/lib/v4-periphery new file mode 160000 index 0000000..3779387 --- /dev/null +++ b/lib/v4-periphery @@ -0,0 +1 @@ +Subproject commit 3779387e5d296f39df543d23524b050f89a62917 From 83c5e65df191150412ffd846e8dc08202fb4220f Mon Sep 17 00:00:00 2001 From: vivekascoder Date: Thu, 8 Jan 2026 01:48:18 +0530 Subject: [PATCH 10/24] forge install: permit2 --- .gitmodules | 3 +++ lib/permit2 | 1 + 2 files changed, 4 insertions(+) create mode 160000 lib/permit2 diff --git a/.gitmodules b/.gitmodules index b302c6f..f515237 100644 --- a/.gitmodules +++ b/.gitmodules @@ -16,3 +16,6 @@ [submodule "lib/v4-periphery"] path = lib/v4-periphery url = https://github.com/uniswap/v4-periphery +[submodule "lib/permit2"] + path = lib/permit2 + url = https://github.com/uniswap/permit2 diff --git a/lib/permit2 b/lib/permit2 new file mode 160000 index 0000000..cc56ad0 --- /dev/null +++ b/lib/permit2 @@ -0,0 +1 @@ +Subproject commit cc56ad0f3439c502c246fc5cfcc3db92bb8b7219 From 3311373567ab4bfaf045e53aa52bdc84649bbf42 Mon Sep 17 00:00:00 2001 From: vivekascoder Date: Thu, 8 Jan 2026 01:48:57 +0530 Subject: [PATCH 11/24] forge install: universal-router v1.6.0 --- .gitmodules | 3 +++ lib/universal-router | 1 + 2 files changed, 4 insertions(+) create mode 160000 lib/universal-router diff --git a/.gitmodules b/.gitmodules index f515237..514a18f 100644 --- a/.gitmodules +++ b/.gitmodules @@ -19,3 +19,6 @@ [submodule "lib/permit2"] path = lib/permit2 url = https://github.com/uniswap/permit2 +[submodule "lib/universal-router"] + path = lib/universal-router + url = https://github.com/uniswap/universal-router diff --git a/lib/universal-router b/lib/universal-router new file mode 160000 index 0000000..41183d6 --- /dev/null +++ b/lib/universal-router @@ -0,0 +1 @@ +Subproject commit 41183d6eb154f0ab0e74a0e911a5ef9ea51fc4bd From bd2ff683a65ad4fa40ac38db2b4baf7eb8013192 Mon Sep 17 00:00:00 2001 From: vivekascoder Date: Thu, 8 Jan 2026 01:49:02 +0530 Subject: [PATCH 12/24] forge install: v3-core v1.0.0 --- .gitmodules | 3 +++ lib/v3-core | 1 + 2 files changed, 4 insertions(+) create mode 160000 lib/v3-core diff --git a/.gitmodules b/.gitmodules index 514a18f..ba7a499 100644 --- a/.gitmodules +++ b/.gitmodules @@ -22,3 +22,6 @@ [submodule "lib/universal-router"] path = lib/universal-router url = https://github.com/uniswap/universal-router +[submodule "lib/v3-core"] + path = lib/v3-core + url = https://github.com/uniswap/v3-core diff --git a/lib/v3-core b/lib/v3-core new file mode 160000 index 0000000..e3589b1 --- /dev/null +++ b/lib/v3-core @@ -0,0 +1 @@ +Subproject commit e3589b192d0be27e100cd0daaf6c97204fdb1899 From 156f0d992b47c13dc2ce26fdaca02646bc724b05 Mon Sep 17 00:00:00 2001 From: vivekascoder Date: Thu, 8 Jan 2026 01:49:04 +0530 Subject: [PATCH 13/24] forge install: v2-core v1.0.1 --- .gitmodules | 3 +++ lib/v2-core | 1 + 2 files changed, 4 insertions(+) create mode 160000 lib/v2-core diff --git a/.gitmodules b/.gitmodules index ba7a499..99e84a3 100644 --- a/.gitmodules +++ b/.gitmodules @@ -25,3 +25,6 @@ [submodule "lib/v3-core"] path = lib/v3-core url = https://github.com/uniswap/v3-core +[submodule "lib/v2-core"] + path = lib/v2-core + url = https://github.com/uniswap/v2-core diff --git a/lib/v2-core b/lib/v2-core new file mode 160000 index 0000000..4dd5906 --- /dev/null +++ b/lib/v2-core @@ -0,0 +1 @@ +Subproject commit 4dd59067c76dea4a0e8e4bfdda41877a6b16dedc From 5e61e411e0b902e552a9b6aa51bfeebf1114cd5a Mon Sep 17 00:00:00 2001 From: vivekascoder Date: Thu, 8 Jan 2026 01:49:17 +0530 Subject: [PATCH 14/24] forge install: openzeppelin-contracts v5.5.0 --- .gitmodules | 3 +++ lib/openzeppelin-contracts | 1 + 2 files changed, 4 insertions(+) create mode 160000 lib/openzeppelin-contracts diff --git a/.gitmodules b/.gitmodules index 99e84a3..a4410f8 100644 --- a/.gitmodules +++ b/.gitmodules @@ -28,3 +28,6 @@ [submodule "lib/v2-core"] path = lib/v2-core url = https://github.com/uniswap/v2-core +[submodule "lib/openzeppelin-contracts"] + path = lib/openzeppelin-contracts + url = https://github.com/OpenZeppelin/openzeppelin-contracts diff --git a/lib/openzeppelin-contracts b/lib/openzeppelin-contracts new file mode 160000 index 0000000..fcbae53 --- /dev/null +++ b/lib/openzeppelin-contracts @@ -0,0 +1 @@ +Subproject commit fcbae5394ae8ad52d8e580a3477db99814b9d565 From d226bac69d18ffd09e3bba2a7df700e6dacce803 Mon Sep 17 00:00:00 2001 From: vivekascoder Date: Sat, 10 Jan 2026 19:19:47 +0530 Subject: [PATCH 15/24] feat: swap v4 + fork test --- .vscode/settings.json | 151 +++++++++--------- contracts/tranches/SwapContract.sol | 192 +++++++++++++++++++++++ foundry.toml | 3 +- lib/universal-router | 2 +- lib/v4-core | 2 +- remappings.txt | 6 + test/SwapContract.fork.t.sol | 227 ++++++++++++++++++++++++++++ 7 files changed, 505 insertions(+), 78 deletions(-) create mode 100644 contracts/tranches/SwapContract.sol create mode 100644 remappings.txt create mode 100644 test/SwapContract.fork.t.sol diff --git a/.vscode/settings.json b/.vscode/settings.json index 28aae35..05c3f75 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,77 +1,78 @@ { - "recon.foundryConfigPath": "foundry.toml", - "recon.defaultFuzzer": "medusa", - "security.olympix.detectors.arbitraryAddressSpoofingAttack": true, - "security.olympix.detectors.arbitraryTransferFrom": true, - "security.olympix.detectors.noAccessControlPayableFallback": true, - "security.olympix.detectors.ownerSinglePointOfFailure": true, - "security.olympix.detectors.anyTxOrigin": true, - "security.olympix.detectors.faultyDiv": true, - "security.olympix.detectors.enumConversionOutOfRange": true, - "security.olympix.detectors.uintToIntConversion": true, - "security.olympix.detectors.downcastOfNumberToAddress": true, - "security.olympix.detectors.unsafeDowncast": true, - "security.olympix.detectors.swappedShiftParams": true, - "security.olympix.detectors.unaryPlusExpression": true, - "security.olympix.detectors.uncheckedBlockWithSubtraction": true, - "security.olympix.detectors.assemblyReturnInsteadOfLeave": true, - "security.olympix.detectors.callsAssemblyReturn": true, - "security.olympix.detectors.delegateCallInLoop": true, - "security.olympix.detectors.emptyPayableFallback": true, - "security.olympix.detectors.lowLevelCallParamsVerified": true, - "security.olympix.detectors.improperDiamondPattern": true, - "security.olympix.detectors.missingGapVariable": true, - "security.olympix.detectors.unenforcedStateMaintenanceKeywords": true, - "security.olympix.detectors.unboundedPragma": true, - "security.olympix.detectors.directionalOverrideCharacter": true, - "security.olympix.detectors.abiEncodePackedDynamicTypes": true, - "security.olympix.detectors.callsInLoop": true, - "security.olympix.detectors.callWithoutGasBudget": true, - "security.olympix.detectors.msgValueReuse": true, - "security.olympix.detectors.abiEncoderArray": true, - "security.olympix.detectors.blockRandomness": true, - "security.olympix.project.includePath": "/contracts", - "security.olympix.project.testsPath": "/test", - "security.olympix.project.opixTestDir": "/test/auto", - "security.olympix.detectors.arbitraryDelegatecall": true, - "security.olympix.detectors.increasingLengthArrayAsLoopVariable": true, - "security.olympix.detectors.noParameterValidationInConstructor": true, - "security.olympix.detectors.reentrancy": true, - "security.olympix.detectors.reentrancyEvents": true, - "security.olympix.detectors.uncheckedLowLevel": true, - "security.olympix.detectors.uncheckedSend": true, - "security.olympix.detectors.uncheckedTokenTransfer": true, - "security.olympix.detectors.unsafeSelfDestruct": true, - "security.olympix.detectors.unusedReturnFunctionCall": true, - "security.olympix.detectors.signatureReplayAttacks": true, - "security.olympix.detectors.arrayLengthAssignment": true, - "security.olympix.detectors.arrayParameterLocation": true, - "security.olympix.detectors.nestedStructInMapping": true, - "security.olympix.detectors.insufficientParameterAssertion": true, - "security.olympix.detectors.uninitializedFunctionPointerConstructor": true, - "security.olympix.detectors.uninitializedLocalStorage": true, - "security.olympix.detectors.uninitializedStateVariable": true, - "security.olympix.detectors.structWithMappingDeletion": true, - "security.olympix.detectors.signedIntegerArray": true, - "security.olympix.detectors.erc20Interface": true, - "security.olympix.detectors.erc721Interface": true, - "security.olympix.detectors.functionSelectorClash": true, - "security.olympix.detectors.defaultVisibility": true, - "security.olympix.detectors.missingRevertReasonTests": true, - "security.olympix.detectors.multipleConstructors": true, - "security.olympix.detectors.sameNamedContracts": true, - "security.olympix.detectors.shadowingBuiltin": true, - "security.olympix.detectors.shadowingReservedKeyword": true, - "security.olympix.detectors.shadowingState": true, - "security.olympix.detectors.arbitrarySendEther": true, - "security.olympix.detectors.etherBalanceCheckStrictEquality": true, - "security.olympix.detectors.eventsPriceChange": true, - "security.olympix.detectors.externalCallPotentialOutOfGas": true, - "security.olympix.detectors.expectsOptionalErc20Functionality": true, - "security.olympix.detectors.lockedEther": true, - "security.olympix.detectors.possibleDivisionByZero": true, - "security.olympix.detectors.zeroAsParameter": true, - "security.olympix.detectors.oracleManipulation": true, - "security.olympix.project.useAiToPruneFindings": true, - "security.olympix.detectors.missingEventsAssertion": false + "files.exclude": { + "**/lib": true + }, + "security.olympix.detectors.arbitraryAddressSpoofingAttack": true, + "security.olympix.detectors.arbitraryTransferFrom": true, + "security.olympix.detectors.noAccessControlPayableFallback": true, + "security.olympix.detectors.ownerSinglePointOfFailure": true, + "security.olympix.detectors.anyTxOrigin": true, + "security.olympix.detectors.faultyDiv": true, + "security.olympix.detectors.enumConversionOutOfRange": true, + "security.olympix.detectors.uintToIntConversion": true, + "security.olympix.detectors.downcastOfNumberToAddress": true, + "security.olympix.detectors.unsafeDowncast": true, + "security.olympix.detectors.swappedShiftParams": true, + "security.olympix.detectors.unaryPlusExpression": true, + "security.olympix.detectors.uncheckedBlockWithSubtraction": true, + "security.olympix.detectors.assemblyReturnInsteadOfLeave": true, + "security.olympix.detectors.callsAssemblyReturn": true, + "security.olympix.detectors.delegateCallInLoop": true, + "security.olympix.detectors.emptyPayableFallback": true, + "security.olympix.detectors.lowLevelCallParamsVerified": true, + "security.olympix.detectors.improperDiamondPattern": true, + "security.olympix.detectors.missingGapVariable": true, + "security.olympix.detectors.unenforcedStateMaintenanceKeywords": true, + "security.olympix.detectors.unboundedPragma": true, + "security.olympix.detectors.directionalOverrideCharacter": true, + "security.olympix.detectors.abiEncodePackedDynamicTypes": true, + "security.olympix.detectors.callsInLoop": true, + "security.olympix.detectors.callWithoutGasBudget": true, + "security.olympix.detectors.msgValueReuse": true, + "security.olympix.detectors.abiEncoderArray": true, + "security.olympix.detectors.blockRandomness": true, + "security.olympix.project.includePath": "/contracts", + "security.olympix.project.testsPath": "/test", + "security.olympix.project.opixTestDir": "/test/auto", + "security.olympix.detectors.arbitraryDelegatecall": true, + "security.olympix.detectors.increasingLengthArrayAsLoopVariable": true, + "security.olympix.detectors.noParameterValidationInConstructor": true, + "security.olympix.detectors.reentrancy": true, + "security.olympix.detectors.reentrancyEvents": true, + "security.olympix.detectors.uncheckedLowLevel": true, + "security.olympix.detectors.uncheckedSend": true, + "security.olympix.detectors.uncheckedTokenTransfer": true, + "security.olympix.detectors.unsafeSelfDestruct": true, + "security.olympix.detectors.unusedReturnFunctionCall": true, + "security.olympix.detectors.signatureReplayAttacks": true, + "security.olympix.detectors.arrayLengthAssignment": true, + "security.olympix.detectors.arrayParameterLocation": true, + "security.olympix.detectors.nestedStructInMapping": true, + "security.olympix.detectors.insufficientParameterAssertion": true, + "security.olympix.detectors.uninitializedFunctionPointerConstructor": true, + "security.olympix.detectors.uninitializedLocalStorage": true, + "security.olympix.detectors.uninitializedStateVariable": true, + "security.olympix.detectors.structWithMappingDeletion": true, + "security.olympix.detectors.signedIntegerArray": true, + "security.olympix.detectors.erc20Interface": true, + "security.olympix.detectors.erc721Interface": true, + "security.olympix.detectors.functionSelectorClash": true, + "security.olympix.detectors.defaultVisibility": true, + "security.olympix.detectors.missingRevertReasonTests": true, + "security.olympix.detectors.multipleConstructors": true, + "security.olympix.detectors.sameNamedContracts": true, + "security.olympix.detectors.shadowingBuiltin": true, + "security.olympix.detectors.shadowingReservedKeyword": true, + "security.olympix.detectors.shadowingState": true, + "security.olympix.detectors.arbitrarySendEther": true, + "security.olympix.detectors.etherBalanceCheckStrictEquality": true, + "security.olympix.detectors.eventsPriceChange": true, + "security.olympix.detectors.externalCallPotentialOutOfGas": true, + "security.olympix.detectors.expectsOptionalErc20Functionality": true, + "security.olympix.detectors.lockedEther": true, + "security.olympix.detectors.possibleDivisionByZero": true, + "security.olympix.detectors.zeroAsParameter": true, + "security.olympix.detectors.oracleManipulation": true, + "security.olympix.project.useAiToPruneFindings": true, + "security.olympix.detectors.missingEventsAssertion": false } diff --git a/contracts/tranches/SwapContract.sol b/contracts/tranches/SwapContract.sol new file mode 100644 index 0000000..50b7cab --- /dev/null +++ b/contracts/tranches/SwapContract.sol @@ -0,0 +1,192 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.24; + +import {UniversalRouter} from "@uniswap/universal-router/contracts/UniversalRouter.sol"; +import {Commands} from "@uniswap/universal-router/contracts/libraries/Commands.sol"; +import {IPoolManager} from "@uniswap/v4-core/src/interfaces/IPoolManager.sol"; +import {IV4Router} from "@uniswap/v4-periphery/src/interfaces/IV4Router.sol"; +import {Actions} from "@uniswap/v4-periphery/src/libraries/Actions.sol"; +import {IPermit2} from "@uniswap/permit2/src/interfaces/IPermit2.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {StateLibrary} from "@uniswap/v4-core/src/libraries/StateLibrary.sol"; +import {PoolKey} from "@uniswap/v4-core/src/types/PoolKey.sol"; +import {Currency} from "@uniswap/v4-core/src/types/Currency.sol"; + +/// @title SwapContract +/// @notice A contract for performing swaps on Uniswap V4 pools via Universal Router +/// @dev Based on https://docs.uniswap.org/contracts/v4/quickstart/swap +contract SwapContract { + using StateLibrary for IPoolManager; + + UniversalRouter public immutable router; + IPoolManager public immutable poolManager; + IPermit2 public immutable permit2; + + constructor(address _router, address _poolManager, address _permit2) { + router = UniversalRouter(payable(_router)); + poolManager = IPoolManager(_poolManager); + permit2 = IPermit2(_permit2); + } + + /// @notice Approve a token for use with Permit2 and the Universal Router + /// @param token The token to approve + /// @param amount The amount to approve + /// @param expiration The expiration time for the approval + function approveTokenWithPermit2(address token, uint160 amount, uint48 expiration) external { + IERC20(token).approve(address(permit2), type(uint256).max); + permit2.approve(token, address(router), amount, expiration); + } + + /// @notice Swap exact input tokens for output tokens (token0 -> token1) + /// @param key The PoolKey identifying the Uniswap V4 pool + /// @param amountIn Exact amount of input tokens to swap + /// @param minAmountOut Minimum amount of output tokens (slippage protection) + /// @param deadline Timestamp after which the transaction reverts + /// @return amountOut The amount of output tokens received + function swapExactInputSingle(PoolKey calldata key, uint128 amountIn, uint128 minAmountOut, uint256 deadline) + external + returns (uint256 amountOut) + { + // Encode the Universal Router command + bytes memory commands = abi.encodePacked(uint8(Commands.V4_SWAP)); + bytes[] memory inputs = new bytes[](1); + + // Encode V4Router actions + bytes memory actions = + abi.encodePacked(uint8(Actions.SWAP_EXACT_IN_SINGLE), uint8(Actions.SETTLE_ALL), uint8(Actions.TAKE_ALL)); + + // Prepare parameters for each action + bytes[] memory params = new bytes[](3); + params[0] = abi.encode( + IV4Router.ExactInputSingleParams({ + poolKey: key, + zeroForOne: true, + amountIn: amountIn, + amountOutMinimum: minAmountOut, + hookData: bytes("") + }) + ); + params[1] = abi.encode(key.currency0, amountIn); + params[2] = abi.encode(key.currency1, minAmountOut); + + // Combine actions and params into inputs + inputs[0] = abi.encode(actions, params); + + // Execute the swap + router.execute(commands, inputs, deadline); + + // Verify and return the output amount + amountOut = IERC20(Currency.unwrap(key.currency1)).balanceOf(address(this)); + require(amountOut >= minAmountOut, "Insufficient output amount"); + return amountOut; + } + + /// @notice Swap exact input tokens for output tokens with direction control + /// @param key The PoolKey identifying the Uniswap V4 pool + /// @param zeroForOne Direction: true = token0 -> token1, false = token1 -> token0 + /// @param amountIn Exact amount of input tokens to swap + /// @param minAmountOut Minimum amount of output tokens (slippage protection) + /// @param deadline Timestamp after which the transaction reverts + /// @param hookData Arbitrary data passed to the pool's hook + /// @return amountOut The amount of output tokens received + function swap( + PoolKey calldata key, + bool zeroForOne, + uint128 amountIn, + uint128 minAmountOut, + uint256 deadline, + bytes calldata hookData + ) external returns (uint256 amountOut) { + // Determine input and output currencies based on swap direction + Currency inputCurrency = zeroForOne ? key.currency0 : key.currency1; + Currency outputCurrency = zeroForOne ? key.currency1 : key.currency0; + + // Encode the Universal Router command + bytes memory commands = abi.encodePacked(uint8(Commands.V4_SWAP)); + bytes[] memory inputs = new bytes[](1); + + // Encode V4Router actions + bytes memory actions = + abi.encodePacked(uint8(Actions.SWAP_EXACT_IN_SINGLE), uint8(Actions.SETTLE_ALL), uint8(Actions.TAKE_ALL)); + + // Prepare parameters for each action + bytes[] memory params = new bytes[](3); + params[0] = abi.encode( + IV4Router.ExactInputSingleParams({ + poolKey: key, + zeroForOne: zeroForOne, + amountIn: amountIn, + amountOutMinimum: minAmountOut, + hookData: hookData + }) + ); + params[1] = abi.encode(inputCurrency, amountIn); + params[2] = abi.encode(outputCurrency, minAmountOut); + + // Combine actions and params into inputs + inputs[0] = abi.encode(actions, params); + + // Execute the swap + router.execute(commands, inputs, deadline); + + // Verify and return the output amount + amountOut = IERC20(Currency.unwrap(outputCurrency)).balanceOf(address(this)); + require(amountOut >= minAmountOut, "Insufficient output amount"); + return amountOut; + } + + /// @notice Swap tokens to receive an exact amount of output tokens + /// @param key The PoolKey identifying the Uniswap V4 pool + /// @param zeroForOne Direction: true = token0 -> token1, false = token1 -> token0 + /// @param amountOut Exact amount of output tokens desired + /// @param maxAmountIn Maximum amount of input tokens willing to spend (slippage protection) + /// @param deadline Timestamp after which the transaction reverts + /// @param hookData Arbitrary data passed to the pool's hook + /// @return amountIn The amount of input tokens spent + function swapExactOutput( + PoolKey calldata key, + bool zeroForOne, + uint128 amountOut, + uint128 maxAmountIn, + uint256 deadline, + bytes calldata hookData + ) external returns (uint256 amountIn) { + // Determine input and output currencies based on swap direction + Currency inputCurrency = zeroForOne ? key.currency0 : key.currency1; + Currency outputCurrency = zeroForOne ? key.currency1 : key.currency0; + + // Encode the Universal Router command + bytes memory commands = abi.encodePacked(uint8(Commands.V4_SWAP)); + bytes[] memory inputs = new bytes[](1); + + // Encode V4Router actions + bytes memory actions = + abi.encodePacked(uint8(Actions.SWAP_EXACT_OUT_SINGLE), uint8(Actions.SETTLE_ALL), uint8(Actions.TAKE_ALL)); + + // Prepare parameters for each action + bytes[] memory params = new bytes[](3); + params[0] = abi.encode( + IV4Router.ExactOutputSingleParams({ + poolKey: key, + zeroForOne: zeroForOne, + amountOut: amountOut, + amountInMaximum: maxAmountIn, + hookData: hookData + }) + ); + params[1] = abi.encode(inputCurrency, maxAmountIn); + params[2] = abi.encode(outputCurrency, amountOut); + + // Combine actions and params into inputs + inputs[0] = abi.encode(actions, params); + + // Execute the swap + router.execute(commands, inputs, deadline); + + // Calculate actual input amount spent + uint256 inputBalance = IERC20(Currency.unwrap(inputCurrency)).balanceOf(address(this)); + amountIn = maxAmountIn - inputBalance; + + return amountIn; + } +} diff --git a/foundry.toml b/foundry.toml index 7716e0b..7023360 100644 --- a/foundry.toml +++ b/foundry.toml @@ -3,5 +3,6 @@ src = 'contracts' out = 'out' libs = ['node_modules', 'lib'] test = 'test' -cache_path = 'cache_forge' +cache_path = 'cache_forge' via_ir = true +evm_version = 'cancun' diff --git a/lib/universal-router b/lib/universal-router index 41183d6..705f7bb 160000 --- a/lib/universal-router +++ b/lib/universal-router @@ -1 +1 @@ -Subproject commit 41183d6eb154f0ab0e74a0e911a5ef9ea51fc4bd +Subproject commit 705f7bb9836ebc4f6ad1aad91629c3d0fc4128d4 diff --git a/lib/v4-core b/lib/v4-core index e50237c..d153b04 160000 --- a/lib/v4-core +++ b/lib/v4-core @@ -1 +1 @@ -Subproject commit e50237c43811bd9b526eff40f26772152a42daba +Subproject commit d153b048868a60c2403a3ef5b2301bb247884d46 diff --git a/remappings.txt b/remappings.txt new file mode 100644 index 0000000..9cc0909 --- /dev/null +++ b/remappings.txt @@ -0,0 +1,6 @@ +@uniswap/v4-core/=lib/v4-core/ +@uniswap/v4-periphery/=lib/v4-periphery/ +@uniswap/permit2/=lib/permit2/ +@uniswap/universal-router/=lib/universal-router/ +@uniswap/v3-core/=lib/v3-core/ +@uniswap/v2-core/=lib/v2-core/ \ No newline at end of file diff --git a/test/SwapContract.fork.t.sol b/test/SwapContract.fork.t.sol new file mode 100644 index 0000000..29dd3a1 --- /dev/null +++ b/test/SwapContract.fork.t.sol @@ -0,0 +1,227 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.24; + +import {Test} from "forge-std/Test.sol"; +import {console2} from "forge-std/console2.sol"; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +import {SwapContract} from "../contracts/tranches/SwapContract.sol"; +import {PoolKey} from "@uniswap/v4-core/src/types/PoolKey.sol"; +import {Currency} from "@uniswap/v4-core/src/types/Currency.sol"; +import {IHooks} from "@uniswap/v4-core/src/interfaces/IHooks.sol"; +import {IPoolManager} from "@uniswap/v4-core/src/interfaces/IPoolManager.sol"; +import {StateLibrary} from "@uniswap/v4-core/src/libraries/StateLibrary.sol"; + +/// @title SwapContract Fork Test +/// @notice Fork test for SwapContract using Uniswap V4 on Ethereum mainnet +/// @dev Based on https://getfoundry.sh/forge/tests/fork-testing/ +contract SwapContractForkTest is Test { + using StateLibrary for IPoolManager; + + // ============ Deployed Uniswap V4 Contracts on Mainnet ============ + address constant POOL_MANAGER = 0x000000000004444c5dc75cB358380D2e3dE08A90; + address constant UNIVERSAL_ROUTER = 0x66a9893cC07D91D95644AEDD05D03f95e1dBA8Af; + address constant PERMIT2 = 0x000000000022D473030F116dDEE9F6B43aC78BA3; + address constant POSITION_MANAGER = 0xbD216513d74C8cf14cf4747E6AaA6420FF64ee9e; + address constant QUOTER = 0x52F0E24D1c21C8A0cB1e5a5dD6198556BD9E1203; + address constant STATE_VIEW = 0x7fFE42C4a5DEeA5b0feC41C94C136Cf115597227; + + // ============ Common Mainnet Tokens ============ + address constant WETH = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2; + address constant USDC = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48; + address constant USDT = 0xdAC17F958D2ee523a2206206994597C13D831ec7; + address constant DAI = 0x6B175474E89094C44Da98b954EedeAC495271d0F; + + // ============ Test State ============ + SwapContract public swapContract; + IPoolManager public poolManager; + uint256 public mainnetFork; + + // Test accounts + address public alice; + address public bob; + + function setUp() public { + // Create mainnet fork + string memory rpcUrl = vm.envString("MAINNET_RPC_URL"); + mainnetFork = vm.createFork(rpcUrl); + vm.selectFork(mainnetFork); + + console2.log("Fork created at block:", block.number); + + // Set up test accounts + alice = makeAddr("alice"); + bob = makeAddr("bob"); + + // Deploy SwapContract with mainnet Uniswap V4 addresses + swapContract = new SwapContract(UNIVERSAL_ROUTER, POOL_MANAGER, PERMIT2); + + poolManager = IPoolManager(POOL_MANAGER); + + console2.log("SwapContract deployed at:", address(swapContract)); + console2.log("Using Universal Router:", UNIVERSAL_ROUTER); + console2.log("Using Pool Manager:", POOL_MANAGER); + console2.log("Using Permit2:", PERMIT2); + } + + /// @notice Verify the fork is set up correctly + function test_ForkSetup() public view { + assertEq(vm.activeFork(), mainnetFork); + + // Verify contracts exist on mainnet + assertTrue(POOL_MANAGER.code.length > 0, "PoolManager not deployed"); + assertTrue(UNIVERSAL_ROUTER.code.length > 0, "UniversalRouter not deployed"); + assertTrue(PERMIT2.code.length > 0, "Permit2 not deployed"); + + console2.log("All Uniswap V4 contracts verified on mainnet"); + } + + /// @notice Verify SwapContract is deployed correctly + function test_SwapContractDeployment() public view { + assertEq(address(swapContract.router()), UNIVERSAL_ROUTER); + assertEq(address(swapContract.poolManager()), POOL_MANAGER); + assertEq(address(swapContract.permit2()), PERMIT2); + } + + /// @notice Test WETH balance can be acquired via deal + function test_CanDealWETH() public { + uint256 amount = 10 ether; + deal(WETH, alice, amount); + + assertEq(IERC20(WETH).balanceOf(alice), amount); + console2.log("Alice WETH balance:", IERC20(WETH).balanceOf(alice)); + } + + /// @notice Test USDC balance can be acquired via deal + function test_CanDealUSDC() public { + uint256 amount = 10_000e6; // 10,000 USDC (6 decimals) + deal(USDC, alice, amount); + + assertEq(IERC20(USDC).balanceOf(alice), amount); + console2.log("Alice USDC balance:", IERC20(USDC).balanceOf(alice)); + } + + /// @notice Test approving tokens with Permit2 + function test_ApproveTokenWithPermit2() public { + // Give Alice some WETH + deal(WETH, address(swapContract), 10 ether); + + // Approve WETH for Permit2 and Universal Router + swapContract.approveTokenWithPermit2(WETH, type(uint160).max, uint48(block.timestamp + 1 days)); + + // Check the approval was set + uint256 permit2Allowance = IERC20(WETH).allowance(address(swapContract), PERMIT2); + assertEq(permit2Allowance, type(uint256).max); + + console2.log("Permit2 allowance set successfully"); + } + + /// @notice Test swap with a real WETH/USDC V4 pool if one exists + /// @dev This test will attempt to find and use a real V4 pool + function test_SwapExactInputSingle() public { + // Set up: Give the swap contract some WETH + uint128 amountIn = 0.1 ether; + deal(WETH, address(swapContract), amountIn); + + // Approve tokens via Permit2 + swapContract.approveTokenWithPermit2(WETH, type(uint160).max, uint48(block.timestamp + 1 days)); + + // Create a PoolKey for WETH/USDC + // Note: currency0 must be < currency1 (sorted by address) + // USDC (0xA0b8...) < WETH (0xC02a...) so USDC is currency0 + PoolKey memory key = PoolKey({ + currency0: Currency.wrap(USDC), + currency1: Currency.wrap(WETH), + fee: 3000, // 0.3% fee tier + tickSpacing: 60, // Standard tick spacing for 0.3% + hooks: IHooks(address(0)) // No hooks + }); + + console2.log("Attempting swap..."); + console2.log("Input amount (WETH):", amountIn); + console2.log("currency0 (USDC):", USDC); + console2.log("currency1 (WETH):", WETH); + + // Try the swap - this may revert if no pool exists with these exact parameters + // In a real scenario, you would query the StateView to find valid pools + uint256 deadline = block.timestamp + 20; + + // Note: Since WETH is currency1 and we're swapping WETH -> USDC, + // we need to use zeroForOne = false (swapping token1 for token0) + // But swapExactInputSingle always uses zeroForOne = true + // So we need to use the more flexible swap() function + + try swapContract.swap( + key, + false, // zeroForOne = false because we're swapping WETH (currency1) -> USDC (currency0) + amountIn, + 0, // minAmountOut (0 for testing, use proper slippage in production) + deadline, + bytes("") + ) returns (uint256 amountOut) { + console2.log("Swap successful!"); + console2.log("Amount out (USDC):", amountOut); + + // Verify we received USDC + uint256 usdcBalance = IERC20(USDC).balanceOf(address(swapContract)); + assertGt(usdcBalance, 0, "Should have received USDC"); + } catch Error(string memory reason) { + console2.log("Swap failed with reason:", reason); + // This is expected if no V4 pool exists with these exact parameters + } catch (bytes memory) { + console2.log("Swap failed - pool may not exist with these parameters"); + // This is expected if no V4 pool exists + } + } + + /// @notice Test swap with native ETH pool (ETH/USDC) + /// @dev Uses Currency.wrap(address(0)) for native ETH + function test_SwapWithNativeETH() public { + // Set up: Give the swap contract some ETH + uint128 amountIn = 0.1 ether; + vm.deal(address(swapContract), amountIn); + + // Create a PoolKey for ETH/USDC + // Native ETH is represented as address(0) + // address(0) < USDC so ETH is currency0 + PoolKey memory key = PoolKey({ + currency0: Currency.wrap(address(0)), // Native ETH + currency1: Currency.wrap(USDC), + fee: 3000, + tickSpacing: 60, + hooks: IHooks(address(0)) + }); + + console2.log("Attempting ETH -> USDC swap..."); + console2.log("Input amount (ETH):", amountIn); + + uint256 deadline = block.timestamp + 20; + + try swapContract.swap( + key, + true, // zeroForOne = true (ETH -> USDC) + amountIn, + 0, + deadline, + bytes("") + ) returns (uint256 amountOut) { + console2.log("Swap successful!"); + console2.log("Amount out (USDC):", amountOut); + } catch Error(string memory reason) { + console2.log("Swap failed with reason:", reason); + } catch (bytes memory) { + console2.log("Swap failed - ETH/USDC pool may not exist on V4"); + } + } + + /// @notice Helper to log pool state if needed + function _logPoolState(PoolKey memory key) internal view { + console2.log("=== Pool Key ==="); + console2.log("currency0:", Currency.unwrap(key.currency0)); + console2.log("currency1:", Currency.unwrap(key.currency1)); + console2.log("fee:", key.fee); + console2.log("tickSpacing:", key.tickSpacing); + console2.log("hooks:", address(key.hooks)); + } +} From 95479f71982a2ff5fd03ed5a59e94e2b2cb060c3 Mon Sep 17 00:00:00 2001 From: vivekascoder Date: Mon, 12 Jan 2026 02:17:32 +0530 Subject: [PATCH 16/24] feat: update SwapContract and SteakhouseUSDC to support new reward claiming and swapping functionality --- contracts/tranches/SwapContract.sol | 29 ++- .../tranches/interfaces/IDistributor.sol | 186 ++++++++++++++++++ .../tranches/interfaces/ISwapContract.sol | 30 +++ .../strategies/morpho/SteakhouseUSDC.sol | 103 +++++++++- 4 files changed, 340 insertions(+), 8 deletions(-) create mode 100644 contracts/tranches/interfaces/IDistributor.sol create mode 100644 contracts/tranches/interfaces/ISwapContract.sol diff --git a/contracts/tranches/SwapContract.sol b/contracts/tranches/SwapContract.sol index 50b7cab..954d171 100644 --- a/contracts/tranches/SwapContract.sol +++ b/contracts/tranches/SwapContract.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: BUSL-1.1 -pragma solidity ^0.8.24; +pragma solidity ^0.8.28; import {UniversalRouter} from "@uniswap/universal-router/contracts/UniversalRouter.sol"; import {Commands} from "@uniswap/universal-router/contracts/libraries/Commands.sol"; @@ -59,11 +59,7 @@ contract SwapContract { bytes[] memory params = new bytes[](3); params[0] = abi.encode( IV4Router.ExactInputSingleParams({ - poolKey: key, - zeroForOne: true, - amountIn: amountIn, - amountOutMinimum: minAmountOut, - hookData: bytes("") + poolKey: key, zeroForOne: true, amountIn: amountIn, amountOutMinimum: minAmountOut, hookData: bytes("") }) ); params[1] = abi.encode(key.currency0, amountIn); @@ -189,4 +185,25 @@ contract SwapContract { return amountIn; } + + /// @notice Swap exact input tokens for output tokens with ABI-encoded PoolKey + /// @dev This function decodes the poolKeyData and calls the swap function + /// @param poolKeyData ABI-encoded PoolKey struct + /// @param zeroForOne Direction: true = token0 -> token1, false = token1 -> token0 + /// @param amountIn Exact amount of input tokens to swap + /// @param minAmountOut Minimum amount of output tokens (slippage protection) + /// @param deadline Timestamp after which the transaction reverts + /// @param hookData Arbitrary data passed to the pool's hook + /// @return amountOut The amount of output tokens received + function swapWithEncodedKey( + bytes calldata poolKeyData, + bool zeroForOne, + uint128 amountIn, + uint128 minAmountOut, + uint256 deadline, + bytes calldata hookData + ) external returns (uint256 amountOut) { + PoolKey memory key = abi.decode(poolKeyData, (PoolKey)); + return this.swap(key, zeroForOne, amountIn, minAmountOut, deadline, hookData); + } } diff --git a/contracts/tranches/interfaces/IDistributor.sol b/contracts/tranches/interfaces/IDistributor.sol new file mode 100644 index 0000000..99d4cb6 --- /dev/null +++ b/contracts/tranches/interfaces/IDistributor.sol @@ -0,0 +1,186 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.28; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +/// @notice Merkle tree structure for reward distribution +struct MerkleTree { + /// @notice Root of a Merkle tree whose leaves are `(address user, address token, uint amount)` + bytes32 merkleRoot; + /// @dev Deprecated: this used to be the IPFS hash of the complete tree data + bytes32 ipfsHash; +} + +/// @notice Claim tracking structure +struct Claim { + /// @notice Cumulative amount claimed by the user for this token + uint208 amount; + /// @notice Timestamp of the last claim + uint48 timestamp; + /// @notice Merkle root that was active when the last claim occurred + bytes32 merkleRoot; +} + +/// @title IDistributor +/// @notice Interface for the Merkl Distributor contract +/// @dev Manages the distribution of Merkl rewards and allows users to claim their earned tokens +interface IDistributor { + /*////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + EVENTS + //////////////////////////////////////////////////////////////////////////////////////////////////////////////////*/ + + event Claimed(address indexed user, address indexed token, uint256 amount); + event ClaimRecipientUpdated(address indexed user, address indexed token, address indexed recipient); + event DisputeAmountUpdated(uint256 _disputeAmount); + event Disputed(string reason); + event DisputePeriodUpdated(uint48 _disputePeriod); + event DisputeResolved(bool valid); + event DisputeTokenUpdated(address indexed _disputeToken); + event EpochDurationUpdated(uint32 newEpochDuration); + event MainOperatorStatusUpdated(address indexed operator, address indexed token, bool isWhitelisted); + event OperatorClaimingToggled(address indexed user, bool isEnabled); + event OperatorToggled(address indexed user, address indexed operator, bool isWhitelisted); + event Recovered(address indexed token, address indexed to, uint256 amount); + event Revoked(); + event TreeUpdated(bytes32 merkleRoot, bytes32 ipfsHash, uint48 endOfDisputePeriod); + event TrustedToggled(address indexed eoa, bool trust); + event UpgradeabilityRevoked(); + + /*////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + PUBLIC VARIABLES + //////////////////////////////////////////////////////////////////////////////////////////////////////////////////*/ + + /// @notice Current active Merkle tree containing claimable token data + function tree() external view returns (bytes32 merkleRoot, bytes32 ipfsHash); + + /// @notice Previous Merkle tree that was active before the last update + function lastTree() external view returns (bytes32 merkleRoot, bytes32 ipfsHash); + + /// @notice Token required as a deposit to dispute a tree update + function disputeToken() external view returns (IERC20); + + /// @notice Address that created the current ongoing dispute + function disputer() external view returns (address); + + /// @notice Timestamp after which the current tree becomes effective and undisputable + function endOfDisputePeriod() external view returns (uint48); + + /// @notice Number of epochs to wait before a tree update becomes effective + function disputePeriod() external view returns (uint48); + + /// @notice Amount of disputeToken required to create a dispute + function disputeAmount() external view returns (uint256); + + /// @notice Tracks cumulative claimed amounts for each user and token + function claimed(address user, address token) + external + view + returns (uint208 amount, uint48 timestamp, bytes32 merkleRoot); + + /// @notice Trusted addresses authorized to update the Merkle root + function canUpdateMerkleRoot(address) external view returns (uint256); + + /// @notice Authorization for operators to claim on behalf of users + function operators(address user, address operator) external view returns (uint256); + + /// @notice Whether contract upgradeability has been permanently disabled + function upgradeabilityDeactivated() external view returns (uint128); + + /// @notice Custom recipient addresses for user claims per token + function claimRecipient(address user, address token) external view returns (address); + + /// @notice Global operators authorized to claim specific tokens on behalf of any user + function mainOperators(address operator, address token) external view returns (uint256); + + /// @notice Success message that must be returned by `IClaimRecipient.onClaim` callback + function CALLBACK_SUCCESS() external view returns (bytes32); + + /*////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + MAIN FUNCTIONS + //////////////////////////////////////////////////////////////////////////////////////////////////////////////////*/ + + /// @notice Claims rewards for a set of users based on Merkle proofs + /// @param users Addresses claiming rewards (or being claimed for) + /// @param tokens ERC20 tokens being claimed + /// @param amounts Cumulative amounts earned (not incremental amounts) + /// @param proofs Merkle proofs validating each claim + function claim( + address[] calldata users, + address[] calldata tokens, + uint256[] calldata amounts, + bytes32[][] calldata proofs + ) external; + + /// @notice Claims rewards with custom recipient addresses and callback data + /// @param users Addresses claiming rewards (or being claimed for) + /// @param tokens ERC20 tokens being claimed + /// @param amounts Cumulative amounts earned (not incremental amounts) + /// @param proofs Merkle proofs validating each claim + /// @param recipients Custom recipient addresses for each claim + /// @param datas Arbitrary data passed to recipient's onClaim callback + function claimWithRecipient( + address[] calldata users, + address[] calldata tokens, + uint256[] calldata amounts, + bytes32[][] calldata proofs, + address[] calldata recipients, + bytes[] memory datas + ) external; + + /// @notice Returns the currently active Merkle root for claim verification + function getMerkleRoot() external view returns (bytes32); + + /// @notice Returns the epoch duration used for dispute period calculations + function getEpochDuration() external view returns (uint32 epochDuration); + + /*////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + USER ADMIN FUNCTIONS + //////////////////////////////////////////////////////////////////////////////////////////////////////////////////*/ + + /// @notice Toggles an operator's authorization to claim rewards on behalf of a user + function toggleOperator(address user, address operator) external; + + /// @notice Sets a custom recipient address for a user's token claims + function setClaimRecipient(address recipient, address token) external; + + /// @notice Toggles a main operator's authorization to claim tokens on behalf of any user + function toggleMainOperatorStatus(address operator, address token) external; + + /// @notice Creates a dispute to freeze the current Merkle tree update + function disputeTree(string memory reason) external; + + /*////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + GOVERNANCE FUNCTIONS + //////////////////////////////////////////////////////////////////////////////////////////////////////////////////*/ + + /// @notice Updates the active Merkle tree with new reward data + function updateTree(MerkleTree calldata _tree) external; + + /// @notice Toggles an address's authorization to update the Merkle tree + function toggleTrusted(address trustAddress) external; + + /// @notice Permanently disables contract upgradeability + function revokeUpgradeability() external; + + /// @notice Updates the epoch duration used for dispute period calculations + function setEpochDuration(uint32 epochDuration) external; + + /// @notice Resolves an ongoing dispute + function resolveDispute(bool valid) external; + + /// @notice Reverts to the previous Merkle tree immediately + function revokeTree() external; + + /// @notice Recovers ERC20 tokens accidentally sent to the contract + function recoverERC20(address tokenAddress, address to, uint256 amountToRecover) external; + + /// @notice Updates the dispute period duration + function setDisputePeriod(uint48 _disputePeriod) external; + + /// @notice Updates the token required as collateral for disputes + function setDisputeToken(IERC20 _disputeToken) external; + + /// @notice Updates the amount of tokens required to create a dispute + function setDisputeAmount(uint256 _disputeAmount) external; +} + diff --git a/contracts/tranches/interfaces/ISwapContract.sol b/contracts/tranches/interfaces/ISwapContract.sol new file mode 100644 index 0000000..eacb936 --- /dev/null +++ b/contracts/tranches/interfaces/ISwapContract.sol @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.28; + +/// @title ISwapContract +/// @notice Interface for the SwapContract that performs swaps on Uniswap V4 pools +/// @dev Uses bytes for poolKey to avoid importing v4-core types which causes version conflicts +interface ISwapContract { + /// @notice Swap exact input tokens for output tokens with direction control + /// @param poolKeyData ABI-encoded PoolKey struct + /// @param zeroForOne Direction: true = token0 -> token1, false = token1 -> token0 + /// @param amountIn Exact amount of input tokens to swap + /// @param minAmountOut Minimum amount of output tokens (slippage protection) + /// @param deadline Timestamp after which the transaction reverts + /// @param hookData Arbitrary data passed to the pool's hook + /// @return amountOut The amount of output tokens received + function swapWithEncodedKey( + bytes calldata poolKeyData, + bool zeroForOne, + uint128 amountIn, + uint128 minAmountOut, + uint256 deadline, + bytes calldata hookData + ) external returns (uint256 amountOut); + + /// @notice Approve a token for use with Permit2 and the Universal Router + /// @param token The token to approve + /// @param amount The amount to approve + /// @param expiration The expiration time for the approval + function approveTokenWithPermit2(address token, uint160 amount, uint48 expiration) external; +} diff --git a/contracts/tranches/strategies/morpho/SteakhouseUSDC.sol b/contracts/tranches/strategies/morpho/SteakhouseUSDC.sol index 4204f9f..34aa4e0 100644 --- a/contracts/tranches/strategies/morpho/SteakhouseUSDC.sol +++ b/contracts/tranches/strategies/morpho/SteakhouseUSDC.sol @@ -8,6 +8,8 @@ import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol import {IErrors} from "../../interfaces/IErrors.sol"; import {IStrataCDO} from "../../interfaces/IStrataCDO.sol"; import {IERC20Cooldown} from "../../interfaces/cooldown/ICooldown.sol"; +import {IDistributor} from "../../interfaces/IDistributor.sol"; +import {ISwapContract} from "../../interfaces/ISwapContract.sol"; import {Strategy} from "../../Strategy.sol"; contract SteakhouseUSDC is Strategy { @@ -16,6 +18,12 @@ contract SteakhouseUSDC is Strategy { IERC20Cooldown public erc20Cooldown; + /// @notice Merkl distributor contract for claiming rewards + IDistributor public distributor; + + /// @notice SwapContract for swapping rewards to USDC + ISwapContract public swapContract; + /** * configuration */ @@ -23,6 +31,9 @@ contract SteakhouseUSDC is Strategy { uint256 public steakhouseUSDCooldownSrt; event CooldownsChanged(uint256 jrt, uint256 srt); + event RewardsClaimed(address indexed rewardToken, uint256 rewardAmount, uint256 usdcReceived); + event DistributorUpdated(address indexed distributor); + event SwapContractUpdated(address indexed swapContract); constructor(IERC4626 steakhouseUSD_) { steakhouseUSD = steakhouseUSD_; @@ -159,7 +170,7 @@ contract SteakhouseUSDC is Strategy { { if (token == address(steakhouseUSD)) { return rounding == Math.Rounding.Floor - ? steakhouseUSD.previewRedeem(tokenAmount) // aka convertToAssets(tokenAmount) + ? steakhouseUSD.previewRedeem(tokenAmount) // aka convertToAssets(tokenAmount) : steakhouseUSD.previewMint(tokenAmount); } if (token == address(USDC)) { @@ -185,7 +196,7 @@ contract SteakhouseUSDC is Strategy { { if (token == address(steakhouseUSD)) { return rounding == Math.Rounding.Floor - ? steakhouseUSD.previewDeposit(baseAssets) // aka convertToShares(baseAssets) + ? steakhouseUSD.previewDeposit(baseAssets) // aka convertToShares(baseAssets) : steakhouseUSD.previewWithdraw(baseAssets); } if (token == address(USDC)) { @@ -222,4 +233,92 @@ contract SteakhouseUSDC is Strategy { erc20Cooldown.setCooldownDisabled(steakhouseUSD, isDisabled); emit CooldownsChanged(steakhouseUSDCooldownJrt_, steakhouseUSDCooldownSrt_); } + + /** + * @notice Sets the Merkl distributor contract address + * @param distributor_ The address of the Merkl distributor contract + */ + function setDistributor(IDistributor distributor_) external onlyRole(UPDATER_STRAT_CONFIG_ROLE) { + distributor = distributor_; + emit DistributorUpdated(address(distributor_)); + } + + /** + * @notice Sets the SwapContract address for swapping rewards + * @param swapContract_ The address of the SwapContract + */ + function setSwapContract(ISwapContract swapContract_) external onlyRole(UPDATER_STRAT_CONFIG_ROLE) { + swapContract = swapContract_; + emit SwapContractUpdated(address(swapContract_)); + } + + /** + * @notice Claims rewards from the Merkl distributor and swaps them to USDC + * @dev This function claims rewards for this contract and swaps them to USDC using the SwapContract + * @param tokens Array of reward token addresses to claim + * @param amounts Array of cumulative amounts earned (from Merkle tree) + * @param proofs Array of Merkle proofs for each claim + * @param poolKeysData Array of ABI-encoded PoolKey structs for swapping each reward token to USDC + * @param zeroForOnes Array of swap directions for each token + * @param minAmountsOut Array of minimum USDC amounts expected from each swap (slippage protection) + * @param deadline Timestamp after which the swaps will revert + * @return totalUsdcReceived Total USDC received from all swaps + */ + function claimRewards( + address[] calldata tokens, + uint256[] calldata amounts, + bytes32[][] calldata proofs, + bytes[] calldata poolKeysData, + bool[] calldata zeroForOnes, + uint128[] calldata minAmountsOut, + uint256 deadline + ) external onlyRole(UPDATER_STRAT_CONFIG_ROLE) returns (uint256 totalUsdcReceived) { + uint256 length = tokens.length; + require( + length == amounts.length && length == proofs.length && length == poolKeysData.length + && length == zeroForOnes.length && length == minAmountsOut.length, + "Array length mismatch" + ); + require(address(distributor) != address(0), "Distributor not set"); + require(address(swapContract) != address(0), "SwapContract not set"); + + // Build the users array - all claims are for this contract + address[] memory users = new address[](length); + for (uint256 i = 0; i < length; i++) { + users[i] = address(this); + } + + // Claim rewards from the distributor + distributor.claim(users, tokens, amounts, proofs); + + // Swap each reward token to USDC + for (uint256 i = 0; i < length; i++) { + address rewardToken = tokens[i]; + + // Skip if the reward is already USDC + if (rewardToken == address(USDC)) { + uint256 usdcBalance = USDC.balanceOf(address(this)); + totalUsdcReceived += usdcBalance; + emit RewardsClaimed(rewardToken, usdcBalance, usdcBalance); + continue; + } + + // Get the balance of the reward token we just claimed + uint256 rewardBalance = IERC20(rewardToken).balanceOf(address(this)); + if (rewardBalance == 0) continue; + + // Approve the SwapContract to spend the reward tokens + SafeERC20.forceApprove(IERC20(rewardToken), address(swapContract), rewardBalance); + + // Swap the reward token to USDC + uint256 usdcReceived = swapContract.swapWithEncodedKey( + poolKeysData[i], zeroForOnes[i], uint128(rewardBalance), minAmountsOut[i], deadline, bytes("") + ); + + totalUsdcReceived += usdcReceived; + emit RewardsClaimed(rewardToken, rewardBalance, usdcReceived); + } + + return totalUsdcReceived; + } } From a9d193d5e0f36f8128c03349965d76ede417c7db Mon Sep 17 00:00:00 2001 From: vivekascoder Date: Mon, 12 Jan 2026 15:56:31 +0530 Subject: [PATCH 17/24] revert changes in deployments --- src/deployments/TimelockHandler.ts | 134 ++- src/deployments/TranchesDeployments.ts | 1364 ++++++++++++------------ 2 files changed, 716 insertions(+), 782 deletions(-) diff --git a/src/deployments/TimelockHandler.ts b/src/deployments/TimelockHandler.ts index 760a9d8..30eee91 100644 --- a/src/deployments/TimelockHandler.ts +++ b/src/deployments/TimelockHandler.ts @@ -1,87 +1,77 @@ -import { TAddress } from "dequanto/models/TAddress"; -import { TEth } from "dequanto/models/TEth"; -import { ETimelockTxStatus } from "dequanto/services/TimelockService/ITimelockService"; -import { TimelockService } from "dequanto/services/TimelockService/TimelockService"; +import { TAddress } from 'dequanto/models/TAddress'; +import { TEth } from 'dequanto/models/TEth'; +import { ETimelockTxStatus } from 'dequanto/services/TimelockService/ITimelockService'; +import { TimelockService } from 'dequanto/services/TimelockService/TimelockService'; + +import { $address } from 'dequanto/utils/$address'; +import { l } from 'dequanto/utils/$logger'; +import { TranchesDeployments } from './TranchesDeployments'; +import { StrataMasterChef } from '@0xc/hardhat/StrataMasterChef/StrataMasterChef'; +import { ChainAccountService } from 'dequanto/ChainAccountService'; -import { $address } from "dequanto/utils/$address"; -import { l } from "dequanto/utils/$logger"; -import { TranchesDeployments } from "./TranchesDeployments"; -import { StrataMasterChef } from "@0xc/hardhat/StrataMasterChef/StrataMasterChef"; -import { ChainAccountService } from "dequanto/ChainAccountService"; -export class TimelockHandler { - constructor(public ds: TranchesDeployments, public owner: TEth.IAccount) {} - async get() { - let timelockAcc = await ChainAccountService.get("timelock/eth/strata"); - return new StrataMasterChef(timelockAcc.address, this.ds.client); - } +export class TimelockHandler { - async isOwner(contract: { owner: () => Promise }) { - let timelock = await this.get(); - if ($address.isEmpty(timelock?.address)) { - // No timelock for chain - return false; + constructor (public ds: TranchesDeployments, public owner: TEth.IAccount) { } - let owner = await contract.owner(); - return $address.eq(owner, timelock.address); - } - async isProposer(address: TAddress) { - let timelock = await this.get(); - return await timelock.hasRole(await timelock.PROPOSER_ROLE(), address); - } + async get () { + let timelockAcc = await ChainAccountService.get('timelock/eth/strata'); + return new StrataMasterChef(timelockAcc.address, this.ds.client); + } - async process( - pendingKey: string, - actions: TEth.DataLike[], - options?: { simulate?: boolean; execute?: boolean } - ) { - let timelock = await this.get(); - let service = new TimelockService(timelock, options); - let { status, schedule } = await service.getPendingByTitle(pendingKey); - if (schedule == null) { - schedule = await service.scheduleCallBatch( - pendingKey, - this.owner, - actions - ); - l`✅ Scheduled green<'${pendingKey}'> ${schedule.txSchedule}`; - return { status: ETimelockTxStatus.Pending }; + async isOwner (contract: { owner: () => Promise }) { + let timelock = await this.get(); + if ($address.isEmpty(timelock?.address)) { + // No timelock for chain + return false; + } + let owner = await contract.owner(); + return $address.eq(owner, timelock.address); } - if (status === ETimelockTxStatus.Pending) { - l`⏳ Schedule green<'${pendingKey}'> is still pending`; - return { status }; + + async isProposer (address: TAddress) { + let timelock = await this.get(); + return await timelock.hasRole(await timelock.PROPOSER_ROLE(), address); } - let result = await service.executePendingByTitle(this.owner, pendingKey); - l`✅✅ Executed ${result.schedule.txExecute}`; - return { status: ETimelockTxStatus.Executed }; - } + async process (pendingKey: string, actions: TEth.DataLike[], options?: { simulate?: boolean, execute?: boolean }) { + let timelock = await this.get(); + let service = new TimelockService(timelock, options); + let { status, schedule } = await service.getPendingByTitle(pendingKey); + if (schedule == null) { + schedule = await service.scheduleCallBatch(pendingKey, this.owner, actions); + l`✅ Scheduled green<'${ pendingKey }'> ${schedule.txSchedule}`; + return { status: ETimelockTxStatus.Pending }; + } + if (status === ETimelockTxStatus.Pending) { + l`⏳ Schedule green<'${ pendingKey }'> is still pending`; + return { status }; + } + + let result = await service.executePendingByTitle(this.owner, pendingKey); + l`✅✅ Executed ${result.schedule.txExecute}`; + return { status: ETimelockTxStatus.Executed }; + } - async execute(sender, txParams) { - let timelock = await this.get(); + async execute (sender, txParams) { + let timelock = await this.get(); - await timelock - .$receipt() - .executeBatch( - sender, - [txParams.to as TAddress], - [txParams.value as bigint], - [txParams.data as TEth.Hex], - txParams.predecessor, - txParams.salt - ); - } + await timelock.$receipt().executeBatch( + sender, + [txParams.to as TAddress], + [txParams.value as bigint], + [txParams.data as TEth.Hex], + txParams.predecessor, + txParams.salt + ) + } - async processOrExitWait( - pendingKey: string, - actions: TEth.DataLike[], - options?: { simulate?: boolean; execute?: boolean } - ) { - let { status } = await this.process(pendingKey, actions, options); - if (status === ETimelockTxStatus.Pending) { - throw new Error(`Okay. Wait for ${pendingKey} to complete`); + async processOrExitWait (pendingKey: string, actions: TEth.DataLike[], options?: { simulate?: boolean, execute?: boolean }) { + let { status } = await this.process(pendingKey, actions, options); + if (status === ETimelockTxStatus.Pending) { + throw new Error(`Okay. Wait for ${pendingKey} to complete`); + } } - } } diff --git a/src/deployments/TranchesDeployments.ts b/src/deployments/TranchesDeployments.ts index 27cb13c..f2b1d7d 100644 --- a/src/deployments/TranchesDeployments.ts +++ b/src/deployments/TranchesDeployments.ts @@ -1,747 +1,691 @@ -import memd from "memd"; -import { AccessControlManager } from "@0xc/hardhat/AccessControlManager/AccessControlManager"; -import { MockStakedUSDe } from "@0xc/hardhat/MockStakedUSDe/MockStakedUSDe"; -import { MockUSDe } from "@0xc/hardhat/MockUSDe/MockUSDe"; -import { Tranche } from "@0xc/hardhat/Tranche/Tranche"; -import { Web3Client } from "dequanto/clients/Web3Client"; -import { Deployments } from "dequanto/contracts/deploy/Deployments"; -import { TEth } from "dequanto/models/TEth"; -import { Platforms } from "../platforms/Platforms"; -import { IPlatform, IPlatformAccounts } from "../platforms/IPlatform"; -import { $require } from "dequanto/utils/$require"; -import { SUSDeStrategy } from "@0xc/hardhat/sUSDeStrategy/sUSDeStrategy"; -import { StrataCDO } from "@0xc/hardhat/StrataCDO/StrataCDO"; -import { ERC20Cooldown } from "@0xc/hardhat/ERC20Cooldown/ERC20Cooldown"; -import { UnstakeCooldown } from "@0xc/hardhat/UnstakeCooldown/UnstakeCooldown"; -import { Accounting } from "@0xc/hardhat/Accounting/Accounting"; -import { Tranches } from "../platforms/Tranches"; -import { $address } from "dequanto/utils/$address"; -import { IERC4626 } from "dequanto/prebuilt/openzeppelin/IERC4626"; -import { $contract } from "dequanto/utils/$contract"; -import { SUSDeCooldownRequestImpl } from "@0xc/hardhat/sUSDeCooldownRequestImpl/sUSDeCooldownRequestImpl"; -import { $date } from "dequanto/utils/$date"; -import { AprPairFeed } from "@0xc/hardhat/AprPairFeed/AprPairFeed"; -import { SUSDeAprPairProvider } from "@0xc/hardhat/sUSDeAprPairProvider/sUSDeAprPairProvider"; -import { MockStakedUSDS } from "@0xc/hardhat/MockStakedUSDS/MockStakedUSDS"; -import { $erc4626 } from "../../test/tranches/utils/$erc4626"; -import { TrancheDepositor } from "@0xc/hardhat/TrancheDepositor/TrancheDepositor"; -import { SafeTx } from "dequanto/safe/SafeTx"; -import { InMemoryServiceTransport } from "dequanto/safe/transport/InMemoryServiceTransport"; -import { l } from "dequanto/utils/$logger"; -import { Addresses } from "@s/constants"; -import { MockERC4626 } from "@0xc/hardhat/MockERC4626/MockERC4626"; -import { AaveAprPairProvider } from "@0xc/hardhat/AaveAprPairProvider/AaveAprPairProvider"; -import { CDOLens } from "@0xc/hardhat/CDOLens/CDOLens"; -import { TwoStepConfigManager } from "@0xc/hardhat/TwoStepConfigManager/TwoStepConfigManager"; -import { ContractBase } from "dequanto/contracts/ContractBase"; -import { Constructor } from "dequanto/utils/types"; +import memd from 'memd'; +import { AccessControlManager } from '@0xc/hardhat/AccessControlManager/AccessControlManager' +import { MockStakedUSDe } from '@0xc/hardhat/MockStakedUSDe/MockStakedUSDe' +import { MockUSDe } from '@0xc/hardhat/MockUSDe/MockUSDe' +import { Tranche } from '@0xc/hardhat/Tranche/Tranche' +import { Web3Client } from 'dequanto/clients/Web3Client' +import { Deployments } from 'dequanto/contracts/deploy/Deployments' +import { TEth } from 'dequanto/models/TEth' +import { Platforms } from '../platforms/Platforms' +import { IPlatform, IPlatformAccounts } from '../platforms/IPlatform' +import { $require } from 'dequanto/utils/$require' +import { SUSDeStrategy } from '@0xc/hardhat/sUSDeStrategy/sUSDeStrategy' +import { StrataCDO } from '@0xc/hardhat/StrataCDO/StrataCDO' +import { ERC20Cooldown } from '@0xc/hardhat/ERC20Cooldown/ERC20Cooldown' +import { UnstakeCooldown } from '@0xc/hardhat/UnstakeCooldown/UnstakeCooldown' +import { Accounting } from '@0xc/hardhat/Accounting/Accounting' +import { Tranches } from '../platforms/Tranches' +import { $address } from 'dequanto/utils/$address' +import { IERC4626 } from 'dequanto/prebuilt/openzeppelin/IERC4626' +import { $contract } from 'dequanto/utils/$contract' +import { SUSDeCooldownRequestImpl } from '@0xc/hardhat/sUSDeCooldownRequestImpl/sUSDeCooldownRequestImpl'; +import { $date } from 'dequanto/utils/$date'; +import { AprPairFeed } from '@0xc/hardhat/AprPairFeed/AprPairFeed'; +import { SUSDeAprPairProvider } from '@0xc/hardhat/sUSDeAprPairProvider/sUSDeAprPairProvider'; +import { MockStakedUSDS } from '@0xc/hardhat/MockStakedUSDS/MockStakedUSDS'; +import { $erc4626 } from '../../test/tranches/utils/$erc4626'; +import { TrancheDepositor } from '@0xc/hardhat/TrancheDepositor/TrancheDepositor'; +import { SafeTx } from 'dequanto/safe/SafeTx'; +import { InMemoryServiceTransport } from 'dequanto/safe/transport/InMemoryServiceTransport'; +import { l } from 'dequanto/utils/$logger'; +import { Addresses } from '@s/constants'; +import { MockERC4626 } from '@0xc/hardhat/MockERC4626/MockERC4626'; +import { AaveAprPairProvider } from '@0xc/hardhat/AaveAprPairProvider/AaveAprPairProvider'; +import { CDOLens } from '@0xc/hardhat/CDOLens/CDOLens'; +import { TwoStepConfigManager } from '@0xc/hardhat/TwoStepConfigManager/TwoStepConfigManager'; +import { ContractBase } from 'dequanto/contracts/ContractBase'; +import { Constructor } from 'dequanto/utils/types'; + export class TranchesDeployments { - ds: Deployments; - platform: IPlatform; - owner: TEth.IAccount; - deployer: TEth.EoAccount; - client: Web3Client; - ethenaInfo: typeof Tranches.ethena; - - accounts: IPlatformAccounts; - - constructor(params: { - client: Web3Client; - deployer: TEth.EoAccount; - owner?: TEth.IAccount; - accounts?: IPlatformAccounts; - deployments?: "throw" | "redeploy"; - }) { - this.deployer = params.deployer; - this.owner = params.owner ?? params.deployer; - this.client = params.client; - this.platform = Platforms[params.client.network]; - this.accounts = params.accounts; - - this.ds = new Deployments(params.client, params.deployer, { - directory: "./deployments/", - whenBytecodeChanged: - params.deployments ?? (this.isTestnet() ? null : "throw"), - fork: params.client.forked?.platform, - }); - - let info = JSON.parse( - JSON.stringify(Tranches.ethena) - ) as typeof Tranches.ethena; - - if (this.platform.Tranches?.ethena) { - info.jrt = { - ...info.jrt, - ...(this.platform.Tranches.ethena.jrt ?? {}), - } as any; - info.srt = { - ...info.srt, - ...(this.platform.Tranches.ethena.srt ?? {}), - } as any; - } - this.ethenaInfo = info; - } - - async get( - Ctor: Constructor, - params?: { id?: "jrUSDe" | "srUSDe"; cdo?: "USDe" } - ) { - if (params?.id === "jrUSDe") { - return await this.ds.get(Ctor, { id: "USDeJrt" }); - } - if (params?.id === "srUSDe") { - return await this.ds.get(Ctor, { id: "USDeSrt" }); + + ds: Deployments + platform: IPlatform + owner: TEth.IAccount + deployer: TEth.EoAccount + client: Web3Client + ethenaInfo: typeof Tranches.ethena; + + accounts: IPlatformAccounts + + constructor(params: { + client: Web3Client + deployer: TEth.EoAccount + owner?: TEth.IAccount + accounts?: IPlatformAccounts + deployments?: 'throw' | 'redeploy' + }) { + this.deployer = params.deployer; + this.owner = params.owner ?? params.deployer; + this.client = params.client; + this.platform = Platforms[params.client.network]; + this.accounts = params.accounts; + + this.ds = new Deployments(params.client, params.deployer, { + directory: './deployments/', + whenBytecodeChanged: params.deployments ?? (this.isTestnet() ? null : 'throw'), + fork: params.client.forked?.platform + }); + + let info = JSON.parse(JSON.stringify(Tranches.ethena)) as typeof Tranches.ethena; + + if (this.platform.Tranches?.ethena) { + info.jrt = { ...info.jrt, ...(this.platform.Tranches.ethena.jrt ?? {}) } as any; + info.srt = { ...info.srt, ...(this.platform.Tranches.ethena.srt ?? {}) } as any; + } + this.ethenaInfo = info; } - let all = await this.ds.store.getDeployments(); - let byName = all.filter((d) => d.name === Ctor.name); - $require.gt(byName.length, 0, `${Ctor.name} not found in deployments`); - if (byName.length === 1) { - return await this.ds.get(Ctor, { id: byName[0].id }); + async get(Ctor: Constructor, params?: { id?: 'jrUSDe' | 'srUSDe', cdo?: 'USDe' }) { + if (params?.id === 'jrUSDe') { + return await this.ds.get(Ctor, { id: 'USDeJrt' }) + } + if (params?.id === 'srUSDe') { + return await this.ds.get(Ctor, { id: 'USDeSrt' }) + } + let all = await this.ds.store.getDeployments(); + let byName = all.filter(d => d.name === Ctor.name); + $require.gt(byName.length, 0, `${Ctor.name} not found in deployments`); + + if (byName.length === 1) { + return await this.ds.get(Ctor, { id: byName[0].id }); + } + let cdo = params?.cdo ?? 'USDe'; + let byCdo = byName.filter(x => x.id.toLowerCase().includes(cdo.toLowerCase())); + if (byCdo.length === 1) { + return await this.ds.get(Ctor, { id: byCdo[0].id }); + } + + if (byCdo.length === 0) { + throw new Error(`No ${Ctor.name} found for CDO ${cdo} in deployments`); + } + if (byCdo.length > 1) { + throw new Error(`Multiple ${Ctor.name} found for CDO ${cdo}: ${byCdo.map(x => x.id).join(', ')}`); + } } - let cdo = params?.cdo ?? "USDe"; - let byCdo = byName.filter((x) => - x.id.toLowerCase().includes(cdo.toLowerCase()) - ); - if (byCdo.length === 1) { - return await this.ds.get(Ctor, { id: byCdo[0].id }); + + @memd.deco.memoize() + async ensureEthena() { + let network = this.ds.client.network; + if (network === 'hardhat') { + let USDe = await this.ds.ensureContract(MockUSDe); + let sUSDe = await this.ds.ensureContract(MockStakedUSDe, { + arguments: [ + USDe.address, + this.owner.address, + this.owner.address + ] + }); + let sUSDS = await this.ds.ensureContract(MockStakedUSDS, { + arguments: [ + USDe.address, + ] + }); + let pUSDe = await this.ds.ensureContract(MockERC4626, { + id: 'pUSDeMock', + arguments: [USDe.address] + }); + await sUSDe.$receipt().setCooldownDuration(this.owner, $date.parseTimespan('1week', { get: 's' })); + + return { USDe, sUSDe, sUSDS, pUSDe, }; + } + if (network === 'hoodi') { + let USDeAddress = $require.Address(this.platform.Tokens['USDe'].address); + let sUSDeAddress = $require.Address(this.platform.Tokens['sUSDe'].address); + let pUSDeAddress = $require.Address(this.platform.Tokens['pUSDe'].address); + let sUSDS = await this.ds.ensureContract(MockStakedUSDS, { + arguments: [ + USDeAddress, + ] + }); + return { + USDe: new MockUSDe(USDeAddress, this.ds.client), + sUSDe: new MockStakedUSDe(sUSDeAddress, this.ds.client), + sUSDS: sUSDS, + pUSDe: new MockERC4626(pUSDeAddress, this.ds.client) + }; + } + + let USDeAddress = $require.Address(this.platform.Tokens['USDe'].address); + let sUSDeAddress = $require.Address(this.platform.Tokens['sUSDe'].address); + let sUSDSAddress = $require.Address(this.platform.Tokens['sUSDS'].address); + let pUSDeAddress = $require.Address(this.platform.Tokens['pUSDe'].address); + return { + USDe: new MockUSDe(USDeAddress, this.ds.client), + sUSDe: new MockStakedUSDe(sUSDeAddress, this.ds.client), + sUSDS: new MockStakedUSDS(sUSDSAddress, this.ds.client), + pUSDe: new MockERC4626(pUSDeAddress, this.ds.client) + }; } - if (byCdo.length === 0) { - throw new Error(`No ${Ctor.name} found for CDO ${cdo} in deployments`); + @memd.deco.memoize() + async ensureACM() { + const owner = this.owner!; + const acm = await this.ds.ensureContract(AccessControlManager, { + arguments: [owner.address] + }); + + if (this.isTestnet() === false) { + let ownerIsAdmin = await acm.hasRole('0x', owner.address); + let deployer = this.deployer; + await this.ds.configure(acm, { + title: `Grant Owner the AccessControlManager Admin Role`, + shouldUpdate: ownerIsAdmin === false, + async updater() { + await acm.$receipt().grantRole(deployer, '0x', owner.address); + await acm.$receipt().revokeRole(owner, '0x', deployer.address); + } + }); + let deployerIsAdmin = await acm.hasRole('0x', deployer.address); + await this.ds.configure(acm, { + title: `Revoke Deployer the AccessControlManager Admin Role`, + shouldUpdate: deployerIsAdmin && $address.eq(deployer.address, owner.address) === false, + async updater() { + await acm.$receipt().revokeRole(owner, '0x', deployer.address); + } + }); + } + return acm; } - if (byCdo.length > 1) { - throw new Error( - `Multiple ${Ctor.name} found for CDO ${cdo}: ${byCdo - .map((x) => x.id) - .join(", ")}` - ); + + async ensureRole(role: TEth.Hex, account: TEth.Address) { + let acm = await this.ensureACM(); + let has = await acm.hasRole(role, account); + if (has === false) { + await acm.$receipt().grantRole(this.owner, role, account); + } } - } - - @memd.deco.memoize() - async ensureEthena() { - let network = this.ds.client.network; - if (network === "hardhat") { - let USDe = await this.ds.ensureContract(MockUSDe); - let sUSDe = await this.ds.ensureContract(MockStakedUSDe, { - arguments: [USDe.address, this.owner.address, this.owner.address], - }); - let sUSDS = await this.ds.ensureContract(MockStakedUSDS, { - arguments: [USDe.address], - }); - let pUSDe = await this.ds.ensureContract(MockERC4626, { - id: "pUSDeMock", - arguments: [USDe.address], - }); - await sUSDe - .$receipt() - .setCooldownDuration( - this.owner, - $date.parseTimespan("1week", { get: "s" }) - ); + async ensureRoles(roles: Record>) { + const acm = await this.ensureACM(); + const admin = this.owner; + for (let role in roles) { + for (let address in roles[role]) { + await ensure(role, address as TEth.Address, roles[role][address]); + } + } - return { USDe, sUSDe, sUSDS, pUSDe }; + async function ensure(role: string, address: TEth.Address, has: boolean) { + let roleHash = role.startsWith('0x') + ? role as TEth.Hex + : $contract.keccak256(role); + let hasCurrent = await acm.hasRole(roleHash, address); + if (hasCurrent !== has) { + l`gray: cyan<${role}> ${has ? '🟢' : '🔴'} cyan<${address}> ⌛`; + if (has) { + await acm.$receipt().grantRole(admin, roleHash, address) + } else { + await acm.$receipt().revokeRole(admin, roleHash, address) + } + } + } } - if (network === "hoodi") { - let USDeAddress = $require.Address(this.platform.Tokens["USDe"].address); - let sUSDeAddress = $require.Address( - this.platform.Tokens["sUSDe"].address - ); - let pUSDeAddress = $require.Address( - this.platform.Tokens["pUSDe"].address - ); - let sUSDS = await this.ds.ensureContract(MockStakedUSDS, { - arguments: [USDeAddress], - }); - return { - USDe: new MockUSDe(USDeAddress, this.ds.client), - sUSDe: new MockStakedUSDe(sUSDeAddress, this.ds.client), - sUSDS: sUSDS, - pUSDe: new MockERC4626(pUSDeAddress, this.ds.client), - }; + + async addRoles() { + let roles = { + [this.owner.address]: [ + $contract.keccak256('PAUSER_ROLE'), + $contract.keccak256('UPDATER_STRAT_CONFIG_ROLE'), + $contract.keccak256('UPDATER_FEED_ROLE'), + $contract.keccak256('RESERVE_MANAGER_ROLE'), + ] + }; + for (let account in roles) { + for (let role of roles[account]) { + await this.ensureRole(role, account as TEth.Address); + } + } } - let USDeAddress = $require.Address(this.platform.Tokens["USDe"].address); - let sUSDeAddress = $require.Address(this.platform.Tokens["sUSDe"].address); - let sUSDSAddress = $require.Address(this.platform.Tokens["sUSDS"].address); - let pUSDeAddress = $require.Address(this.platform.Tokens["pUSDe"].address); - return { - USDe: new MockUSDe(USDeAddress, this.ds.client), - sUSDe: new MockStakedUSDe(sUSDeAddress, this.ds.client), - sUSDS: new MockStakedUSDS(sUSDSAddress, this.ds.client), - pUSDe: new MockERC4626(pUSDeAddress, this.ds.client), - }; - } - - @memd.deco.memoize() - async ensureACM() { - const owner = this.owner!; - const acm = await this.ds.ensureContract(AccessControlManager, { - arguments: [owner.address], - }); - - if (this.isTestnet() === false) { - let ownerIsAdmin = await acm.hasRole("0x", owner.address); - let deployer = this.deployer; - await this.ds.configure(acm, { - title: `Grant Owner the AccessControlManager Admin Role`, - shouldUpdate: ownerIsAdmin === false, - async updater() { - await acm.$receipt().grantRole(deployer, "0x", owner.address); - await acm.$receipt().revokeRole(owner, "0x", deployer.address); - }, - }); - let deployerIsAdmin = await acm.hasRole("0x", deployer.address); - await this.ds.configure(acm, { - title: `Revoke Deployer the AccessControlManager Admin Role`, - shouldUpdate: - deployerIsAdmin && - $address.eq(deployer.address, owner.address) === false, - async updater() { - await acm.$receipt().revokeRole(owner, "0x", deployer.address); - }, - }); + async ensureEthenaTranches(cdo: StrataCDO) { + const { USDe, sUSDe } = await this.ensureEthena(); + + const acm = await this.ensureACM(); + const info = this.ethenaInfo; + let { contract: jrtVault } = await this.ds.ensureWithProxy(Tranche, { + id: 'USDeJrt', + initialize: [ + this.owner.address, + acm.address, + info.jrt.name, + info.jrt.symbol, + USDe.address, + cdo.address, + ] + }); + let { contract: srtVault } = await this.ds.ensureWithProxy(Tranche, { + id: 'USDeSrt', + initialize: [ + this.owner.address, + acm.address, + info.srt.name, + info.srt.symbol, + USDe.address, + cdo.address, + ] + }); + + return { + jrtVault: jrtVault as Tranche & IERC4626, + srtVault: srtVault as Tranche & IERC4626, + }; } - return acm; - } - - async ensureRole(role: TEth.Hex, account: TEth.Address) { - let acm = await this.ensureACM(); - let has = await acm.hasRole(role, account); - if (has === false) { - await acm.$receipt().grantRole(this.owner, role, account); + + async ensureConfigManager() { + let { cdo, acm } = await this.ensureEthenaCDO(); + let owner = this.accounts.timelock.admin; + let { contract: configManager } = await this.ds.ensureWithProxy(TwoStepConfigManager, { + id: 'USDeConfigManager', + arguments: [cdo.address], + initialize: [] + }); + + await this.ds.configure(cdo, { + title: `Set Two-Step Config Manager`, + async shouldUpdate() { + return $address.eq(await cdo.twoStepConfigManager(), configManager.address) === false + }, + async updater() { + await cdo.$receipt().setTwoStepConfigManager(owner, configManager.address); + } + }); + + await this.ensureRoles({ + 'PROPOSER_CONFIG_ROLE': { + [this.accounts.safe.admin.address]: true + }, + 'UPDATER_STRAT_CONFIG_ROLE': { + [this.accounts.timelock.config.address]: true + } + }); + + return { configManager }; } - } - async ensureRoles(roles: Record>) { - const acm = await this.ensureACM(); - const admin = this.owner; - for (let role in roles) { - for (let address in roles[role]) { - await ensure(role, address as TEth.Address, roles[role][address]); - } + + @memd.deco.memoize() + async ensureCooldowns() { + const acm = await this.ensureACM(); + const { contract: erc20Cooldown } = await this.ds.ensureWithProxy(ERC20Cooldown, { + initialize: [ + this.owner.address, + acm.address + ] + }); + const { contract: unstakeCooldown } = await this.ds.ensureWithProxy(UnstakeCooldown, { + initialize: [ + this.owner.address, + acm.address + ] + }); + + let { sUSDe } = await this.ensureEthena(); + const { contractBeaconProxy: sUSDeCooldownRequestImpl } = await this.ds.ensureWithBeacon(SUSDeCooldownRequestImpl, { + //const { contract: sUSDeCooldownRequestImpl } = await this.ds.ensureWithProxy(SUSDeCooldownRequestImpl, { + id: 'SUSDeCooldownRequestBeacon', + arguments: [sUSDe.address], + initialize: [$address.ZERO, $address.ZERO] + }); + + await this.ds.configure(unstakeCooldown, { + shouldUpdate: async () => { + let impl = await unstakeCooldown.implementations(sUSDe.address); + return $address.eq(impl, sUSDeCooldownRequestImpl.address) === false + }, + updater: async () => { + await unstakeCooldown.$receipt().setImplementations(this.owner, [sUSDe.address], [sUSDeCooldownRequestImpl.address]); + } + }); + + return { + erc20Cooldown, + unstakeCooldown, + acm, + }; } - async function ensure(role: string, address: TEth.Address, has: boolean) { - let roleHash = role.startsWith("0x") - ? (role as TEth.Hex) - : $contract.keccak256(role); - let hasCurrent = await acm.hasRole(roleHash, address); - if (hasCurrent !== has) { - l`gray: cyan<${role}> ${ - has ? "🟢" : "🔴" - } cyan<${address}> ⌛`; - if (has) { - await acm.$receipt().grantRole(admin, roleHash, address); - } else { - await acm.$receipt().revokeRole(admin, roleHash, address); + async ensureFeeds() { + const acm = await this.ensureACM(); + const { sUSDe, USDe, sUSDS } = await this.ensureEthena(); + const { contract: sUSDeAprPairProvider } = await this.ds.ensure(SUSDeAprPairProvider, { + arguments: [ + sUSDS.address, + sUSDe.address, + ] + }); + let CURRENT_PROVIDER = sUSDeAprPairProvider.address; + let aaveAprPairProvider: AaveAprPairProvider; + + const network = this.client.network; + const aavePool = Addresses[network]?.AavePool; + if (aavePool) { + const { contract } = await this.ds.ensure(AaveAprPairProvider, { + arguments: [ + $require.Address(Addresses[network].AavePool), + [ + $require.Address(Addresses[network].USDC), + $require.Address(Addresses[network].USDT), + ], + sUSDe.address + ] + }); + aaveAprPairProvider = contract; + CURRENT_PROVIDER = aaveAprPairProvider.address; } - } + + const stalePeriodAfter = $date.parseTimespan(this.platform.Feed.stalePeriodAfter, { get: 's' }); + const { contract: feed } = await this.ds.ensureWithProxy(AprPairFeed, { + id: 'sUSDeAprFeeds', + initialize: [ + this.owner.address, + acm.address, + sUSDeAprPairProvider.address, + BigInt(stalePeriodAfter), + "Ethena CDO APR Pair" + ] + }); + + await this.ds.configure(feed, { + title: 'Update AprPair Feed Provider', + shouldUpdate: async () => { + let providerAddress = await feed.provider(); + return !$address.eq(providerAddress, CURRENT_PROVIDER); + }, + updater: async () => { + await feed.$receipt().setProvider(this.owner, CURRENT_PROVIDER) + } + }); + + return { + feed, + sUSDeAprPairProvider, + aaveAprPairProvider, + }; } - } - - async addRoles() { - let roles = { - [this.owner.address]: [ - $contract.keccak256("PAUSER_ROLE"), - $contract.keccak256("UPDATER_STRAT_CONFIG_ROLE"), - $contract.keccak256("UPDATER_FEED_ROLE"), - $contract.keccak256("RESERVE_MANAGER_ROLE"), - ], - }; - for (let account in roles) { - for (let role of roles[account]) { - await this.ensureRole(role, account as TEth.Address); - } + + @memd.deco.memoize() + async ensureEthenaCDO() { + const { USDe, sUSDe, pUSDe, } = await this.ensureEthena(); + + const acm = await this.ensureACM(); + const info = this.ethenaInfo; + + const { contract: cdo } = await this.ds.ensureWithProxy(StrataCDO, { + id: 'USDeCDO', + arguments: [], + initialize: [ + this.owner.address, + acm.address, + ] + }); + + // Strategy + const { erc20Cooldown, unstakeCooldown } = await this.ensureCooldowns(); + + const { contract: strategy } = await this.ds.ensureWithProxy(SUSDeStrategy, { + arguments: [ + sUSDe.address + ], + initialize: [ + this.owner.address, + acm.address, + cdo.address, + erc20Cooldown.address, + unstakeCooldown.address + ] + }); + await this.ensureRole(await erc20Cooldown.COOLDOWN_WORKER_ROLE(), strategy.address) + + + // Accounting + const accounting = await this.ensureAccounting(cdo.address); + + // Oracle + const { feed, sUSDeAprPairProvider } = await this.ensureFeeds(); + + + const { jrtVault, srtVault } = await this.ensureEthenaTranches(cdo); + + await this.ds.configure(cdo, { + shouldUpdate: async () => $address.eq(await cdo.strategy(), $address.ZERO), + updater: async (x, value) => { + await cdo.$receipt().configure( + this.owner, + accounting.address, + strategy.address, + jrtVault.address, + srtVault.address + ); + }, + }); + + const output = { + acm, + jrtVault, + srtVault, + cdo, + strategy, + accounting, + erc20Cooldown, + unstakeCooldown, + feed, + USDe, + sUSDe, + sUSDeAprPairProvider, + pUSDe, + }; + + await this.configure(info, output); + return output; } - } - - async ensureEthenaTranches(cdo: StrataCDO) { - const { USDe, sUSDe } = await this.ensureEthena(); - - const acm = await this.ensureACM(); - const info = this.ethenaInfo; - let { contract: jrtVault } = await this.ds.ensureWithProxy(Tranche, { - id: "USDeJrt", - initialize: [ - this.owner.address, - acm.address, - info.jrt.name, - info.jrt.symbol, - USDe.address, - cdo.address, - ], - }); - let { contract: srtVault } = await this.ds.ensureWithProxy(Tranche, { - id: "USDeSrt", - initialize: [ - this.owner.address, - acm.address, - info.srt.name, - info.srt.symbol, - USDe.address, - cdo.address, - ], - }); - - return { - jrtVault: jrtVault as Tranche & IERC4626, - srtVault: srtVault as Tranche & IERC4626, - }; - } - - async ensureConfigManager() { - let { cdo, acm } = await this.ensureEthenaCDO(); - let owner = this.accounts.timelock.admin; - let { contract: configManager } = await this.ds.ensureWithProxy( - TwoStepConfigManager, - { - id: "USDeConfigManager", - arguments: [cdo.address], - initialize: [], - } - ); - - await this.ds.configure(cdo, { - title: `Set Two-Step Config Manager`, - async shouldUpdate() { - return ( - $address.eq( - await cdo.twoStepConfigManager(), - configManager.address - ) === false - ); - }, - async updater() { - await cdo - .$receipt() - .setTwoStepConfigManager(owner, configManager.address); - }, - }); - - await this.ensureRoles({ - PROPOSER_CONFIG_ROLE: { - [this.accounts.safe.admin.address]: true, - }, - UPDATER_STRAT_CONFIG_ROLE: { - [this.accounts.timelock.config.address]: true, - }, - }); - - return { configManager }; - } - - @memd.deco.memoize() - async ensureCooldowns() { - const acm = await this.ensureACM(); - const { contract: erc20Cooldown } = await this.ds.ensureWithProxy( - ERC20Cooldown, - { - initialize: [this.owner.address, acm.address], - } - ); - const { contract: unstakeCooldown } = await this.ds.ensureWithProxy( - UnstakeCooldown, - { - initialize: [this.owner.address, acm.address], - } - ); - - let { sUSDe } = await this.ensureEthena(); - const { contractBeaconProxy: sUSDeCooldownRequestImpl } = - await this.ds.ensureWithBeacon(SUSDeCooldownRequestImpl, { - //const { contract: sUSDeCooldownRequestImpl } = await this.ds.ensureWithProxy(SUSDeCooldownRequestImpl, { - id: "SUSDeCooldownRequestBeacon", - arguments: [sUSDe.address], - initialize: [$address.ZERO, $address.ZERO], - }); - - await this.ds.configure(unstakeCooldown, { - shouldUpdate: async () => { - let impl = await unstakeCooldown.implementations(sUSDe.address); - return $address.eq(impl, sUSDeCooldownRequestImpl.address) === false; - }, - updater: async () => { - await unstakeCooldown - .$receipt() - .setImplementations( - this.owner, - [sUSDe.address], - [sUSDeCooldownRequestImpl.address] - ); - }, - }); - - return { - erc20Cooldown, - unstakeCooldown, - acm, - }; - } - - async ensureFeeds() { - const acm = await this.ensureACM(); - const { sUSDe, USDe, sUSDS } = await this.ensureEthena(); - const { contract: sUSDeAprPairProvider } = await this.ds.ensure( - SUSDeAprPairProvider, - { - arguments: [sUSDS.address, sUSDe.address], - } - ); - let CURRENT_PROVIDER = sUSDeAprPairProvider.address; - let aaveAprPairProvider: AaveAprPairProvider; - - const network = this.client.network; - const aavePool = Addresses[network]?.AavePool; - if (aavePool) { - const { contract } = await this.ds.ensure(AaveAprPairProvider, { - arguments: [ - $require.Address(Addresses[network].AavePool), - [ - $require.Address(Addresses[network].USDC), - $require.Address(Addresses[network].USDT), - ], - sUSDe.address, - ], - }); - aaveAprPairProvider = contract; - CURRENT_PROVIDER = aaveAprPairProvider.address; + + async ensureAccounting(cdo: TEth.Address) { + const acm = await this.ensureACM(); + const { feed } = await this.ensureFeeds(); + const { contract: accounting } = await this.ds.ensureWithProxy(Accounting, { + id: `USDeAccounting`, + initialize: [ + this.owner.address, + acm.address, + cdo, + feed.address, + ] + }); + return accounting; } - const stalePeriodAfter = $date.parseTimespan( - this.platform.Feed.stalePeriodAfter, - { get: "s" } - ); - const { contract: feed } = await this.ds.ensureWithProxy(AprPairFeed, { - id: "sUSDeAprFeeds", - initialize: [ - this.owner.address, - acm.address, - sUSDeAprPairProvider.address, - BigInt(stalePeriodAfter), - "Ethena CDO APR Pair", - ], - }); - - await this.ds.configure(feed, { - title: "Update AprPair Feed Provider", - shouldUpdate: async () => { - let providerAddress = await feed.provider(); - return !$address.eq(providerAddress, CURRENT_PROVIDER); - }, - updater: async () => { - await feed.$receipt().setProvider(this.owner, CURRENT_PROVIDER); - }, - }); - - return { - feed, - sUSDeAprPairProvider, - aaveAprPairProvider, - }; - } - - @memd.deco.memoize() - async ensureEthenaCDO() { - const { USDe, sUSDe, pUSDe } = await this.ensureEthena(); - - const acm = await this.ensureACM(); - const info = this.ethenaInfo; - - const { contract: cdo } = await this.ds.ensureWithProxy(StrataCDO, { - id: "USDeCDO", - arguments: [], - initialize: [this.owner.address, acm.address], - }); - - // Strategy - const { erc20Cooldown, unstakeCooldown } = await this.ensureCooldowns(); - - const { contract: strategy } = await this.ds.ensureWithProxy( - SUSDeStrategy, - { - arguments: [sUSDe.address], - initialize: [ - this.owner.address, - acm.address, - cdo.address, - erc20Cooldown.address, - unstakeCooldown.address, - ], - } - ); - await this.ensureRole( - await erc20Cooldown.COOLDOWN_WORKER_ROLE(), - strategy.address - ); - - // Accounting - const accounting = await this.ensureAccounting(cdo.address); - - // Oracle - const { feed, sUSDeAprPairProvider } = await this.ensureFeeds(); - - const { jrtVault, srtVault } = await this.ensureEthenaTranches(cdo); - - await this.ds.configure(cdo, { - shouldUpdate: async () => - $address.eq(await cdo.strategy(), $address.ZERO), - updater: async (x, value) => { - await cdo - .$receipt() - .configure( - this.owner, - accounting.address, - strategy.address, - jrtVault.address, - srtVault.address - ); - }, - }); - - const output = { - acm, - jrtVault, - srtVault, - cdo, - strategy, - accounting, - erc20Cooldown, - unstakeCooldown, - feed, - USDe, - sUSDe, - sUSDeAprPairProvider, - pUSDe, - }; - - await this.configure(info, output); - return output; - } - - async ensureAccounting(cdo: TEth.Address) { - const acm = await this.ensureACM(); - const { feed } = await this.ensureFeeds(); - const { contract: accounting } = await this.ds.ensureWithProxy(Accounting, { - id: `USDeAccounting`, - initialize: [this.owner.address, acm.address, cdo, feed.address], - }); - return accounting; - } - - async configure( - info: (typeof Tranches)["ethena"], - contracts: { - acm: AccessControlManager; - jrtVault: Tranche; - srtVault: Tranche; - cdo: StrataCDO; - strategy: SUSDeStrategy; - accounting: Accounting; - feed: AprPairFeed; + async configure(info: typeof Tranches['ethena'], contracts: { + acm: AccessControlManager, + jrtVault: Tranche, + srtVault: Tranche, + cdo: StrataCDO, + strategy: SUSDeStrategy, + accounting: Accounting, + feed: AprPairFeed + }) { + + let { + acm, + jrtVault, + srtVault, + cdo, + strategy, + accounting, + feed, + } = contracts; + + await this.addRoles(); + + await this.ensureRole($contract.keccak256('UPDATER_CDO_APR_ROLE'), feed.address); + await this.setCooldown(strategy, info); + + if (await jrtVault.totalSupply() === 0n) { + if (this.client.network === 'eth') { + throw new Error(`Already deployed`); + await this.initialDepositAtomic({ jrtVault, srtVault, cdo }); + } else { + //await this.initialDeposit({ jrtVault, srtVault, cdo }); + } + } + + await this.setTrancheActions(cdo, jrtVault, info, 'jrt'); + await this.setTrancheActions(cdo, srtVault, info, 'srt'); } - ) { - let { acm, jrtVault, srtVault, cdo, strategy, accounting, feed } = - contracts; - - await this.addRoles(); - - await this.ensureRole( - $contract.keccak256("UPDATER_CDO_APR_ROLE"), - feed.address - ); - await this.setCooldown(strategy, info); - - if ((await jrtVault.totalSupply()) === 0n) { - if (this.client.network === "eth") { - throw new Error(`Already deployed`); - await this.initialDepositAtomic({ jrtVault, srtVault, cdo }); - } else { - //await this.initialDeposit({ jrtVault, srtVault, cdo }); - } + + async setTrancheActions(cdo: StrataCDO, tranche: Tranche, info: typeof Tranches['ethena'], type: 'srt' | 'jrt') { + let actions = type === 'jrt' + ? await cdo.actionsJrt() + : await cdo.actionsSrt(); + + let current = type === 'jrt' + ? info.jrt + : info.srt; + + if (actions.isDepositEnabled !== current.depositsEnabled || actions.isWithdrawEnabled !== current.withdrawalsEnabled) { + await cdo.$receipt().setActionStates( + this.owner, + tranche.address, + current.depositsEnabled, + current.withdrawalsEnabled, + ); + } } - await this.setTrancheActions(cdo, jrtVault, info, "jrt"); - await this.setTrancheActions(cdo, srtVault, info, "srt"); - } - - async setTrancheActions( - cdo: StrataCDO, - tranche: Tranche, - info: (typeof Tranches)["ethena"], - type: "srt" | "jrt" - ) { - let actions = - type === "jrt" ? await cdo.actionsJrt() : await cdo.actionsSrt(); - - let current = type === "jrt" ? info.jrt : info.srt; - - if ( - actions.isDepositEnabled !== current.depositsEnabled || - actions.isWithdrawEnabled !== current.withdrawalsEnabled - ) { - await cdo - .$receipt() - .setActionStates( - this.owner, - tranche.address, - current.depositsEnabled, - current.withdrawalsEnabled - ); + async setCooldown(strategy: SUSDeStrategy, info: typeof Tranches['ethena']) { + let cooldowns = [info.jrt.sUSDeCooldown, info.srt.sUSDeCooldown] + .map(mix => { + if (typeof mix === 'string') { + return $date.parseTimespan(mix, { get: 's' }); + } + return mix; + }) + .map(BigInt); + + + let current = await Promise.all([ + await strategy.sUSDeCooldownJrt(), + await strategy.sUSDeCooldownSrt(), + ]); + await this.ds.configure(strategy, { + shouldUpdate: () => { + return cooldowns[0] !== current[0] || cooldowns[1] !== current[1] + }, + updater: async () => { + await strategy.$receipt().setCooldowns( + this.owner, + cooldowns[0], + cooldowns[1] + ); + } + }); } - } - - async setCooldown( - strategy: SUSDeStrategy, - info: (typeof Tranches)["ethena"] - ) { - let cooldowns = [info.jrt.sUSDeCooldown, info.srt.sUSDeCooldown] - .map((mix) => { - if (typeof mix === "string") { - return $date.parseTimespan(mix, { get: "s" }); + + private async initialDeposit(tranches: { jrtVault: Tranche, srtVault: Tranche, cdo: StrataCDO }) { + let { USDe } = await this.ensureEthena(); + let { jrtVault, srtVault, cdo } = tranches; + + if (this.owner.type === 'safe') { + throw new Error(`Mainnet deployment not ready`); } - return mix; - }) - .map(BigInt); - - let current = await Promise.all([ - await strategy.sUSDeCooldownJrt(), - await strategy.sUSDeCooldownSrt(), - ]); - await this.ds.configure(strategy, { - shouldUpdate: () => { - return cooldowns[0] !== current[0] || cooldowns[1] !== current[1]; - }, - updater: async () => { - await strategy - .$receipt() - .setCooldowns(this.owner, cooldowns[0], cooldowns[1]); - }, - }); - } - - private async initialDeposit(tranches: { - jrtVault: Tranche; - srtVault: Tranche; - cdo: StrataCDO; - }) { - let { USDe } = await this.ensureEthena(); - let { jrtVault, srtVault, cdo } = tranches; - - if (this.owner.type === "safe") { - throw new Error(`Mainnet deployment not ready`); + const AMOUNT = 20n * 10n ** 18n; + let balance = await USDe.balanceOf(this.owner.address); + if (balance < AMOUNT) { + if (this.client.network === 'hardhat' || this.client.network === 'hoodi') { + await USDe.$receipt().mint(this.owner, this.owner.address, AMOUNT * 100n); + } else { + throw new Error(`Not enough balance for initial deposit.`); + } + } + + await cdo.$receipt().setActionStates( + this.owner, + $address.ZERO, + true, + false, + ); + await $erc4626.deposit(jrtVault as any, this.owner, AMOUNT / 2n); + await $erc4626.deposit(srtVault as any, this.owner, AMOUNT / 2n); + await cdo.$receipt().setActionStates( + this.owner, + $address.ZERO, + false, + false, + ); } - const AMOUNT = 20n * 10n ** 18n; - let balance = await USDe.balanceOf(this.owner.address); - if (balance < AMOUNT) { - if ( - this.client.network === "hardhat" || - this.client.network === "hoodi" - ) { - await USDe.$receipt().mint( - this.owner, - this.owner.address, - AMOUNT * 100n + private async initialDepositAtomic(tranches: { jrtVault: Tranche, srtVault: Tranche, cdo: StrataCDO }) { + let { USDe } = await this.ensureEthena(); + let { jrtVault, srtVault, cdo } = tranches; + + + const AMOUNT = 40n * 10n ** 18n; + let balance = await USDe.balanceOf(this.owner.address); + if (balance < AMOUNT) { + throw new Error(`Not enough balance for initial deposit.`); + } + + const AMOUNT_SRT = AMOUNT / 2n; + const AMOUNT_JRT = AMOUNT / 2n; + + let actions = [ + await cdo.$data().setActionStates(this.owner, $address.ZERO, true, false), + + await USDe.$data().approve(this.owner, jrtVault.address, AMOUNT_JRT), + await jrtVault.$data().deposit(this.owner, AMOUNT_JRT, this.owner.address), + + await USDe.$data().approve(this.owner, srtVault.address, AMOUNT_SRT), + await srtVault.$data().deposit(this.owner, AMOUNT_SRT, this.owner.address), + + await cdo.$data().setActionStates(this.owner, $address.ZERO, false, false), + + ]; + + let safeTx = new SafeTx(this.owner, this.client, { + safeTransport: new InMemoryServiceTransport(this.client, this.deployer) + }); + + l`yellow` + let tx = await safeTx.executeBatch( + ...actions ); - } else { - throw new Error(`Not enough balance for initial deposit.`); - } + await tx.wait(); } - await cdo - .$receipt() - .setActionStates(this.owner, $address.ZERO, true, false); - await $erc4626.deposit(jrtVault as any, this.owner, AMOUNT / 2n); - await $erc4626.deposit(srtVault as any, this.owner, AMOUNT / 2n); - await cdo - .$receipt() - .setActionStates(this.owner, $address.ZERO, false, false); - } - private async initialDepositAtomic(tranches: { - jrtVault: Tranche; - srtVault: Tranche; - cdo: StrataCDO; - }) { - let { USDe } = await this.ensureEthena(); - let { jrtVault, srtVault, cdo } = tranches; - - const AMOUNT = 40n * 10n ** 18n; - let balance = await USDe.balanceOf(this.owner.address); - if (balance < AMOUNT) { - throw new Error(`Not enough balance for initial deposit.`); + async ensureDepositor() { + let acm = await this.ensureACM(); + let { pUSDe } = await this.ensureEthena(); + let { cdo, jrtVault, USDe } = await this.ensureEthenaCDO(); + let { contract: depositor } = await this.ds.ensureWithProxy(TrancheDepositor, { + id: 'TrancheDepositorV2', + initialize: [ + this.owner.address, + acm.address + ] + }); + + + await this.ensureRole($contract.keccak256('DEPOSITOR_CONFIG_ROLE'), this.owner.address); + + let status = await depositor.tranches(jrtVault.address, USDe.address); + if (status == false) { + await depositor.$receipt().addCdo(this.owner, cdo.address); + } + + if (pUSDe) { + let status = await depositor.autoWithdrawals(pUSDe.address); + if (status === false) { + await depositor.$receipt().addAutoWithdrawals(this.owner, [pUSDe.address], [true]); + } + } + return depositor; } - const AMOUNT_SRT = AMOUNT / 2n; - const AMOUNT_JRT = AMOUNT / 2n; - - let actions = [ - await cdo.$data().setActionStates(this.owner, $address.ZERO, true, false), - - await USDe.$data().approve(this.owner, jrtVault.address, AMOUNT_JRT), - await jrtVault - .$data() - .deposit(this.owner, AMOUNT_JRT, this.owner.address), - - await USDe.$data().approve(this.owner, srtVault.address, AMOUNT_SRT), - await srtVault - .$data() - .deposit(this.owner, AMOUNT_SRT, this.owner.address), - - await cdo - .$data() - .setActionStates(this.owner, $address.ZERO, false, false), - ]; - - let safeTx = new SafeTx(this.owner, this.client, { - safeTransport: new InMemoryServiceTransport(this.client, this.deployer), - }); - - l`yellow`; - let tx = await safeTx.executeBatch(...actions); - await tx.wait(); - } - - async ensureDepositor() { - let acm = await this.ensureACM(); - let { pUSDe } = await this.ensureEthena(); - let { cdo, jrtVault, USDe } = await this.ensureEthenaCDO(); - let { contract: depositor } = await this.ds.ensureWithProxy( - TrancheDepositor, - { - id: "TrancheDepositorV2", - initialize: [this.owner.address, acm.address], - } - ); - - await this.ensureRole( - $contract.keccak256("DEPOSITOR_CONFIG_ROLE"), - this.owner.address - ); - - let status = await depositor.tranches(jrtVault.address, USDe.address); - if (status == false) { - await depositor.$receipt().addCdo(this.owner, cdo.address); + public isTestnet() { + return this.client.platform !== 'eth'; } - if (pUSDe) { - let status = await depositor.autoWithdrawals(pUSDe.address); - if (status === false) { - await depositor - .$receipt() - .addAutoWithdrawals(this.owner, [pUSDe.address], [true]); - } + async ensureLenses() { + let { contract: cdoLens } = await this.ds.ensureWithProxy(CDOLens, { + initialize: [ + this.owner.address + ] + }); } - return depositor; - } - - public isTestnet() { - return this.client.platform !== "eth"; - } - - async ensureLenses() { - let { contract: cdoLens } = await this.ds.ensureWithProxy(CDOLens, { - initialize: [this.owner.address], - }); - } } From 9b00e47d1b30e948ed63807eb7c45870a9bacf65 Mon Sep 17 00:00:00 2001 From: vivekascoder Date: Mon, 12 Jan 2026 19:56:08 +0530 Subject: [PATCH 18/24] feat: withdraw from latest changes onto sUSDeStrategy --- .../strategies/morpho/SteakhouseUSDC.sol | 32 +++++++++++++++++-- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/contracts/tranches/strategies/morpho/SteakhouseUSDC.sol b/contracts/tranches/strategies/morpho/SteakhouseUSDC.sol index 34aa4e0..757faa9 100644 --- a/contracts/tranches/strategies/morpho/SteakhouseUSDC.sol +++ b/contracts/tranches/strategies/morpho/SteakhouseUSDC.sol @@ -88,7 +88,8 @@ contract SteakhouseUSDC is Strategy { * @notice Processes asset withdrawals for the CDO contract. * @dev This method is called by the CDO contract to handle asset withdrawals. * If withdrawing steakhouseUSD shares, a cooldown period is applied based on the tranche type. - * If withdrawing USDC, the steakhouseUSD shares are redeemed directly. + * If withdrawing USDC, the steakhouseUSD shares are redeemed directly (no cooldown mechanism). + * An overloaded version accepts a shouldSkipCooldown parameter to skip the cooldown for steakhouseUSD withdrawals. * @param tranche The address of the tranche withdrawing assets * @param token The address of the token to be withdrawn * @param tokenAmount The amount of tokens to be withdrawn (not used in this implementation) @@ -105,14 +106,39 @@ contract SteakhouseUSDC is Strategy { address sender, address receiver ) external onlyCDO returns (uint256) { + return withdrawInner(tranche, token, tokenAmount, baseAssets, sender, receiver, false); + } + + function withdraw( + address tranche, + address token, + uint256 tokenAmount, + uint256 baseAssets, + address sender, + address receiver, + bool shouldSkipCooldown + ) external onlyCDO returns (uint256) { + return withdrawInner(tranche, token, tokenAmount, baseAssets, sender, receiver, shouldSkipCooldown); + } + + function withdrawInner( + address tranche, + address token, + uint256 tokenAmount, + uint256 baseAssets, + address sender, + address receiver, + bool shouldSkipCooldown + ) internal returns (uint256) { uint256 shares = steakhouseUSD.previewWithdraw(baseAssets); if (token == address(steakhouseUSD)) { - uint256 cooldownSeconds = cdo.isJrt(tranche) ? steakhouseUSDCooldownJrt : steakhouseUSDCooldownSrt; + uint256 cooldownSeconds = + shouldSkipCooldown ? 0 : (cdo.isJrt(tranche) ? steakhouseUSDCooldownJrt : steakhouseUSDCooldownSrt); erc20Cooldown.transfer(steakhouseUSD, sender, receiver, shares, cooldownSeconds); return shares; } if (token == address(USDC)) { - // Morpho allows direct withdrawal - no unstaking needed + // Morpho allows direct withdrawal - no cooldown needed steakhouseUSD.withdraw(baseAssets, receiver, address(this)); return baseAssets; } From e444d7a3fa85eadf2ea0ccc5660a7fc063c23d61 Mon Sep 17 00:00:00 2001 From: vivekascoder Date: Wed, 14 Jan 2026 20:06:57 +0530 Subject: [PATCH 19/24] feat: add morpho-blue submodule for enhanced functionality --- .gitmodules | 3 +++ lib/morpho-blue | 1 + 2 files changed, 4 insertions(+) create mode 160000 lib/morpho-blue diff --git a/.gitmodules b/.gitmodules index a4410f8..115e872 100644 --- a/.gitmodules +++ b/.gitmodules @@ -31,3 +31,6 @@ [submodule "lib/openzeppelin-contracts"] path = lib/openzeppelin-contracts url = https://github.com/OpenZeppelin/openzeppelin-contracts +[submodule "lib/morpho-blue"] + path = lib/morpho-blue + url = https://github.com/morpho-org/morpho-blue diff --git a/lib/morpho-blue b/lib/morpho-blue new file mode 160000 index 0000000..48b2a62 --- /dev/null +++ b/lib/morpho-blue @@ -0,0 +1 @@ +Subproject commit 48b2a62d9d911a27f886fb7909ad57e29f7dacc9 From d2f30585465335f595ea731ec37264ddfab22e53 Mon Sep 17 00:00:00 2001 From: vivekascoder Date: Wed, 14 Jan 2026 20:07:14 +0530 Subject: [PATCH 20/24] chore: update remappings and submodule references; --- .vscode/settings.json | 6 +- .../strategies/morpho/IMetaMorpho.sol | 217 +++++++++++ .../tranches/strategies/morpho/IMorpho.sol | 355 ++++++++++++++++++ .../morpho/MorphoAprPairProvider.sol | 133 +++++++ .../tranches/strategies/morpho/PendingLib.sol | 48 +++ foundry.lock | 41 ++ lib/morpho-blue | 2 +- remappings.txt | 3 +- test/MorphoAprPairProvider.fork.t.sol | 260 +++++++++++++ test/SteakhouseUSDC.t.sol | 252 +++++++++++++ 10 files changed, 1312 insertions(+), 5 deletions(-) create mode 100644 contracts/tranches/strategies/morpho/IMetaMorpho.sol create mode 100644 contracts/tranches/strategies/morpho/IMorpho.sol create mode 100644 contracts/tranches/strategies/morpho/MorphoAprPairProvider.sol create mode 100644 contracts/tranches/strategies/morpho/PendingLib.sol create mode 100644 foundry.lock create mode 100644 test/MorphoAprPairProvider.fork.t.sol create mode 100644 test/SteakhouseUSDC.t.sol diff --git a/.vscode/settings.json b/.vscode/settings.json index 05c3f75..2c263f9 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,7 +1,7 @@ { - "files.exclude": { - "**/lib": true - }, + // "files.exclude": { + // "**/lib": true + // }, "security.olympix.detectors.arbitraryAddressSpoofingAttack": true, "security.olympix.detectors.arbitraryTransferFrom": true, "security.olympix.detectors.noAccessControlPayableFallback": true, diff --git a/contracts/tranches/strategies/morpho/IMetaMorpho.sol b/contracts/tranches/strategies/morpho/IMetaMorpho.sol new file mode 100644 index 0000000..f7ed0fe --- /dev/null +++ b/contracts/tranches/strategies/morpho/IMetaMorpho.sol @@ -0,0 +1,217 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity >=0.5.0; + +import {IMorpho, Id, MarketParams} from "@metamorpho/morpho-blue/interfaces/IMorpho.sol"; +import {IERC4626} from "@openzeppelin/contracts/interfaces/IERC4626.sol"; +import {IERC20Permit} from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Permit.sol"; + +import {MarketConfig, PendingUint192, PendingAddress} from "./PendingLib.sol"; + +struct MarketAllocation { + /// @notice The market to allocate. + MarketParams marketParams; + /// @notice The amount of assets to allocate. + uint256 assets; +} + +interface IMulticall { + function multicall(bytes[] calldata) external returns (bytes[] memory); +} + +interface IOwnable { + function owner() external view returns (address); + function transferOwnership(address) external; + function renounceOwnership() external; + function acceptOwnership() external; + function pendingOwner() external view returns (address); +} + +/// @dev This interface is used for factorizing IMetaMorphoStaticTyping and IMetaMorpho. +/// @dev Consider using the IMetaMorpho interface instead of this one. +interface IMetaMorphoBase { + /// @notice The address of the Morpho contract. + function MORPHO() external view returns (IMorpho); + + /// @notice OpenZeppelin decimals offset used by the ERC4626 implementation. + /// @dev Calculated to be max(0, 18 - underlyingDecimals). + /// @dev When equal to zero (<=> token decimals >= 18), the protection against the inflation front-running attack on + /// empty vault is low (see https://docs.openzeppelin.com/contracts/5.x/erc4626#inflation-attack). To protect + /// against this attack, vault deployers should make an initial deposit of a non-trivial amount in the vault or + /// depositors should check that the share price does not exceed a certain limit. + function DECIMALS_OFFSET() external view returns (uint8); + + /// @notice The address of the curator. + function curator() external view returns (address); + + /// @notice Stores whether an address is an allocator or not. + function isAllocator(address target) external view returns (bool); + + /// @notice The current guardian. Can be set even without the timelock set. + function guardian() external view returns (address); + + /// @notice The current fee. + function fee() external view returns (uint96); + + /// @notice The fee recipient. + function feeRecipient() external view returns (address); + + /// @notice The skim recipient. + function skimRecipient() external view returns (address); + + /// @notice The current timelock. + function timelock() external view returns (uint256); + + /// @dev Stores the order of markets on which liquidity is supplied upon deposit. + /// @dev Can contain any market. A market is skipped as soon as its supply cap is reached. + function supplyQueue(uint256) external view returns (Id); + + /// @notice Returns the length of the supply queue. + function supplyQueueLength() external view returns (uint256); + + /// @dev Stores the order of markets from which liquidity is withdrawn upon withdrawal. + /// @dev Always contain all non-zero cap markets as well as all markets on which the vault supplies liquidity, + /// without duplicate. + function withdrawQueue(uint256) external view returns (Id); + + /// @notice Returns the length of the withdraw queue. + function withdrawQueueLength() external view returns (uint256); + + /// @notice Stores the total assets managed by this vault when the fee was last accrued. + /// @dev May be greater than `totalAssets()` due to removal of markets with non-zero supply or socialized bad debt. + /// This difference will decrease the fee accrued until one of the functions updating `lastTotalAssets` is + /// triggered (deposit/mint/withdraw/redeem/setFee/setFeeRecipient). + function lastTotalAssets() external view returns (uint256); + + /// @notice Submits a `newTimelock`. + /// @dev Warning: Reverts if a timelock is already pending. Revoke the pending timelock to overwrite it. + /// @dev In case the new timelock is higher than the current one, the timelock is set immediately. + function submitTimelock(uint256 newTimelock) external; + + /// @notice Accepts the pending timelock. + function acceptTimelock() external; + + /// @notice Revokes the pending timelock. + /// @dev Does not revert if there is no pending timelock. + function revokePendingTimelock() external; + + /// @notice Submits a `newSupplyCap` for the market defined by `marketParams`. + /// @dev Warning: Reverts if a cap is already pending. Revoke the pending cap to overwrite it. + /// @dev Warning: Reverts if a market removal is pending. + /// @dev In case the new cap is lower than the current one, the cap is set immediately. + function submitCap(MarketParams memory marketParams, uint256 newSupplyCap) external; + + /// @notice Accepts the pending cap of the market defined by `marketParams`. + function acceptCap(MarketParams memory marketParams) external; + + /// @notice Revokes the pending cap of the market defined by `id`. + /// @dev Does not revert if there is no pending cap. + function revokePendingCap(Id id) external; + + /// @notice Submits a forced market removal from the vault, eventually losing all funds supplied to the market. + /// @notice This forced removal is expected to be used as an emergency process in case a market constantly reverts. + /// To softly remove a sane market, the curator role is expected to bundle a reallocation that empties the market + /// first (using `reallocate`), followed by the removal of the market (using `updateWithdrawQueue`). + /// @dev Warning: Removing a market with non-zero supply will instantly impact the vault's price per share. + /// @dev Warning: Reverts for non-zero cap or if there is a pending cap. Successfully submitting a zero cap will + /// prevent such reverts. + function submitMarketRemoval(MarketParams memory marketParams) external; + + /// @notice Revokes the pending removal of the market defined by `id`. + /// @dev Does not revert if there is no pending market removal. + function revokePendingMarketRemoval(Id id) external; + + /// @notice Submits a `newGuardian`. + /// @notice Warning: a malicious guardian could disrupt the vault's operation, and would have the power to revoke + /// any pending guardian. + /// @dev In case there is no guardian, the gardian is set immediately. + /// @dev Warning: Submitting a gardian will overwrite the current pending gardian. + function submitGuardian(address newGuardian) external; + + /// @notice Accepts the pending guardian. + function acceptGuardian() external; + + /// @notice Revokes the pending guardian. + function revokePendingGuardian() external; + + /// @notice Skims the vault `token` balance to `skimRecipient`. + function skim(address) external; + + /// @notice Sets `newAllocator` as an allocator or not (`newIsAllocator`). + function setIsAllocator(address newAllocator, bool newIsAllocator) external; + + /// @notice Sets `curator` to `newCurator`. + function setCurator(address newCurator) external; + + /// @notice Sets the `fee` to `newFee`. + function setFee(uint256 newFee) external; + + /// @notice Sets `feeRecipient` to `newFeeRecipient`. + function setFeeRecipient(address newFeeRecipient) external; + + /// @notice Sets `skimRecipient` to `newSkimRecipient`. + function setSkimRecipient(address newSkimRecipient) external; + + /// @notice Sets `supplyQueue` to `newSupplyQueue`. + /// @param newSupplyQueue is an array of enabled markets, and can contain duplicate markets, but it would only + /// increase the cost of depositing to the vault. + function setSupplyQueue(Id[] calldata newSupplyQueue) external; + + /// @notice Updates the withdraw queue. Some markets can be removed, but no market can be added. + /// @notice Removing a market requires the vault to have 0 supply on it, or to have previously submitted a removal + /// for this market (with the function `submitMarketRemoval`). + /// @notice Warning: Anyone can supply on behalf of the vault so the call to `updateWithdrawQueue` that expects a + /// market to be empty can be griefed by a front-run. To circumvent this, the allocator can simply bundle a + /// reallocation that withdraws max from this market with a call to `updateWithdrawQueue`. + /// @dev Warning: Removing a market with supply will decrease the fee accrued until one of the functions updating + /// `lastTotalAssets` is triggered (deposit/mint/withdraw/redeem/setFee/setFeeRecipient). + /// @dev Warning: `updateWithdrawQueue` is not idempotent. Submitting twice the same tx will change the queue twice. + /// @param indexes The indexes of each market in the previous withdraw queue, in the new withdraw queue's order. + function updateWithdrawQueue(uint256[] calldata indexes) external; + + /// @notice Reallocates the vault's liquidity so as to reach a given allocation of assets on each given market. + /// @dev The behavior of the reallocation can be altered by state changes, including: + /// - Deposits on the vault that supplies to markets that are expected to be supplied to during reallocation. + /// - Withdrawals from the vault that withdraws from markets that are expected to be withdrawn from during + /// reallocation. + /// - Donations to the vault on markets that are expected to be supplied to during reallocation. + /// - Withdrawals from markets that are expected to be withdrawn from during reallocation. + /// @dev Sender is expected to pass `assets = type(uint256).max` with the last MarketAllocation of `allocations` to + /// supply all the remaining withdrawn liquidity, which would ensure that `totalWithdrawn` = `totalSupplied`. + /// @dev A supply in a reallocation step will make the reallocation revert if the amount is greater than the net + /// amount from previous steps (i.e. total withdrawn minus total supplied). + function reallocate(MarketAllocation[] calldata allocations) external; +} + +/// @dev This interface is inherited by MetaMorpho so that function signatures are checked by the compiler. +/// @dev Consider using the IMetaMorpho interface instead of this one. +interface IMetaMorphoStaticTyping is IMetaMorphoBase { + /// @notice Returns the current configuration of each market. + function config(Id) external view returns (uint184 cap, bool enabled, uint64 removableAt); + + /// @notice Returns the pending guardian. + function pendingGuardian() external view returns (address guardian, uint64 validAt); + + /// @notice Returns the pending cap for each market. + function pendingCap(Id) external view returns (uint192 value, uint64 validAt); + + /// @notice Returns the pending timelock. + function pendingTimelock() external view returns (uint192 value, uint64 validAt); +} + +/// @title IMetaMorpho +/// @author Morpho Labs +/// @custom:contact security@morpho.org +/// @dev Use this interface for MetaMorpho to have access to all the functions with the appropriate function signatures. +interface IMetaMorpho is IMetaMorphoBase, IERC4626, IERC20Permit, IOwnable, IMulticall { + /// @notice Returns the current configuration of each market. + function config(Id) external view returns (MarketConfig memory); + + /// @notice Returns the pending guardian. + function pendingGuardian() external view returns (PendingAddress memory); + + /// @notice Returns the pending cap for each market. + function pendingCap(Id) external view returns (PendingUint192 memory); + + /// @notice Returns the pending timelock. + function pendingTimelock() external view returns (PendingUint192 memory); +} diff --git a/contracts/tranches/strategies/morpho/IMorpho.sol b/contracts/tranches/strategies/morpho/IMorpho.sol new file mode 100644 index 0000000..53e1bb0 --- /dev/null +++ b/contracts/tranches/strategies/morpho/IMorpho.sol @@ -0,0 +1,355 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity >=0.5.0; + +type Id is bytes32; + +struct MarketParams { + address loanToken; + address collateralToken; + address oracle; + address irm; + uint256 lltv; +} + +/// @dev Warning: For `feeRecipient`, `supplyShares` does not contain the accrued shares since the last interest +/// accrual. +struct Position { + uint256 supplyShares; + uint128 borrowShares; + uint128 collateral; +} + +/// @dev Warning: `totalSupplyAssets` does not contain the accrued interest since the last interest accrual. +/// @dev Warning: `totalBorrowAssets` does not contain the accrued interest since the last interest accrual. +/// @dev Warning: `totalSupplyShares` does not contain the additional shares accrued by `feeRecipient` since the last +/// interest accrual. +struct Market { + uint128 totalSupplyAssets; + uint128 totalSupplyShares; + uint128 totalBorrowAssets; + uint128 totalBorrowShares; + uint128 lastUpdate; + uint128 fee; +} + +struct Authorization { + address authorizer; + address authorized; + bool isAuthorized; + uint256 nonce; + uint256 deadline; +} + +struct Signature { + uint8 v; + bytes32 r; + bytes32 s; +} + +/// @dev This interface is used for factorizing IMorphoStaticTyping and IMorpho. +/// @dev Consider using the IMorpho interface instead of this one. +interface IMorphoBase { + /// @notice The EIP-712 domain separator. + /// @dev Warning: Every EIP-712 signed message based on this domain separator can be reused on chains sharing the + /// same chain id and on forks because the domain separator would be the same. + function DOMAIN_SEPARATOR() external view returns (bytes32); + + /// @notice The owner of the contract. + /// @dev It has the power to change the owner. + /// @dev It has the power to set fees on markets and set the fee recipient. + /// @dev It has the power to enable but not disable IRMs and LLTVs. + function owner() external view returns (address); + + /// @notice The fee recipient of all markets. + /// @dev The recipient receives the fees of a given market through a supply position on that market. + function feeRecipient() external view returns (address); + + /// @notice Whether the `irm` is enabled. + function isIrmEnabled(address irm) external view returns (bool); + + /// @notice Whether the `lltv` is enabled. + function isLltvEnabled(uint256 lltv) external view returns (bool); + + /// @notice Whether `authorized` is authorized to modify `authorizer`'s position on all markets. + /// @dev Anyone is authorized to modify their own positions, regardless of this variable. + function isAuthorized(address authorizer, address authorized) external view returns (bool); + + /// @notice The `authorizer`'s current nonce. Used to prevent replay attacks with EIP-712 signatures. + function nonce(address authorizer) external view returns (uint256); + + /// @notice Sets `newOwner` as `owner` of the contract. + /// @dev Warning: No two-step transfer ownership. + /// @dev Warning: The owner can be set to the zero address. + function setOwner(address newOwner) external; + + /// @notice Enables `irm` as a possible IRM for market creation. + /// @dev Warning: It is not possible to disable an IRM. + function enableIrm(address irm) external; + + /// @notice Enables `lltv` as a possible LLTV for market creation. + /// @dev Warning: It is not possible to disable a LLTV. + function enableLltv(uint256 lltv) external; + + /// @notice Sets the `newFee` for the given market `marketParams`. + /// @param newFee The new fee, scaled by WAD. + /// @dev Warning: The recipient can be the zero address. + function setFee(MarketParams memory marketParams, uint256 newFee) external; + + /// @notice Sets `newFeeRecipient` as `feeRecipient` of the fee. + /// @dev Warning: If the fee recipient is set to the zero address, fees will accrue there and will be lost. + /// @dev Modifying the fee recipient will allow the new recipient to claim any pending fees not yet accrued. To + /// ensure that the current recipient receives all due fees, accrue interest manually prior to making any changes. + function setFeeRecipient(address newFeeRecipient) external; + + /// @notice Creates the market `marketParams`. + /// @dev Here is the list of assumptions on the market's dependencies (tokens, IRM and oracle) that guarantees + /// Morpho behaves as expected: + /// - The token should be ERC-20 compliant, except that it can omit return values on `transfer` and `transferFrom`. + /// - The token balance of Morpho should only decrease on `transfer` and `transferFrom`. In particular, tokens with + /// burn functions are not supported. + /// - The token should not re-enter Morpho on `transfer` nor `transferFrom`. + /// - The token balance of the sender (resp. receiver) should decrease (resp. increase) by exactly the given amount + /// on `transfer` and `transferFrom`. In particular, tokens with fees on transfer are not supported. + /// - The IRM should not re-enter Morpho. + /// - The oracle should return a price with the correct scaling. + /// - The oracle price should not be able to change instantly such that the new price is less than the old price + /// multiplied by LLTV*LIF. In particular, if the loan asset is a vault that can receive donations, the oracle + /// should not price its shares using the AUM. + /// @dev Here is a list of assumptions on the market's dependencies which, if broken, could break Morpho's liveness + /// properties (funds could get stuck): + /// - The token should not revert on `transfer` and `transferFrom` if balances and approvals are right. + /// - The amount of assets supplied and borrowed should not be too high (max ~1e32), otherwise the number of shares + /// might not fit within 128 bits. + /// - The IRM should not revert on `borrowRate`. + /// - The IRM should not return a very high borrow rate (otherwise the computation of `interest` in + /// `_accrueInterest` can overflow). + /// - The oracle should not revert `price`. + /// - The oracle should not return a very high price (otherwise the computation of `maxBorrow` in `_isHealthy` or of + /// `assetsRepaid` in `liquidate` can overflow). + /// @dev The borrow share price of a market with less than 1e4 assets borrowed can be decreased by manipulations, to + /// the point where `totalBorrowShares` is very large and borrowing overflows. + function createMarket(MarketParams memory marketParams) external; + + /// @notice Supplies `assets` or `shares` on behalf of `onBehalf`, optionally calling back the caller's + /// `onMorphoSupply` function with the given `data`. + /// @dev Either `assets` or `shares` should be zero. Most use cases should rely on `assets` as an input so the + /// caller is guaranteed to have `assets` tokens pulled from their balance, but the possibility to mint a specific + /// amount of shares is given for full compatibility and precision. + /// @dev Supplying a large amount can revert for overflow. + /// @dev Supplying an amount of shares may lead to supply more or fewer assets than expected due to slippage. + /// Consider using the `assets` parameter to avoid this. + /// @param marketParams The market to supply assets to. + /// @param assets The amount of assets to supply. + /// @param shares The amount of shares to mint. + /// @param onBehalf The address that will own the increased supply position. + /// @param data Arbitrary data to pass to the `onMorphoSupply` callback. Pass empty data if not needed. + /// @return assetsSupplied The amount of assets supplied. + /// @return sharesSupplied The amount of shares minted. + function supply( + MarketParams memory marketParams, + uint256 assets, + uint256 shares, + address onBehalf, + bytes memory data + ) external returns (uint256 assetsSupplied, uint256 sharesSupplied); + + /// @notice Withdraws `assets` or `shares` on behalf of `onBehalf` and sends the assets to `receiver`. + /// @dev Either `assets` or `shares` should be zero. To withdraw max, pass the `shares`'s balance of `onBehalf`. + /// @dev `msg.sender` must be authorized to manage `onBehalf`'s positions. + /// @dev Withdrawing an amount corresponding to more shares than supplied will revert for underflow. + /// @dev It is advised to use the `shares` input when withdrawing the full position to avoid reverts due to + /// conversion roundings between shares and assets. + /// @param marketParams The market to withdraw assets from. + /// @param assets The amount of assets to withdraw. + /// @param shares The amount of shares to burn. + /// @param onBehalf The address of the owner of the supply position. + /// @param receiver The address that will receive the withdrawn assets. + /// @return assetsWithdrawn The amount of assets withdrawn. + /// @return sharesWithdrawn The amount of shares burned. + function withdraw( + MarketParams memory marketParams, + uint256 assets, + uint256 shares, + address onBehalf, + address receiver + ) external returns (uint256 assetsWithdrawn, uint256 sharesWithdrawn); + + /// @notice Borrows `assets` or `shares` on behalf of `onBehalf` and sends the assets to `receiver`. + /// @dev Either `assets` or `shares` should be zero. Most use cases should rely on `assets` as an input so the + /// caller is guaranteed to borrow `assets` of tokens, but the possibility to mint a specific amount of shares is + /// given for full compatibility and precision. + /// @dev `msg.sender` must be authorized to manage `onBehalf`'s positions. + /// @dev Borrowing a large amount can revert for overflow. + /// @dev Borrowing an amount of shares may lead to borrow fewer assets than expected due to slippage. + /// Consider using the `assets` parameter to avoid this. + /// @param marketParams The market to borrow assets from. + /// @param assets The amount of assets to borrow. + /// @param shares The amount of shares to mint. + /// @param onBehalf The address that will own the increased borrow position. + /// @param receiver The address that will receive the borrowed assets. + /// @return assetsBorrowed The amount of assets borrowed. + /// @return sharesBorrowed The amount of shares minted. + function borrow( + MarketParams memory marketParams, + uint256 assets, + uint256 shares, + address onBehalf, + address receiver + ) external returns (uint256 assetsBorrowed, uint256 sharesBorrowed); + + /// @notice Repays `assets` or `shares` on behalf of `onBehalf`, optionally calling back the caller's + /// `onMorphoRepay` function with the given `data`. + /// @dev Either `assets` or `shares` should be zero. To repay max, pass the `shares`'s balance of `onBehalf`. + /// @dev Repaying an amount corresponding to more shares than borrowed will revert for underflow. + /// @dev It is advised to use the `shares` input when repaying the full position to avoid reverts due to conversion + /// roundings between shares and assets. + /// @dev An attacker can front-run a repay with a small repay making the transaction revert for underflow. + /// @param marketParams The market to repay assets to. + /// @param assets The amount of assets to repay. + /// @param shares The amount of shares to burn. + /// @param onBehalf The address of the owner of the debt position. + /// @param data Arbitrary data to pass to the `onMorphoRepay` callback. Pass empty data if not needed. + /// @return assetsRepaid The amount of assets repaid. + /// @return sharesRepaid The amount of shares burned. + function repay( + MarketParams memory marketParams, + uint256 assets, + uint256 shares, + address onBehalf, + bytes memory data + ) external returns (uint256 assetsRepaid, uint256 sharesRepaid); + + /// @notice Supplies `assets` of collateral on behalf of `onBehalf`, optionally calling back the caller's + /// `onMorphoSupplyCollateral` function with the given `data`. + /// @dev Interest are not accrued since it's not required and it saves gas. + /// @dev Supplying a large amount can revert for overflow. + /// @param marketParams The market to supply collateral to. + /// @param assets The amount of collateral to supply. + /// @param onBehalf The address that will own the increased collateral position. + /// @param data Arbitrary data to pass to the `onMorphoSupplyCollateral` callback. Pass empty data if not needed. + function supplyCollateral(MarketParams memory marketParams, uint256 assets, address onBehalf, bytes memory data) + external; + + /// @notice Withdraws `assets` of collateral on behalf of `onBehalf` and sends the assets to `receiver`. + /// @dev `msg.sender` must be authorized to manage `onBehalf`'s positions. + /// @dev Withdrawing an amount corresponding to more collateral than supplied will revert for underflow. + /// @param marketParams The market to withdraw collateral from. + /// @param assets The amount of collateral to withdraw. + /// @param onBehalf The address of the owner of the collateral position. + /// @param receiver The address that will receive the collateral assets. + function withdrawCollateral(MarketParams memory marketParams, uint256 assets, address onBehalf, address receiver) + external; + + /// @notice Liquidates the given `repaidShares` of debt asset or seize the given `seizedAssets` of collateral on the + /// given market `marketParams` of the given `borrower`'s position, optionally calling back the caller's + /// `onMorphoLiquidate` function with the given `data`. + /// @dev Either `seizedAssets` or `repaidShares` should be zero. + /// @dev Seizing more than the collateral balance will underflow and revert without any error message. + /// @dev Repaying more than the borrow balance will underflow and revert without any error message. + /// @dev An attacker can front-run a liquidation with a small repay making the transaction revert for underflow. + /// @param marketParams The market of the position. + /// @param borrower The owner of the position. + /// @param seizedAssets The amount of collateral to seize. + /// @param repaidShares The amount of shares to repay. + /// @param data Arbitrary data to pass to the `onMorphoLiquidate` callback. Pass empty data if not needed. + /// @return The amount of assets seized. + /// @return The amount of assets repaid. + function liquidate( + MarketParams memory marketParams, + address borrower, + uint256 seizedAssets, + uint256 repaidShares, + bytes memory data + ) external returns (uint256, uint256); + + /// @notice Executes a flash loan. + /// @dev Flash loans have access to the whole balance of the contract (the liquidity and deposited collateral of all + /// markets combined, plus donations). + /// @dev Warning: Not ERC-3156 compliant but compatibility is easily reached: + /// - `flashFee` is zero. + /// - `maxFlashLoan` is the token's balance of this contract. + /// - The receiver of `assets` is the caller. + /// @param token The token to flash loan. + /// @param assets The amount of assets to flash loan. + /// @param data Arbitrary data to pass to the `onMorphoFlashLoan` callback. + function flashLoan(address token, uint256 assets, bytes calldata data) external; + + /// @notice Sets the authorization for `authorized` to manage `msg.sender`'s positions. + /// @param authorized The authorized address. + /// @param newIsAuthorized The new authorization status. + function setAuthorization(address authorized, bool newIsAuthorized) external; + + /// @notice Sets the authorization for `authorization.authorized` to manage `authorization.authorizer`'s positions. + /// @dev Warning: Reverts if the signature has already been submitted. + /// @dev The signature is malleable, but it has no impact on the security here. + /// @dev The nonce is passed as argument to be able to revert with a different error message. + /// @param authorization The `Authorization` struct. + /// @param signature The signature. + function setAuthorizationWithSig(Authorization calldata authorization, Signature calldata signature) external; + + /// @notice Accrues interest for the given market `marketParams`. + function accrueInterest(MarketParams memory marketParams) external; + + /// @notice Returns the data stored on the different `slots`. + function extSloads(bytes32[] memory slots) external view returns (bytes32[] memory); +} + +/// @dev This interface is inherited by Morpho so that function signatures are checked by the compiler. +/// @dev Consider using the IMorpho interface instead of this one. +interface IMorphoStaticTyping is IMorphoBase { + /// @notice The state of the position of `user` on the market corresponding to `id`. + /// @dev Warning: For `feeRecipient`, `supplyShares` does not contain the accrued shares since the last interest + /// accrual. + function position(Id id, address user) + external + view + returns (uint256 supplyShares, uint128 borrowShares, uint128 collateral); + + /// @notice The state of the market corresponding to `id`. + /// @dev Warning: `totalSupplyAssets` does not contain the accrued interest since the last interest accrual. + /// @dev Warning: `totalBorrowAssets` does not contain the accrued interest since the last interest accrual. + /// @dev Warning: `totalSupplyShares` does not contain the accrued shares by `feeRecipient` since the last interest + /// accrual. + function market(Id id) + external + view + returns ( + uint128 totalSupplyAssets, + uint128 totalSupplyShares, + uint128 totalBorrowAssets, + uint128 totalBorrowShares, + uint128 lastUpdate, + uint128 fee + ); + + /// @notice The market params corresponding to `id`. + /// @dev This mapping is not used in Morpho. It is there to enable reducing the cost associated to calldata on layer + /// 2s by creating a wrapper contract with functions that take `id` as input instead of `marketParams`. + function idToMarketParams(Id id) + external + view + returns (address loanToken, address collateralToken, address oracle, address irm, uint256 lltv); +} + +/// @title IMorpho +/// @author Morpho Labs +/// @custom:contact security@morpho.org +/// @dev Use this interface for Morpho to have access to all the functions with the appropriate function signatures. +interface IMorpho is IMorphoBase { + /// @notice The state of the position of `user` on the market corresponding to `id`. + /// @dev Warning: For `feeRecipient`, `p.supplyShares` does not contain the accrued shares since the last interest + /// accrual. + function position(Id id, address user) external view returns (Position memory p); + + /// @notice The state of the market corresponding to `id`. + /// @dev Warning: `m.totalSupplyAssets` does not contain the accrued interest since the last interest accrual. + /// @dev Warning: `m.totalBorrowAssets` does not contain the accrued interest since the last interest accrual. + /// @dev Warning: `m.totalSupplyShares` does not contain the accrued shares by `feeRecipient` since the last + /// interest accrual. + function market(Id id) external view returns (Market memory m); + + /// @notice The market params corresponding to `id`. + /// @dev This mapping is not used in Morpho. It is there to enable reducing the cost associated to calldata on layer + /// 2s by creating a wrapper contract with functions that take `id` as input instead of `marketParams`. + function idToMarketParams(Id id) external view returns (MarketParams memory); +} diff --git a/contracts/tranches/strategies/morpho/MorphoAprPairProvider.sol b/contracts/tranches/strategies/morpho/MorphoAprPairProvider.sol new file mode 100644 index 0000000..6bf3a9e --- /dev/null +++ b/contracts/tranches/strategies/morpho/MorphoAprPairProvider.sol @@ -0,0 +1,133 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.28; + +import {IStrategyAprPairProvider} from "../../interfaces/IAprPairFeed.sol"; +import {IMetaMorpho} from "./IMetaMorpho.sol"; +import {Id, MarketParams, Market, IMorpho} from "@metamorpho/morpho-blue/interfaces/IMorpho.sol"; +import {MathLib, WAD} from "@metamorpho/morpho-blue/libraries/MathLib.sol"; +import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; +import {SharesMathLib} from "@metamorpho/morpho-blue/libraries/SharesMathLib.sol"; +import {MorphoLib} from "@metamorpho/morpho-blue/libraries/periphery/MorphoLib.sol"; +import {MarketParamsLib} from "@metamorpho/morpho-blue/libraries/MarketParamsLib.sol"; +import {MorphoBalancesLib} from "@metamorpho/morpho-blue/libraries/periphery/MorphoBalancesLib.sol"; +import {UtilsLib} from "@metamorpho/morpho-blue/libraries/UtilsLib.sol"; +import {IIrm} from "@metamorpho/morpho-blue/interfaces/IIrm.sol"; + +/// @title MorphoAprPairProvider +/// @notice Provides APR pair data from MetaMorpho vaults +/// @dev APRs are returned in SD7x12 format (scaled by 1e12) +contract MorphoAprPairProvider is IStrategyAprPairProvider { + using SharesMathLib for uint256; + using MathLib for uint256; + using Math for uint256; + using MarketParamsLib for MarketParams; + using MorphoLib for IMorpho; + using MorphoBalancesLib for IMorpho; + using UtilsLib for uint256; + + /// @notice The Morpho Blue contract + IMorpho public immutable morpho; + + /// @notice The MetaMorpho vault used as base APR source + address public immutable baseVault; + + /// @notice The MetaMorpho vault used as target APR source (optional, can be same as baseVault) + address public immutable targetVault; + + /// @notice Scale factor for converting from WAD (1e18) to SD7x12 (1e12) + uint256 private constant WAD_TO_SD7X12 = 1e6; + + /// @param morphoAddress The Morpho Blue contract address + /// @param baseVault_ The MetaMorpho vault for base APR + /// @param targetVault_ The MetaMorpho vault for target APR (use address(0) for same as base) + constructor(address morphoAddress, address baseVault_, address targetVault_) { + require(morphoAddress != address(0), "Morpho address cannot be 0"); + require(baseVault_ != address(0), "Base vault address cannot be 0"); + + morpho = IMorpho(morphoAddress); + baseVault = baseVault_; + targetVault = targetVault_ == address(0) ? baseVault_ : targetVault_; + } + + /// @notice Returns the APR pair (target and base) from the configured vaults + /// @return aprTarget The target APR scaled by 1e12 + /// @return aprBase The base APR scaled by 1e12 + /// @return timestamp The current block timestamp + function getAprPair() external view returns (int64 aprTarget, int64 aprBase, uint64 timestamp) { + timestamp = uint64(block.timestamp); + aprTarget = getAPRtarget(); + aprBase = getAPRbase(); + } + + /// @notice Calculates the target APR from the target vault + /// @return The target APR as int64, scaled by 1e12 + function getAPRtarget() public view returns (int64) { + uint256 apyWad = supplyAPYVaultV1(targetVault); + // Convert from WAD (1e18) to SD7x12 (1e12) + uint256 apr = apyWad / WAD_TO_SD7X12; + return int64(int256(apr)); + } + + /// @notice Calculates the base APR from the base vault + /// @return The base APR as int64, scaled by 1e12 + function getAPRbase() public view returns (int64) { + uint256 apyWad = supplyAPYVaultV1(baseVault); + // Convert from WAD (1e18) to SD7x12 (1e12) + uint256 apr = apyWad / WAD_TO_SD7X12; + return int64(int256(apr)); + } + + /// @notice Returns the total assets supplied into a specific morpho blue market by a MetaMorpho `vault`. + /// @param vault The address of the MetaMorpho vault. + /// @param marketParams The morpho blue market. + function vaultAssetsInMarket(address vault, MarketParams memory marketParams) public view returns (uint256 assets) { + assets = morpho.expectedSupplyAssets(marketParams, vault); + } + + /// @notice Returns the current APY of a Morpho Blue market. + /// @param marketParams The morpho blue market parameters. + /// @param market The morpho blue market state. + function supplyAPYMarketV1(MarketParams memory marketParams, Market memory market) + public + view + returns (uint256 supplyApy) + { + // Get the borrow rate + uint256 borrowRate; + if (marketParams.irm == address(0)) { + return 0; + } else { + borrowRate = IIrm(marketParams.irm).borrowRateView(marketParams, market).wTaylorCompounded(365 days); + } + + (uint256 totalSupplyAssets,, uint256 totalBorrowAssets,) = morpho.expectedMarketBalances(marketParams); + + // Get the supply rate + uint256 utilization = totalBorrowAssets == 0 ? 0 : totalBorrowAssets.wDivUp(totalSupplyAssets); + supplyApy = borrowRate.wMulDown(1 ether - market.fee).wMulDown(utilization); + } + + /// @notice Returns the current APY of a MetaMorpho vault. + /// @dev It is computed as the sum of all APY of enabled markets weighted by the supply on these markets. + /// @param vault The address of the MetaMorpho vault. + function supplyAPYVaultV1(address vault) public view returns (uint256 avgSupplyApy) { + uint256 ratio; + uint256 queueLength = IMetaMorpho(vault).withdrawQueueLength(); + + uint256 totalAmount = IMetaMorpho(vault).totalAssets(); + if (totalAmount == 0) return 0; + + for (uint256 i; i < queueLength; ++i) { + Id idMarket = IMetaMorpho(vault).withdrawQueue(i); + + MarketParams memory marketParams = morpho.idToMarketParams(idMarket); + Market memory market = morpho.market(idMarket); + + uint256 currentSupplyAPY = supplyAPYMarketV1(marketParams, market); + uint256 vaultAsset = vaultAssetsInMarket(vault, marketParams); + ratio += currentSupplyAPY.wMulDown(vaultAsset); + } + + avgSupplyApy = ratio.mulDivDown(WAD - IMetaMorpho(vault).fee(), totalAmount); + } +} diff --git a/contracts/tranches/strategies/morpho/PendingLib.sol b/contracts/tranches/strategies/morpho/PendingLib.sol new file mode 100644 index 0000000..45be617 --- /dev/null +++ b/contracts/tranches/strategies/morpho/PendingLib.sol @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity ^0.8.0; + +struct MarketConfig { + /// @notice The maximum amount of assets that can be allocated to the market. + /// @dev The exposure to a given market can go beyond the cap in case of interest or donations. + uint184 cap; + /// @notice Whether the market is in the withdraw queue. + bool enabled; + /// @notice The timestamp at which the market can be instantly removed from the withdraw queue. + uint64 removableAt; +} + +struct PendingUint192 { + /// @notice The pending value to set. + uint192 value; + /// @notice The timestamp at which the pending value becomes valid. + uint64 validAt; +} + +struct PendingAddress { + /// @notice The pending value to set. + address value; + /// @notice The timestamp at which the pending value becomes valid. + uint64 validAt; +} + +/// @title PendingLib +/// @author Morpho Labs +/// @custom:contact security@morpho.org +/// @notice Library to manage pending values and their validity timestamp. +library PendingLib { + /// @dev Updates `pending`'s value to `newValue` and its corresponding `validAt` timestamp. + /// @dev Assumes `timelock` <= `MAX_TIMELOCK`. + function update(PendingUint192 storage pending, uint184 newValue, uint256 timelock) internal { + pending.value = newValue; + // Safe "unchecked" cast because timelock <= MAX_TIMELOCK. + pending.validAt = uint64(block.timestamp + timelock); + } + + /// @dev Updates `pending`'s value to `newValue` and its corresponding `validAt` timestamp. + /// @dev Assumes `timelock` <= `MAX_TIMELOCK`. + function update(PendingAddress storage pending, address newValue, uint256 timelock) internal { + pending.value = newValue; + // Safe "unchecked" cast because timelock <= MAX_TIMELOCK. + pending.validAt = uint64(block.timestamp + timelock); + } +} diff --git a/foundry.lock b/foundry.lock new file mode 100644 index 0000000..c433d6e --- /dev/null +++ b/foundry.lock @@ -0,0 +1,41 @@ +{ + "lib/chimera": { + "rev": "aaa856e70f2756fd3762bd7665262dba13ff0dd7" + }, + "lib/forge-std": { + "rev": "77041d2ce690e692d6e03cc812b57d1ddaa4d505" + }, + "lib/morpho-blue": { + "tag": { + "name": "v1.0.0", + "rev": "55d2d99304fb3fb930c688462ae2ccabb1d533ad" + } + }, + "lib/openzeppelin-contracts": { + "rev": "fcbae5394ae8ad52d8e580a3477db99814b9d565" + }, + "lib/permit2": { + "rev": "cc56ad0f3439c502c246fc5cfcc3db92bb8b7219" + }, + "lib/properties": { + "rev": "d346d8ed5c1a9f7deebe92fcbaf237b185fda326" + }, + "lib/setup-helpers": { + "rev": "9120629af5b6e8f22c78d596b1e1b00aac47bc5b" + }, + "lib/universal-router": { + "rev": "705f7bb9836ebc4f6ad1aad91629c3d0fc4128d4" + }, + "lib/v2-core": { + "rev": "4dd59067c76dea4a0e8e4bfdda41877a6b16dedc" + }, + "lib/v3-core": { + "rev": "e3589b192d0be27e100cd0daaf6c97204fdb1899" + }, + "lib/v4-core": { + "rev": "d153b048868a60c2403a3ef5b2301bb247884d46" + }, + "lib/v4-periphery": { + "rev": "3779387e5d296f39df543d23524b050f89a62917" + } +} \ No newline at end of file diff --git a/lib/morpho-blue b/lib/morpho-blue index 48b2a62..55d2d99 160000 --- a/lib/morpho-blue +++ b/lib/morpho-blue @@ -1 +1 @@ -Subproject commit 48b2a62d9d911a27f886fb7909ad57e29f7dacc9 +Subproject commit 55d2d99304fb3fb930c688462ae2ccabb1d533ad diff --git a/remappings.txt b/remappings.txt index 9cc0909..f963d4a 100644 --- a/remappings.txt +++ b/remappings.txt @@ -3,4 +3,5 @@ @uniswap/permit2/=lib/permit2/ @uniswap/universal-router/=lib/universal-router/ @uniswap/v3-core/=lib/v3-core/ -@uniswap/v2-core/=lib/v2-core/ \ No newline at end of file +@uniswap/v2-core/=lib/v2-core/ +@metamorpho/morpho-blue/=lib/morpho-blue/src/ \ No newline at end of file diff --git a/test/MorphoAprPairProvider.fork.t.sol b/test/MorphoAprPairProvider.fork.t.sol new file mode 100644 index 0000000..e3ec405 --- /dev/null +++ b/test/MorphoAprPairProvider.fork.t.sol @@ -0,0 +1,260 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.28; + +import {Test} from "forge-std/Test.sol"; +import {console2} from "forge-std/console2.sol"; + +import {MorphoAprPairProvider} from "../contracts/tranches/strategies/morpho/MorphoAprPairProvider.sol"; +import {IMetaMorpho} from "../contracts/tranches/strategies/morpho/IMetaMorpho.sol"; +import {IStrategyAprPairProvider} from "../contracts/tranches/interfaces/IAprPairFeed.sol"; + +/// @title MorphoAprPairProvider Fork Test +/// @notice Fork test for MorphoAprPairProvider using Morpho Blue on Ethereum mainnet +/// @dev Uses the official Morpho contract at 0xBBBBBbbBBb9cC5e90e3b3Af64bdAF62C37EEFFCb +/// @dev Reference: https://docs.morpho.org/get-started/resources/addresses/ +contract MorphoAprPairProviderForkTest is Test { + // ============ Morpho Protocol Contracts on Mainnet ============ + /// @dev Morpho Blue core contract - https://docs.morpho.org/get-started/resources/addresses/ + address constant MORPHO = 0xBBBBBbbBBb9cC5e90e3b3Af64bdAF62C37EEFFCb; + + // ============ Well-known MetaMorpho Vaults on Mainnet ============ + /// @dev Steakhouse USDC vault (MetaMorpho) + address constant STEAKHOUSE_USDC = 0xBEEF01735c132Ada46AA9aA4c54623cAA92A64CB; + + /// @dev Gauntlet USDC Prime vault (MetaMorpho) + address constant GAUNTLET_USDC_PRIME = 0xdd0f28e19C1780eb6396170735D45153D261490d; + + /// @dev Gauntlet WETH Prime vault (MetaMorpho) + address constant GAUNTLET_WETH_PRIME = 0x4881Ef0BF6d2365D3dd6499ccd7532bcdBCE0658; + + /// @dev Re7 WETH vault (MetaMorpho) + address constant RE7_WETH = 0x78Fc2c2eD1A4cDb5402365934aE5648aDAd094d0; + + // ============ Test State ============ + MorphoAprPairProvider public aprProvider; + uint256 public mainnetFork; + + function setUp() public { + // Create mainnet fork + string memory rpcUrl = vm.envString("MAINNET_RPC_URL"); + mainnetFork = vm.createFork(rpcUrl); + vm.selectFork(mainnetFork); + + console2.log("Fork created at block:", block.number); + console2.log("Using Morpho at:", MORPHO); + } + + /// @notice Verify the fork is set up correctly + function test_ForkSetup() public view { + assertEq(vm.activeFork(), mainnetFork); + + // Verify Morpho contract exists on mainnet + assertTrue(MORPHO.code.length > 0, "Morpho not deployed"); + assertTrue(STEAKHOUSE_USDC.code.length > 0, "Steakhouse USDC vault not deployed"); + + console2.log("Morpho contract verified on mainnet"); + } + + /// @notice Test deployment with Steakhouse USDC vault + function test_DeployWithSteakhouseUSDC() public { + aprProvider = new MorphoAprPairProvider(MORPHO, STEAKHOUSE_USDC, address(0)); + + assertEq(address(aprProvider.morpho()), MORPHO); + assertEq(aprProvider.baseVault(), STEAKHOUSE_USDC); + assertEq(aprProvider.targetVault(), STEAKHOUSE_USDC); + + console2.log("MorphoAprPairProvider deployed with:"); + console2.log(" Morpho:", address(aprProvider.morpho())); + console2.log(" Base Vault:", aprProvider.baseVault()); + console2.log(" Target Vault:", aprProvider.targetVault()); + } + + /// @notice Test deployment with different target and base vaults + function test_DeployWithDifferentVaults() public { + aprProvider = new MorphoAprPairProvider(MORPHO, STEAKHOUSE_USDC, GAUNTLET_USDC_PRIME); + + assertEq(address(aprProvider.morpho()), MORPHO); + assertEq(aprProvider.baseVault(), STEAKHOUSE_USDC); + assertEq(aprProvider.targetVault(), GAUNTLET_USDC_PRIME); + } + + /// @notice Test that deployment reverts with zero Morpho address + function test_RevertOnZeroMorphoAddress() public { + vm.expectRevert("Morpho address cannot be 0"); + new MorphoAprPairProvider(address(0), STEAKHOUSE_USDC, address(0)); + } + + /// @notice Test that deployment reverts with zero base vault address + function test_RevertOnZeroBaseVaultAddress() public { + vm.expectRevert("Base vault address cannot be 0"); + new MorphoAprPairProvider(MORPHO, address(0), address(0)); + } + + /// @notice Test getAprPair returns valid data from Steakhouse USDC + function test_GetAprPairSteakhouseUSDC() public { + aprProvider = new MorphoAprPairProvider(MORPHO, STEAKHOUSE_USDC, address(0)); + + (int64 aprTarget, int64 aprBase, uint64 timestamp) = aprProvider.getAprPair(); + + console2.log("=== Steakhouse USDC APR Data ==="); + console2.log("APR Target (scaled 1e12):", uint256(uint64(aprTarget))); + console2.log("APR Base (scaled 1e12):", uint256(uint64(aprBase))); + console2.log("Timestamp:", timestamp); + + // Convert to percentage for logging (divide by 1e10 to get percentage with 2 decimals) + console2.log("APR Target (%):", uint256(uint64(aprTarget)) / 1e10); + console2.log("APR Base (%):", uint256(uint64(aprBase)) / 1e10); + + // Verify timestamp is current + assertEq(timestamp, uint64(block.timestamp)); + + // Verify APRs are non-negative (they should be positive for an active vault) + assertTrue(aprTarget >= 0, "Target APR should be non-negative"); + assertTrue(aprBase >= 0, "Base APR should be non-negative"); + + // Verify APRs are reasonable (less than 100% = 1e12) + assertTrue(uint64(aprTarget) < 1e12, "Target APR should be less than 100%"); + assertTrue(uint64(aprBase) < 1e12, "Base APR should be less than 100%"); + } + + /// @notice Test getAprPair with Gauntlet USDC Prime vault + function test_GetAprPairGauntletUSDCPrime() public { + aprProvider = new MorphoAprPairProvider(MORPHO, GAUNTLET_USDC_PRIME, address(0)); + + (int64 aprTarget, int64 aprBase, uint64 timestamp) = aprProvider.getAprPair(); + + console2.log("=== Gauntlet USDC Prime APR Data ==="); + console2.log("APR Target (scaled 1e12):", uint256(uint64(aprTarget))); + console2.log("APR Base (scaled 1e12):", uint256(uint64(aprBase))); + console2.log("APR Target (%):", uint256(uint64(aprTarget)) / 1e10); + console2.log("APR Base (%):", uint256(uint64(aprBase)) / 1e10); + + assertEq(timestamp, uint64(block.timestamp)); + assertTrue(aprBase >= 0, "Base APR should be non-negative"); + } + + /// @notice Test getAprPair with WETH vault + function test_GetAprPairGauntletWETH() public { + aprProvider = new MorphoAprPairProvider(MORPHO, GAUNTLET_WETH_PRIME, address(0)); + + (int64 aprTarget, int64 aprBase, uint64 timestamp) = aprProvider.getAprPair(); + + console2.log("=== Gauntlet WETH Prime APR Data ==="); + console2.log("APR Base (scaled 1e12):", uint256(uint64(aprBase))); + console2.log("APR Base (%):", uint256(uint64(aprBase)) / 1e10); + + assertEq(timestamp, uint64(block.timestamp)); + assertTrue(aprBase >= 0, "Base APR should be non-negative"); + } + + /// @notice Test individual APR getter functions + function test_IndividualAPRGetters() public { + aprProvider = new MorphoAprPairProvider(MORPHO, STEAKHOUSE_USDC, address(0)); + + int64 aprTarget = aprProvider.getAPRtarget(); + int64 aprBase = aprProvider.getAPRbase(); + + console2.log("=== Individual APR Getters ==="); + console2.log("getAPRtarget():", uint256(uint64(aprTarget))); + console2.log("getAPRbase():", uint256(uint64(aprBase))); + + // Since target and base vault are the same, they should return the same value + assertEq(aprTarget, aprBase, "Same vault should return same APR"); + } + + /// @notice Test supplyAPYVaultV1 directly + function test_SupplyAPYVaultV1() public { + aprProvider = new MorphoAprPairProvider(MORPHO, STEAKHOUSE_USDC, address(0)); + + uint256 apyWad = aprProvider.supplyAPYVaultV1(STEAKHOUSE_USDC); + + console2.log("=== Supply APY (WAD) ==="); + console2.log("Steakhouse USDC APY (WAD):", apyWad); + console2.log("Steakhouse USDC APY (%):", apyWad / 1e16); // Convert to percentage + + // Verify APY is reasonable (less than 100% = 1e18) + assertTrue(apyWad < 1e18, "APY should be less than 100%"); + } + + /// @notice Test with different vaults to compare APRs + function test_CompareVaultAPRs() public { + console2.log("=== Comparing Vault APRs ==="); + + // Test Steakhouse USDC + MorphoAprPairProvider steakhouseProvider = new MorphoAprPairProvider(MORPHO, STEAKHOUSE_USDC, address(0)); + uint256 steakhouseApy = steakhouseProvider.supplyAPYVaultV1(STEAKHOUSE_USDC); + console2.log("Steakhouse USDC APY (%):", steakhouseApy / 1e16); + + // Test Gauntlet USDC Prime + MorphoAprPairProvider gauntletProvider = new MorphoAprPairProvider(MORPHO, GAUNTLET_USDC_PRIME, address(0)); + uint256 gauntletApy = gauntletProvider.supplyAPYVaultV1(GAUNTLET_USDC_PRIME); + console2.log("Gauntlet USDC Prime APY (%):", gauntletApy / 1e16); + + // Test Gauntlet WETH Prime + MorphoAprPairProvider wethProvider = new MorphoAprPairProvider(MORPHO, GAUNTLET_WETH_PRIME, address(0)); + uint256 wethApy = wethProvider.supplyAPYVaultV1(GAUNTLET_WETH_PRIME); + console2.log("Gauntlet WETH Prime APY (%):", wethApy / 1e16); + } + + /// @notice Test that the provider implements IStrategyAprPairProvider interface + function test_ImplementsInterface() public { + aprProvider = new MorphoAprPairProvider(MORPHO, STEAKHOUSE_USDC, address(0)); + + // Call through the interface + IStrategyAprPairProvider provider = IStrategyAprPairProvider(address(aprProvider)); + (int64 aprTarget, int64 aprBase, uint64 timestamp) = provider.getAprPair(); + + console2.log("=== Interface Test ==="); + console2.log("Called through IStrategyAprPairProvider"); + console2.log("APR Target:", uint256(uint64(aprTarget))); + console2.log("APR Base:", uint256(uint64(aprBase))); + + assertEq(timestamp, uint64(block.timestamp)); + } + + /// @notice Test vault with different target and base + function test_DifferentTargetAndBase() public { + // Use Steakhouse USDC as base and Gauntlet USDC Prime as target + aprProvider = new MorphoAprPairProvider(MORPHO, STEAKHOUSE_USDC, GAUNTLET_USDC_PRIME); + + (int64 aprTarget, int64 aprBase, uint64 timestamp) = aprProvider.getAprPair(); + + console2.log("=== Different Target and Base ==="); + console2.log("Base (Steakhouse USDC) APR:", uint256(uint64(aprBase))); + console2.log("Target (Gauntlet USDC Prime) APR:", uint256(uint64(aprTarget))); + console2.log("Base APR (%):", uint256(uint64(aprBase)) / 1e10); + console2.log("Target APR (%):", uint256(uint64(aprTarget)) / 1e10); + + assertEq(timestamp, uint64(block.timestamp)); + + // Target and base may be different since they come from different vaults + // Both should be non-negative + assertTrue(aprTarget >= 0, "Target APR should be non-negative"); + assertTrue(aprBase >= 0, "Base APR should be non-negative"); + } + + /// @notice Test querying market-level data + function test_VaultAssetsInMarket() public { + aprProvider = new MorphoAprPairProvider(MORPHO, STEAKHOUSE_USDC, address(0)); + + // Get the first market from the withdraw queue + uint256 queueLength = IMetaMorpho(STEAKHOUSE_USDC).withdrawQueueLength(); + console2.log("=== Vault Market Analysis ==="); + console2.log("Number of markets in withdraw queue:", queueLength); + + assertTrue(queueLength > 0, "Vault should have at least one market"); + } + + /// @notice Test that APR values are stable across multiple calls + function test_APRStability() public { + aprProvider = new MorphoAprPairProvider(MORPHO, STEAKHOUSE_USDC, address(0)); + + (int64 aprTarget1, int64 aprBase1,) = aprProvider.getAprPair(); + (int64 aprTarget2, int64 aprBase2,) = aprProvider.getAprPair(); + + // APRs should be identical when called in the same block + assertEq(aprTarget1, aprTarget2, "APR target should be stable within same block"); + assertEq(aprBase1, aprBase2, "APR base should be stable within same block"); + } +} + diff --git a/test/SteakhouseUSDC.t.sol b/test/SteakhouseUSDC.t.sol new file mode 100644 index 0000000..a8bcd4c --- /dev/null +++ b/test/SteakhouseUSDC.t.sol @@ -0,0 +1,252 @@ +pragma solidity 0.8.28; + +import {Test} from "forge-std/Test.sol"; +import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import {IERC4626} from "@openzeppelin/contracts/interfaces/IERC4626.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; + +import {MockERC4626} from "../contracts/test/MockERC4626.sol"; + +import {Tranche} from "../contracts/tranches/Tranche.sol"; +import {Accounting} from "../contracts/tranches/Accounting.sol"; + +import {SteakhouseUSDC} from "../contracts/tranches/strategies/morpho/SteakhouseUSDC.sol"; +import {AccessControlManager} from "../contracts/governance/AccessControlManager.sol"; + +import {AprPairFeed} from "../contracts/tranches/oracles/AprPairFeed.sol"; +import {IStrategyAprPairProvider} from "../contracts/tranches/interfaces/IAprPairFeed.sol"; + +import {console2} from "forge-std/console2.sol"; + +import {StrataCDO} from "../contracts/tranches/StrataCDO.sol"; + +import {ERC20Cooldown} from "../contracts/tranches/base/cooldown/ERC20Cooldown.sol"; +import {CooldownBase} from "../contracts/tranches/base/cooldown/CooldownBase.sol"; + +import {ITranche} from "../contracts/tranches/interfaces/ITranche.sol"; +import {IStrategy} from "../contracts/tranches/interfaces/IStrategy.sol"; +import {IAccounting} from "../contracts/tranches/interfaces/IAccounting.sol"; + +// Mock USDC token +contract MockUSDC is ERC20 { + constructor() ERC20("MockUSDC", "USDC") {} + + function decimals() public pure override returns (uint8) { + return 6; + } + + function mint(address to, uint256 amount) external { + _mint(to, amount); + } +} + +// Simple mock APR provider +contract MockAprPairProvider is IStrategyAprPairProvider { + int64 public aprTarget = 500e9; // 5% scaled by 1e12 + int64 public aprBase = 800e9; // 8% scaled by 1e12 + + function getAprPair() external view returns (int64, int64, uint64) { + return (aprTarget, aprBase, uint64(block.timestamp)); + } + + function setAprs(int64 _aprTarget, int64 _aprBase) external { + aprTarget = _aprTarget; + aprBase = _aprBase; + } +} + +contract SteakhouseUSDCTest is Test { + // External protocols + MockUSDC public USDC; + MockERC4626 public steakhouseUSD; + + // Auth + AccessControlManager public acm; + + // Strata CDO + StrataCDO public cdo; + + // Tranches + Tranche public jrtVault; + Tranche public srtVault; + + // Accounting Component + Accounting public accounting; + + // Basic Feed + AprPairFeed public feed; + MockAprPairProvider public aprProvider; + + // Strategy + SteakhouseUSDC public steakhouseStrategy; + ERC20Cooldown public erc20Cooldown; + + address account; + + function setUp() public { + address owner = msg.sender; + + vm.startPrank(owner); + + // Prepare USDC and steakhouseUSD vault + USDC = new MockUSDC(); + steakhouseUSD = new MockERC4626(IERC20(address(USDC))); + + // Prepare Acm + acm = new AccessControlManager(owner); + + // Create CDO + cdo = StrataCDO( + address( + new ERC1967Proxy( + address(new StrataCDO()), abi.encodeWithSelector(StrataCDO.initialize.selector, owner, address(acm)) + ) + ) + ); + + // Prepare Tranches + jrtVault = Tranche( + address( + new ERC1967Proxy( + address(new Tranche()), + abi.encodeWithSelector( + Tranche.initialize.selector, + owner, + address(acm), + "jrtVault", + "jrtUSDC", + IERC20(address(USDC)), + address(cdo) + ) + ) + ) + ); + srtVault = Tranche( + address( + new ERC1967Proxy( + address(new Tranche()), + abi.encodeWithSelector( + Tranche.initialize.selector, + owner, + address(acm), + "srtVault", + "srtUSDC", + IERC20(address(USDC)), + address(cdo) + ) + ) + ) + ); + + // Prepare cooldown + erc20Cooldown = ERC20Cooldown( + address( + new ERC1967Proxy( + address(new ERC20Cooldown()), + abi.encodeWithSelector(CooldownBase.initialize.selector, owner, address(acm)) + ) + ) + ); + + // Prepare Strategy + steakhouseStrategy = SteakhouseUSDC( + address( + new ERC1967Proxy( + address(new SteakhouseUSDC(IERC4626(address(steakhouseUSD)))), + abi.encodeWithSelector( + SteakhouseUSDC.initialize.selector, owner, address(acm), address(cdo), address(erc20Cooldown) + ) + ) + ) + ); + acm.grantRole(erc20Cooldown.COOLDOWN_WORKER_ROLE(), address(steakhouseStrategy)); + acm.grantRole(steakhouseStrategy.UPDATER_STRAT_CONFIG_ROLE(), owner); + + // Prepare Feed + aprProvider = new MockAprPairProvider(); + feed = AprPairFeed( + address( + new ERC1967Proxy( + address(new AprPairFeed()), + abi.encodeWithSelector( + AprPairFeed.initialize.selector, + owner, + address(acm), + IStrategyAprPairProvider(address(aprProvider)), + 4 hours, + "Steakhouse CDO APR Pair" + ) + ) + ) + ); + + // Prepare accounting + accounting = Accounting( + address( + new ERC1967Proxy( + address(new Accounting()), + abi.encodeWithSelector( + Accounting.initialize.selector, owner, address(acm), address(cdo), address(feed) + ) + ) + ) + ); + + // Configure CDO + cdo.configure( + IAccounting(address(accounting)), + IStrategy(address(steakhouseStrategy)), + ITranche(address(jrtVault)), + ITranche(address(srtVault)) + ); + acm.grantRole(cdo.PAUSER_ROLE(), owner); + cdo.setActionStates(address(0), true, true); + + vm.stopPrank(); + } + + function test_Flow() public { + assert(address(USDC) != address(0)); + + account = msg.sender; + address owner = msg.sender; + + vm.startPrank(owner); + + // Set cooldown periods (7 days for both tranches) + uint256 cooldownPeriod = 7 days; + steakhouseStrategy.setCooldowns(cooldownPeriod, cooldownPeriod); + + // test deposit + uint256 shares = 1000 * 10 ** USDC.decimals(); // 1000 USDC + USDC.mint(account, shares); + USDC.approve(address(jrtVault), shares); + jrtVault.deposit(address(USDC), shares, address(0xdead)); + assertBalance(jrtVault, address(0xdead), shares, "Deposit shares failed"); + + USDC.mint(account, shares); + USDC.approve(address(jrtVault), shares); + jrtVault.deposit(address(USDC), shares, account); + jrtVault.withdraw(address(USDC), shares, account, account); + assertBalance(USDC, account, 0, "Cooldown period failed"); + + vm.warp(block.timestamp + 7 days); + erc20Cooldown.finalize(steakhouseUSD, account); + assertBalance(USDC, account, shares, "After-Cooldown period failed"); + + vm.stopPrank(); + } + + function depositGeneric(IERC4626 vault, uint256 amount) internal { + IERC20 asset = IERC20(vault.asset()); + asset.approve(address(vault), amount); + vault.deposit(amount, account); + } + + function assertBalance(IERC20 token, address owner, uint256 amount, string memory message) internal { + uint256 balance = token.balanceOf(owner); + assertEq(balance, amount, message); + } +} + From 4f48e263373f1f5d491c29c4bc84614c76ff980b Mon Sep 17 00:00:00 2001 From: vivekascoder Date: Fri, 16 Jan 2026 16:49:18 +0530 Subject: [PATCH 21/24] chore: morpho strategy contract changed to generic name, deployment solidity script --- .../tranches/strategies/morpho/IMorpho.sol | 355 ------------------ ...{SteakhouseUSDC.sol => MorphoStrategy.sol} | 179 ++++----- ...akhouseUSDC.t.sol => MorphoStrategy.t.sol} | 30 +- test/SmokehouseDeploy.t.sol | 294 +++++++++++++++ 4 files changed, 399 insertions(+), 459 deletions(-) delete mode 100644 contracts/tranches/strategies/morpho/IMorpho.sol rename contracts/tranches/strategies/morpho/{SteakhouseUSDC.sol => MorphoStrategy.sol} (63%) rename test/{SteakhouseUSDC.t.sol => MorphoStrategy.t.sol} (89%) create mode 100644 test/SmokehouseDeploy.t.sol diff --git a/contracts/tranches/strategies/morpho/IMorpho.sol b/contracts/tranches/strategies/morpho/IMorpho.sol deleted file mode 100644 index 53e1bb0..0000000 --- a/contracts/tranches/strategies/morpho/IMorpho.sol +++ /dev/null @@ -1,355 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-or-later -pragma solidity >=0.5.0; - -type Id is bytes32; - -struct MarketParams { - address loanToken; - address collateralToken; - address oracle; - address irm; - uint256 lltv; -} - -/// @dev Warning: For `feeRecipient`, `supplyShares` does not contain the accrued shares since the last interest -/// accrual. -struct Position { - uint256 supplyShares; - uint128 borrowShares; - uint128 collateral; -} - -/// @dev Warning: `totalSupplyAssets` does not contain the accrued interest since the last interest accrual. -/// @dev Warning: `totalBorrowAssets` does not contain the accrued interest since the last interest accrual. -/// @dev Warning: `totalSupplyShares` does not contain the additional shares accrued by `feeRecipient` since the last -/// interest accrual. -struct Market { - uint128 totalSupplyAssets; - uint128 totalSupplyShares; - uint128 totalBorrowAssets; - uint128 totalBorrowShares; - uint128 lastUpdate; - uint128 fee; -} - -struct Authorization { - address authorizer; - address authorized; - bool isAuthorized; - uint256 nonce; - uint256 deadline; -} - -struct Signature { - uint8 v; - bytes32 r; - bytes32 s; -} - -/// @dev This interface is used for factorizing IMorphoStaticTyping and IMorpho. -/// @dev Consider using the IMorpho interface instead of this one. -interface IMorphoBase { - /// @notice The EIP-712 domain separator. - /// @dev Warning: Every EIP-712 signed message based on this domain separator can be reused on chains sharing the - /// same chain id and on forks because the domain separator would be the same. - function DOMAIN_SEPARATOR() external view returns (bytes32); - - /// @notice The owner of the contract. - /// @dev It has the power to change the owner. - /// @dev It has the power to set fees on markets and set the fee recipient. - /// @dev It has the power to enable but not disable IRMs and LLTVs. - function owner() external view returns (address); - - /// @notice The fee recipient of all markets. - /// @dev The recipient receives the fees of a given market through a supply position on that market. - function feeRecipient() external view returns (address); - - /// @notice Whether the `irm` is enabled. - function isIrmEnabled(address irm) external view returns (bool); - - /// @notice Whether the `lltv` is enabled. - function isLltvEnabled(uint256 lltv) external view returns (bool); - - /// @notice Whether `authorized` is authorized to modify `authorizer`'s position on all markets. - /// @dev Anyone is authorized to modify their own positions, regardless of this variable. - function isAuthorized(address authorizer, address authorized) external view returns (bool); - - /// @notice The `authorizer`'s current nonce. Used to prevent replay attacks with EIP-712 signatures. - function nonce(address authorizer) external view returns (uint256); - - /// @notice Sets `newOwner` as `owner` of the contract. - /// @dev Warning: No two-step transfer ownership. - /// @dev Warning: The owner can be set to the zero address. - function setOwner(address newOwner) external; - - /// @notice Enables `irm` as a possible IRM for market creation. - /// @dev Warning: It is not possible to disable an IRM. - function enableIrm(address irm) external; - - /// @notice Enables `lltv` as a possible LLTV for market creation. - /// @dev Warning: It is not possible to disable a LLTV. - function enableLltv(uint256 lltv) external; - - /// @notice Sets the `newFee` for the given market `marketParams`. - /// @param newFee The new fee, scaled by WAD. - /// @dev Warning: The recipient can be the zero address. - function setFee(MarketParams memory marketParams, uint256 newFee) external; - - /// @notice Sets `newFeeRecipient` as `feeRecipient` of the fee. - /// @dev Warning: If the fee recipient is set to the zero address, fees will accrue there and will be lost. - /// @dev Modifying the fee recipient will allow the new recipient to claim any pending fees not yet accrued. To - /// ensure that the current recipient receives all due fees, accrue interest manually prior to making any changes. - function setFeeRecipient(address newFeeRecipient) external; - - /// @notice Creates the market `marketParams`. - /// @dev Here is the list of assumptions on the market's dependencies (tokens, IRM and oracle) that guarantees - /// Morpho behaves as expected: - /// - The token should be ERC-20 compliant, except that it can omit return values on `transfer` and `transferFrom`. - /// - The token balance of Morpho should only decrease on `transfer` and `transferFrom`. In particular, tokens with - /// burn functions are not supported. - /// - The token should not re-enter Morpho on `transfer` nor `transferFrom`. - /// - The token balance of the sender (resp. receiver) should decrease (resp. increase) by exactly the given amount - /// on `transfer` and `transferFrom`. In particular, tokens with fees on transfer are not supported. - /// - The IRM should not re-enter Morpho. - /// - The oracle should return a price with the correct scaling. - /// - The oracle price should not be able to change instantly such that the new price is less than the old price - /// multiplied by LLTV*LIF. In particular, if the loan asset is a vault that can receive donations, the oracle - /// should not price its shares using the AUM. - /// @dev Here is a list of assumptions on the market's dependencies which, if broken, could break Morpho's liveness - /// properties (funds could get stuck): - /// - The token should not revert on `transfer` and `transferFrom` if balances and approvals are right. - /// - The amount of assets supplied and borrowed should not be too high (max ~1e32), otherwise the number of shares - /// might not fit within 128 bits. - /// - The IRM should not revert on `borrowRate`. - /// - The IRM should not return a very high borrow rate (otherwise the computation of `interest` in - /// `_accrueInterest` can overflow). - /// - The oracle should not revert `price`. - /// - The oracle should not return a very high price (otherwise the computation of `maxBorrow` in `_isHealthy` or of - /// `assetsRepaid` in `liquidate` can overflow). - /// @dev The borrow share price of a market with less than 1e4 assets borrowed can be decreased by manipulations, to - /// the point where `totalBorrowShares` is very large and borrowing overflows. - function createMarket(MarketParams memory marketParams) external; - - /// @notice Supplies `assets` or `shares` on behalf of `onBehalf`, optionally calling back the caller's - /// `onMorphoSupply` function with the given `data`. - /// @dev Either `assets` or `shares` should be zero. Most use cases should rely on `assets` as an input so the - /// caller is guaranteed to have `assets` tokens pulled from their balance, but the possibility to mint a specific - /// amount of shares is given for full compatibility and precision. - /// @dev Supplying a large amount can revert for overflow. - /// @dev Supplying an amount of shares may lead to supply more or fewer assets than expected due to slippage. - /// Consider using the `assets` parameter to avoid this. - /// @param marketParams The market to supply assets to. - /// @param assets The amount of assets to supply. - /// @param shares The amount of shares to mint. - /// @param onBehalf The address that will own the increased supply position. - /// @param data Arbitrary data to pass to the `onMorphoSupply` callback. Pass empty data if not needed. - /// @return assetsSupplied The amount of assets supplied. - /// @return sharesSupplied The amount of shares minted. - function supply( - MarketParams memory marketParams, - uint256 assets, - uint256 shares, - address onBehalf, - bytes memory data - ) external returns (uint256 assetsSupplied, uint256 sharesSupplied); - - /// @notice Withdraws `assets` or `shares` on behalf of `onBehalf` and sends the assets to `receiver`. - /// @dev Either `assets` or `shares` should be zero. To withdraw max, pass the `shares`'s balance of `onBehalf`. - /// @dev `msg.sender` must be authorized to manage `onBehalf`'s positions. - /// @dev Withdrawing an amount corresponding to more shares than supplied will revert for underflow. - /// @dev It is advised to use the `shares` input when withdrawing the full position to avoid reverts due to - /// conversion roundings between shares and assets. - /// @param marketParams The market to withdraw assets from. - /// @param assets The amount of assets to withdraw. - /// @param shares The amount of shares to burn. - /// @param onBehalf The address of the owner of the supply position. - /// @param receiver The address that will receive the withdrawn assets. - /// @return assetsWithdrawn The amount of assets withdrawn. - /// @return sharesWithdrawn The amount of shares burned. - function withdraw( - MarketParams memory marketParams, - uint256 assets, - uint256 shares, - address onBehalf, - address receiver - ) external returns (uint256 assetsWithdrawn, uint256 sharesWithdrawn); - - /// @notice Borrows `assets` or `shares` on behalf of `onBehalf` and sends the assets to `receiver`. - /// @dev Either `assets` or `shares` should be zero. Most use cases should rely on `assets` as an input so the - /// caller is guaranteed to borrow `assets` of tokens, but the possibility to mint a specific amount of shares is - /// given for full compatibility and precision. - /// @dev `msg.sender` must be authorized to manage `onBehalf`'s positions. - /// @dev Borrowing a large amount can revert for overflow. - /// @dev Borrowing an amount of shares may lead to borrow fewer assets than expected due to slippage. - /// Consider using the `assets` parameter to avoid this. - /// @param marketParams The market to borrow assets from. - /// @param assets The amount of assets to borrow. - /// @param shares The amount of shares to mint. - /// @param onBehalf The address that will own the increased borrow position. - /// @param receiver The address that will receive the borrowed assets. - /// @return assetsBorrowed The amount of assets borrowed. - /// @return sharesBorrowed The amount of shares minted. - function borrow( - MarketParams memory marketParams, - uint256 assets, - uint256 shares, - address onBehalf, - address receiver - ) external returns (uint256 assetsBorrowed, uint256 sharesBorrowed); - - /// @notice Repays `assets` or `shares` on behalf of `onBehalf`, optionally calling back the caller's - /// `onMorphoRepay` function with the given `data`. - /// @dev Either `assets` or `shares` should be zero. To repay max, pass the `shares`'s balance of `onBehalf`. - /// @dev Repaying an amount corresponding to more shares than borrowed will revert for underflow. - /// @dev It is advised to use the `shares` input when repaying the full position to avoid reverts due to conversion - /// roundings between shares and assets. - /// @dev An attacker can front-run a repay with a small repay making the transaction revert for underflow. - /// @param marketParams The market to repay assets to. - /// @param assets The amount of assets to repay. - /// @param shares The amount of shares to burn. - /// @param onBehalf The address of the owner of the debt position. - /// @param data Arbitrary data to pass to the `onMorphoRepay` callback. Pass empty data if not needed. - /// @return assetsRepaid The amount of assets repaid. - /// @return sharesRepaid The amount of shares burned. - function repay( - MarketParams memory marketParams, - uint256 assets, - uint256 shares, - address onBehalf, - bytes memory data - ) external returns (uint256 assetsRepaid, uint256 sharesRepaid); - - /// @notice Supplies `assets` of collateral on behalf of `onBehalf`, optionally calling back the caller's - /// `onMorphoSupplyCollateral` function with the given `data`. - /// @dev Interest are not accrued since it's not required and it saves gas. - /// @dev Supplying a large amount can revert for overflow. - /// @param marketParams The market to supply collateral to. - /// @param assets The amount of collateral to supply. - /// @param onBehalf The address that will own the increased collateral position. - /// @param data Arbitrary data to pass to the `onMorphoSupplyCollateral` callback. Pass empty data if not needed. - function supplyCollateral(MarketParams memory marketParams, uint256 assets, address onBehalf, bytes memory data) - external; - - /// @notice Withdraws `assets` of collateral on behalf of `onBehalf` and sends the assets to `receiver`. - /// @dev `msg.sender` must be authorized to manage `onBehalf`'s positions. - /// @dev Withdrawing an amount corresponding to more collateral than supplied will revert for underflow. - /// @param marketParams The market to withdraw collateral from. - /// @param assets The amount of collateral to withdraw. - /// @param onBehalf The address of the owner of the collateral position. - /// @param receiver The address that will receive the collateral assets. - function withdrawCollateral(MarketParams memory marketParams, uint256 assets, address onBehalf, address receiver) - external; - - /// @notice Liquidates the given `repaidShares` of debt asset or seize the given `seizedAssets` of collateral on the - /// given market `marketParams` of the given `borrower`'s position, optionally calling back the caller's - /// `onMorphoLiquidate` function with the given `data`. - /// @dev Either `seizedAssets` or `repaidShares` should be zero. - /// @dev Seizing more than the collateral balance will underflow and revert without any error message. - /// @dev Repaying more than the borrow balance will underflow and revert without any error message. - /// @dev An attacker can front-run a liquidation with a small repay making the transaction revert for underflow. - /// @param marketParams The market of the position. - /// @param borrower The owner of the position. - /// @param seizedAssets The amount of collateral to seize. - /// @param repaidShares The amount of shares to repay. - /// @param data Arbitrary data to pass to the `onMorphoLiquidate` callback. Pass empty data if not needed. - /// @return The amount of assets seized. - /// @return The amount of assets repaid. - function liquidate( - MarketParams memory marketParams, - address borrower, - uint256 seizedAssets, - uint256 repaidShares, - bytes memory data - ) external returns (uint256, uint256); - - /// @notice Executes a flash loan. - /// @dev Flash loans have access to the whole balance of the contract (the liquidity and deposited collateral of all - /// markets combined, plus donations). - /// @dev Warning: Not ERC-3156 compliant but compatibility is easily reached: - /// - `flashFee` is zero. - /// - `maxFlashLoan` is the token's balance of this contract. - /// - The receiver of `assets` is the caller. - /// @param token The token to flash loan. - /// @param assets The amount of assets to flash loan. - /// @param data Arbitrary data to pass to the `onMorphoFlashLoan` callback. - function flashLoan(address token, uint256 assets, bytes calldata data) external; - - /// @notice Sets the authorization for `authorized` to manage `msg.sender`'s positions. - /// @param authorized The authorized address. - /// @param newIsAuthorized The new authorization status. - function setAuthorization(address authorized, bool newIsAuthorized) external; - - /// @notice Sets the authorization for `authorization.authorized` to manage `authorization.authorizer`'s positions. - /// @dev Warning: Reverts if the signature has already been submitted. - /// @dev The signature is malleable, but it has no impact on the security here. - /// @dev The nonce is passed as argument to be able to revert with a different error message. - /// @param authorization The `Authorization` struct. - /// @param signature The signature. - function setAuthorizationWithSig(Authorization calldata authorization, Signature calldata signature) external; - - /// @notice Accrues interest for the given market `marketParams`. - function accrueInterest(MarketParams memory marketParams) external; - - /// @notice Returns the data stored on the different `slots`. - function extSloads(bytes32[] memory slots) external view returns (bytes32[] memory); -} - -/// @dev This interface is inherited by Morpho so that function signatures are checked by the compiler. -/// @dev Consider using the IMorpho interface instead of this one. -interface IMorphoStaticTyping is IMorphoBase { - /// @notice The state of the position of `user` on the market corresponding to `id`. - /// @dev Warning: For `feeRecipient`, `supplyShares` does not contain the accrued shares since the last interest - /// accrual. - function position(Id id, address user) - external - view - returns (uint256 supplyShares, uint128 borrowShares, uint128 collateral); - - /// @notice The state of the market corresponding to `id`. - /// @dev Warning: `totalSupplyAssets` does not contain the accrued interest since the last interest accrual. - /// @dev Warning: `totalBorrowAssets` does not contain the accrued interest since the last interest accrual. - /// @dev Warning: `totalSupplyShares` does not contain the accrued shares by `feeRecipient` since the last interest - /// accrual. - function market(Id id) - external - view - returns ( - uint128 totalSupplyAssets, - uint128 totalSupplyShares, - uint128 totalBorrowAssets, - uint128 totalBorrowShares, - uint128 lastUpdate, - uint128 fee - ); - - /// @notice The market params corresponding to `id`. - /// @dev This mapping is not used in Morpho. It is there to enable reducing the cost associated to calldata on layer - /// 2s by creating a wrapper contract with functions that take `id` as input instead of `marketParams`. - function idToMarketParams(Id id) - external - view - returns (address loanToken, address collateralToken, address oracle, address irm, uint256 lltv); -} - -/// @title IMorpho -/// @author Morpho Labs -/// @custom:contact security@morpho.org -/// @dev Use this interface for Morpho to have access to all the functions with the appropriate function signatures. -interface IMorpho is IMorphoBase { - /// @notice The state of the position of `user` on the market corresponding to `id`. - /// @dev Warning: For `feeRecipient`, `p.supplyShares` does not contain the accrued shares since the last interest - /// accrual. - function position(Id id, address user) external view returns (Position memory p); - - /// @notice The state of the market corresponding to `id`. - /// @dev Warning: `m.totalSupplyAssets` does not contain the accrued interest since the last interest accrual. - /// @dev Warning: `m.totalBorrowAssets` does not contain the accrued interest since the last interest accrual. - /// @dev Warning: `m.totalSupplyShares` does not contain the accrued shares by `feeRecipient` since the last - /// interest accrual. - function market(Id id) external view returns (Market memory m); - - /// @notice The market params corresponding to `id`. - /// @dev This mapping is not used in Morpho. It is there to enable reducing the cost associated to calldata on layer - /// 2s by creating a wrapper contract with functions that take `id` as input instead of `marketParams`. - function idToMarketParams(Id id) external view returns (MarketParams memory); -} diff --git a/contracts/tranches/strategies/morpho/SteakhouseUSDC.sol b/contracts/tranches/strategies/morpho/MorphoStrategy.sol similarity index 63% rename from contracts/tranches/strategies/morpho/SteakhouseUSDC.sol rename to contracts/tranches/strategies/morpho/MorphoStrategy.sol index 757faa9..61d24b9 100644 --- a/contracts/tranches/strategies/morpho/SteakhouseUSDC.sol +++ b/contracts/tranches/strategies/morpho/MorphoStrategy.sol @@ -12,32 +12,32 @@ import {IDistributor} from "../../interfaces/IDistributor.sol"; import {ISwapContract} from "../../interfaces/ISwapContract.sol"; import {Strategy} from "../../Strategy.sol"; -contract SteakhouseUSDC is Strategy { - IERC4626 public immutable steakhouseUSD; - IERC20 public immutable USDC; +contract MorphoStrategy is Strategy { + IERC4626 public immutable vault; + IERC20 public immutable asset; IERC20Cooldown public erc20Cooldown; /// @notice Merkl distributor contract for claiming rewards IDistributor public distributor; - /// @notice SwapContract for swapping rewards to USDC + /// @notice SwapContract for swapping rewards to asset ISwapContract public swapContract; /** * configuration */ - uint256 public steakhouseUSDCooldownJrt; - uint256 public steakhouseUSDCooldownSrt; + uint256 public vaultCooldownJrt; + uint256 public vaultCooldownSrt; event CooldownsChanged(uint256 jrt, uint256 srt); - event RewardsClaimed(address indexed rewardToken, uint256 rewardAmount, uint256 usdcReceived); + event RewardsClaimed(address indexed rewardToken, uint256 rewardAmount, uint256 assetReceived); event DistributorUpdated(address indexed distributor); event SwapContractUpdated(address indexed swapContract); - constructor(IERC4626 steakhouseUSD_) { - steakhouseUSD = steakhouseUSD_; - USDC = IERC20(steakhouseUSD_.asset()); + constructor(IERC4626 vault_) { + vault = vault_; + asset = IERC20(vault_.asset()); } function initialize(address owner_, address acm_, IStrataCDO cdo_, IERC20Cooldown erc20Cooldown_) @@ -50,18 +50,18 @@ contract SteakhouseUSDC is Strategy { cdo = cdo_; erc20Cooldown = erc20Cooldown_; - SafeERC20.forceApprove(steakhouseUSD, address(erc20Cooldown), type(uint256).max); + SafeERC20.forceApprove(vault, address(erc20Cooldown), type(uint256).max); } /** * @notice Processes asset deposits for the CDO contract. * @dev This method is called by the CDO contract to handle asset deposits. - * If the deposited token is USDC, it will be deposited to receive steakhouseUSD shares. - * If the deposited token is already steakhouseUSD shares, it will be accepted as is. + * If the deposited token is the base asset, it will be deposited to receive vault shares. + * If the deposited token is already vault shares, it will be accepted as is. * @param tranche The address of the tranche depositing assets (not used in this strategy) * @param token The address of the token being deposited * @param tokenAmount The amount of tokens being deposited - * @param baseAssets The amount of base assets represented by the deposit (used for steakhouseUSD deposits) + * @param baseAssets The amount of base assets represented by the deposit (used for vault deposits) * @param owner The address of the asset owner from whom to transfer tokens * @return The amount of base assets received after deposit */ @@ -72,12 +72,12 @@ contract SteakhouseUSDC is Strategy { { SafeERC20.safeTransferFrom(IERC20(token), owner, address(this), tokenAmount); - if (token == address(USDC)) { - SafeERC20.forceApprove(USDC, address(steakhouseUSD), tokenAmount); - steakhouseUSD.deposit(tokenAmount, address(this)); + if (token == address(asset)) { + SafeERC20.forceApprove(asset, address(vault), tokenAmount); + vault.deposit(tokenAmount, address(this)); return tokenAmount; } - if (token == address(steakhouseUSD)) { + if (token == address(vault)) { // already transferred in ↑ return baseAssets; } @@ -87,16 +87,16 @@ contract SteakhouseUSDC is Strategy { /** * @notice Processes asset withdrawals for the CDO contract. * @dev This method is called by the CDO contract to handle asset withdrawals. - * If withdrawing steakhouseUSD shares, a cooldown period is applied based on the tranche type. - * If withdrawing USDC, the steakhouseUSD shares are redeemed directly (no cooldown mechanism). - * An overloaded version accepts a shouldSkipCooldown parameter to skip the cooldown for steakhouseUSD withdrawals. + * If withdrawing vault shares, a cooldown period is applied based on the tranche type. + * If withdrawing the base asset, the vault shares are redeemed directly (no cooldown mechanism). + * An overloaded version accepts a shouldSkipCooldown parameter to skip the cooldown for vault share withdrawals. * @param tranche The address of the tranche withdrawing assets * @param token The address of the token to be withdrawn * @param tokenAmount The amount of tokens to be withdrawn (not used in this implementation) * @param baseAssets The amount of base assets to be withdrawn * @param receiver The address that will receive the withdrawn assets * @param sender The account that initiated the withdrawal - * @return The amount of tokens withdrawn (shares for steakhouseUSD, baseAssets for USDC) + * @return The amount of tokens withdrawn (shares for vault, baseAssets for base asset) */ function withdraw( address tranche, @@ -130,16 +130,16 @@ contract SteakhouseUSDC is Strategy { address receiver, bool shouldSkipCooldown ) internal returns (uint256) { - uint256 shares = steakhouseUSD.previewWithdraw(baseAssets); - if (token == address(steakhouseUSD)) { + uint256 shares = vault.previewWithdraw(baseAssets); + if (token == address(vault)) { uint256 cooldownSeconds = - shouldSkipCooldown ? 0 : (cdo.isJrt(tranche) ? steakhouseUSDCooldownJrt : steakhouseUSDCooldownSrt); - erc20Cooldown.transfer(steakhouseUSD, sender, receiver, shares, cooldownSeconds); + shouldSkipCooldown ? 0 : (cdo.isJrt(tranche) ? vaultCooldownJrt : vaultCooldownSrt); + erc20Cooldown.transfer(vault, sender, receiver, shares, cooldownSeconds); return shares; } - if (token == address(USDC)) { + if (token == address(asset)) { // Morpho allows direct withdrawal - no cooldown needed - steakhouseUSD.withdraw(baseAssets, receiver, address(this)); + vault.withdraw(baseAssets, receiver, address(this)); return baseAssets; } revert UnsupportedToken(token); @@ -148,21 +148,21 @@ contract SteakhouseUSDC is Strategy { /** * @notice Allows the CDO to withdraw tokens from the strategy's reserve * @dev This function is part of the reserve reduction process and can only be called by the CDO. - * It handles both steakhouseUSD shares and USDC tokens, applying different transfer mechanisms for each. - * For steakhouseUSD shares, it uses erc20Cooldown with no cooldown period. - * For USDC, it withdraws directly from the Morpho vault. - * @param token The address of the token to be withdrawn (either steakhouseUSD or USDC) + * It handles both vault shares and base asset tokens, applying different transfer mechanisms for each. + * For vault shares, it uses erc20Cooldown with no cooldown period. + * For base asset, it withdraws directly from the Morpho vault. + * @param token The address of the token to be withdrawn (either vault shares or base asset) * @param tokenAmount The amount of tokens to be withdrawn * @param receiver The address that will receive the withdrawn tokens */ function reduceReserve(address token, uint256 tokenAmount, address receiver) external onlyCDO { - if (token == address(steakhouseUSD)) { - erc20Cooldown.transfer(steakhouseUSD, receiver, receiver, tokenAmount, 0); + if (token == address(vault)) { + erc20Cooldown.transfer(vault, receiver, receiver, tokenAmount, 0); return; } - if (token == address(USDC)) { + if (token == address(asset)) { // Direct withdrawal from Morpho vault - steakhouseUSD.withdraw(tokenAmount, receiver, address(this)); + vault.withdraw(tokenAmount, receiver, address(this)); return; } revert UnsupportedToken(token); @@ -170,94 +170,94 @@ contract SteakhouseUSDC is Strategy { /** * @notice Calculates the total assets managed by this strategy - * @dev This function returns the current value of the strategy's assets in USDC. - * @return baseAssets The total amount of USDC managed by this strategy + * @dev This function returns the current value of the strategy's assets in the base asset. + * @return baseAssets The total amount of base asset managed by this strategy */ function totalAssets() external view returns (uint256 baseAssets) { - uint256 shares = steakhouseUSD.balanceOf(address(this)); - baseAssets = steakhouseUSD.previewRedeem(shares); + uint256 shares = vault.balanceOf(address(this)); + baseAssets = vault.previewRedeem(shares); return baseAssets; } /** - * @notice Converts a given amount of supported tokens to their equivalent in USDC - * @dev This function handles conversion for both steakhouseUSD shares and USDC tokens. - * For steakhouseUSD shares, it uses the vault's exchange rate, considering the rounding direction. - * For USDC, it returns the input amount as is. - * @param token The address of the token to convert (either steakhouseUSD or USDC) + * @notice Converts a given amount of supported tokens to their equivalent in the base asset + * @dev This function handles conversion for both vault shares and base asset tokens. + * For vault shares, it uses the vault's exchange rate, considering the rounding direction. + * For base asset, it returns the input amount as is. + * @param token The address of the token to convert (either vault shares or base asset) * @param tokenAmount The amount of tokens to convert * @param rounding The rounding direction to use for the conversion (floor or ceiling) - * @return The equivalent amount in USDC + * @return The equivalent amount in the base asset */ function convertToAssets(address token, uint256 tokenAmount, Math.Rounding rounding) external view returns (uint256) { - if (token == address(steakhouseUSD)) { + if (token == address(vault)) { return rounding == Math.Rounding.Floor - ? steakhouseUSD.previewRedeem(tokenAmount) // aka convertToAssets(tokenAmount) - : steakhouseUSD.previewMint(tokenAmount); + ? vault.previewRedeem(tokenAmount) // aka convertToAssets(tokenAmount) + : vault.previewMint(tokenAmount); } - if (token == address(USDC)) { + if (token == address(asset)) { return tokenAmount; } revert UnsupportedToken(token); } /** - * @notice Converts a given amount of base assets (USDC) to the equivalent amount of supported tokens - * @dev This function handles conversion for both steakhouseUSD shares and USDC tokens. - * For steakhouseUSD shares, it uses the vault's exchange rate, considering the rounding direction. - * For USDC, it returns the input amount as is. - * @param token The address of the token to convert to (either steakhouseUSD or USDC) - * @param baseAssets The amount of base assets (USDC) to convert + * @notice Converts a given amount of base assets to the equivalent amount of supported tokens + * @dev This function handles conversion for both vault shares and base asset tokens. + * For vault shares, it uses the vault's exchange rate, considering the rounding direction. + * For base asset, it returns the input amount as is. + * @param token The address of the token to convert to (either vault shares or base asset) + * @param baseAssets The amount of base assets to convert * @param rounding The rounding direction to use for the conversion (floor or ceiling) - * @return The equivalent amount in the requested token (steakhouseUSD shares or USDC) + * @return The equivalent amount in the requested token (vault shares or base asset) */ function convertToTokens(address token, uint256 baseAssets, Math.Rounding rounding) external view returns (uint256) { - if (token == address(steakhouseUSD)) { + if (token == address(vault)) { return rounding == Math.Rounding.Floor - ? steakhouseUSD.previewDeposit(baseAssets) // aka convertToShares(baseAssets) - : steakhouseUSD.previewWithdraw(baseAssets); + ? vault.previewDeposit(baseAssets) // aka convertToShares(baseAssets) + : vault.previewWithdraw(baseAssets); } - if (token == address(USDC)) { + if (token == address(asset)) { return baseAssets; } revert UnsupportedToken(token); } /** - * @notice Returns an array of supported tokens: steakhouseUSD shares and USDC + * @notice Returns an array of supported tokens: vault shares and base asset */ function getSupportedTokens() external view returns (IERC20[] memory) { IERC20[] memory tokens = new IERC20[](2); - tokens[0] = IERC20(address(steakhouseUSD)); - tokens[1] = USDC; + tokens[0] = IERC20(address(vault)); + tokens[1] = asset; return tokens; } /** - * @notice Updates the cooldown periods for steakhouseUSD share withdrawals + * @notice Updates the cooldown periods for vault share withdrawals */ - function setCooldowns(uint256 steakhouseUSDCooldownJrt_, uint256 steakhouseUSDCooldownSrt_) + function setCooldowns(uint256 vaultCooldownJrt_, uint256 vaultCooldownSrt_) external onlyRole(UPDATER_STRAT_CONFIG_ROLE) { uint256 WEEK = 7 days; - if (steakhouseUSDCooldownJrt_ > WEEK || steakhouseUSDCooldownSrt_ > WEEK) { + if (vaultCooldownJrt_ > WEEK || vaultCooldownSrt_ > WEEK) { revert InvalidConfigCooldown(); } - steakhouseUSDCooldownJrt = steakhouseUSDCooldownJrt_; - steakhouseUSDCooldownSrt = steakhouseUSDCooldownSrt_; + vaultCooldownJrt = vaultCooldownJrt_; + vaultCooldownSrt = vaultCooldownSrt_; - bool isDisabled = steakhouseUSDCooldownJrt_ == 0 && steakhouseUSDCooldownSrt_ == 0; - erc20Cooldown.setCooldownDisabled(steakhouseUSD, isDisabled); - emit CooldownsChanged(steakhouseUSDCooldownJrt_, steakhouseUSDCooldownSrt_); + bool isDisabled = vaultCooldownJrt_ == 0 && vaultCooldownSrt_ == 0; + erc20Cooldown.setCooldownDisabled(vault, isDisabled); + emit CooldownsChanged(vaultCooldownJrt_, vaultCooldownSrt_); } /** @@ -279,16 +279,16 @@ contract SteakhouseUSDC is Strategy { } /** - * @notice Claims rewards from the Merkl distributor and swaps them to USDC - * @dev This function claims rewards for this contract and swaps them to USDC using the SwapContract + * @notice Claims rewards from the Merkl distributor and swaps them to the base asset + * @dev This function claims rewards for this contract and swaps them to the base asset using the SwapContract * @param tokens Array of reward token addresses to claim * @param amounts Array of cumulative amounts earned (from Merkle tree) * @param proofs Array of Merkle proofs for each claim - * @param poolKeysData Array of ABI-encoded PoolKey structs for swapping each reward token to USDC + * @param poolKeysData Array of ABI-encoded PoolKey structs for swapping each reward token to base asset * @param zeroForOnes Array of swap directions for each token - * @param minAmountsOut Array of minimum USDC amounts expected from each swap (slippage protection) + * @param minAmountsOut Array of minimum base asset amounts expected from each swap (slippage protection) * @param deadline Timestamp after which the swaps will revert - * @return totalUsdcReceived Total USDC received from all swaps + * @return totalAssetReceived Total base asset received from all swaps */ function claimRewards( address[] calldata tokens, @@ -298,7 +298,7 @@ contract SteakhouseUSDC is Strategy { bool[] calldata zeroForOnes, uint128[] calldata minAmountsOut, uint256 deadline - ) external onlyRole(UPDATER_STRAT_CONFIG_ROLE) returns (uint256 totalUsdcReceived) { + ) external onlyRole(UPDATER_STRAT_CONFIG_ROLE) returns (uint256 totalAssetReceived) { uint256 length = tokens.length; require( length == amounts.length && length == proofs.length && length == poolKeysData.length @@ -317,15 +317,15 @@ contract SteakhouseUSDC is Strategy { // Claim rewards from the distributor distributor.claim(users, tokens, amounts, proofs); - // Swap each reward token to USDC + // Swap each reward token to the base asset for (uint256 i = 0; i < length; i++) { address rewardToken = tokens[i]; - // Skip if the reward is already USDC - if (rewardToken == address(USDC)) { - uint256 usdcBalance = USDC.balanceOf(address(this)); - totalUsdcReceived += usdcBalance; - emit RewardsClaimed(rewardToken, usdcBalance, usdcBalance); + // Skip if the reward is already the base asset + if (rewardToken == address(asset)) { + uint256 assetBalance = asset.balanceOf(address(this)); + totalAssetReceived += assetBalance; + emit RewardsClaimed(rewardToken, assetBalance, assetBalance); continue; } @@ -336,15 +336,16 @@ contract SteakhouseUSDC is Strategy { // Approve the SwapContract to spend the reward tokens SafeERC20.forceApprove(IERC20(rewardToken), address(swapContract), rewardBalance); - // Swap the reward token to USDC - uint256 usdcReceived = swapContract.swapWithEncodedKey( + // Swap the reward token to the base asset + uint256 assetReceived = swapContract.swapWithEncodedKey( poolKeysData[i], zeroForOnes[i], uint128(rewardBalance), minAmountsOut[i], deadline, bytes("") ); - totalUsdcReceived += usdcReceived; - emit RewardsClaimed(rewardToken, rewardBalance, usdcReceived); + totalAssetReceived += assetReceived; + emit RewardsClaimed(rewardToken, rewardBalance, assetReceived); } - return totalUsdcReceived; + return totalAssetReceived; } } + diff --git a/test/SteakhouseUSDC.t.sol b/test/MorphoStrategy.t.sol similarity index 89% rename from test/SteakhouseUSDC.t.sol rename to test/MorphoStrategy.t.sol index a8bcd4c..c10b769 100644 --- a/test/SteakhouseUSDC.t.sol +++ b/test/MorphoStrategy.t.sol @@ -11,7 +11,7 @@ import {MockERC4626} from "../contracts/test/MockERC4626.sol"; import {Tranche} from "../contracts/tranches/Tranche.sol"; import {Accounting} from "../contracts/tranches/Accounting.sol"; -import {SteakhouseUSDC} from "../contracts/tranches/strategies/morpho/SteakhouseUSDC.sol"; +import {MorphoStrategy} from "../contracts/tranches/strategies/morpho/MorphoStrategy.sol"; import {AccessControlManager} from "../contracts/governance/AccessControlManager.sol"; import {AprPairFeed} from "../contracts/tranches/oracles/AprPairFeed.sol"; @@ -56,10 +56,10 @@ contract MockAprPairProvider is IStrategyAprPairProvider { } } -contract SteakhouseUSDCTest is Test { +contract MorphoStrategyTest is Test { // External protocols MockUSDC public USDC; - MockERC4626 public steakhouseUSD; + MockERC4626 public morphoVault; // Auth AccessControlManager public acm; @@ -79,7 +79,7 @@ contract SteakhouseUSDCTest is Test { MockAprPairProvider public aprProvider; // Strategy - SteakhouseUSDC public steakhouseStrategy; + MorphoStrategy public morphoStrategy; ERC20Cooldown public erc20Cooldown; address account; @@ -89,9 +89,9 @@ contract SteakhouseUSDCTest is Test { vm.startPrank(owner); - // Prepare USDC and steakhouseUSD vault + // Prepare USDC and morpho vault USDC = new MockUSDC(); - steakhouseUSD = new MockERC4626(IERC20(address(USDC))); + morphoVault = new MockERC4626(IERC20(address(USDC))); // Prepare Acm acm = new AccessControlManager(owner); @@ -150,18 +150,18 @@ contract SteakhouseUSDCTest is Test { ); // Prepare Strategy - steakhouseStrategy = SteakhouseUSDC( + morphoStrategy = MorphoStrategy( address( new ERC1967Proxy( - address(new SteakhouseUSDC(IERC4626(address(steakhouseUSD)))), + address(new MorphoStrategy(IERC4626(address(morphoVault)))), abi.encodeWithSelector( - SteakhouseUSDC.initialize.selector, owner, address(acm), address(cdo), address(erc20Cooldown) + MorphoStrategy.initialize.selector, owner, address(acm), address(cdo), address(erc20Cooldown) ) ) ) ); - acm.grantRole(erc20Cooldown.COOLDOWN_WORKER_ROLE(), address(steakhouseStrategy)); - acm.grantRole(steakhouseStrategy.UPDATER_STRAT_CONFIG_ROLE(), owner); + acm.grantRole(erc20Cooldown.COOLDOWN_WORKER_ROLE(), address(morphoStrategy)); + acm.grantRole(morphoStrategy.UPDATER_STRAT_CONFIG_ROLE(), owner); // Prepare Feed aprProvider = new MockAprPairProvider(); @@ -175,7 +175,7 @@ contract SteakhouseUSDCTest is Test { address(acm), IStrategyAprPairProvider(address(aprProvider)), 4 hours, - "Steakhouse CDO APR Pair" + "Morpho CDO APR Pair" ) ) ) @@ -196,7 +196,7 @@ contract SteakhouseUSDCTest is Test { // Configure CDO cdo.configure( IAccounting(address(accounting)), - IStrategy(address(steakhouseStrategy)), + IStrategy(address(morphoStrategy)), ITranche(address(jrtVault)), ITranche(address(srtVault)) ); @@ -216,7 +216,7 @@ contract SteakhouseUSDCTest is Test { // Set cooldown periods (7 days for both tranches) uint256 cooldownPeriod = 7 days; - steakhouseStrategy.setCooldowns(cooldownPeriod, cooldownPeriod); + morphoStrategy.setCooldowns(cooldownPeriod, cooldownPeriod); // test deposit uint256 shares = 1000 * 10 ** USDC.decimals(); // 1000 USDC @@ -232,7 +232,7 @@ contract SteakhouseUSDCTest is Test { assertBalance(USDC, account, 0, "Cooldown period failed"); vm.warp(block.timestamp + 7 days); - erc20Cooldown.finalize(steakhouseUSD, account); + erc20Cooldown.finalize(morphoVault, account); assertBalance(USDC, account, shares, "After-Cooldown period failed"); vm.stopPrank(); diff --git a/test/SmokehouseDeploy.t.sol b/test/SmokehouseDeploy.t.sol new file mode 100644 index 0000000..05d8828 --- /dev/null +++ b/test/SmokehouseDeploy.t.sol @@ -0,0 +1,294 @@ +// SPDX-License-Identifier: BUSL-1.1 + +pragma solidity 0.8.28; + +import {Test} from "forge-std/Test.sol"; +import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {IERC4626} from "@openzeppelin/contracts/interfaces/IERC4626.sol"; + +import {AccessControlManager} from "../contracts/governance/AccessControlManager.sol"; +import {StrataCDO} from "../contracts/tranches/StrataCDO.sol"; +import {Tranche} from "../contracts/tranches/Tranche.sol"; +import {Accounting} from "../contracts/tranches/Accounting.sol"; +import {AprPairFeed} from "../contracts/tranches/oracles/AprPairFeed.sol"; + +import {MorphoStrategy} from "../contracts/tranches/strategies/morpho/MorphoStrategy.sol"; +import {MorphoAprPairProvider} from "../contracts/tranches/strategies/morpho/MorphoAprPairProvider.sol"; + +import {ERC20Cooldown} from "../contracts/tranches/base/cooldown/ERC20Cooldown.sol"; +import {CooldownBase} from "../contracts/tranches/base/cooldown/CooldownBase.sol"; + +import {IAccounting} from "../contracts/tranches/interfaces/IAccounting.sol"; +import {IStrategy} from "../contracts/tranches/interfaces/IStrategy.sol"; +import {ITranche} from "../contracts/tranches/interfaces/ITranche.sol"; +import {IStrataCDO} from "../contracts/tranches/interfaces/IStrataCDO.sol"; +import {IAprPairFeed} from "../contracts/tranches/interfaces/IAprPairFeed.sol"; +import {IERC20Cooldown} from "../contracts/tranches/interfaces/cooldown/ICooldown.sol"; + +contract SmokehouseDeploy is Test { + // Mainnet addresses + address public constant MORPHO = 0xBBBBBbbBBb9cC5e90e3b3Af64bdAF62C37EEFFCb; + + // Smokehouse USDC vault - the base vault for strategy and base APR + address public constant SMOKEHOUSE_USDC = 0xBEeFFF209270748ddd194831b3fa287a5386f5bC; + + // Steakhouse USDC vault - the target vault for target APR + address public constant STEAKHOUSE_USDC = 0xBEEF01735c132Ada46AA9aA4c54623cAA92A64CB; + + // USDC address on mainnet + address public constant USDC = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48; + + // Smokehouse was deployed at a recent block + uint256 constant MAINNET_BLOCK = 21834000; + + // Roles + bytes32 constant PAUSER_ROLE = keccak256("PAUSER_ROLE"); + bytes32 constant UPDATER_STRAT_CONFIG_ROLE = keccak256("UPDATER_STRAT_CONFIG_ROLE"); + bytes32 constant UPDATER_FEED_ROLE = keccak256("UPDATER_FEED_ROLE"); + bytes32 constant UPDATER_CDO_APR_ROLE = keccak256("UPDATER_CDO_APR_ROLE"); + bytes32 constant RESERVE_MANAGER_ROLE = keccak256("RESERVE_MANAGER_ROLE"); + bytes32 constant CDO_OWNER_ROLE = keccak256("CDO_OWNER_ROLE"); + bytes32 constant COOLDOWN_WORKER_ROLE = keccak256("COOLDOWN_WORKER_ROLE"); + + // Deployed contracts + address internal owner; + AccessControlManager internal acm; + StrataCDO internal cdo; + Tranche internal jrtVault; + Tranche internal srtVault; + ERC20Cooldown internal erc20Cooldown; + MorphoStrategy internal strategy; + MorphoAprPairProvider internal provider; + AprPairFeed internal feed; + Accounting internal accounting; + + function setUp() public virtual { + string memory rpcUrl = vm.envString("MAINNET_RPC_URL"); + uint256 forkId = vm.createFork(rpcUrl, MAINNET_BLOCK); + vm.selectFork(forkId); + + owner = makeAddr("strataOwner"); + vm.label(owner, "DeployerOwner"); + vm.label(MORPHO, "Morpho"); + vm.label(SMOKEHOUSE_USDC, "SmokehouseUSDC"); + vm.label(STEAKHOUSE_USDC, "SteakhouseUSDC"); + vm.label(USDC, "USDC"); + + vm.deal(owner, 100 ether); + } + + function testDeploySmokehouseStackMatchesScript() public { + _deployStrataStack(); + + // Verify CDO configuration + assertEq(address(cdo.strategy()), address(strategy)); + assertEq(address(cdo.jrtVault()), address(jrtVault)); + assertEq(address(cdo.srtVault()), address(srtVault)); + assertEq(jrtVault.asset(), USDC); + assertEq(srtVault.asset(), USDC); + + // Verify strategy configuration + assertEq(address(strategy.vault()), SMOKEHOUSE_USDC); + assertEq(address(strategy.asset()), USDC); + assertEq(strategy.vaultCooldownJrt(), 7 days); + assertEq(strategy.vaultCooldownSrt(), 0); + + // Verify provider configuration + assertEq(address(provider.morpho()), MORPHO); + assertEq(provider.baseVault(), SMOKEHOUSE_USDC); + assertEq(provider.targetVault(), STEAKHOUSE_USDC); + + // Verify feed configuration + assertEq(address(feed.provider()), address(provider)); + assertEq(feed.roundStaleAfter(), 4 hours); + assertEq(address(accounting.aprPairFeed()), address(feed)); + + // Verify roles + assertTrue(acm.hasRole(PAUSER_ROLE, owner)); + assertTrue(acm.hasRole(UPDATER_STRAT_CONFIG_ROLE, owner)); + assertTrue(acm.hasRole(UPDATER_FEED_ROLE, owner)); + assertTrue(acm.hasRole(UPDATER_CDO_APR_ROLE, address(feed))); + assertTrue(acm.hasRole(COOLDOWN_WORKER_ROLE, address(strategy))); + + // Verify action states + (bool jrtDepositsEnabled, bool jrtWithdrawalsEnabled) = cdo.actionsJrt(); + (bool srtDepositsEnabled, bool srtWithdrawalsEnabled) = cdo.actionsSrt(); + assertTrue(jrtDepositsEnabled && jrtWithdrawalsEnabled); + assertTrue(srtDepositsEnabled && srtWithdrawalsEnabled); + + // Verify supported tokens + IERC20[] memory supported = strategy.getSupportedTokens(); + assertEq(supported.length, 2); + assertEq(address(supported[0]), SMOKEHOUSE_USDC); + assertEq(address(supported[1]), USDC); + } + + function testAprPairProviderReturnsValidData() public { + _deployStrataStack(); + + (int64 aprTarget, int64 aprBase, uint64 timestamp) = provider.getAprPair(); + + // APRs should be positive and reasonable (< 100% = 1e12) + assertTrue(aprTarget >= 0, "Target APR should be non-negative"); + assertTrue(aprBase >= 0, "Base APR should be non-negative"); + assertTrue(aprTarget < 1e12, "Target APR should be less than 100%"); + assertTrue(aprBase < 1e12, "Base APR should be less than 100%"); + assertEq(timestamp, uint64(block.timestamp)); + } + + function _deployStrataStack() internal { + vm.startPrank(owner); + + // 1. Deploy AccessControlManager + acm = new AccessControlManager(owner); + vm.label(address(acm), "AccessControlManager"); + + // 2. Deploy StrataCDO + StrataCDO cdoImpl = new StrataCDO(); + vm.label(address(cdoImpl), "StrataCDO_Impl"); + cdo = StrataCDO( + address( + new ERC1967Proxy( + address(cdoImpl), abi.encodeWithSelector(StrataCDO.initialize.selector, owner, address(acm)) + ) + ) + ); + vm.label(address(cdo), "StrataCDO"); + + // 3. Deploy Tranches + jrtVault = _deployTranche("JRT", "Junior Tranche"); + srtVault = _deployTranche("SRT", "Senior Tranche"); + + // 4. Deploy ERC20Cooldown + ERC20Cooldown erc20CooldownImpl = new ERC20Cooldown(); + vm.label(address(erc20CooldownImpl), "ERC20Cooldown_Impl"); + erc20Cooldown = ERC20Cooldown( + address( + new ERC1967Proxy( + address(erc20CooldownImpl), + abi.encodeWithSelector(CooldownBase.initialize.selector, owner, address(acm)) + ) + ) + ); + vm.label(address(erc20Cooldown), "ERC20Cooldown"); + + // 5. Deploy MorphoStrategy (uses Smokehouse vault) + MorphoStrategy strategyImpl = new MorphoStrategy(IERC4626(SMOKEHOUSE_USDC)); + vm.label(address(strategyImpl), "MorphoStrategy_Impl"); + strategy = MorphoStrategy( + address( + new ERC1967Proxy( + address(strategyImpl), + abi.encodeWithSelector( + MorphoStrategy.initialize.selector, + owner, + address(acm), + IStrataCDO(address(cdo)), + IERC20Cooldown(address(erc20Cooldown)) + ) + ) + ) + ); + vm.label(address(strategy), "MorphoStrategy"); + + // 6. Deploy MorphoAprPairProvider + // baseVault = Smokehouse (for base APR), targetVault = Steakhouse (for target APR) + provider = new MorphoAprPairProvider(MORPHO, SMOKEHOUSE_USDC, STEAKHOUSE_USDC); + vm.label(address(provider), "MorphoAprPairProvider"); + + // 7. Deploy AprPairFeed + AprPairFeed feedImpl = new AprPairFeed(); + vm.label(address(feedImpl), "AprPairFeed_Impl"); + feed = AprPairFeed( + address( + new ERC1967Proxy( + address(feedImpl), + abi.encodeWithSelector( + AprPairFeed.initialize.selector, + owner, + address(acm), + provider, + uint256(4 hours), + "Smokehouse CDO APR Pair" + ) + ) + ) + ); + vm.label(address(feed), "AprPairFeed"); + + // 8. Deploy Accounting + Accounting accountingImpl = new Accounting(); + vm.label(address(accountingImpl), "Accounting_Impl"); + accounting = Accounting( + address( + new ERC1967Proxy( + address(accountingImpl), + abi.encodeWithSelector( + Accounting.initialize.selector, + owner, + address(acm), + IStrataCDO(address(cdo)), + IAprPairFeed(address(feed)) + ) + ) + ) + ); + vm.label(address(accounting), "Accounting"); + + // 9. Grant roles + _grantRole(PAUSER_ROLE, owner); + _grantRole(UPDATER_STRAT_CONFIG_ROLE, owner); + _grantRole(UPDATER_FEED_ROLE, owner); + _grantRole(UPDATER_CDO_APR_ROLE, address(feed)); + _grantRole(COOLDOWN_WORKER_ROLE, address(strategy)); + + // 10. Configure CDO + cdo.configure( + IAccounting(address(accounting)), + IStrategy(address(strategy)), + ITranche(address(jrtVault)), + ITranche(address(srtVault)) + ); + + // 11. Set strategy cooldowns (7 days for JRT, 0 for SRT) + strategy.setCooldowns(7 days, 0); + + // 12. Enable actions on tranches + cdo.setActionStates(address(jrtVault), true, true); + cdo.setActionStates(address(srtVault), true, true); + + // 13. Set reserve basis points + accounting.setReserveBps(0.02e18); + + vm.stopPrank(); + } + + function _deployTranche(string memory name, string memory symbol) internal returns (Tranche) { + Tranche trancheImpl = new Tranche(); + vm.label(address(trancheImpl), string.concat(name, "_Tranche_Impl")); + + address proxy = address( + new ERC1967Proxy( + address(trancheImpl), + abi.encodeWithSelector( + Tranche.initialize.selector, + owner, + address(acm), + name, + symbol, + IERC20(USDC), + IStrataCDO(address(cdo)) + ) + ) + ); + string memory label = string.concat(name, "_Tranche"); + vm.label(proxy, label); + return Tranche(proxy); + } + + function _grantRole(bytes32 role, address grantee) internal { + acm.grantRole(role, grantee); + } +} + From 0dd659a69d8b1297df023fc2d112ca9a8ddd5587 Mon Sep 17 00:00:00 2001 From: vivekascoder Date: Sat, 17 Jan 2026 02:37:34 +0530 Subject: [PATCH 22/24] feat: implement vesting mechanism for reward deposits in MorphoStrategy contract --- .../strategies/morpho/MorphoStrategy.sol | 128 +++++++++++++++++- 1 file changed, 125 insertions(+), 3 deletions(-) diff --git a/contracts/tranches/strategies/morpho/MorphoStrategy.sol b/contracts/tranches/strategies/morpho/MorphoStrategy.sol index 61d24b9..0b1a593 100644 --- a/contracts/tranches/strategies/morpho/MorphoStrategy.sol +++ b/contracts/tranches/strategies/morpho/MorphoStrategy.sol @@ -30,10 +30,20 @@ contract MorphoStrategy is Strategy { uint256 public vaultCooldownJrt; uint256 public vaultCooldownSrt; + /** + * @notice Vesting configuration for reward deposits + * @dev VestedUSDC tracks the total amount of USDC deposited from rewards that is still vesting. + * This amount decreases linearly over the vesting duration. + */ + uint256 public vestedUSDC; + uint256 public vestingDuration; + uint256 public lastVestingUpdate; + event CooldownsChanged(uint256 jrt, uint256 srt); event RewardsClaimed(address indexed rewardToken, uint256 rewardAmount, uint256 assetReceived); event DistributorUpdated(address indexed distributor); event SwapContractUpdated(address indexed swapContract); + event VestingDurationUpdated(uint256 newDuration); constructor(IERC4626 vault_) { vault = vault_; @@ -51,6 +61,9 @@ contract MorphoStrategy is Strategy { erc20Cooldown = erc20Cooldown_; SafeERC20.forceApprove(vault, address(erc20Cooldown), type(uint256).max); + + // Initialize vesting state + lastVestingUpdate = block.timestamp; } /** @@ -168,6 +181,77 @@ contract MorphoStrategy is Strategy { revert UnsupportedToken(token); } + /** + * @notice Calculates the current unvested USDC amount + * @dev Returns the amount of USDC that is still vesting, decreasing linearly over time + * @return The amount of unvested USDC + */ + function getUnvestedUSDC() public view returns (uint256) { + if (vestedUSDC == 0 || vestingDuration == 0) { + return 0; + } + + uint256 elapsed = block.timestamp > lastVestingUpdate ? block.timestamp - lastVestingUpdate : 0; + + if (elapsed >= vestingDuration) { + return 0; // Fully vested + } + + // Linear vesting: unvested = vestedUSDC * (1 - elapsed / duration) + // Using: unvested = vestedUSDC * (vestingDuration - elapsed) / vestingDuration + return (vestedUSDC * (vestingDuration - elapsed)) / vestingDuration; + } + + /** + * @notice Updates the vesting state by applying time decay + * @dev This should be called before modifying vestedUSDC to apply time-based decay. + * When vesting occurs, the newly vested amount is transferred to the junior vault + * so that users can withdraw their money back. + */ + function _updateVesting() internal { + uint256 vestedUSDCBefore = vestedUSDC; + + if (vestedUSDC > 0 && vestingDuration > 0 && lastVestingUpdate > 0) { + uint256 elapsed = block.timestamp > lastVestingUpdate ? block.timestamp - lastVestingUpdate : 0; + + if (elapsed >= vestingDuration) { + // Fully vested, reset to zero + vestedUSDC = 0; + } else { + // Apply linear decay + vestedUSDC = (vestedUSDC * (vestingDuration - elapsed)) / vestingDuration; + } + } + lastVestingUpdate = block.timestamp; + + // Calculate newly vested amount and transfer to junior vault + if (vestedUSDCBefore > vestedUSDC) { + uint256 newlyVested = vestedUSDCBefore - vestedUSDC; + _transferVestedToJuniorVault(newlyVested); + } + } + + /** + * @notice Transfers newly vested assets to the junior vault + * @dev Uses USDC already in the strategy contract (from current reward claims after swapping). + * Transfers assets directly to the vault to increase its total value without minting shares. + * The vestedUSDC is kept as USDC in the strategy contract, not deposited to the vault. + * If the junior vault is not yet configured, the transfer is skipped. + * @param newlyVested The amount of newly vested base assets to transfer + */ + function _transferVestedToJuniorVault(uint256 newlyVested) internal { + if (newlyVested == 0) return; + + // Skip if junior vault is not configured yet + if (address(cdo.jrtVault()) == address(0)) { + return; + } + + // The USDC should already be in the strategy contract from reward claims + // Transfer directly to the vault to increase its total value without minting shares + SafeERC20.safeTransfer(asset, address(cdo.jrtVault()), newlyVested); + } + /** * @notice Calculates the total assets managed by this strategy * @dev This function returns the current value of the strategy's assets in the base asset. @@ -176,6 +260,19 @@ contract MorphoStrategy is Strategy { function totalAssets() external view returns (uint256 baseAssets) { uint256 shares = vault.balanceOf(address(this)); baseAssets = vault.previewRedeem(shares); + + // Add USDC balance in strategy contract (where vestedUSDC is held) + uint256 usdcBalance = asset.balanceOf(address(this)); + baseAssets += usdcBalance; + + // Subtract unvested USDC from rewards + uint256 unvested = getUnvestedUSDC(); + if (baseAssets > unvested) { + baseAssets -= unvested; + } else { + baseAssets = 0; + } + return baseAssets; } @@ -279,8 +376,10 @@ contract MorphoStrategy is Strategy { } /** - * @notice Claims rewards from the Merkl distributor and swaps them to the base asset - * @dev This function claims rewards for this contract and swaps them to the base asset using the SwapContract + * @notice Claims rewards from the Merkl distributor, swaps them to the base asset, and deposits to Junior Vault + * @dev This function claims rewards for this contract, swaps them to the base asset using the SwapContract, + * and deposits the resulting assets into the Morpho vault. The increased vault shares benefit the + * strategy's totalAssets which flows through CDO accounting to the Junior tranche. * @param tokens Array of reward token addresses to claim * @param amounts Array of cumulative amounts earned (from Merkle tree) * @param proofs Array of Merkle proofs for each claim @@ -288,7 +387,7 @@ contract MorphoStrategy is Strategy { * @param zeroForOnes Array of swap directions for each token * @param minAmountsOut Array of minimum base asset amounts expected from each swap (slippage protection) * @param deadline Timestamp after which the swaps will revert - * @return totalAssetReceived Total base asset received from all swaps + * @return totalAssetReceived Total base asset received from all swaps and deposited to vault */ function claimRewards( address[] calldata tokens, @@ -345,7 +444,30 @@ contract MorphoStrategy is Strategy { emit RewardsClaimed(rewardToken, rewardBalance, assetReceived); } + // Handle vesting - vestedUSDC is kept as USDC in the strategy contract + // This benefits the Junior tranche through the CDO accounting mechanism + if (totalAssetReceived > 0) { + // Update vesting state - this will transfer newly vested amounts to junior vault + // using the USDC currently in the strategy contract from this claim + _updateVesting(); + + // Track the new reward deposit as vested USDC (stays as USDC in strategy, not in vault) + vestedUSDC += totalAssetReceived; + } + return totalAssetReceived; } + + /** + * @notice Sets the vesting duration for reward deposits + * @param vestingDuration_ The duration in seconds over which rewards vest (decrease to 0) + */ + function setVestingDuration(uint256 vestingDuration_) external onlyRole(UPDATER_STRAT_CONFIG_ROLE) { + // Update vesting state before changing duration + _updateVesting(); + + vestingDuration = vestingDuration_; + emit VestingDurationUpdated(vestingDuration_); + } } From ea14cf01582b4b5c88b610d903a37a33c2e1e2e3 Mon Sep 17 00:00:00 2001 From: vivekascoder Date: Sat, 17 Jan 2026 03:14:11 +0530 Subject: [PATCH 23/24] refactor: rename vault to morphoVault in MorphoStrategy contract for clarity and consistency --- .../strategies/morpho/MorphoStrategy.sol | 46 +++++++++---------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/contracts/tranches/strategies/morpho/MorphoStrategy.sol b/contracts/tranches/strategies/morpho/MorphoStrategy.sol index 0b1a593..c541392 100644 --- a/contracts/tranches/strategies/morpho/MorphoStrategy.sol +++ b/contracts/tranches/strategies/morpho/MorphoStrategy.sol @@ -13,7 +13,7 @@ import {ISwapContract} from "../../interfaces/ISwapContract.sol"; import {Strategy} from "../../Strategy.sol"; contract MorphoStrategy is Strategy { - IERC4626 public immutable vault; + IERC4626 public immutable morphoVault; IERC20 public immutable asset; IERC20Cooldown public erc20Cooldown; @@ -46,7 +46,7 @@ contract MorphoStrategy is Strategy { event VestingDurationUpdated(uint256 newDuration); constructor(IERC4626 vault_) { - vault = vault_; + morphoVault = vault_; asset = IERC20(vault_.asset()); } @@ -60,7 +60,7 @@ contract MorphoStrategy is Strategy { cdo = cdo_; erc20Cooldown = erc20Cooldown_; - SafeERC20.forceApprove(vault, address(erc20Cooldown), type(uint256).max); + SafeERC20.forceApprove(morphoVault, address(erc20Cooldown), type(uint256).max); // Initialize vesting state lastVestingUpdate = block.timestamp; @@ -86,11 +86,11 @@ contract MorphoStrategy is Strategy { SafeERC20.safeTransferFrom(IERC20(token), owner, address(this), tokenAmount); if (token == address(asset)) { - SafeERC20.forceApprove(asset, address(vault), tokenAmount); - vault.deposit(tokenAmount, address(this)); + SafeERC20.forceApprove(asset, address(morphoVault), tokenAmount); + morphoVault.deposit(tokenAmount, address(this)); return tokenAmount; } - if (token == address(vault)) { + if (token == address(morphoVault)) { // already transferred in ↑ return baseAssets; } @@ -143,16 +143,16 @@ contract MorphoStrategy is Strategy { address receiver, bool shouldSkipCooldown ) internal returns (uint256) { - uint256 shares = vault.previewWithdraw(baseAssets); - if (token == address(vault)) { + uint256 shares = morphoVault.previewWithdraw(baseAssets); + if (token == address(morphoVault)) { uint256 cooldownSeconds = shouldSkipCooldown ? 0 : (cdo.isJrt(tranche) ? vaultCooldownJrt : vaultCooldownSrt); - erc20Cooldown.transfer(vault, sender, receiver, shares, cooldownSeconds); + erc20Cooldown.transfer(morphoVault, sender, receiver, shares, cooldownSeconds); return shares; } if (token == address(asset)) { // Morpho allows direct withdrawal - no cooldown needed - vault.withdraw(baseAssets, receiver, address(this)); + morphoVault.withdraw(baseAssets, receiver, address(this)); return baseAssets; } revert UnsupportedToken(token); @@ -169,13 +169,13 @@ contract MorphoStrategy is Strategy { * @param receiver The address that will receive the withdrawn tokens */ function reduceReserve(address token, uint256 tokenAmount, address receiver) external onlyCDO { - if (token == address(vault)) { - erc20Cooldown.transfer(vault, receiver, receiver, tokenAmount, 0); + if (token == address(morphoVault)) { + erc20Cooldown.transfer(morphoVault, receiver, receiver, tokenAmount, 0); return; } if (token == address(asset)) { // Direct withdrawal from Morpho vault - vault.withdraw(tokenAmount, receiver, address(this)); + morphoVault.withdraw(tokenAmount, receiver, address(this)); return; } revert UnsupportedToken(token); @@ -258,8 +258,8 @@ contract MorphoStrategy is Strategy { * @return baseAssets The total amount of base asset managed by this strategy */ function totalAssets() external view returns (uint256 baseAssets) { - uint256 shares = vault.balanceOf(address(this)); - baseAssets = vault.previewRedeem(shares); + uint256 shares = morphoVault.balanceOf(address(this)); + baseAssets = morphoVault.previewRedeem(shares); // Add USDC balance in strategy contract (where vestedUSDC is held) uint256 usdcBalance = asset.balanceOf(address(this)); @@ -291,10 +291,10 @@ contract MorphoStrategy is Strategy { view returns (uint256) { - if (token == address(vault)) { + if (token == address(morphoVault)) { return rounding == Math.Rounding.Floor - ? vault.previewRedeem(tokenAmount) // aka convertToAssets(tokenAmount) - : vault.previewMint(tokenAmount); + ? morphoVault.previewRedeem(tokenAmount) // aka convertToAssets(tokenAmount) + : morphoVault.previewMint(tokenAmount); } if (token == address(asset)) { return tokenAmount; @@ -317,10 +317,10 @@ contract MorphoStrategy is Strategy { view returns (uint256) { - if (token == address(vault)) { + if (token == address(morphoVault)) { return rounding == Math.Rounding.Floor - ? vault.previewDeposit(baseAssets) // aka convertToShares(baseAssets) - : vault.previewWithdraw(baseAssets); + ? morphoVault.previewDeposit(baseAssets) // aka convertToShares(baseAssets) + : morphoVault.previewWithdraw(baseAssets); } if (token == address(asset)) { return baseAssets; @@ -333,7 +333,7 @@ contract MorphoStrategy is Strategy { */ function getSupportedTokens() external view returns (IERC20[] memory) { IERC20[] memory tokens = new IERC20[](2); - tokens[0] = IERC20(address(vault)); + tokens[0] = IERC20(address(morphoVault)); tokens[1] = asset; return tokens; } @@ -353,7 +353,7 @@ contract MorphoStrategy is Strategy { vaultCooldownSrt = vaultCooldownSrt_; bool isDisabled = vaultCooldownJrt_ == 0 && vaultCooldownSrt_ == 0; - erc20Cooldown.setCooldownDisabled(vault, isDisabled); + erc20Cooldown.setCooldownDisabled(morphoVault, isDisabled); emit CooldownsChanged(vaultCooldownJrt_, vaultCooldownSrt_); } From fd7ad4d76f175b6254f1813ac1d55785e9915f8e Mon Sep 17 00:00:00 2001 From: vivekascoder Date: Sat, 17 Jan 2026 03:26:41 +0530 Subject: [PATCH 24/24] feat: extend MorphoStrategy constructor to include distributor, swap contract, and vesting duration; add mock contracts for testing --- .../strategies/morpho/MorphoStrategy.sol | 5 +- test/MorphoStrategy.t.sol | 143 +++++++++++++++++- test/SmokehouseDeploy.t.sol | 143 +++++++++++++++++- 3 files changed, 286 insertions(+), 5 deletions(-) diff --git a/contracts/tranches/strategies/morpho/MorphoStrategy.sol b/contracts/tranches/strategies/morpho/MorphoStrategy.sol index c541392..bd87d24 100644 --- a/contracts/tranches/strategies/morpho/MorphoStrategy.sol +++ b/contracts/tranches/strategies/morpho/MorphoStrategy.sol @@ -45,9 +45,12 @@ contract MorphoStrategy is Strategy { event SwapContractUpdated(address indexed swapContract); event VestingDurationUpdated(uint256 newDuration); - constructor(IERC4626 vault_) { + constructor(IERC4626 vault_, IDistributor distributor_, ISwapContract swapContract_, uint256 vestingDuration_) { morphoVault = vault_; asset = IERC20(vault_.asset()); + distributor = distributor_; + swapContract = swapContract_; + vestingDuration = vestingDuration_; } function initialize(address owner_, address acm_, IStrataCDO cdo_, IERC20Cooldown erc20Cooldown_) diff --git a/test/MorphoStrategy.t.sol b/test/MorphoStrategy.t.sol index c10b769..6a195de 100644 --- a/test/MorphoStrategy.t.sol +++ b/test/MorphoStrategy.t.sol @@ -1,9 +1,10 @@ +// SPDX-License-Identifier: BUSL-1.1 pragma solidity 0.8.28; import {Test} from "forge-std/Test.sol"; import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; import {IERC4626} from "@openzeppelin/contracts/interfaces/IERC4626.sol"; -import {IERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; import {MockERC4626} from "../contracts/test/MockERC4626.sol"; @@ -27,6 +28,8 @@ import {CooldownBase} from "../contracts/tranches/base/cooldown/CooldownBase.sol import {ITranche} from "../contracts/tranches/interfaces/ITranche.sol"; import {IStrategy} from "../contracts/tranches/interfaces/IStrategy.sol"; import {IAccounting} from "../contracts/tranches/interfaces/IAccounting.sol"; +import {IDistributor, MerkleTree} from "../contracts/tranches/interfaces/IDistributor.sol"; +import {ISwapContract} from "../contracts/tranches/interfaces/ISwapContract.sol"; // Mock USDC token contract MockUSDC is ERC20 { @@ -56,6 +59,130 @@ contract MockAprPairProvider is IStrategyAprPairProvider { } } +// Mock Distributor +contract MockDistributor is IDistributor { + function claim(address[] calldata, address[] calldata, uint256[] calldata, bytes32[][] calldata) + external + override + {} + + function claimWithRecipient( + address[] calldata, + address[] calldata, + uint256[] calldata, + bytes32[][] calldata, + address[] calldata, + bytes[] memory + ) external override {} + + // Minimal implementation for interface compliance + function tree() external pure override returns (bytes32, bytes32) { + return (bytes32(0), bytes32(0)); + } + + function lastTree() external pure override returns (bytes32, bytes32) { + return (bytes32(0), bytes32(0)); + } + + function disputeToken() external pure override returns (IERC20) { + return IERC20(address(0)); + } + + function disputer() external pure override returns (address) { + return address(0); + } + + function endOfDisputePeriod() external pure override returns (uint48) { + return 0; + } + + function disputePeriod() external pure override returns (uint48) { + return 0; + } + + function disputeAmount() external pure override returns (uint256) { + return 0; + } + + function claimed(address, address) external pure override returns (uint208, uint48, bytes32) { + return (0, 0, bytes32(0)); + } + + function canUpdateMerkleRoot(address) external pure override returns (uint256) { + return 0; + } + + function operators(address, address) external pure override returns (uint256) { + return 0; + } + + function upgradeabilityDeactivated() external pure override returns (uint128) { + return 0; + } + + function claimRecipient(address, address) external pure override returns (address) { + return address(0); + } + + function mainOperators(address, address) external pure override returns (uint256) { + return 0; + } + + function CALLBACK_SUCCESS() external pure override returns (bytes32) { + return bytes32(0); + } + + function getMerkleRoot() external pure override returns (bytes32) { + return bytes32(0); + } + + function getEpochDuration() external pure override returns (uint32) { + return 0; + } + + function toggleOperator(address, address) external override {} + + function setClaimRecipient(address, address) external override {} + + function toggleMainOperatorStatus(address, address) external override {} + + function disputeTree(string memory) external override {} + + function updateTree(MerkleTree calldata _tree) external override {} + + function toggleTrusted(address) external override {} + + function revokeUpgradeability() external override {} + + function setEpochDuration(uint32) external override {} + + function resolveDispute(bool) external override {} + + function revokeTree() external override {} + + function recoverERC20(address, address, uint256) external override {} + + function setDisputePeriod(uint48) external override {} + + function setDisputeToken(IERC20) external override {} + + function setDisputeAmount(uint256) external override {} +} + +// Mock SwapContract +contract MockSwapContract is ISwapContract { + function swapWithEncodedKey(bytes calldata, bool, uint128, uint128, uint256, bytes calldata) + external + pure + override + returns (uint256) + { + return 0; + } + + function approveTokenWithPermit2(address, uint160, uint48) external override {} +} + contract MorphoStrategyTest is Test { // External protocols MockUSDC public USDC; @@ -149,11 +276,23 @@ contract MorphoStrategyTest is Test { ) ); + // Prepare mocks for strategy constructor + MockDistributor mockDistributor = new MockDistributor(); + MockSwapContract mockSwapContract = new MockSwapContract(); + uint256 vestingDuration = 30 days; + // Prepare Strategy morphoStrategy = MorphoStrategy( address( new ERC1967Proxy( - address(new MorphoStrategy(IERC4626(address(morphoVault)))), + address( + new MorphoStrategy( + IERC4626(address(morphoVault)), + IDistributor(address(mockDistributor)), + ISwapContract(address(mockSwapContract)), + vestingDuration + ) + ), abi.encodeWithSelector( MorphoStrategy.initialize.selector, owner, address(acm), address(cdo), address(erc20Cooldown) ) diff --git a/test/SmokehouseDeploy.t.sol b/test/SmokehouseDeploy.t.sol index 05d8828..3d5402b 100644 --- a/test/SmokehouseDeploy.t.sol +++ b/test/SmokehouseDeploy.t.sol @@ -25,6 +25,8 @@ import {ITranche} from "../contracts/tranches/interfaces/ITranche.sol"; import {IStrataCDO} from "../contracts/tranches/interfaces/IStrataCDO.sol"; import {IAprPairFeed} from "../contracts/tranches/interfaces/IAprPairFeed.sol"; import {IERC20Cooldown} from "../contracts/tranches/interfaces/cooldown/ICooldown.sol"; +import {IDistributor, MerkleTree} from "../contracts/tranches/interfaces/IDistributor.sol"; +import {ISwapContract} from "../contracts/tranches/interfaces/ISwapContract.sol"; contract SmokehouseDeploy is Test { // Mainnet addresses @@ -51,6 +53,10 @@ contract SmokehouseDeploy is Test { bytes32 constant CDO_OWNER_ROLE = keccak256("CDO_OWNER_ROLE"); bytes32 constant COOLDOWN_WORKER_ROLE = keccak256("COOLDOWN_WORKER_ROLE"); + // Mock contracts for strategy constructor + MockDistributor internal mockDistributor; + MockSwapContract internal mockSwapContract; + // Deployed contracts address internal owner; AccessControlManager internal acm; @@ -76,6 +82,10 @@ contract SmokehouseDeploy is Test { vm.label(USDC, "USDC"); vm.deal(owner, 100 ether); + + // Deploy mock contracts for strategy constructor + mockDistributor = new MockDistributor(); + mockSwapContract = new MockSwapContract(); } function testDeploySmokehouseStackMatchesScript() public { @@ -89,7 +99,7 @@ contract SmokehouseDeploy is Test { assertEq(srtVault.asset(), USDC); // Verify strategy configuration - assertEq(address(strategy.vault()), SMOKEHOUSE_USDC); + assertEq(address(strategy.morphoVault()), SMOKEHOUSE_USDC); assertEq(address(strategy.asset()), USDC); assertEq(strategy.vaultCooldownJrt(), 7 days); assertEq(strategy.vaultCooldownSrt(), 0); @@ -174,7 +184,12 @@ contract SmokehouseDeploy is Test { vm.label(address(erc20Cooldown), "ERC20Cooldown"); // 5. Deploy MorphoStrategy (uses Smokehouse vault) - MorphoStrategy strategyImpl = new MorphoStrategy(IERC4626(SMOKEHOUSE_USDC)); + MorphoStrategy strategyImpl = new MorphoStrategy( + IERC4626(SMOKEHOUSE_USDC), + IDistributor(address(mockDistributor)), + ISwapContract(address(mockSwapContract)), + 30 days // vestingDuration + ); vm.label(address(strategyImpl), "MorphoStrategy_Impl"); strategy = MorphoStrategy( address( @@ -292,3 +307,127 @@ contract SmokehouseDeploy is Test { } } +// Mock Distributor +contract MockDistributor is IDistributor { + function claim(address[] calldata, address[] calldata, uint256[] calldata, bytes32[][] calldata) + external + override + {} + + function claimWithRecipient( + address[] calldata, + address[] calldata, + uint256[] calldata, + bytes32[][] calldata, + address[] calldata, + bytes[] memory + ) external override {} + + // Minimal implementation for interface compliance + function tree() external pure override returns (bytes32, bytes32) { + return (bytes32(0), bytes32(0)); + } + + function lastTree() external pure override returns (bytes32, bytes32) { + return (bytes32(0), bytes32(0)); + } + + function disputeToken() external pure override returns (IERC20) { + return IERC20(address(0)); + } + + function disputer() external pure override returns (address) { + return address(0); + } + + function endOfDisputePeriod() external pure override returns (uint48) { + return 0; + } + + function disputePeriod() external pure override returns (uint48) { + return 0; + } + + function disputeAmount() external pure override returns (uint256) { + return 0; + } + + function claimed(address, address) external pure override returns (uint208, uint48, bytes32) { + return (0, 0, bytes32(0)); + } + + function canUpdateMerkleRoot(address) external pure override returns (uint256) { + return 0; + } + + function operators(address, address) external pure override returns (uint256) { + return 0; + } + + function upgradeabilityDeactivated() external pure override returns (uint128) { + return 0; + } + + function claimRecipient(address, address) external pure override returns (address) { + return address(0); + } + + function mainOperators(address, address) external pure override returns (uint256) { + return 0; + } + + function CALLBACK_SUCCESS() external pure override returns (bytes32) { + return bytes32(0); + } + + function getMerkleRoot() external pure override returns (bytes32) { + return bytes32(0); + } + + function getEpochDuration() external pure override returns (uint32) { + return 0; + } + + function toggleOperator(address, address) external override {} + + function setClaimRecipient(address, address) external override {} + + function toggleMainOperatorStatus(address, address) external override {} + + function disputeTree(string memory) external override {} + + function updateTree(MerkleTree calldata _tree) external override {} + + function toggleTrusted(address) external override {} + + function revokeUpgradeability() external override {} + + function setEpochDuration(uint32) external override {} + + function resolveDispute(bool) external override {} + + function revokeTree() external override {} + + function recoverERC20(address, address, uint256) external override {} + + function setDisputePeriod(uint48) external override {} + + function setDisputeToken(IERC20) external override {} + + function setDisputeAmount(uint256) external override {} +} + +// Mock SwapContract +contract MockSwapContract is ISwapContract { + function swapWithEncodedKey(bytes calldata, bool, uint128, uint128, uint256, bytes calldata) + external + pure + override + returns (uint256) + { + return 0; + } + + function approveTokenWithPermit2(address, uint160, uint48) external override {} +} +