diff --git a/contracts/test/renzo/Errors.sol b/contracts/test/renzo/Errors.sol new file mode 100644 index 0000000..805a664 --- /dev/null +++ b/contracts/test/renzo/Errors.sol @@ -0,0 +1,102 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.28; + +/** + * @title Errors + * @author Renzo Protocol + * @notice This contract defines custom errors used throughout the LiquidVaults protocol + * @dev All errors are defined as custom errors for gas efficiency + */ + +/// @dev Error when Zero Input value +error InvalidZeroInput(); + +/// @dev Error when caller is not Rebalance admin +error NotRebalanceAdmin(); + +/// @dev Error when caller is not Exchange rate admin +error NotExchangeRateAdmin(); + +/// @dev Error when array lengths do not match +error MismatchedArrayLengths(); + +/// @dev Error when admin tries to execute Non whitelisted strategy +error UnAuthorizedStrategy(address strategy); + +/// @dev Error when owner tries to remove non zero underlying delegate strategy +error NonZeroUnderlyingDelegateStrategy(); + +/// @dev Error when Withdrawal is not claimable +error WithdrawalNotClaimable(); + +/// @dev Error when caller try to claim invalidWithdrawIndex +error InvalidWithdrawIndex(); + +/// @dev Error when called is not vault +error NotUnderlyingVault(); + +/// @dev Error when caller is not Withdraw Queue +error NotWithdrawQueue(); + +/// @dev Error when caller tries to create already existing vault +error VaultAlreadyCreated(); + +/// @dev Error when caller is not whitelisted +error NotWhitelisted(); + +/// @dev Error when fee bps out of range +error InvalidFeeBps(); + +/// @dev Error when caller does not have pauser role +error NotPauser(); + +/// @dev Error when eulerswap param is invalid +error InvalidEquilibriumReserve(); + +/// @dev Error when pool is already installed for the euler account +error PoolAlreadyInstalled(); + +/// @dev Error when unexpected asset address is passed in +error InvalidAsset(); + +/// @dev Error when no pool is installed for the euler account when it is expected +error NoPoolInstalled(); + +/// @dev Error when decimals are invalid +error InvalidDecimals(); + +/// @dev Error when caller is not owner +error NotOwner(); + +/// @dev Error when active withdrawal is in progress +error ActiveWithdrawalInProgress(); + +/// @dev Error when no active withdrawal is in progress +error NoActiveWithdrawalInProgress(); + +/// @dev Error when active deposit is in progress +error ActiveDepositInProgress(); + +/// @dev Error when expected amount is invalid +error InvalidExpectedAmount(); + +/// @dev Error when no active deposit is in progress +error NoActiveDepositInProgress(); + +/// @dev Error when Oracle Price is invalid +error InvalidOraclePrice(); + +/// @dev Error when superstate deposit address is already set +error SuperstateAddressAlreadySet(); + +/// @dev Error when current tick has changed +error InvalidTick(); + +/// @dev Error when debt value is greater than collateral value +error InvalidDebtValue(); + +/// @dev Error when referral code is invalid +error InvalidReferralCode(); + +/// @dev Error when interest rate mode is invalid +error InvalidInterestRateMode(); \ No newline at end of file diff --git a/contracts/test/renzo/LEZyVault.sol b/contracts/test/renzo/LEZyVault.sol new file mode 100644 index 0000000..61f20c6 --- /dev/null +++ b/contracts/test/renzo/LEZyVault.sol @@ -0,0 +1,621 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.28; + +import {ERC4626Upgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC20/extensions/ERC4626Upgradeable.sol"; +import {IERC20Metadata} from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {PausableUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/PausableUpgradeable.sol"; +import {Address} from "@openzeppelin/contracts/utils/Address.sol"; +import {OwnableUpgradeable} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; + +import {IBeacon, BeaconProxy} from "@openzeppelin/contracts/proxy/beacon/BeaconProxy.sol"; +import "./interfaces/IDelegateStrategy.sol"; +import "./Errors.sol"; +import "./LEZyVaultStorage.sol"; + +/** + * @title LEZyVault + * @author Renzo Protocol + * @notice Main vault contract implementing ERC4626 standard with additional yield strategies + * @dev This contract manages user deposits and integrates with various DeFi protocols through delegate strategies + * @dev WARNING: Rebasing Tokens Should not be used as underlying + * It supports whitelisting, performance fees, and pausable functionality + */ +contract LEZyVault is ERC4626Upgradeable, OwnableUpgradeable, PausableUpgradeable, LEZyVaultStorageV1 { + using Math for uint256; + using Address for address; + using EnumerableSet for EnumerableSet.AddressSet; + using SafeERC20 for IERC20; + + uint256 public constant BASIS_POINTS = 10000; // BASIS_POINTS used for percentage (10000 basis points equal 100%) + uint256 public constant SHARE_OFFSET = 1e3; + uint256 public constant BALANCE_OFFSET = 1e3; + + IBeacon public immutable withdrawQueueBeacon; + address public immutable WETH; + + /// @dev Event emit when delegate strategies added + event DelegateStrategyAdded(address[] delegateStrategies); + /// @dev Event emit when delegate strategies removed + event DelegateStrategyRemoved(address[] delegateStrategies); + /// @dev Event emitted when whitelist enabled is updated + event WhitelistEnabledUpdated(bool isEnabled); + /// @dev Event emit when whitelist is updated + event WhitelistUpdated(address[] accounts, bool[] status); + /// @dev Event emit when whitelistedEthSender updated + event EthSenderWhitelistUpdated(address[] accounts, bool[] status); + /// @dev Event emit when performanceFee configuration is updated + event PerformanceFeeConfigUpdated(uint256 performanceFeeBps, address feeRecipient); + /// @dev Event emitted when the vault is initialized + event VaultInitialized( + address indexed roleManager, + address indexed withdrawQueue, + uint256 performanceFeeBps, + address indexed feeRecipient + ); + /// @dev Event emitted when total underlying is updated + event TotalUnderlyingUpdated(uint256 oldValue, uint256 newValue); + /// @dev Event emitted when highest rate is recorded + event HighestRateRecorded(uint256 newRate); + /// @dev Event emitted when performance fees are accrued + event PerformanceFeesAccrued(uint256 feeShares, uint256 feeAssets); + + /// @dev Only allowed address to rebalance the vault + modifier onlyRebalanceAdmin() { + if (!roleManager.isRebalanceAdmin(msg.sender)) revert NotRebalanceAdmin(); + _; + } + + modifier onlyWithdrawQueue() { + if (_msgSender() != address(withdrawQueue)) revert NotWithdrawQueue(); + _; + } + + /// @dev Allows only whitelisted address + modifier onlyWhitelisted() { + if (depositWhitelistEnabled && !whitelist[_msgSender()]) revert NotWhitelisted(); + _; + } + /// @dev Only allowed address to pause + modifier onlyPauser() { + if (!roleManager.isPauser(_msgSender())) revert NotPauser(); + _; + } + + /// @dev Only allowed address to track underlying + modifier onlyExchangeRateAdmin() { + if (!roleManager.isExchangeRateAdmin(_msgSender())) revert NotExchangeRateAdmin(); + _; + } + + constructor(IBeacon _withdrawQueueBeacon, address _wethAddress) { + if (address(_withdrawQueueBeacon) == address(0) || _wethAddress == address(0)) { + revert InvalidZeroInput(); + } + withdrawQueueBeacon = _withdrawQueueBeacon; + WETH = _wethAddress; + _disableInitializers(); + } + + /** + * @notice Initializes the vault with configuration parameters + * @dev Can only be called once during deployment via proxy + * @param _asset The ERC20 token that the vault will accept as deposits + * @param name_ The name of the vault token + * @param symbol_ The symbol of the vault token + * @param _roleManager The RoleManager contract for access control + * @param _owner The initial owner of the vault + * @param _feeRecipient The address that will receive performance fees + * @param _feeBps The performance fee in basis points (max 10000) + * @param _withdrawCoolDownPeriod The cooldown period for withdrawals in seconds + */ + function initialize( + IERC20 _asset, + string memory name_, + string memory symbol_, + IRoleManager _roleManager, + address _owner, + address _feeRecipient, + uint256 _feeBps, + uint256 _withdrawCoolDownPeriod + ) external initializer { + // Check for zero values + if ( + address(_asset) == address(0) || bytes(name_).length == 0 || bytes(symbol_).length == 0 + || address(_roleManager) == address(0) || address(_owner) == address(0) + || address(_feeRecipient) == address(0) || _feeBps == 0 || _withdrawCoolDownPeriod == 0 + ) revert InvalidZeroInput(); + + if (_feeBps > BASIS_POINTS) revert InvalidFeeBps(); + + __ERC4626_init(_asset); + __ERC20_init(name_, symbol_); + _transferOwnership(_owner); + + roleManager = _roleManager; + feeRecipient = _feeRecipient; + performanceFeeBps = _feeBps; + + // create withdraw queue for vault + withdrawQueue = IWithdrawQueue( + address( + new BeaconProxy( + address(withdrawQueueBeacon), + abi.encodeWithSelector( + IWithdrawQueue.initialize.selector, _owner, address(this), _withdrawCoolDownPeriod + ) + ) + ) + ); + + // record Initial rate as cached rate + _recordRate(totalAssets()); + + // Emit initialization event + emit VaultInitialized(address(_roleManager), address(withdrawQueue), _feeBps, _feeRecipient); + } + + /** + * @notice Pause the vault + * @dev permissioned call (onlyPuaser) + */ + function pause() external onlyPauser { + _pause(); + } + + /** + * @notice UnPause the vault + * @dev permissioned call (onlyOwner) + */ + function unpause() external onlyOwner { + _unpause(); + } + + /** + ************************* + ** OnlyOwner functions ** + ************************* + */ + + /** + * @notice Adds new delegate strategies to the vault's allowed list + * @dev Only callable by owner. Strategies must have valid addresses + * @param delegateStrategies Array of strategy contract addresses to add + */ + function addDelegateStrategies(address[] calldata delegateStrategies) external onlyOwner { + if (delegateStrategies.length == 0) revert InvalidZeroInput(); + for (uint256 i = 0; i < delegateStrategies.length;) { + if (delegateStrategies[i] == address(0)) revert InvalidZeroInput(); + // Add/Remove strategy according to status + allowedDelegateStrategy.add(delegateStrategies[i]); + unchecked { + ++i; + } + } + emit DelegateStrategyAdded(delegateStrategies); + } + + /** + * @notice Removes delegate strategies from the vault's allowed list + * @dev Only callable by owner. Strategies must have zero underlying value before removal + * @param delegateStrategies Array of strategy contract addresses to remove + */ + function removeDelegateStrategies(address[] calldata delegateStrategies) external onlyOwner { + if (delegateStrategies.length == 0) revert InvalidZeroInput(); + for (uint256 i = 0; i < delegateStrategies.length;) { + if (delegateStrategies[i] == address(0)) revert InvalidZeroInput(); + // NOTE: Skipping underlying check + // _checkUnderlying(delegateStrategies[i]); + // Add/Remove strategy according to status + allowedDelegateStrategy.remove(delegateStrategies[i]); + unchecked { + ++i; + } + } + emit DelegateStrategyRemoved(delegateStrategies); + } + + /** + * @notice Updates the whitelist status for multiple accounts + * @dev Only callable by owner. Arrays must have matching lengths + * @param accounts Array of addresses to update whitelist status for + * @param status Array of boolean values indicating whitelist status + */ + function updateWhitelist(address[] calldata accounts, bool[] calldata status) external onlyOwner { + // Check for the array length + if (accounts.length != status.length) revert MismatchedArrayLengths(); + + for (uint256 i = 0; i < accounts.length;) { + // Check if account address is zero + if (accounts[i] == address(0)) revert InvalidZeroInput(); + // Update whitelist + whitelist[accounts[i]] = status[i]; + unchecked { + ++i; + } + } + + // Emit the event + emit WhitelistUpdated(accounts, status); + } + + /** + * @notice Updates the ETH sender whitelist status for multiple accounts + * @dev Only callable by owner. Arrays must have matching lengths + * @param accounts Array of addresses to update ETH sender whitelist status for + * @param status Array of boolean values indicating ETH sender whitelist status + */ + function updateWhitelistedEthSender(address[] calldata accounts, bool[] calldata status) external onlyOwner { + // Check for the array length + if (accounts.length != status.length) revert MismatchedArrayLengths(); + for (uint256 i = 0; i < accounts.length;) { + // Check if account address is zero + if (accounts[i] == address(0)) revert InvalidZeroInput(); + // Update whitelist + whitelistedEthSender[accounts[i]] = status[i]; + unchecked { + ++i; + } + } + // Emit the event + emit EthSenderWhitelistUpdated(accounts, status); + } + + /** + * @notice Configures the performance fee and fee recipient + * @dev Only callable by owner. Fee must be within valid range (0-5000 bps) + * @param _performanceFeeBps Performance fee in basis points (10000 = 100%) + * @param _feeRecipient Address that will receive performance fees + */ + function configurePerformanceFee(uint256 _performanceFeeBps, address _feeRecipient) external onlyOwner { + if (_performanceFeeBps == 0 || _feeRecipient == address(0)) revert InvalidZeroInput(); + + if (_performanceFeeBps > BASIS_POINTS) revert InvalidFeeBps(); + + performanceFeeBps = _performanceFeeBps; + feeRecipient = _feeRecipient; + emit PerformanceFeeConfigUpdated(_performanceFeeBps, _feeRecipient); + } + + /* + * + * @notice Sets the whitelist enabled status + * @dev permissioned call (onlyOwner) + * @dev Emits WhitelistEnabledUpdated event + * @param _isEnabled Boolean value indicating if whitelist is enabled + */ + function setDepositWhitelistEnabled(bool _isEnabled) external onlyOwner { + depositWhitelistEnabled = _isEnabled; + emit WhitelistEnabledUpdated(_isEnabled); + } + + /** + ********************* + ** Admin functions ** + ********************* + */ + + function manage(address target, bytes calldata payload) external onlyRebalanceAdmin returns (bytes memory result) { + _isAllowedDelegateStrategy(target); + result = target.functionDelegateCall(payload); + + _fillWithdrawQueue(); + + _trackUnderlying(); + } + + /** + * @notice Executes multiple management actions on delegate strategies + * @dev Only callable by rebalance admin. All targets must be allowed strategies + * @param targets Array of delegate strategy addresses to call + * @param payloads Array of encoded function calls to execute + * @return results Array of return data from the executed calls + */ + function manage(address[] calldata targets, bytes[] calldata payloads) + external + onlyRebalanceAdmin + returns (bytes[] memory results) + { + if (targets.length == 0 || payloads.length == 0) revert InvalidZeroInput(); + if (targets.length != payloads.length) revert MismatchedArrayLengths(); + + results = new bytes[](targets.length); + for (uint256 i = 0; i < targets.length;) { + _isAllowedDelegateStrategy(targets[i]); + results[i] = targets[i].functionDelegateCall(payloads[i]); + unchecked { + ++i; + } + } + + _fillWithdrawQueue(); + + _trackUnderlying(); + } + + /** + * @notice Updates the vault's total underlying asset value + * @dev Only callable by exchange rate admin. Used to sync vault state with strategies + */ + function trackUnderlying() external onlyExchangeRateAdmin { + _trackUnderlying(); + } + + /** + ******************************** + ** ERC4626 Override functions ** + ******************************** + */ + + /** + * @notice Deposits assets into the vault and mints shares + * @dev Overrides ERC4626 deposit. Only whitelisted addresses can deposit + * @param assets The amount of underlying assets to deposit + * @param receiver The address that will receive the minted shares + * @return The amount of shares minted + */ + function deposit(uint256 assets, address receiver) public virtual override onlyWhitelisted returns (uint256) { + require(assets <= maxDeposit(receiver), "ERC4626: deposit more than max"); + + // convert To shares with total + uint256 shares = previewDeposit(assets); + + _deposit(_msgSender(), receiver, assets, shares); + + // Track total underlying + totalUnderlying += assets; + + _fillWithdrawQueue(); + + return shares; + } + + /** + * @notice Burns shares and updates the total assets accordingly + * @dev Only callable by the withdraw queue contract + * @param _shares The amount of shares to burn + * @param _assets The amount of assets to deduct from total underlying + */ + function burnSharesAndUpdateAssets(uint256 _shares, uint256 _assets) external onlyWithdrawQueue { + // update shares + _burn(_msgSender(), _shares); + + // update assets + totalUnderlying -= _assets; + } + + /** + * @notice Withdraw is not supported directly - use withdraw queue instead + * @dev Always reverts. Users must use the withdraw queue for withdrawals + */ + function withdraw(uint256 assets, address receiver, address owner) public virtual override returns (uint256) { + revert(); + } + + /** + * @notice Mint is not supported + * @dev Always reverts. Use deposit function instead + */ + function mint(uint256 shares, address receiver) public virtual override returns (uint256) { + revert(); + } + + /** + * @notice Redeem is not supported directly - use withdraw queue instead + * @dev Always reverts. Users must use the withdraw queue for redemptions + */ + function redeem(uint256 shares, address receiver, address owner) public virtual override returns (uint256) { + revert(); + } + + /** + ******************** + ** View functions ** + ******************** + */ + function isAllowedDelegateStrategy(address _delegateStrategy) public view returns (bool) { + return allowedDelegateStrategy.contains(_delegateStrategy); + } + + /** + * @dev See {IERC4626-convertToShares}. + */ + function convertToShares(uint256 assets) public view virtual override returns (uint256) { + return _convertToSharesWithTotals(assets, totalSupply(), totalAssets(), Math.Rounding.Floor); + } + + /** + * @dev See {IERC4626-convertToAssets}. + */ + function convertToAssets(uint256 shares) public view virtual override returns (uint256) { + return _convertToAssetsWithTotals(shares, totalSupply(), totalAssets(), Math.Rounding.Floor); + } + + /** + * @dev See {IERC4626-previewRedeem}. + */ + function previewDeposit(uint256 assets) public view virtual override returns (uint256) { + return _convertToSharesWithTotals(assets, totalSupply(), totalAssets(), Math.Rounding.Floor); + } + + /// @dev Preview Redeem is used by WithdrawQueue to calculate assets for shares + /** + * @dev See {IERC4626-previewRedeem}. + */ + function previewRedeem(uint256 shares) public view virtual override returns (uint256) { + return _convertToAssetsWithTotals(shares, totalSupply(), totalAssets(), Math.Rounding.Floor); + } + + /// @dev Revert as Mint is not supported + /** + * @dev See {IERC4626-previewMint}. + */ + function previewMint(uint256 shares) public view virtual override returns (uint256) { + revert(); + } + + /// @dev Revert as Withdraw is not supported + /** + * @dev See {IERC4626-previewWithdraw}. + */ + function previewWithdraw(uint256 assets) public view virtual override returns (uint256) { + revert(); + } + + /** + ************************************* + ** ERC4626 Override View functions ** + ************************************* + */ + + function totalAssets() public view virtual override returns (uint256) { + return totalUnderlying; + } + + /** + ************************ + ** Internal Functions ** + ************************ + */ + + /// @dev Checks if _delegateStrategy is allowed + function _isAllowedDelegateStrategy(address _delegateStrategy) internal view { + if (!allowedDelegateStrategy.contains(_delegateStrategy)) { + revert UnAuthorizedStrategy(_delegateStrategy); + } + } + + /// @notice Tracks Underlying value of the vault through delegate strategy + function _trackUnderlying() internal { + uint256 currentUnderlying = super.totalAssets() + IERC20(asset()).balanceOf(address(withdrawQueue)); + + // get underlyingValue calldata + bytes memory underlyingPayload = abi.encodeWithSelector(IDelegateStrategy.underlyingValue.selector, asset()); + + for (uint256 i = 0; i < allowedDelegateStrategy.length();) { + currentUnderlying += abi.decode( + (allowedDelegateStrategy.at(i)).functionDelegateCall(underlyingPayload), (uint256) + ); + unchecked { + ++i; + } + } + + // Deduct performance fee + _accrueFee(currentUnderlying); + + // Store old value for event + uint256 oldUnderlying = totalUnderlying; + + // Update totalUnderlying + totalUnderlying = currentUnderlying; + + // Emit event if value changed + if (oldUnderlying != currentUnderlying) { + emit TotalUnderlyingUpdated(oldUnderlying, currentUnderlying); + } + } + + /// @dev Accrue Performance fee on the Interest Earned + function _accrueFee(uint256 currentUnderlying) internal { + uint256 newRate = + _convertToAssetsWithTotals(10 ** decimals(), totalSupply(), currentUnderlying, Math.Rounding.Floor); + + if (newRate > lastCachedHighestRate) { + // get the total gains from previous rate + uint256 totalInterest = currentUnderlying - totalSupply().mulDiv(lastCachedHighestRate, 10 ** decimals()); + + // deduct fee + uint256 feeAssets = (totalInterest * performanceFeeBps) / BASIS_POINTS; + + // fee shares to mint + // The fee assets is subtracted from the currentUnderlying in this calculation to compensate for the fact + // that currentUnderlying is already increased by the total interest (including the fee assets). + uint256 feeShares = _convertToSharesWithTotals( + feeAssets, totalSupply(), currentUnderlying - feeAssets, Math.Rounding.Floor + ); + + if (feeShares != 0) { + _mint(feeRecipient, feeShares); + // Emit event for performance fees accrued + emit PerformanceFeesAccrued(feeShares, feeAssets); + } + + // record rate + _recordRate(currentUnderlying); + } + } + + /// @dev Record the Highest rate + function _recordRate(uint256 _currentUnderlying) internal { + uint256 rate = + _convertToAssetsWithTotals(10 ** decimals(), totalSupply(), _currentUnderlying, Math.Rounding.Floor); + + // if current rate is greater than last cached highest rate + if (rate > lastCachedHighestRate) { + // record last cached highest rate + lastCachedHighestRate = rate; + // Emit event for new highest rate + emit HighestRateRecorded(rate); + } + } + + /// @dev Check for 0 underlying value + function _checkUnderlying(address _delegateStrategy) internal view { + if (IDelegateStrategy(_delegateStrategy).underlyingValue(asset()) > 0) { + revert NonZeroUnderlyingDelegateStrategy(); + } + } + + /// @dev Fill the Withdraw Queue from the balance + function _fillWithdrawQueue() internal { + // get the queue deficit + uint256 queueDeficit = withdrawQueue.getQueueDeficit(); + if (queueDeficit > 0) { + // Get amount to fill the queue + uint256 amountToFill = super.totalAssets() > queueDeficit ? queueDeficit : super.totalAssets(); + if (amountToFill > 0) { + // Increase allowance to withdraw Queue + IERC20(asset()).safeIncreaseAllowance(address(withdrawQueue), amountToFill); + + // Fill the Withdraw Queue + withdrawQueue.fillWithdrawQueue(amountToFill); + } + } + } + + /// @dev Returns the amount of assets that the vault would exchange for the amount of `shares` provided. + /// @dev It assumes that the arguments `newTotalSupply` and `newTotalAssets` are up to date. + function _convertToAssetsWithTotals( + uint256 shares, + uint256 newTotalSupply, + uint256 newTotalAssets, + Math.Rounding rounding + ) internal view returns (uint256) { + return shares.mulDiv(newTotalAssets + BALANCE_OFFSET, newTotalSupply + SHARE_OFFSET, rounding); + } + + /// @dev Returns the amount of shares that the vault would exchange for the amount of `assets` provided. + /// @dev It assumes that the arguments `newTotalSupply` and `newTotalAssets` are up to date. + function _convertToSharesWithTotals( + uint256 assets, + uint256 newTotalSupply, + uint256 newTotalAssets, + Math.Rounding rounding + ) internal view returns (uint256) { + return assets.mulDiv(newTotalSupply + SHARE_OFFSET, newTotalAssets + BALANCE_OFFSET, rounding); + } + + /** + * @notice Receives ETH sent to the contract + * @dev Only whitelisted addresses can send ETH to this contract + * @dev Warning : ETH can be sent directly via selfdestruct + */ + receive() external payable { + // only allow whitelisted address to send ETH + if (msg.sender != WETH && !whitelistedEthSender[msg.sender]) revert(); + // do nothing + } +} diff --git a/contracts/test/renzo/LEZyVaultStorage.sol b/contracts/test/renzo/LEZyVaultStorage.sol new file mode 100644 index 0000000..796997b --- /dev/null +++ b/contracts/test/renzo/LEZyVaultStorage.sol @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.28; + +import { IWithdrawQueue } from "./interfaces/IWithdrawQueue.sol"; +import { IRoleManager } from "./interfaces/IRoleManager.sol"; +import { EnumerableSet } from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; + +/** + * @title LEZyVaultStorageV1 + * @author Renzo Protocol + * @notice Storage contract for LEZyVault implementation + * @dev This abstract contract defines the storage layout for the LEZyVault upgradeable contract + */ +abstract contract LEZyVaultStorageV1 { + /// @dev Tracks allowed DelegateStrategies + EnumerableSet.AddressSet internal allowedDelegateStrategy; + + /// @dev Tracks the roleManager contract + IRoleManager public roleManager; + + /// @dev Tracks total underlying value + uint256 public totalUnderlying; + + /// @dev Tracks WithdrawQueue contract + IWithdrawQueue public withdrawQueue; + + /// @dev track deposit whitelisted status + bool public depositWhitelistEnabled; + + /// @dev tracks whitelisted addresses for deposits - requires whitelistEnabled to be true + mapping(address => bool) public whitelist; + + /// @dev Track High watermark exchange rate + uint256 public lastCachedHighestRate; + + /// @dev Track performance fee BPS + uint256 public performanceFeeBps; + + /// @dev Tracks fee Recipient + address public feeRecipient; + + /// @dev Tracks whitelisted addresses to allow sending ETH to vault + mapping(address => bool) public whitelistedEthSender; +} \ No newline at end of file diff --git a/contracts/test/renzo/WithdrawQueue.sol b/contracts/test/renzo/WithdrawQueue.sol new file mode 100644 index 0000000..831a510 --- /dev/null +++ b/contracts/test/renzo/WithdrawQueue.sol @@ -0,0 +1,235 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.28; + +import "./WithdrawQueueStorage.sol"; +import { PausableUpgradeable } from "@openzeppelin/contracts-upgradeable/utils/PausableUpgradeable.sol"; + +import { OwnableUpgradeable } from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; +import { + ReentrancyGuardUpgradeable +} from "@openzeppelin/contracts-upgradeable/utils/ReentrancyGuardUpgradeable.sol"; +import "./Errors.sol"; +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; + +contract WithdrawQueue is + OwnableUpgradeable, + PausableUpgradeable, + ReentrancyGuardUpgradeable, + WithdrawQueueStorageV1 +{ + using SafeERC20 for IERC20; + + modifier onlyVault() { + if (_msgSender() != address(vault)) revert NotUnderlyingVault(); + _; + } + + event WithdrawRequestCreated( + address indexed withdrawer, + WithdrawRequest _withdrawRequest, + uint256 withdrawRequestIndex + ); + event WithdrawRequestClaimed(WithdrawRequest _withdrawRequest); + event WithdrawQueueFilled(uint256 assetAmount); + event CoolDownPeriodUpdated(uint256 oldCoolDownPeriod, uint256 newCoolDownPeriod); + event WithdrawQueueInitialized(address indexed vault, uint256 coolDownPeriod); + event QueuedWithdrawToFillUpdated(uint256 oldValue, uint256 newValue); + event QueuedWithdrawFilledUpdated(uint256 oldValue, uint256 newValue); + + modifier onlyPauser() { + if (!vault.roleManager().isPauser(_msgSender())) revert NotPauser(); + _; + } + + /// @dev Prevents implementation contract from being initialized. + /// @custom:oz-upgrades-unsafe-allow constructor + constructor() { + _disableInitializers(); + } + + function initialize( + address _owner, + address _vault, + uint256 _cooldownPeriod + ) external initializer { + if (_owner == address(0) || _vault == address(0) || _cooldownPeriod == 0) + revert InvalidZeroInput(); + vault = ILEZyVault(_vault); + coolDownPeriod = _cooldownPeriod; + _transferOwnership(_owner); + __ReentrancyGuard_init(); + __Pausable_init(); + + // Emit initialization event + emit WithdrawQueueInitialized(_vault, _cooldownPeriod); + } + + /** + * @notice Pause the vault + * @dev permissioned call (onlyPuaser) + */ + function pause() external onlyPauser { + _pause(); + } + + /** + * @notice UnPause the vault + * @dev permissioned call (onlyOwner) + */ + function unpause() external onlyOwner { + _unpause(); + } + + /** + * @notice Updates the coolDownPeriod for withdrawal requests + * @dev It is a permissioned call (onlyWithdrawQueueAdmin) + * @param _newCoolDownPeriod new coolDownPeriod in seconds + */ + function updateCoolDownPeriod(uint256 _newCoolDownPeriod) external onlyOwner { + if (_newCoolDownPeriod == 0) revert InvalidZeroInput(); + emit CoolDownPeriodUpdated(coolDownPeriod, _newCoolDownPeriod); + coolDownPeriod = _newCoolDownPeriod; + } + + /** + * @notice Creates a Withdraw request for the user + * @param _shares Shares to withdraw + */ + function withdraw(uint256 _shares) external nonReentrant whenNotPaused { + // check if shares more than max redeem allowed + require(_shares <= vault.maxRedeem(_msgSender()), "ERC4626: redeem more than max"); + + // check for 0 value + if (_shares == 0) revert InvalidZeroInput(); + + // transfer vault shares to this address + IERC20(address(vault)).safeTransferFrom(_msgSender(), address(this), _shares); + + // Calculate amount to Redeem + uint256 amountToRedeem = vault.previewRedeem(_shares); + + // Store old value for event + uint256 oldQueuedWithdrawToFill = withdrawQueue.queuedWithdrawToFill; + + // increase the queuedWithdrawalToFill + withdrawQueue.queuedWithdrawToFill += amountToRedeem; + + // Emit event for queue update + emit QueuedWithdrawToFillUpdated( + oldQueuedWithdrawToFill, + withdrawQueue.queuedWithdrawToFill + ); + + // increment the withdrawRequestNonce + withdrawRequestNonce++; + + // Create Withdraw Request + WithdrawRequest memory withdrawRequest = WithdrawRequest( + withdrawRequestNonce, + amountToRedeem, + _shares, + block.timestamp, + withdrawQueue.queuedWithdrawToFill + ); + + // add withdraw request for msg.sender + withdrawRequests[_msgSender()].push(withdrawRequest); + + // emit the event + emit WithdrawRequestCreated( + _msgSender(), + withdrawRequest, + withdrawRequests[_msgSender()].length - 1 + ); + } + + /** + * @notice Claim user withdraw request for underlying + * @param withdrawRequestIndex Index of the withdraw request in user withdraw requests + * @param user address of user to claim the withdraw request for + */ + function claim(uint256 withdrawRequestIndex, address user) external nonReentrant whenNotPaused { + // check if provided withdrawRequest Index is valid + if (withdrawRequestIndex >= withdrawRequests[user].length) revert InvalidWithdrawIndex(); + + WithdrawRequest memory _withdrawRequest = withdrawRequests[user][withdrawRequestIndex]; + + // check if withdraw request is filled completely and cooldown is passed + if ( + _withdrawRequest.fillAt > withdrawQueue.queuedWithdrawFilled || + (block.timestamp - _withdrawRequest.createdAt) < coolDownPeriod + ) revert WithdrawalNotClaimable(); + uint256 claimAmountToRedeem = vault.previewRedeem(_withdrawRequest.sharesLocked); + + if (claimAmountToRedeem < _withdrawRequest.amountToRedeem) { + // Increase the Queue filled by the delta + withdrawQueue.queuedWithdrawFilled += (_withdrawRequest.amountToRedeem - + claimAmountToRedeem); + // update amount to redeem + _withdrawRequest.amountToRedeem = claimAmountToRedeem; + } + + // delete the withdraw request + withdrawRequests[user][withdrawRequestIndex] = withdrawRequests[user][ + withdrawRequests[user].length - 1 + ]; + withdrawRequests[user].pop(); + + // burn vault shares + vault.burnSharesAndUpdateAssets( + _withdrawRequest.sharesLocked, + _withdrawRequest.amountToRedeem + ); + + // send selected redeem asset to user + IERC20(vault.asset()).safeTransfer(user, _withdrawRequest.amountToRedeem); + + // emit the event + emit WithdrawRequestClaimed(_withdrawRequest); + } + + /** + * @notice Fills the withdraw queue of the vault + * @dev Permissioned call (onlyVault) + * @param _assetAmount amount of underlying asset to fill the queue deficit + */ + function fillWithdrawQueue(uint256 _assetAmount) external nonReentrant onlyVault { + if (_assetAmount == 0) revert InvalidZeroInput(); + + // Transfer asset amount from msg.sender + IERC20(vault.asset()).safeTransferFrom(msg.sender, address(this), _assetAmount); + + // Store old value for event + uint256 oldQueuedWithdrawFilled = withdrawQueue.queuedWithdrawFilled; + + // track queue filled + withdrawQueue.queuedWithdrawFilled += _assetAmount; + + // Emit event for filled update + emit QueuedWithdrawFilledUpdated( + oldQueuedWithdrawFilled, + withdrawQueue.queuedWithdrawFilled + ); + + emit WithdrawQueueFilled(_assetAmount); + } + + /** + ******************** + ** View functions ** + ******************** + */ + + /** + * @notice To get the current queue deficit for the vault + * @return uint256 Current queue deficit + */ + function getQueueDeficit() public view returns (uint256) { + return withdrawQueue.queuedWithdrawToFill - withdrawQueue.queuedWithdrawFilled; + } + + function totalUserWithdrawRequests(address _user) public view returns (uint256) { + return withdrawRequests[_user].length; + } +} \ No newline at end of file diff --git a/contracts/test/renzo/WithdrawQueueStorage.sol b/contracts/test/renzo/WithdrawQueueStorage.sol new file mode 100644 index 0000000..a8e18d8 --- /dev/null +++ b/contracts/test/renzo/WithdrawQueueStorage.sol @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.28; + +import {ILEZyVault} from "./interfaces/ILEZyVault.sol"; + +abstract contract WithdrawQueueStorageV1 { + struct WithdrawQueue { + uint256 queuedWithdrawToFill; + uint256 queuedWithdrawFilled; + } + + struct WithdrawRequest { + uint256 withdrawRequestID; + uint256 amountToRedeem; + uint256 sharesLocked; + uint256 createdAt; + uint256 fillAt; + } + + /// @dev Tracks the vault of the withdraw queue + ILEZyVault public vault; + + /// @dev Tracks the withdraw queue state + WithdrawQueue public withdrawQueue; + + /// @dev nonce for tracking withdraw requests, This only increments (doesn't decrement) + uint256 public withdrawRequestNonce; + + /// @dev mapping of withdraw requests array, indexed by user address + mapping(address => WithdrawRequest[]) public withdrawRequests; + + /// @dev cooldown period for user to claim their withdrawal + uint256 public coolDownPeriod; +} diff --git a/contracts/test/renzo/interfaces/IDelegateStrategy.sol b/contracts/test/renzo/interfaces/IDelegateStrategy.sol new file mode 100644 index 0000000..62b76f2 --- /dev/null +++ b/contracts/test/renzo/interfaces/IDelegateStrategy.sol @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.28; + +/** + * @title IDelegateStrategy + * @author Renzo Protocol + * @notice Interface that all delegate strategies must implement + * @dev Delegate strategies are external contracts that manage vault assets in various DeFi protocols + */ +interface IDelegateStrategy { + /** + * @notice Returns the value of assets managed by this strategy + * @dev WARNING: Don't use balanceOf(this) to avoid double counting + * @param _asset The asset address to query the value for + * @return The total value of the specified asset managed by this strategy + */ + function underlyingValue(address _asset) external view returns (uint256); +} \ No newline at end of file diff --git a/contracts/test/renzo/interfaces/ILEZyVault.sol b/contracts/test/renzo/interfaces/ILEZyVault.sol new file mode 100644 index 0000000..865234b --- /dev/null +++ b/contracts/test/renzo/interfaces/ILEZyVault.sol @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.28; + +import {IERC4626} from "@openzeppelin/contracts/interfaces/IERC4626.sol"; +import { IRoleManager } from "./IRoleManager.sol"; + +/** + * @title ILEZyVault + * @author Renzo Protocol + * @notice Interface for the LEZyVault contract + * @dev Extends ERC4626 standard with additional functionality for managing shares and assets + */ +interface ILEZyVault is IERC4626 { + /** + * @notice Burns shares and updates the total assets accordingly + * @dev This function should only be called by authorized contracts (e.g., WithdrawQueue) + * @param _shares The amount of shares to burn + * @param _assets The amount of assets to deduct from total assets + */ + function burnSharesAndUpdateAssets(uint256 _shares, uint256 _assets) external; + + /** + * @notice Returns the RoleManager contract address + * @return The RoleManager contract instance used for access control + */ + function roleManager() external returns (IRoleManager); +} \ No newline at end of file diff --git a/contracts/test/renzo/interfaces/IRoleManager.sol b/contracts/test/renzo/interfaces/IRoleManager.sol new file mode 100644 index 0000000..bd150a7 --- /dev/null +++ b/contracts/test/renzo/interfaces/IRoleManager.sol @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.28; + +/** + * @title IRoleManager + * @author Renzo Protocol + * @notice Interface for the RoleManager contract that handles access control + * @dev This interface defines functions to check various role permissions + */ +interface IRoleManager { + /** + * @notice Checks if an address has the REBALANCE_ADMIN role + * @param potentialAddress The address to check + * @return bool True if the address has the REBALANCE_ADMIN role, false otherwise + */ + function isRebalanceAdmin(address potentialAddress) external view returns (bool); + + /** + * @notice Checks if an address has the PAUSER role + * @param potentialAddress The address to check + * @return bool True if the address has the PAUSER role, false otherwise + */ + function isPauser(address potentialAddress) external view returns (bool); + + /** + * @notice Checks if an address has the EXCHANGE_RATE_ADMIN role + * @param potentialAddress The address to check + * @return bool True if the address has the EXCHANGE_RATE_ADMIN role, false otherwise + */ + function isExchangeRateAdmin(address potentialAddress) external view returns (bool); +} \ No newline at end of file diff --git a/contracts/test/renzo/interfaces/IWithdrawQueue.sol b/contracts/test/renzo/interfaces/IWithdrawQueue.sol new file mode 100644 index 0000000..e74e831 --- /dev/null +++ b/contracts/test/renzo/interfaces/IWithdrawQueue.sol @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.28; + +interface IWithdrawQueue { + function withdraw(uint256 _shares, address user) external; + + function fillWithdrawQueue(uint256 _assetAmount) external; + + function getQueueDeficit() external view returns (uint256); + + function initialize(address _owner, address _vault, uint256 _cooldownPeriod) external; + + function setWhiteListed(address[] calldata _accounts, bool[] calldata _accountsStatus) external; +} \ No newline at end of file diff --git a/contracts/tranches/strategies/renzo/interfaces/ILEZyVaultMinimal.sol b/contracts/tranches/strategies/renzo/interfaces/ILEZyVaultMinimal.sol new file mode 100644 index 0000000..de3107c --- /dev/null +++ b/contracts/tranches/strategies/renzo/interfaces/ILEZyVaultMinimal.sol @@ -0,0 +1,12 @@ +/// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.28; + +/** + * @title ILEZyVaultMinimal + * @dev Minimal interface for getting WithdrawQueue from LEZyVault + */ +interface ILEZyVaultMinimal { + function withdrawQueue() external view returns (address); + function asset() external view returns (address); +} + diff --git a/contracts/tranches/strategies/renzo/interfaces/IWithdrawQueueMinimal.sol b/contracts/tranches/strategies/renzo/interfaces/IWithdrawQueueMinimal.sol new file mode 100644 index 0000000..23e6653 --- /dev/null +++ b/contracts/tranches/strategies/renzo/interfaces/IWithdrawQueueMinimal.sol @@ -0,0 +1,29 @@ +/// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.28; + +/** + * @title IWithdrawQueueMinimal + * @dev Minimal interface for interacting with Renzo's WithdrawQueue + */ +interface IWithdrawQueueMinimal { + struct WithdrawRequest { + uint256 withdrawRequestID; + uint256 amountToRedeem; + uint256 sharesLocked; + uint256 createdAt; + uint256 fillAt; + } + + function withdraw(uint256 _shares) external; + function claim(uint256 withdrawRequestIndex, address user) external; + function coolDownPeriod() external view returns (uint256); + function withdrawRequests(address user, uint256 index) external view returns ( + uint256 withdrawRequestID, + uint256 amountToRedeem, + uint256 sharesLocked, + uint256 createdAt, + uint256 fillAt + ); + function totalUserWithdrawRequests(address user) external view returns (uint256); +} + diff --git a/contracts/tranches/strategies/renzo/interfaces/IsUSCC.sol b/contracts/tranches/strategies/renzo/interfaces/IsUSCC.sol new file mode 100644 index 0000000..4edf45d --- /dev/null +++ b/contracts/tranches/strategies/renzo/interfaces/IsUSCC.sol @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import {IERC4626} from "@openzeppelin/contracts/interfaces/IERC4626.sol"; + +/** + * @title IsUSCC + * @notice Interface for the sUSCCStrategy contract exposing vesting state + */ +interface IsUSCC { + /// @notice Returns the underlying ezUSCC vault + function ezUSCC1() external view returns (IERC4626); + + /// @notice Returns the vesting period duration (24 hours) + function VESTING_PERIOD() external view returns (uint256); + + /// @notice Returns the timestamp when the current vesting period started + function lastVestingTimestamp() external view returns (uint256); + + /// @notice Returns the amount being vested in the current period + function vestingAmount() external view returns (uint256); + + /// @notice Returns the raw total assets at the last vesting checkpoint + function lastTotalAssets() external view returns (uint256); + + /// @notice Returns the amount of assets that are still unvested + function getUnvestedAmount() external view returns (uint256); + + /// @notice Returns the total vested assets managed by this strategy + function totalAssets() external view returns (uint256); +} diff --git a/contracts/tranches/strategies/renzo/sUSCCAprPairProvider.sol b/contracts/tranches/strategies/renzo/sUSCCAprPairProvider.sol new file mode 100644 index 0000000..d4fffb0 --- /dev/null +++ b/contracts/tranches/strategies/renzo/sUSCCAprPairProvider.sol @@ -0,0 +1,94 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.28; + +import {IsUSCC} from "./interfaces/IsUSCC.sol"; +import {IStrategyAprPairProvider} from "../../interfaces/IAprPairFeed.sol"; + +interface IsUSDS { + // Sky Savings Rate + function ssr() external view returns (uint256); + // Timestamp + function rho() external view returns (uint64); +} + +/** + * @title sUSCC AprPairProvider + * @notice Fetches target APR from Sky Protocol and base APR from sUSCCStrategy's vesting state + * @dev Similar to sUSDeAprPairProvider but calculates APR based on our strategy's vesting + */ +contract sUSCCAprPairProvider is IStrategyAprPairProvider { + uint256 constant SECONDS_PER_YEAR = 31_536_000; + + IsUSDS public sUSDS; + IsUSCC public sUSCC; + + constructor(IsUSDS _sUSDS, IsUSCC _sUSCC) { + sUSDS = _sUSDS; + sUSCC = _sUSCC; + } + + function getAprPair() external view returns (int64 aprTarget, int64 aprBase, uint64 timestamp) { + timestamp = uint64(block.timestamp); + aprTarget = getAPRtarget(); + aprBase = getAPRbase(); + } + + /** + * @notice Calculates the target APR based on the Sky Savings Rate (SSR) + * @dev Fetches the current SSR (Growth Factor) from Sky's Protocol and converts it to an annual rate + * @return The target APR as an int64, scaled by 1e12 (12 decimal places) + */ + function getAPRtarget() public view returns (int64) { + // growth per second + uint256 ssr = sUSDS.ssr(); + uint256 ONE_in = 1e27; + uint256 ONE_out = 1e12; + if (ssr < ONE_in) { + // not possible, but just in case: return 0 if APRssr is negative + return 0; + } + uint256 apr = (ssr - ONE_in) * SECONDS_PER_YEAR * ONE_out / ONE_in; + return int64(int256(apr)); + } + + /** + * @notice Calculates the base APR for sUSCC based on the current vesting amount + * @dev During the first 24 hours (before vesting starts) or after vesting completes, returns 0 + * During vesting, calculates APR based on remaining unvested amount + * @return The base APR as an int64, scaled by 1e12 (12 decimal places) + */ + function getAPRbase() public view returns (int64) { + uint256 t1 = block.timestamp; + uint256 t0 = sUSCC.lastVestingTimestamp(); + + // If vesting hasn't started yet, APR = 0 + if (t0 == 0) { + return 0; + } + + uint256 deltaT = t1 - t0; + uint256 vestingPeriod = sUSCC.VESTING_PERIOD(); + + // If vesting period has elapsed, APR = 0 until next vesting starts + if (deltaT >= vestingPeriod) { + return 0; + } + + uint256 unvestedAmount = sUSCC.getUnvestedAmount(); + uint256 totalAssets = sUSCC.totalAssets(); + + // Avoid division by zero + if (totalAssets == 0) { + return 0; + } + + // APR = (unvested / remaining_time) * seconds_per_year / total_assets + // unvested / remaining_time = rate of vesting per second + // rate * seconds_per_year / total_assets = APR + uint256 remainingTime = vestingPeriod - deltaT; + uint256 apr = unvestedAmount * SECONDS_PER_YEAR * 1e18 / remainingTime / totalAssets; + + // Convert from 1e18 to 1e12 format + return int64(int256(apr * 1e12 / 1e18)); + } +} diff --git a/contracts/tranches/strategies/renzo/sUSCCCooldownRequestImpl.sol b/contracts/tranches/strategies/renzo/sUSCCCooldownRequestImpl.sol new file mode 100644 index 0000000..7d5c149 --- /dev/null +++ b/contracts/tranches/strategies/renzo/sUSCCCooldownRequestImpl.sol @@ -0,0 +1,129 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.28; + +import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {IERC4626} from "@openzeppelin/contracts/interfaces/IERC4626.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {IUnstakeHandler} from "../../interfaces/cooldown/IUnstakeHandler.sol"; +import {IWithdrawQueueMinimal} from "./interfaces/IWithdrawQueueMinimal.sol"; +import {ILEZyVaultMinimal} from "./interfaces/ILEZyVaultMinimal.sol"; + +/** + * @title sUSCCCooldownRequestImpl + * @dev Implementation of the unstake process for ezUSCC (LEZyVault) tokens with cooldown period. + * This contract is designed to be used within the UnstakeCooldown contract. + * It handles the cooldown request, finalization, and asset transfer for unstaking ezUSCC tokens + * through Renzo's WithdrawQueue mechanism. + */ +contract sUSCCCooldownRequestImpl is IUnstakeHandler, Initializable { + using SafeERC20 for IERC20; + + IERC4626 public immutable ezUSCC; + + IERC20 public immutable USDC; + address public handler; + address public user; + address public receiver; + uint256 public requestedAt; + bool public pending; + + uint256 public withdrawRequestIndex; + + constructor(IERC4626 ezUSCC_) { + _disableInitializers(); + ezUSCC = ezUSCC_; + USDC = IERC20(ezUSCC_.asset()); + } + + function initialize(address handler_, address user_) public virtual initializer { + user = user_; + handler = handler_; + } + + function request() external returns (uint256 unlockAt) { + return request(user); + } + + function request(address receiver_) public returns (uint256 unlockAt) { + require(msg.sender == handler, "NotAuthorized"); + + uint256 shares = IERC20(address(ezUSCC)).balanceOf(address(this)); + IWithdrawQueueMinimal withdrawQueue = _getWithdrawQueue(); + + // Approve shares to WithdrawQueue + IERC20(address(ezUSCC)).forceApprove(address(withdrawQueue), shares); + + // Get current request count before creating new request + uint256 requestCountBefore = withdrawQueue.totalUserWithdrawRequests(address(this)); + + // Create withdraw request in the WithdrawQueue + withdrawQueue.withdraw(shares); + + // Store the withdraw request index (0-indexed, so it's the count before) + withdrawRequestIndex = requestCountBefore; + requestedAt = block.timestamp; + receiver = receiver_; + pending = true; + + // Return when the request can be claimed + return block.timestamp + withdrawQueue.coolDownPeriod(); + } + + /** + * @notice Completes the unstake request and transfers USDC to the receiver + * @dev Claims from WithdrawQueue and forwards USDC to receiver + * @return amount The amount of USDC transferred to receiver + */ + function finalize() external returns (uint256 amount) { + require(msg.sender == handler, "NotAuthorized"); + require(pending, "No pending request"); + + IWithdrawQueueMinimal withdrawQueue = _getWithdrawQueue(); + + // Get the expected amount from the withdraw request + (, uint256 amountToRedeem,,,) = withdrawQueue.withdrawRequests(address(this), withdrawRequestIndex); + + // Claim from WithdrawQueue - this sends USDC to address(this) + withdrawQueue.claim(withdrawRequestIndex, address(this)); + + // Get actual USDC balance received + uint256 usdcBalance = USDC.balanceOf(address(this)); + amount = usdcBalance > 0 ? usdcBalance : amountToRedeem; + + // Transfer USDC to receiver + if (amount > 0) { + USDC.safeTransfer(receiver, amount); + } + + pending = false; + return amount; + } + + /** + * @notice Returns the pending amount to be redeemed in USDC + * @return amount The amount of USDC pending + */ + function getPendingAmount() external view returns (uint256 amount) { + if (!pending) { + return 0; + } + IWithdrawQueueMinimal withdrawQueue = _getWithdrawQueue(); + (, uint256 amountToRedeem,,,) = withdrawQueue.withdrawRequests(address(this), withdrawRequestIndex); + return amountToRedeem; + } + + /** + * @notice Checks if cooldown is active in the WithdrawQueue + * @return True if cooldown period is greater than 0 + */ + function isCooldownActive() public view returns (bool) { + IWithdrawQueueMinimal withdrawQueue = _getWithdrawQueue(); + return withdrawQueue.coolDownPeriod() > 0; + } + + function _getWithdrawQueue() internal view returns (IWithdrawQueueMinimal) { + address queueAddress = ILEZyVaultMinimal(address(ezUSCC)).withdrawQueue(); + return IWithdrawQueueMinimal(queueAddress); + } +} diff --git a/contracts/tranches/strategies/renzo/sUSCCStrategy.sol b/contracts/tranches/strategies/renzo/sUSCCStrategy.sol new file mode 100644 index 0000000..90c73cb --- /dev/null +++ b/contracts/tranches/strategies/renzo/sUSCCStrategy.sol @@ -0,0 +1,295 @@ +// 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, IUnstakeCooldown} from "../../interfaces/cooldown/ICooldown.sol"; +import {Strategy} from "../../Strategy.sol"; + +contract sUSCCStrategy is Strategy { + IERC4626 public immutable ezUSCC1; + IERC20 public immutable USDC; + + IERC20Cooldown public erc20Cooldown; + IUnstakeCooldown public unstakeCooldown; + + /// @notice Vesting period duration (24 hours) + uint256 public constant VESTING_PERIOD = 24 hours; + + /// @notice Timestamp when the current vesting period started + uint256 public lastVestingTimestamp; + + /// @notice Amount being vested in the current period + uint256 public vestingAmount; + + /// @notice Raw total assets at the last vesting checkpoint + uint256 public lastTotalAssets; + + + event VestingUpdated(uint256 vestingAmount, uint256 lastTotalAssets, uint256 timestamp); + + constructor(IERC4626 ezUSCC1_, IERC20 USDC_) { + ezUSCC1 = ezUSCC1_; + USDC = USDC_; + } + + function initialize( + address owner_, + address acm_, + IStrataCDO cdo_, + IERC20Cooldown erc20Cooldown_, + IUnstakeCooldown unstakeCooldown_ + ) public virtual initializer { + AccessControlled_init(owner_, acm_); + + cdo = cdo_; + erc20Cooldown = erc20Cooldown_; + unstakeCooldown = unstakeCooldown_; + + SafeERC20.forceApprove(ezUSCC1, address(unstakeCooldown), type(uint256).max); + } + + /** + * @notice Processes asset deposits for the CDO contract. + * @dev This method is called by the CDO contract to handle asset deposits. + * The only accepted token is USDC and it will be staked to receive ezUSCC shares. + * @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 + * @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) + { + if (token != address(USDC)) { + revert UnsupportedToken(token); + } + + _updateVesting(); + + SafeERC20.safeTransferFrom(IERC20(token), owner, address(this), tokenAmount); + + SafeERC20.forceApprove(USDC, address(ezUSCC1), tokenAmount); + ezUSCC1.deposit(tokenAmount, address(this)); + + // Update lastTotalAssets to include the new deposit so it is not + // counted as yield gain in the next vesting period. + lastTotalAssets = _getRawTotalAssets(); + + return tokenAmount; + } + + /** + * @notice Processes asset withdrawals for the CDO contract. + * @dev This method is called by the CDO contract to handle asset withdrawals. + */ + 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, + address token, + uint256, + /* tokenAmount */ + uint256 baseAssets, + address sender, + address receiver, + bool + ) internal returns (uint256) { + + if (token != address(USDC)) { + revert UnsupportedToken(token); + } + + + _updateVesting(); + + uint256 shares = ezUSCC1.convertToShares(baseAssets); + unstakeCooldown.transfer(ezUSCC1, sender, receiver, shares); + + // Update lastTotalAssets to reflect the withdrawal so it is not + // counted as negative gain in the next vesting period. + lastTotalAssets = _getRawTotalAssets(); + + return baseAssets; + } + + /** + * @notice Allows the CDO to withdraw tokens from the strategy's reserve + * @param token The address of the token to be withdrawn (USDC only) + * @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(USDC)) { + revert UnsupportedToken(token); + } + // tokenAmount is in USDC, convert to ezUSCC shares and trigger unstaking + uint256 shares = ezUSCC1.convertToShares(tokenAmount); + if (shares == 0) { + revert ZeroAmount(); + } + unstakeCooldown.transfer(ezUSCC1, receiver, receiver, shares); + } + + /*////////////////////////////////////////////////////////////// + VESTING LOGIC + //////////////////////////////////////////////////////////////*/ + + /** + * @notice Returns the raw total assets from the underlying vault + * @dev This is the actual value without vesting adjustments + */ + function _getRawTotalAssets() internal view returns (uint256) { + uint256 shares = ezUSCC1.balanceOf(address(this)); + return ezUSCC1.previewRedeem(shares); + } + + /** + * @notice Returns the amount of assets that are still unvested + * @dev Calculates based on time elapsed since last vesting timestamp + */ + function getUnvestedAmount() public view returns (uint256) { + if (lastVestingTimestamp == 0) { + return 0; + } + uint256 timeSinceLastVesting = block.timestamp - lastVestingTimestamp; + if (timeSinceLastVesting >= VESTING_PERIOD) { + return 0; + } + uint256 deltaT; + unchecked { + deltaT = VESTING_PERIOD - timeSinceLastVesting; + } + return (deltaT * vestingAmount) / VESTING_PERIOD; + } + + /** + * @notice Updates the vesting state + * @dev Called on every deposit/withdraw to ensure vesting is up to date + * - First call: initializes vesting without any vesting amount + * - During vesting period: no update + * - After vesting period: calculates new gain and starts new vesting + */ + function _updateVesting() internal { + uint256 rawAssets = _getRawTotalAssets(); + + if (lastVestingTimestamp == 0) { + // First deposit - initialize without vesting + lastVestingTimestamp = block.timestamp; + lastTotalAssets = rawAssets; + vestingAmount = 0; + emit VestingUpdated(0, rawAssets, block.timestamp); + return; + } + + uint256 timeSinceLastVesting = block.timestamp - lastVestingTimestamp; + if (timeSinceLastVesting < VESTING_PERIOD) { + // Still in current vesting window - no update needed + return; + } + + // Vesting period has elapsed, calculate new gain + // Gain = rawAssets - lastTotalAssets (the increase due to exchange rate change) + uint256 gain = rawAssets > lastTotalAssets ? rawAssets - lastTotalAssets : 0; + + // Start new vesting period + vestingAmount = gain; + lastVestingTimestamp = block.timestamp; + lastTotalAssets = rawAssets; + + emit VestingUpdated(gain, rawAssets, block.timestamp); + } + + /** + * @notice Calculates the total assets managed by this strategy (vested only) + * @dev Returns raw assets minus unvested amount + * @return baseAssets The total amount of vested USDC managed by this strategy + */ + function totalAssets() external view returns (uint256 baseAssets) { + uint256 rawAssets = _getRawTotalAssets(); + uint256 unvested = getUnvestedAmount(); + return rawAssets > unvested ? rawAssets - unvested : 0; + } + + /*////////////////////////////////////////////////////////////// + CONVERSION METHODS + //////////////////////////////////////////////////////////////*/ + + /** + * @notice Converts a given amount of supported tokens to their equivalent in USDC + * @param token The address of the token to convert (USDC only) + * @param tokenAmount The amount of tokens to convert + * @return The equivalent amount in USDC + */ + function convertToAssets(address token, uint256 tokenAmount, Math.Rounding) + external + view + returns (uint256) + { + if (token != address(USDC)) { + revert UnsupportedToken(token); + } + return tokenAmount; + } + + /** + * @notice Converts a given amount of base assets (USDC) to the equivalent amount of supported tokens + * @param token The address of the token to convert to (USDC only) + * @param baseAssets The amount of base assets (USDC) to convert + * @return The equivalent amount in the requested token + */ + function convertToTokens(address token, uint256 baseAssets, Math.Rounding) + external + view + returns (uint256) + { + if (token != address(USDC)) { + revert UnsupportedToken(token); + } + return baseAssets; + } + + /** + * @notice Returns an array of supported tokens: USDC only (deposits and withdrawals are in USDC) + */ + function getSupportedTokens() external view returns (IERC20[] memory) { + IERC20[] memory tokens = new IERC20[](1); + tokens[0] = USDC; + return tokens; + } +} diff --git a/test/renzo/LEZyVault.t.sol b/test/renzo/LEZyVault.t.sol new file mode 100644 index 0000000..5f3a5bc --- /dev/null +++ b/test/renzo/LEZyVault.t.sol @@ -0,0 +1,375 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.28; + +import {Test} from "forge-std/Test.sol"; +import {LEZyVault} from "../../contracts/test/renzo/LEZyVault.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {IERC20Metadata} from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; +import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import {IBeacon} from "@openzeppelin/contracts/proxy/beacon/IBeacon.sol"; +import {UpgradeableBeacon} from "@openzeppelin/contracts/proxy/beacon/UpgradeableBeacon.sol"; +import {IRoleManager} from "../../contracts/test/renzo/interfaces/IRoleManager.sol"; +import {IWithdrawQueue} from "../../contracts/test/renzo/interfaces/IWithdrawQueue.sol"; +import {WithdrawQueue} from "../../contracts/test/renzo/WithdrawQueue.sol"; +import "forge-std/console2.sol"; + +contract LEZyVaultTest is Test { + LEZyVault public lezyVault; + address public owner; + address public feeRecipient; + address public depositor; + + address public constant USDC = address(0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48); + + // Mainnet addresses + address public constant WETH = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2; + + // USDC whale for forking + address public constant USDC_WHALE = 0x28C6c06298d514Db089934071355E5743bf21d60; // Binance hot wallet + + uint256 public constant MAINNET_BLOCK = 23_000_000; // Update with appropriate block number + + // Mock contracts + MockRoleManager public roleManager; + MockWithdrawQueue public mockWithdrawQueue; + UpgradeableBeacon public withdrawQueueBeacon; + + function setUp() public { + string memory rpcUrl = vm.envString("MAINNET_RPC_URL"); + + uint256 forkId = vm.createFork(rpcUrl, MAINNET_BLOCK); + vm.selectFork(forkId); + + owner = makeAddr("strataOwner"); + feeRecipient = makeAddr("feeRecipient"); + depositor = makeAddr("depositor"); + + vm.label(owner, "Owner"); + vm.label(feeRecipient, "FeeRecipient"); + vm.label(depositor, "Depositor"); + vm.deal(owner, 100 ether); + + // Deploy mock role manager + roleManager = new MockRoleManager(); + + // Deploy mock withdraw queue implementation + mockWithdrawQueue = new MockWithdrawQueue(); + + // Deploy beacon for withdraw queue + withdrawQueueBeacon = new UpgradeableBeacon(address(mockWithdrawQueue), owner); + + // Deploy LEZyVault implementation + LEZyVault implementation = new LEZyVault(IBeacon(address(withdrawQueueBeacon)), WETH); + + // Deploy vault as proxy + address proxyAddress = address( + new ERC1967Proxy( + address(implementation), + abi.encodeWithSelector( + LEZyVault.initialize.selector, + IERC20(USDC), // asset + "Renzo USDC", // name + "USCC", // symbol + roleManager, // roleManager + owner, // owner + feeRecipient, // feeRecipient + 1000, // feeBps (10%) + 7 days // withdrawCoolDownPeriod + ) + ) + ); + lezyVault = LEZyVault(payable(proxyAddress)); + + // Disable whitelist for testing + vm.prank(owner); + lezyVault.setDepositWhitelistEnabled(false); + } + + function test_Deposit_USDC_Mints_USCC() public { + // Get USDC from whale + uint256 depositAmount = 1000 * 10 ** 6; // 1000 USDC (6 decimals) + + vm.startPrank(USDC_WHALE); + IERC20(USDC).transfer(depositor, depositAmount); + vm.stopPrank(); + + // Check initial balances + uint256 initialUSDCBalance = IERC20(USDC).balanceOf(depositor); + uint256 initialUSCCBalance = IERC20(address(lezyVault)).balanceOf(depositor); + uint256 initialVaultUSDCBalance = IERC20(USDC).balanceOf(address(lezyVault)); + + console2.log("Initial USDC balance{depositor}:", initialUSDCBalance); + console2.log("Initial USCC balance{depositor}:", initialUSCCBalance); + console2.log("Initial Vault USDC balance{vault}:", initialVaultUSDCBalance); + + uint256 sharesMinted = _deposit_helper(depositor, depositAmount); + + // Check final balances + uint256 finalUSDCBalance = IERC20(USDC).balanceOf(depositor); + uint256 finalUSCCBalance = IERC20(address(lezyVault)).balanceOf(depositor); + uint256 finalVaultUSDCBalance = IERC20(USDC).balanceOf(address(lezyVault)); + + console2.log("Final USDC balance{depositor}:", finalUSDCBalance); + console2.log("Final USCC balance{depositor}:", finalUSCCBalance); + console2.log("Final Vault USDC balance{vault}:", finalVaultUSDCBalance); + console2.log("Shares minted:", sharesMinted); + + // Assertions + assertEq(finalUSDCBalance, initialUSDCBalance - depositAmount, "USDC should be transferred from depositor"); + assertEq(finalUSCCBalance, initialUSCCBalance + sharesMinted, "USCC should be minted to depositor"); + assertEq(finalVaultUSDCBalance, initialVaultUSDCBalance + depositAmount, "Vault should receive USDC"); + assertGt(sharesMinted, 0, "Shares should be minted"); + + // Verify shares match preview + uint256 expectedShares = lezyVault.previewDeposit(depositAmount); + assertEq(sharesMinted, expectedShares, "Shares minted should match preview"); + } + + function test_Deposit_Multiple_Deposits() public { + uint256 firstDeposit = 500 * 10 ** 6; // 500 USDC + uint256 secondDeposit = 1000 * 10 ** 6; // 1000 USDC + + // Get USDC from whale + vm.startPrank(USDC_WHALE); + IERC20(USDC).transfer(depositor, firstDeposit + secondDeposit); + vm.stopPrank(); + + vm.startPrank(depositor); + IERC20(USDC).approve(address(lezyVault), firstDeposit + secondDeposit); + + // First deposit + uint256 shares1 = lezyVault.deposit(firstDeposit, depositor); + uint256 balanceAfterFirst = IERC20(address(lezyVault)).balanceOf(depositor); + + // Second deposit + uint256 shares2 = lezyVault.deposit(secondDeposit, depositor); + uint256 balanceAfterSecond = IERC20(address(lezyVault)).balanceOf(depositor); + + vm.stopPrank(); + + // Assertions + assertEq(balanceAfterFirst, shares1, "Balance after first deposit should equal shares1"); + assertEq(balanceAfterSecond, shares1 + shares2, "Balance after second deposit should equal shares1 + shares2"); + assertGt(shares2, shares1, "Second deposit should mint more shares (due to exchange rate)"); + } + + function test_Deposit_With_Whitelist() public { + // Enable whitelist + vm.prank(owner); + lezyVault.setDepositWhitelistEnabled(true); + + // Whitelist depositor + address[] memory accounts = new address[](1); + bool[] memory status = new bool[](1); + accounts[0] = depositor; + status[0] = true; + + vm.prank(owner); + lezyVault.updateWhitelist(accounts, status); + + // Get USDC and deposit + uint256 depositAmount = 1000 * 10 ** 6; + + vm.startPrank(USDC_WHALE); + IERC20(USDC).transfer(depositor, depositAmount); + vm.stopPrank(); + + vm.startPrank(depositor); + IERC20(USDC).approve(address(lezyVault), depositAmount); + + uint256 sharesMinted = lezyVault.deposit(depositAmount, depositor); + vm.stopPrank(); + + assertGt(sharesMinted, 0, "Shares should be minted for whitelisted user"); + } + + function test_Deposit_Reverts_When_Not_Whitelisted() public { + // Enable whitelist + vm.prank(owner); + lezyVault.setDepositWhitelistEnabled(true); + + // Don't whitelist depositor + + // Get USDC + uint256 depositAmount = 1000 * 10 ** 6; + + vm.startPrank(USDC_WHALE); + IERC20(USDC).transfer(depositor, depositAmount); + vm.stopPrank(); + + vm.startPrank(depositor); + IERC20(USDC).approve(address(lezyVault), depositAmount); + + // Should revert + vm.expectRevert(); + lezyVault.deposit(depositAmount, depositor); + vm.stopPrank(); + } + + function test_Withdraw_USDC_Burns_USCC() public { + // Get USDC from whale + uint256 depositAmount = 1000 * 10 ** 6; // 1000 USDC (6 decimals) + + vm.startPrank(USDC_WHALE); + IERC20(USDC).transfer(depositor, depositAmount); + vm.stopPrank(); + + // deposit usdc to lezy vault and mint uscc + uint256 initialUSCCBalance = IERC20(address(lezyVault)).balanceOf(depositor); + uint256 sharesMinted = _deposit_helper(depositor, depositAmount); + uint256 finalUSCCBalance = IERC20(address(lezyVault)).balanceOf(depositor); + assertEq(finalUSCCBalance, initialUSCCBalance + sharesMinted, "USCC should be minted to depositor"); + + // Get withdraw queue address from vault and cast to concrete type for full interface access + WithdrawQueue withdrawQueue = WithdrawQueue(address(lezyVault.withdrawQueue())); + + // Record balances before withdrawal + uint256 usccBalanceBeforeWithdraw = IERC20(address(lezyVault)).balanceOf(depositor); + uint256 usdcBalanceBeforeWithdraw = IERC20(USDC).balanceOf(depositor); + + console2.log("USCC balance before withdraw{depositor}:", usccBalanceBeforeWithdraw); + console2.log("USDC balance before withdraw{depositor}:", usdcBalanceBeforeWithdraw); + + // Step 1: Approve USCC (LP tokens) to the WithdrawQueue contract and call withdraw + vm.startPrank(depositor); + IERC20(address(lezyVault)).approve(address(withdrawQueue), sharesMinted); + + // Call withdraw on the WithdrawQueue - this creates a withdraw request + withdrawQueue.withdraw(sharesMinted); + vm.stopPrank(); + + // Verify shares were transferred to withdraw queue + uint256 usccBalanceAfterWithdrawRequest = IERC20(address(lezyVault)).balanceOf(depositor); + uint256 withdrawQueueUsccBalance = IERC20(address(lezyVault)).balanceOf(address(withdrawQueue)); + + console2.log("USCC balance after withdraw request{depositor}:", usccBalanceAfterWithdrawRequest); + console2.log("WithdrawQueue USCC balance{withdrawQueue}:", withdrawQueueUsccBalance); + + assertEq(usccBalanceAfterWithdrawRequest, 0, "All USCC should be transferred to withdraw queue"); + assertEq(withdrawQueueUsccBalance, sharesMinted, "Withdraw queue should hold the shares"); + + // Verify withdraw request was created + uint256 totalRequests = withdrawQueue.totalUserWithdrawRequests(depositor); + assertEq(totalRequests, 1, "Should have 1 withdraw request"); + + // Step 2: Rebalance admin triggers _fillWithdrawQueue by calling manage() + // Create a rebalance admin and set up a dummy delegate strategy + address rebalanceAdmin = makeAddr("rebalanceAdmin"); + roleManager.setRebalanceAdmin(rebalanceAdmin, true); + + // Deploy a dummy delegate strategy + DummyDelegateStrategy dummyStrategy = new DummyDelegateStrategy(); + + // Owner adds the dummy strategy to allowed strategies + address[] memory strategies = new address[](1); + strategies[0] = address(dummyStrategy); + vm.prank(owner); + lezyVault.addDelegateStrategies(strategies); + + // Rebalance admin calls manage() to trigger _fillWithdrawQueue() + // The payload calls a no-op function on the dummy strategy + vm.prank(rebalanceAdmin); + lezyVault.manage(address(dummyStrategy), abi.encodeWithSelector(DummyDelegateStrategy.noop.selector)); + + // Verify USDC was transferred to withdraw queue + uint256 usdcBalanceOfWithdrawQueue = IERC20(USDC).balanceOf(address(withdrawQueue)); + console2.log("USDC balance of withdraw queue after manage:", usdcBalanceOfWithdrawQueue); + assertEq( + usdcBalanceOfWithdrawQueue, + depositAmount, + "USDC balance of withdraw queue should be equal to deposit amount" + ); + + // Step 3: Wait for cooldown period to pass (7 days as configured in setUp) + vm.warp(block.timestamp + 7 days + 1); + + // Record balances before claim + uint256 usdcBalanceBeforeClaim = IERC20(USDC).balanceOf(depositor); + uint256 usccTotalSupplyBeforeClaim = IERC20(address(lezyVault)).totalSupply(); + + console2.log("USDC balance before claim:", usdcBalanceBeforeClaim); + console2.log("USCC total supply before claim:", usccTotalSupplyBeforeClaim); + + // Step 4: Claim the withdrawal - anyone can call this for the user + withdrawQueue.claim(0, depositor); + + // Verify final balances + uint256 usdcBalanceAfterClaim = IERC20(USDC).balanceOf(depositor); + uint256 usccTotalSupplyAfterClaim = IERC20(address(lezyVault)).totalSupply(); + uint256 withdrawQueueUsccBalanceAfterClaim = IERC20(address(lezyVault)).balanceOf(address(withdrawQueue)); + + console2.log("USDC balance after claim:", usdcBalanceAfterClaim); + console2.log("USCC total supply after claim:", usccTotalSupplyAfterClaim); + console2.log("WithdrawQueue USCC balance after claim:", withdrawQueueUsccBalanceAfterClaim); + + // Assertions + assertGt(usdcBalanceAfterClaim, usdcBalanceBeforeClaim, "Depositor should receive USDC back"); + assertEq(usccTotalSupplyAfterClaim, usccTotalSupplyBeforeClaim - sharesMinted, "USCC should be burned"); + assertEq(withdrawQueueUsccBalanceAfterClaim, 0, "Withdraw queue should have no USCC left"); + + // Verify withdraw request was removed + uint256 totalRequestsAfterClaim = withdrawQueue.totalUserWithdrawRequests(depositor); + assertEq(totalRequestsAfterClaim, 0, "Withdraw request should be removed after claim"); + + // Verify approximate USDC amount received (should be close to deposit amount) + uint256 usdcReceived = usdcBalanceAfterClaim - usdcBalanceBeforeClaim; + console2.log("USDC received:", usdcReceived); + assertApproxEqRel(usdcReceived, depositAmount, 0.01e18, "Should receive approximately the deposited amount"); + } + + function _deposit_helper(address depositor, uint256 depositAmount) internal returns (uint256) { + vm.startPrank(depositor); + IERC20(USDC).approve(address(lezyVault), depositAmount); + + uint256 sharesMinted = lezyVault.deposit(depositAmount, depositor); + vm.stopPrank(); + + return sharesMinted; + } +} + +// Mock Role Manager +contract MockRoleManager is IRoleManager { + mapping(address => bool) public isRebalanceAdminMap; + mapping(address => bool) public isPauserMap; + mapping(address => bool) public isExchangeRateAdminMap; + + function isRebalanceAdmin(address potentialAddress) external view override returns (bool) { + return isRebalanceAdminMap[potentialAddress]; + } + + function isPauser(address potentialAddress) external view override returns (bool) { + return isPauserMap[potentialAddress]; + } + + function isExchangeRateAdmin(address potentialAddress) external view override returns (bool) { + return isExchangeRateAdminMap[potentialAddress]; + } + + // Helper functions for testing + function setRebalanceAdmin(address addr, bool status) external { + isRebalanceAdminMap[addr] = status; + } + + function setPauser(address addr, bool status) external { + isPauserMap[addr] = status; + } + + function setExchangeRateAdmin(address addr, bool status) external { + isExchangeRateAdminMap[addr] = status; + } +} + +contract MockWithdrawQueue is WithdrawQueue {} + +// Dummy Delegate Strategy for testing - used to trigger _fillWithdrawQueue via manage() +contract DummyDelegateStrategy { + // Returns 0 underlying value - no assets managed + function underlyingValue(address) external pure returns (uint256) { + return 0; + } + + // No-op function that can be called via delegatecall from manage() + function noop() external {} +} + diff --git a/test/renzo/USCCDeploy.t.sol b/test/renzo/USCCDeploy.t.sol new file mode 100644 index 0000000..e015c6d --- /dev/null +++ b/test/renzo/USCCDeploy.t.sol @@ -0,0 +1,399 @@ +// 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 {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 {IBeacon} from "@openzeppelin/contracts/proxy/beacon/IBeacon.sol"; +import {UpgradeableBeacon} from "@openzeppelin/contracts/proxy/beacon/UpgradeableBeacon.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 {sUSCCStrategy as SUSCCStrategy} from "../../contracts/tranches/strategies/renzo/sUSCCStrategy.sol"; +import { + sUSCCAprPairProvider as SUSCCAprPairProvider, + IsUSDS +} from "../../contracts/tranches/strategies/renzo/sUSCCAprPairProvider.sol"; +import {IsUSCC} from "../../contracts/tranches/strategies/renzo/interfaces/IsUSCC.sol"; +import {sUSCCCooldownRequestImpl} from "../../contracts/tranches/strategies/renzo/sUSCCCooldownRequestImpl.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 {IUnstakeHandler} from "../../contracts/tranches/interfaces/cooldown/IUnstakeHandler.sol"; +import {ICooldown} from "../../contracts/tranches/interfaces/cooldown/ICooldown.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 {IErrors} from "../../contracts/tranches/interfaces/IErrors.sol"; + +import {LEZyVault} from "../../contracts/test/renzo/LEZyVault.sol"; +import {IRoleManager} from "../../contracts/test/renzo/interfaces/IRoleManager.sol"; +import {WithdrawQueue} from "../../contracts/test/renzo/WithdrawQueue.sol"; + +contract USCCDeploy is Test { + LEZyVault public ezUSCC; + address public owner; + address public feeRecipient; + address public depositor; + + address public constant USDC = address(0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48); + + // Mainnet addresses + address public constant WETH = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2; + + // USDC whale for forking + address public constant USDC_WHALE = 0x28C6c06298d514Db089934071355E5743bf21d60; // Binance hot wallet + + uint256 public constant MAINNET_BLOCK = 23_000_000; // Update with appropriate block number + + // 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 + AccessControlManager internal acm; + StrataCDO internal cdo; + Tranche internal jrtVault; + Tranche internal srtVault; + ERC20Cooldown internal erc20Cooldown; + UnstakeCooldown internal unstakeCooldown; + sUSCCCooldownRequestImpl internal cooldownRequestImpl; + SUSCCStrategy internal strategy; + SUSCCAprPairProvider internal provider; + AprPairFeed internal feed; + Accounting internal accounting; + + // Mock contracts + MockRoleManager public roleManager; + MockWithdrawQueue public mockWithdrawQueue; + UpgradeableBeacon public withdrawQueueBeacon; + MockSUSDS public sUSDS; + + 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"); + feeRecipient = makeAddr("feeRecipient"); + depositor = makeAddr("depositor"); + + vm.label(owner, "Owner"); + vm.label(feeRecipient, "FeeRecipient"); + vm.label(depositor, "Depositor"); + vm.label(USDC, "USDC"); + vm.deal(owner, 100 ether); + + roleManager = new MockRoleManager(); + + mockWithdrawQueue = new MockWithdrawQueue(); + withdrawQueueBeacon = new UpgradeableBeacon(address(mockWithdrawQueue), owner); + + LEZyVault ezUSCCImpl = new LEZyVault(IBeacon(address(withdrawQueueBeacon)), WETH); + + // Deploy vault as proxy + address proxyAddress = address( + new ERC1967Proxy( + address(ezUSCCImpl), + abi.encodeWithSelector( + LEZyVault.initialize.selector, + IERC20(USDC), // asset + "Renzo USDC", // name + "USCC", // symbol + roleManager, // roleManager + owner, // owner + feeRecipient, // feeRecipient + 1000, // feeBps (10%) + 7 days // withdrawCoolDownPeriod + ) + ) + ); + + ezUSCC = LEZyVault(payable(proxyAddress)); + + // Disable whitelist for testing + vm.prank(owner); + ezUSCC.setDepositWhitelistEnabled(false); + + // Deploy mock sUSDS for APR target + sUSDS = new MockSUSDS(); + vm.label(address(sUSDS), "sUSDS"); + } + + function testDeploySuperstateStackMatchesScript() 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.ezUSCC1()), address(ezUSCC)); + assertEq(address(strategy.USDC()), USDC); + assertEq(address(strategy.erc20Cooldown()), address(erc20Cooldown)); + assertEq(address(strategy.unstakeCooldown()), address(unstakeCooldown)); + + // Verify feed config + assertEq(address(feed.provider()), address(provider)); + assertEq(feed.roundStaleAfter(), 4 hours); + assertEq(address(accounting.aprPairFeed()), address(feed)); + + // Verify cooldown implementations + assertEq(address(unstakeCooldown.implementations(address(ezUSCC))), address(cooldownRequestImpl)); + + // 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))); + assertTrue(acm.hasRole(COOLDOWN_WORKER_ROLE, owner)); + + // 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, 1); + assertEq(address(supported[0]), USDC); + } + + function _deployStrataStack() internal { + vm.startPrank(owner); + + // 1. Deploy AccessControlManager + acm = new AccessControlManager(owner); + vm.label(address(acm), "AccessControlManager"); + + // 2. Deploy StrataCDO + cdo = StrataCDO( + address( + new ERC1967Proxy( + address(new StrataCDO()), abi.encodeWithSelector(StrataCDO.initialize.selector, owner, address(acm)) + ) + ) + ); + + // 3. Deploy Tranches + jrtVault = _deployTranche("JRT", "Junior Tranch"); + srtVault = _deployTranche("SRT", "Senior Tranch"); + + // 4. Deploy ERC20Cooldown + ERC20Cooldown erc20CooldownImpl = new ERC20Cooldown(); + erc20Cooldown = ERC20Cooldown( + address( + new ERC1967Proxy( + address(erc20CooldownImpl), + abi.encodeWithSelector(CooldownBase.initialize.selector, owner, address(acm)) + ) + ) + ); + + // 5. Deploy UnstakeCooldown + UnstakeCooldown unstakeCooldownImpl = new UnstakeCooldown(); + unstakeCooldown = UnstakeCooldown( + address( + new ERC1967Proxy( + address(unstakeCooldownImpl), + abi.encodeWithSelector(CooldownBase.initialize.selector, owner, address(acm)) + ) + ) + ); + + // 6. Deploy SUSCCCooldownRequestImpl and set implementation + cooldownRequestImpl = new sUSCCCooldownRequestImpl(IERC4626(address(ezUSCC))); + address[] memory tokens = new address[](1); + tokens[0] = address(ezUSCC); + IUnstakeHandler[] memory impls = new IUnstakeHandler[](1); + impls[0] = IUnstakeHandler(address(cooldownRequestImpl)); + unstakeCooldown.setImplementations(tokens, impls); + + // 7. Deploy SUSCCStrategy + SUSCCStrategy strategyImpl = new SUSCCStrategy(IERC4626(address(ezUSCC)), IERC20(USDC)); + strategy = SUSCCStrategy( + address( + new ERC1967Proxy( + address(strategyImpl), + abi.encodeWithSelector( + SUSCCStrategy.initialize.selector, + owner, + address(acm), + address(cdo), + address(erc20Cooldown), + address(unstakeCooldown) + ) + ) + ) + ); + vm.label(address(strategy), "SUSCCStrategy"); + + // 8. Deploy SUSCCAprPairProvider + provider = new SUSCCAprPairProvider(IsUSDS(address(sUSDS)), IsUSCC(address(strategy))); + feed = AprPairFeed( + address( + new ERC1967Proxy( + address(new AprPairFeed()), + abi.encodeWithSelector( + AprPairFeed.initialize.selector, + owner, + address(acm), + address(provider), + 4 hours, + "USCC CDO APR Pair" + ) + ) + ) + ); + vm.label(address(feed), "AprPairFeed"); + + // 9. Deploy Accounting + Accounting accountingImpl = new Accounting(); + accounting = Accounting( + address( + new ERC1967Proxy( + address(accountingImpl), + abi.encodeWithSelector( + Accounting.initialize.selector, owner, address(acm), address(cdo), address(feed) + ) + ) + ) + ); + vm.label(address(accounting), "Accounting"); + + // 10. 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)); + _grantRole(COOLDOWN_WORKER_ROLE, address(owner)); + + // 11. Configure CDO + cdo.configure( + IAccounting(address(accounting)), + IStrategy(address(strategy)), + ITranche(address(jrtVault)), + ITranche(address(srtVault)) + ); + + // 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); + + erc20Cooldown.setCooldownDisabled(IERC20(address(ezUSCC)), true); + 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); + } +} + +// Mock Role Manager +contract MockRoleManager is IRoleManager { + mapping(address => bool) public isRebalanceAdminMap; + mapping(address => bool) public isPauserMap; + mapping(address => bool) public isExchangeRateAdminMap; + + function isRebalanceAdmin(address potentialAddress) external view override returns (bool) { + return isRebalanceAdminMap[potentialAddress]; + } + + function isPauser(address potentialAddress) external view override returns (bool) { + return isPauserMap[potentialAddress]; + } + + function isExchangeRateAdmin(address potentialAddress) external view override returns (bool) { + return isExchangeRateAdminMap[potentialAddress]; + } + + // Helper functions for testing + function setRebalanceAdmin(address addr, bool status) external { + isRebalanceAdminMap[addr] = status; + } + + function setPauser(address addr, bool status) external { + isPauserMap[addr] = status; + } + + function setExchangeRateAdmin(address addr, bool status) external { + isExchangeRateAdminMap[addr] = status; + } +} + +contract MockWithdrawQueue is WithdrawQueue {} + +// Dummy Delegate Strategy for testing - used to trigger _fillWithdrawQueue via manage() +contract DummyDelegateStrategy { + // Returns 0 underlying value - no assets managed + function underlyingValue(address) external pure returns (uint256) { + return 0; + } + + // No-op function that can be called via delegatecall from manage() + function noop() external {} +} + +contract MockSUSDS is IsUSDS { + uint256 public ssrValue = 0; // 0% APR by default + + function ssr() external view override returns (uint256) { + return ssrValue; + } + + function rho() external view override returns (uint64) { + return uint64(block.timestamp); + } + + function setSSR(uint256 _ssr) external { + ssrValue = _ssr; + } +} + diff --git a/test/renzo/USCCTest.t.sol b/test/renzo/USCCTest.t.sol new file mode 100644 index 0000000..0b2a3fa --- /dev/null +++ b/test/renzo/USCCTest.t.sol @@ -0,0 +1,692 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.28; + +import {Test, console2} from "forge-std/Test.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {IERC4626} from "@openzeppelin/contracts/interfaces/IERC4626.sol"; +import {USCCDeploy, DummyDelegateStrategy} from "./USCCDeploy.t.sol"; +import {IsUSCC} from "../../contracts/tranches/strategies/renzo/interfaces/IsUSCC.sol"; +import {ICooldown} from "../../contracts/tranches/interfaces/cooldown/ICooldown.sol"; +import {WithdrawQueue} from "../../contracts/test/renzo/WithdrawQueue.sol"; + +/** + * @title USCCTest + * @notice Unit tests for the USCC (Renzo) Strategy + * @dev Tests deposit, withdrawal, vesting, and cooldown mechanisms. + * Key differences from Neutrl strategy: + * - Only USDC is supported as deposit/withdrawal token (no direct ezUSCC deposits) + * - Withdrawals always go through Renzo's WithdrawQueue with 7-day cooldown + * - Strategy has its own 24-hour vesting for yield (superstate doesn't have built-in vesting) + */ +contract USCCTest is USCCDeploy { + // Test users + address public alice; + address public bob; + + // Test amounts (using ether scale to satisfy MIN_SHARES constraint in Tranche) + uint256 constant INITIAL_BALANCE = 10_000 ether; + uint256 constant DEPOSIT_AMOUNT = 1000 ether; + uint256 constant MIN_SHARES = 1 ether; + + // Helper contracts for withdraw queue operations + DummyDelegateStrategy internal dummyStrategy; + address internal rebalanceAdmin; + + function setUp() public override { + super.setUp(); + + alice = makeAddr("alice"); + bob = makeAddr("bob"); + + vm.label(alice, "Alice"); + vm.label(bob, "Bob"); + + // Deploy the full Strata stack + _deployStrataStack(); + + // Set up rebalance admin and dummy strategy for withdraw queue operations + rebalanceAdmin = makeAddr("rebalanceAdmin"); + roleManager.setRebalanceAdmin(rebalanceAdmin, true); + roleManager.setExchangeRateAdmin(rebalanceAdmin, true); + + dummyStrategy = new DummyDelegateStrategy(); + address[] memory strategies = new address[](1); + strategies[0] = address(dummyStrategy); + vm.prank(owner); + ezUSCC.addDelegateStrategies(strategies); + + // Mint USDC to test users + _mintUSDC(alice, INITIAL_BALANCE); + _mintUSDC(bob, INITIAL_BALANCE); + } + + /*////////////////////////////////////////////////////////////// + DEPOSIT TESTS + //////////////////////////////////////////////////////////////*/ + + function test_DepositUSDC_ToJrtVault() public { + vm.startPrank(alice); + + uint256 balanceBefore = IERC20(USDC).balanceOf(alice); + + // Approve and deposit USDC into JRT vault + IERC20(USDC).approve(address(jrtVault), DEPOSIT_AMOUNT); + uint256 shares = jrtVault.deposit(USDC, DEPOSIT_AMOUNT, alice); + + // Verify shares received + assertGt(shares, 0, "Should receive shares"); + assertEq(jrtVault.balanceOf(alice), shares, "Alice should have shares"); + + // Verify USDC was transferred + uint256 balanceAfter = IERC20(USDC).balanceOf(alice); + assertEq(balanceBefore - balanceAfter, DEPOSIT_AMOUNT, "USDC should be transferred"); + + // Verify strategy holds ezUSCC shares (not raw USDC) + uint256 strategyEzUSCCBalance = IERC20(address(ezUSCC)).balanceOf(address(strategy)); + assertGt(strategyEzUSCCBalance, 0, "Strategy should hold ezUSCC shares"); + + // Verify strategy has no leftover USDC + uint256 strategyUSDCBalance = IERC20(USDC).balanceOf(address(strategy)); + assertEq(strategyUSDCBalance, 0, "Strategy should have no USDC"); + + vm.stopPrank(); + } + + function test_DepositUSDC_ToSrtVault() public { + // First deposit to JRT to meet minimum ratio requirements + _depositToJrt(alice, DEPOSIT_AMOUNT * 2); + + vm.startPrank(alice); + + uint256 balanceBefore = IERC20(USDC).balanceOf(alice); + + // Approve and deposit USDC into SRT vault + IERC20(USDC).approve(address(srtVault), DEPOSIT_AMOUNT); + uint256 shares = srtVault.deposit(USDC, DEPOSIT_AMOUNT, alice); + + // Verify shares received + assertGt(shares, 0, "Should receive shares"); + assertEq(srtVault.balanceOf(alice), shares, "Alice should have shares"); + + // Verify USDC was transferred + uint256 balanceAfter = IERC20(USDC).balanceOf(alice); + assertEq(balanceBefore - balanceAfter, DEPOSIT_AMOUNT, "USDC should be transferred"); + + vm.stopPrank(); + } + + function test_DepositToReceiver() public { + vm.startPrank(alice); + + // Approve and deposit USDC to Bob as receiver + IERC20(USDC).approve(address(jrtVault), DEPOSIT_AMOUNT); + uint256 shares = jrtVault.deposit(USDC, DEPOSIT_AMOUNT, bob); + + // Verify Bob received the shares + assertEq(jrtVault.balanceOf(bob), shares, "Bob should have shares"); + assertEq(jrtVault.balanceOf(alice), 0, "Alice should have no shares"); + + vm.stopPrank(); + } + + /*////////////////////////////////////////////////////////////// + WITHDRAW TESTS + //////////////////////////////////////////////////////////////*/ + + /** + * @notice Test that USDC withdrawal creates an unstake cooldown request + * @dev USCC withdrawals always go through Renzo's WithdrawQueue (7-day cooldown) + */ + function test_WithdrawUSDC_FromJrtVault_CreatesUnstakeRequest() public { + // Bob deposits to maintain liquidity (prevents MinSharesViolation) + _depositToJrt(bob, DEPOSIT_AMOUNT); + + // Alice deposits + _depositToJrt(alice, DEPOSIT_AMOUNT); + + vm.startPrank(alice); + + uint256 shares = jrtVault.balanceOf(alice); + uint256 withdrawShares = shares - MIN_SHARES; + + uint256 usdcBefore = IERC20(USDC).balanceOf(alice); + + // Redeem USDC - creates an unstake cooldown request + jrtVault.redeem(USDC, withdrawShares, alice, alice); + + // USDC should NOT be received immediately (goes through WithdrawQueue cooldown) + uint256 usdcAfter = IERC20(USDC).balanceOf(alice); + assertEq(usdcAfter, usdcBefore, "USDC should not be received immediately"); + + vm.stopPrank(); + + // Check unstake cooldown balance + (uint256 pending, uint256 claimable,,, uint256 totalRequests) = _getUnstakeCooldownBalance(alice); + + assertGt(pending, 0, "Should have pending amount in cooldown"); + assertEq(claimable, 0, "Should have no claimable amount yet"); + assertEq(totalRequests, 1, "Should have one request"); + } + + /** + * @notice Test full withdraw flow: deposit -> redeem -> fill queue -> wait -> finalize -> receive USDC + */ + function test_WithdrawUSDC_FinalizeAfterCooldown() public { + // Bob deposits to maintain liquidity + _depositToJrt(bob, DEPOSIT_AMOUNT); + + // Alice deposits + _depositToJrt(alice, DEPOSIT_AMOUNT); + + vm.startPrank(alice); + + uint256 shares = jrtVault.balanceOf(alice); + uint256 withdrawShares = shares - MIN_SHARES; + + // Redeem USDC + jrtVault.redeem(USDC, withdrawShares, alice, alice); + + vm.stopPrank(); + + // Check pending balance + (uint256 pending,,,, uint256 totalRequests) = _getUnstakeCooldownBalance(alice); + assertGt(pending, 0, "Should have pending amount"); + assertEq(totalRequests, 1, "Should have one request"); + + // Fill the withdraw queue (simulating rebalance admin action) + _fillWithdrawQueue(); + + // Wait for cooldown period (7 days) + vm.warp(block.timestamp + 7 days + 1); + + // Check balance is now claimable + (, uint256 claimable,,,) = _getUnstakeCooldownBalance(alice); + assertGt(claimable, 0, "Should have claimable amount after cooldown"); + + uint256 usdcBefore = IERC20(USDC).balanceOf(alice); + + // Finalize the unstake (claim USDC) + uint256 claimed = unstakeCooldown.finalize(IERC20(address(ezUSCC)), alice); + uint256 usdcAfter = IERC20(USDC).balanceOf(alice); + // Verify USDC received + assertGt(claimed, 0, "Should claim tokens"); + assertEq(usdcAfter - usdcBefore, claimed, "Should receive USDC"); + } + + /** + * @notice Test withdrawing from SRT vault also creates unstake request + */ + function test_WithdrawUSDC_FromSrtVault() public { + // Deposit to JRT first (for coverage ratio) + _depositToJrt(alice, DEPOSIT_AMOUNT * 3); + + // Deposit to SRT + _depositToSrt(alice, DEPOSIT_AMOUNT); + + // Bob deposits to SRT to avoid min shares violation + _depositToJrt(bob, DEPOSIT_AMOUNT * 3); + _depositToSrt(bob, DEPOSIT_AMOUNT); + + vm.startPrank(alice); + + uint256 shares = srtVault.balanceOf(alice); + uint256 withdrawShares = shares - MIN_SHARES; + + // Redeem from SRT + srtVault.redeem(USDC, withdrawShares, alice, alice); + + vm.stopPrank(); + + // Should have unstake cooldown request + (uint256 pending,,,, uint256 totalRequests) = _getUnstakeCooldownBalance(alice); + assertGt(pending, 0, "Should have pending amount in cooldown"); + assertEq(totalRequests, 1, "Should have one request"); + } + + /** + * @notice Test multiple withdrawal requests from the same user + */ + function test_MultipleWithdrawRequests() public { + // Bob deposits to maintain liquidity + _depositToJrt(bob, DEPOSIT_AMOUNT); + + // Alice deposits a larger amount + _depositToJrt(alice, DEPOSIT_AMOUNT * 5); + + vm.startPrank(alice); + + uint256 withdrawAmount = DEPOSIT_AMOUNT; + + // Make multiple withdrawal requests + jrtVault.withdraw(USDC, withdrawAmount, alice, alice); + + vm.warp(block.timestamp + 1 hours); + + jrtVault.withdraw(USDC, withdrawAmount, alice, alice); + + vm.warp(block.timestamp + 1 hours); + + jrtVault.withdraw(USDC, withdrawAmount, alice, alice); + + vm.stopPrank(); + + // Check we have multiple pending requests + (,,,, uint256 totalRequests) = _getUnstakeCooldownBalance(alice); + assertEq(totalRequests, 3, "Should have 3 pending requests"); + + // Fill the withdraw queue + _fillWithdrawQueue(); + + // Warp past all cooldowns (7 days) + vm.warp(block.timestamp + 7 days + 1); + + // Finalize all + uint256 usdcBefore = IERC20(USDC).balanceOf(alice); + uint256 claimed = unstakeCooldown.finalize(IERC20(address(ezUSCC)), alice); + + assertGt(claimed, 0, "Should claim all pending tokens"); + uint256 usdcAfter = IERC20(USDC).balanceOf(alice); + assertEq(usdcAfter - usdcBefore, claimed, "Should receive claimed USDC"); + } + + /*////////////////////////////////////////////////////////////// + VESTING TESTS + //////////////////////////////////////////////////////////////*/ + + /** + * @notice Test that vesting is initialized on first deposit + * @dev Superstate doesn't have built-in vesting, so the strategy implements its own 24h vesting + */ + function test_VestingInitializedOnFirstDeposit() public { + // Before deposit, vesting should not be initialized + assertEq(strategy.lastVestingTimestamp(), 0, "lastVestingTimestamp should be 0 initially"); + assertEq(strategy.vestingAmount(), 0, "vestingAmount should be 0 initially"); + assertEq(strategy.getUnvestedAmount(), 0, "unvested should be 0 initially"); + + // Deposit + _depositToJrt(alice, DEPOSIT_AMOUNT); + + // After first deposit, vesting should be initialized + assertEq(strategy.lastVestingTimestamp(), block.timestamp, "lastVestingTimestamp should be set"); + assertEq(strategy.vestingAmount(), 0, "vestingAmount should be 0 for first deposit"); + // lastTotalAssets is updated after the ezUSCC deposit to include the deposit amount + assertApproxEqRel(strategy.lastTotalAssets(), DEPOSIT_AMOUNT, 0.01e18, "lastTotalAssets should include deposit"); + console2.log("lastTotalAssets", strategy.lastTotalAssets()); + console2.log("DEPOSIT_AMOUNT", DEPOSIT_AMOUNT); + assertEq(strategy.getUnvestedAmount(), 0, "unvested should be 0 after first deposit"); + + // Total assets should equal the deposit amount since no vesting is active + uint256 totalAssets = strategy.totalAssets(); + assertApproxEqRel(totalAssets, DEPOSIT_AMOUNT, 0.01e18, "totalAssets should equal deposit amount"); + } + + /** + * @notice Test that vesting timestamp and amount do not update during the vesting period + * @dev Note: lastTotalAssets IS updated after each deposit (to track capital changes), + * but the vesting parameters (timestamp, vestingAmount) remain unchanged. + */ + function test_DuringVestingPeriod_NoVestingUpdate() public { + _depositToJrt(alice, DEPOSIT_AMOUNT); + + uint256 initialVestingTimestamp = strategy.lastVestingTimestamp(); + + // Warp 12 hours (still within 24h vesting period) + vm.warp(block.timestamp + 12 hours); + + // Simulate yield + _simulateYield(5 ether); + + // Another deposit triggers _updateVesting (but it should be a no-op for vesting params) + _depositToJrt(bob, DEPOSIT_AMOUNT); + + // Vesting timestamp and amount should NOT be updated (still in window) + assertEq( + strategy.lastVestingTimestamp(), + initialVestingTimestamp, + "lastVestingTimestamp should not change during vesting" + ); + assertEq(strategy.vestingAmount(), 0, "vestingAmount should still be 0"); + + // lastTotalAssets WILL be updated to include Bob's deposit + assertGt(strategy.lastTotalAssets(), DEPOSIT_AMOUNT, "lastTotalAssets should include both deposits"); + } + + /** + * @notice Test that vesting updates after the 24-hour period with yield + */ + function test_VestingUpdatesAfterPeriod() public { + _depositToJrt(alice, DEPOSIT_AMOUNT); + + uint256 initialVestingTimestamp = strategy.lastVestingTimestamp(); + + // Simulate a small yield in ezUSCC + _simulateYield(5 ether); + + // Warp past vesting period (24 hours) + vm.warp(block.timestamp + 25 hours); + + // New deposit triggers _updateVesting + _depositToJrt(bob, DEPOSIT_AMOUNT); + + // Vesting should be updated + assertGt(strategy.lastVestingTimestamp(), initialVestingTimestamp, "Vesting timestamp should be updated"); + assertGt(strategy.vestingAmount(), 0, "Vesting amount should reflect yield gain"); + } + + /** + * @notice Test that unvested amount decreases linearly over time + */ + function test_UnvestedAmount_DecreasesOverTime() public { + _depositToJrt(alice, DEPOSIT_AMOUNT); + + // Simulate a small yield and warp past first vesting period + _simulateYield(5 ether); + vm.warp(block.timestamp + 25 hours); + + // Trigger new vesting period + _depositToJrt(bob, DEPOSIT_AMOUNT); + + uint256 vestingAmt = strategy.vestingAmount(); + assertGt(vestingAmt, 0, "vestingAmount should be set"); + + // At start of new vesting, unvested should approximately equal vestingAmount + uint256 unvestedAtStart = strategy.getUnvestedAmount(); + assertApproxEqRel(unvestedAtStart, vestingAmt, 0.01e18, "unvested should equal vestingAmount at start"); + + // Warp 12 hours (halfway through 24h vesting) + vm.warp(block.timestamp + 12 hours); + uint256 unvestedHalfway = strategy.getUnvestedAmount(); + assertApproxEqRel(unvestedHalfway, vestingAmt / 2, 0.05e18, "unvested should be ~50% at halfway"); + + // Warp to end of vesting period + vm.warp(block.timestamp + 12 hours); + uint256 unvestedAtEnd = strategy.getUnvestedAmount(); + assertEq(unvestedAtEnd, 0, "unvested should be 0 at end of vesting period"); + } + + /** + * @notice Test that totalAssets excludes unvested amount during active vesting + */ + function test_TotalAssets_ExcludesUnvested() public { + _depositToJrt(alice, DEPOSIT_AMOUNT); + + // Simulate a small yield and warp past first vesting period + _simulateYield(5 ether); + vm.warp(block.timestamp + 25 hours); + + // Trigger new vesting period + _depositToJrt(bob, DEPOSIT_AMOUNT); + + uint256 rawAssets = ezUSCC.previewRedeem(IERC20(address(ezUSCC)).balanceOf(address(strategy))); + uint256 totalAssets = strategy.totalAssets(); + uint256 unvested = strategy.getUnvestedAmount(); + + // totalAssets = rawAssets - unvested + assertApproxEqRel(totalAssets, rawAssets - unvested, 0.01e18, "totalAssets should be rawAssets - unvested"); + assertGt(unvested, 0, "Should have unvested amount during active vesting"); + } + + /** + * @notice Test totalAssets equals rawAssets when fully vested + */ + function test_TotalAssets_EqualsRawAssetsWhenFullyVested() public { + _depositToJrt(alice, DEPOSIT_AMOUNT); + + // Simulate a small yield and warp past first vesting period + _simulateYield(5 ether); + vm.warp(block.timestamp + 25 hours); + + // Trigger new vesting period + _depositToJrt(bob, DEPOSIT_AMOUNT); + + // Warp past the vesting period so everything is fully vested + vm.warp(block.timestamp + 25 hours); + + uint256 rawAssets = ezUSCC.previewRedeem(IERC20(address(ezUSCC)).balanceOf(address(strategy))); + uint256 totalAssets = strategy.totalAssets(); + uint256 unvested = strategy.getUnvestedAmount(); + + assertEq(unvested, 0, "unvested should be 0"); + assertApproxEqRel(totalAssets, rawAssets, 0.01e18, "totalAssets should equal rawAssets when fully vested"); + } + + /** + * @notice Test that the vesting period constant is 24 hours + */ + function test_VestingPeriodConstant() public view { + assertEq(strategy.VESTING_PERIOD(), 24 hours, "VESTING_PERIOD should be 24 hours"); + } + + /*////////////////////////////////////////////////////////////// + APR CALCULATION TESTS + //////////////////////////////////////////////////////////////*/ + + /** + * @notice APR base should be 0 before any deposit (vesting not started) + */ + function test_APR_zeroBeforeVestingStarts() public view { + int64 aprBase = provider.getAPRbase(); + assertEq(aprBase, 0, "APR base should be 0 before vesting starts"); + } + + /** + * @notice APR base should be 0 during the first 24-hour vesting period (no yield yet) + */ + function test_APR_zeroDuringFirstVestingPeriod() public { + _depositToJrt(alice, DEPOSIT_AMOUNT); + + // During first 24 hours, vestingAmount is 0, so APR = 0 + vm.warp(block.timestamp + 12 hours); + + int64 aprBase = provider.getAPRbase(); + assertEq(aprBase, 0, "APR base should be 0 during first vesting period (no yield yet)"); + } + + /** + * @notice APR base should be positive during active vesting with yield + */ + function test_APR_positiveDuringActiveVesting() public { + _depositToJrt(alice, DEPOSIT_AMOUNT); + + // Simulate yield and warp past first vesting period + _simulateYield(5 ether); + vm.warp(block.timestamp + 25 hours); + + // Trigger new vesting period via deposit + _depositToJrt(bob, DEPOSIT_AMOUNT); + + // Warp a small amount into the new vesting window so the provider sees active vesting + vm.warp(block.timestamp + 1 hours); + + // Now during active vesting, APR should be positive + int64 aprBase = provider.getAPRbase(); + console2.log("APR base during vesting:", aprBase); + assertGt(aprBase, 0, "APR base should be positive during active vesting"); + } + + /** + * @notice APR base should return to 0 after the vesting period completes + */ + function test_APR_zeroAfterVestingComplete() public { + _depositToJrt(alice, DEPOSIT_AMOUNT); + + // Simulate yield and warp past first vesting period + _simulateYield(5 ether); + vm.warp(block.timestamp + 25 hours); + + // Trigger new vesting period + _depositToJrt(bob, DEPOSIT_AMOUNT); + + // Warp past the new vesting period entirely + vm.warp(block.timestamp + 25 hours); + + // APR should be 0 after vesting completes (until new yield is detected) + int64 aprBase = provider.getAPRbase(); + assertEq(aprBase, 0, "APR base should be 0 after vesting completes"); + } + + /** + * @notice APR target should be derived from the Sky Savings Rate (SSR) + */ + function test_APRtarget_fromSkySSR() public { + // Set mock SSR value (5% APR in 1e27 format) + // ssr = 1e27 + (5% * 1e27 / SECONDS_PER_YEAR) + uint256 fivePercent = 5 * 1e25; + uint256 secondsPerYear = 31_536_000; + uint256 ratePerSecond = fivePercent / secondsPerYear; + uint256 ssrValue = 1e27 + ratePerSecond; + sUSDS.setSSR(ssrValue); + + int64 aprTarget = provider.getAPRtarget(); + // APR should be approximately 5% (in 1e12 format = 0.05e12 = 5e10) + assertGt(aprTarget, 0, "APR target should be positive"); + console2.log("APR target:", aprTarget); + } + + /** + * @notice APR target should be 0 when SSR is below 1e27 (no positive rate) + */ + function test_APRtarget_zeroWhenSSRBelowOne() public { + // SSR = 0 (default in MockSUSDS) → APR target = 0 + int64 aprTarget = provider.getAPRtarget(); + assertEq(aprTarget, 0, "APR target should be 0 when SSR < 1e27"); + } + + /** + * @notice Both APR values should be returned together via getAprPair + */ + function test_getAprPair_returnsBothValues() public { + // Set up a 5% SSR for target APR + uint256 fivePercent = 5 * 1e25; + uint256 secondsPerYear = 31_536_000; + uint256 ratePerSecond = fivePercent / secondsPerYear; + sUSDS.setSSR(1e27 + ratePerSecond); + + // Set up active vesting for base APR + _depositToJrt(alice, DEPOSIT_AMOUNT); + _simulateYield(5 ether); + vm.warp(block.timestamp + 25 hours); + _depositToJrt(bob, DEPOSIT_AMOUNT); + vm.warp(block.timestamp + 1 hours); + + (int64 aprTarget, int64 aprBase, uint64 timestamp) = provider.getAprPair(); + + assertGt(aprTarget, 0, "APR target should be positive"); + assertGt(aprBase, 0, "APR base should be positive"); + assertEq(timestamp, uint64(block.timestamp), "Timestamp should be current block"); + console2.log("APR target:", aprTarget); + console2.log("APR base:", aprBase); + } + + /*////////////////////////////////////////////////////////////// + EDGE CASES + //////////////////////////////////////////////////////////////*/ + + function test_RevertOnZeroDeposit() public { + vm.startPrank(alice); + IERC20(USDC).approve(address(jrtVault), 1); + + vm.expectRevert(); + jrtVault.deposit(USDC, 0, alice); + + vm.stopPrank(); + } + + function test_RevertOnExceedingMaxWithdraw() public { + _depositToJrt(alice, DEPOSIT_AMOUNT); + + vm.startPrank(alice); + + uint256 maxWithdraw = jrtVault.maxWithdraw(alice); + + vm.expectRevert(); + jrtVault.withdraw(USDC, maxWithdraw + 1 ether, alice, alice); + + vm.stopPrank(); + } + + function test_TotalAssets() public { + _depositToJrt(alice, DEPOSIT_AMOUNT); + _depositToJrt(bob, DEPOSIT_AMOUNT); + + uint256 totalAssets = strategy.totalAssets(); + assertGt(totalAssets, 0, "Total assets should be positive"); + + // Total assets should be approximately equal to deposits + assertApproxEqRel(totalAssets, DEPOSIT_AMOUNT * 2, 0.01e18, "Total assets should match deposits"); + } + + function test_GetSupportedTokens() public view { + IERC20[] memory supported = strategy.getSupportedTokens(); + assertEq(supported.length, 1, "Should have 1 supported token"); + assertEq(address(supported[0]), USDC, "Only supported token should be USDC"); + } + + /*////////////////////////////////////////////////////////////// + HELPERS + //////////////////////////////////////////////////////////////*/ + + function _mintUSDC(address to, uint256 amount) internal { + deal(USDC, to, amount); + } + + function _depositToJrt(address user, uint256 amount) internal { + vm.startPrank(user); + + IERC20(USDC).approve(address(jrtVault), amount); + jrtVault.deposit(USDC, amount, user); + + vm.stopPrank(); + } + + function _depositToSrt(address user, uint256 amount) internal { + vm.startPrank(user); + + IERC20(USDC).approve(address(srtVault), amount); + srtVault.deposit(USDC, amount, user); + + vm.stopPrank(); + } + + function _fillWithdrawQueue() internal { + WithdrawQueue wq = WithdrawQueue(address(ezUSCC.withdrawQueue())); + + uint256 deficit = wq.getQueueDeficit(); + if (deficit == 0) return; + + // Deal USDC to the vault to cover the withdrawal deficit + deal(USDC, address(ezUSCC), IERC20(USDC).balanceOf(address(ezUSCC)) + deficit); + + // Track underlying so vault recognizes the new USDC + vm.prank(rebalanceAdmin); + ezUSCC.trackUnderlying(); + + // Rebalance admin calls manage() to trigger _fillWithdrawQueue + vm.prank(rebalanceAdmin); + ezUSCC.manage(address(dummyStrategy), abi.encodeWithSelector(DummyDelegateStrategy.noop.selector)); + } + + function _simulateYield(uint256 yieldAmount) internal { + // Deal USDC directly to ezUSCC vault to simulate yield (increases exchange rate) + deal(USDC, address(ezUSCC), IERC20(USDC).balanceOf(address(ezUSCC)) + yieldAmount); + + // Track underlying in the vault to update exchange rate + vm.prank(rebalanceAdmin); + ezUSCC.trackUnderlying(); + } + + function _getUnstakeCooldownBalance(address user) + internal + view + returns ( + uint256 pending, + uint256 claimable, + uint256 nextUnlockAt, + uint256 nextUnlockAmount, + uint256 totalRequests + ) + { + ICooldown.TBalanceState memory state = unstakeCooldown.balanceOf(IERC20(address(ezUSCC)), user); + return (state.pending, state.claimable, state.nextUnlockAt, state.nextUnlockAmount, state.totalRequests); + } +}