diff --git a/contracts/adapters/mellow/MellowDepositQueueAdapter.sol b/contracts/adapters/mellow/MellowDepositQueueAdapter.sol new file mode 100644 index 00000000..ebca3a81 --- /dev/null +++ b/contracts/adapters/mellow/MellowDepositQueueAdapter.sol @@ -0,0 +1,116 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Gearbox Protocol. Generalized leverage for DeFi protocols +// (c) Gearbox Foundation, 2024. +pragma solidity ^0.8.23; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {IERC4626} from "@openzeppelin/contracts/interfaces/IERC4626.sol"; +import {EnumerableSet} from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; + +import {AbstractAdapter} from "../AbstractAdapter.sol"; +import {IMellowDepositQueueAdapter} from "../../interfaces/mellow/IMellowDepositQueueAdapter.sol"; +import {IMellowFlexibleDepositGateway} from "../../interfaces/mellow/IMellowFlexibleDepositGateway.sol"; +import {MellowFlexibleDepositPhantomToken} from "../../helpers/mellow/MellowFlexibleDepositPhantomToken.sol"; + +import {NotImplementedException} from "@gearbox-protocol/core-v3/contracts/interfaces/IExceptions.sol"; + +/// @title Mellow Flexible vaults deposit queue adapter +/// @notice Implements logic allowing CAs to interact with the deposit queue of Mellow flexible vaults, allowing deposits and matured deposit claiming. +contract MellowDepositQueueAdapter is AbstractAdapter, IMellowDepositQueueAdapter { + bytes32 public constant override contractType = "ADAPTER::MELLOW_DEPOSIT_QUEUE"; + uint256 public constant override version = 3_10; + + /// @notice The asset deposited into the vault through the queue + address public immutable asset; + + /// @notice The phantom token representing the pending deposits in the queue + address public immutable phantomToken; + + /// @notice The referral address for the deposits + address public immutable referral; + + /// @notice Constructor + constructor(address _creditManager, address _depositQueueGateway, address _referral, address _phantomToken) + AbstractAdapter(_creditManager, _depositQueueGateway) + { + asset = IMellowFlexibleDepositGateway(_depositQueueGateway).asset(); + phantomToken = _phantomToken; + referral = _referral; + + if (MellowFlexibleDepositPhantomToken(phantomToken).depositQueueGateway() != _depositQueueGateway) { + revert InvalidDepositQueueGatewayException(); + } + + _getMaskOrRevert(asset); + _getMaskOrRevert(phantomToken); + } + + /// @notice Initiates a deposit through the queue with exact amount of assets + /// @param assets The amount of assets to deposit + /// @dev `referral` and `merkleProof` are ignored, since the first is hard-coded on deployment, and the second + /// is on off-chain parameter that is not required + /// @dev Returns true in order to price a new pending deposit using safe prices (to enforce a HF buffer on position opening) + function deposit(uint256 assets, address, bytes32[] calldata) external creditFacadeOnly returns (bool) { + _deposit(assets); + return true; + } + + /// @notice Initiates a deposit through the queue with the entire balance of the asset, except the specified amount + /// @param leftoverAmount The amount of assets to leave on the credit account + /// @dev Returns true in order to price a new pending deposit using safe prices (to enforce a HF buffer on position opening) + function depositDiff(uint256 leftoverAmount) external creditFacadeOnly returns (bool) { + address creditAccount = _creditAccount(); + + uint256 amount = IERC20(asset).balanceOf(creditAccount); + + if (amount <= leftoverAmount) return false; + + unchecked { + amount = amount - leftoverAmount; + } + + _deposit(amount); + return true; + } + + /// @dev Internal implementation for `deposit` and `depositDiff` + function _deposit(uint256 assets) internal { + _executeSwapSafeApprove( + asset, abi.encodeCall(IMellowFlexibleDepositGateway.deposit, (assets, referral, new bytes32[](0))) + ); + } + + /// @notice Cancels a pending deposit request + function cancelDepositRequest() external creditFacadeOnly returns (bool) { + _execute(abi.encodeCall(IMellowFlexibleDepositGateway.cancelDepositRequest, ())); + return false; + } + + /// @notice Claims a specific amount from mature deposits + function claim(uint256 amount) external creditFacadeOnly returns (bool) { + _claim(amount); + return false; + } + + /// @dev Internal implementation for `claim` + function _claim(uint256 amount) internal { + _execute(abi.encodeCall(IMellowFlexibleDepositGateway.claim, (amount))); + } + + /// @notice Claims mature deposits, represented by the corresponding phantom token + function withdrawPhantomToken(address pt, uint256 amount) external creditFacadeOnly returns (bool) { + if (pt != phantomToken) revert IncorrectStakedPhantomTokenException(); + _claim(amount); + return false; + } + + /// @dev Not implemented, as there is no way to go from the phantom token to the asset + function depositPhantomToken(address, uint256) external view creditFacadeOnly returns (bool) { + revert NotImplementedException(); + } + + /// @notice Serialized adapter parameters + function serialize() external view returns (bytes memory serializedData) { + serializedData = abi.encode(creditManager, targetContract, asset, phantomToken, referral); + } +} diff --git a/contracts/adapters/mellow/MellowRedeemQueueAdapter.sol b/contracts/adapters/mellow/MellowRedeemQueueAdapter.sol new file mode 100644 index 00000000..61e7a476 --- /dev/null +++ b/contracts/adapters/mellow/MellowRedeemQueueAdapter.sol @@ -0,0 +1,102 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Gearbox Protocol. Generalized leverage for DeFi protocols +// (c) Gearbox Foundation, 2024. +pragma solidity ^0.8.23; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {IERC4626} from "@openzeppelin/contracts/interfaces/IERC4626.sol"; +import {EnumerableSet} from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; + +import {AbstractAdapter} from "../AbstractAdapter.sol"; +import {IMellowRedeemQueueAdapter} from "../../interfaces/mellow/IMellowRedeemQueueAdapter.sol"; +import {IMellowFlexibleRedeemGateway} from "../../interfaces/mellow/IMellowFlexibleRedeemGateway.sol"; +import {MellowFlexibleRedeemPhantomToken} from "../../helpers/mellow/MellowFlexibleRedeemPhantomToken.sol"; + +import {NotImplementedException} from "@gearbox-protocol/core-v3/contracts/interfaces/IExceptions.sol"; + +/// @title Mellow Flexible vaults redemption queue adapter +/// @notice Implements logic allowing CAs to interact with the redemption queue of Mellow flexible vaults, allowing redemptions and matured redemption claiming. +contract MellowRedeemQueueAdapter is AbstractAdapter, IMellowRedeemQueueAdapter { + bytes32 public constant override contractType = "ADAPTER::MELLOW_REDEEM_QUEUE"; + uint256 public constant override version = 3_10; + + /// @notice The vault token that is redeemed through the queue + address public immutable vaultToken; + + /// @notice The phantom token representing the pending redemptions in the queue + address public immutable phantomToken; + + /// @notice Constructor + constructor(address _creditManager, address _redeemQueueGateway, address _phantomToken) + AbstractAdapter(_creditManager, _redeemQueueGateway) + { + vaultToken = IMellowFlexibleRedeemGateway(_redeemQueueGateway).vaultToken(); + phantomToken = _phantomToken; + + if (MellowFlexibleRedeemPhantomToken(phantomToken).redeemQueueGateway() != _redeemQueueGateway) { + revert InvalidRedeemQueueGatewayException(); + } + + _getMaskOrRevert(vaultToken); + _getMaskOrRevert(phantomToken); + } + + /// @notice Initiates a redemption through the queue with exact amount of shares + /// @param shares The amount of shares to redeem + /// @dev Returns true in order to price a new pending redemption using safe prices (to enforce a HF buffer on position opening) + function redeem(uint256 shares) external creditFacadeOnly returns (bool) { + _redeem(shares); + return true; + } + + /// @notice Initiates a redemption through the queue with the entire balance of the vault token, except the specified amount + /// @param leftoverAmount The amount of vault tokens to leave on the credit account + /// @dev Returns true in order to price a new pending redemption using safe prices (to enforce a HF buffer on position opening) + function redeemDiff(uint256 leftoverAmount) external creditFacadeOnly returns (bool) { + address creditAccount = _creditAccount(); + + uint256 amount = IERC20(vaultToken).balanceOf(creditAccount); + + if (amount <= leftoverAmount) return false; + + unchecked { + amount = amount - leftoverAmount; + } + + _redeem(amount); + return true; + } + + /// @dev Internal implementation for `redeem` and `redeemDiff` + function _redeem(uint256 shares) internal { + _executeSwapSafeApprove(vaultToken, abi.encodeCall(IMellowFlexibleRedeemGateway.redeem, (shares))); + } + + /// @notice Claims a specific amount from mature redemptions + function claim(uint256 amount) external creditFacadeOnly returns (bool) { + _claim(amount); + return false; + } + + /// @dev Internal implementation for `claim` + function _claim(uint256 amount) internal { + _execute(abi.encodeCall(IMellowFlexibleRedeemGateway.claim, (amount))); + } + + /// @notice Claims mature redemptions, represented by the corresponding phantom token + function withdrawPhantomToken(address pt, uint256 amount) external creditFacadeOnly returns (bool) { + if (pt != phantomToken) revert IncorrectStakedPhantomTokenException(); + _claim(amount); + return false; + } + + /// @dev Not implemented, as there is no way to go from the phantom token to the asset + function depositPhantomToken(address, uint256) external view creditFacadeOnly returns (bool) { + revert NotImplementedException(); + } + + /// @notice Serialized adapter parameters + function serialize() external view returns (bytes memory serializedData) { + serializedData = abi.encode(creditManager, targetContract, vaultToken, phantomToken); + } +} diff --git a/contracts/helpers/mellow/MellowFlexibleDepositGateway.sol b/contracts/helpers/mellow/MellowFlexibleDepositGateway.sol new file mode 100644 index 00000000..b09a2298 --- /dev/null +++ b/contracts/helpers/mellow/MellowFlexibleDepositGateway.sol @@ -0,0 +1,98 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Gearbox Protocol. Generalized leverage for DeFi protocols +// (c) Gearbox Foundation, 2025. +pragma solidity ^0.8.23; + +import {Clones} from "@openzeppelin/contracts/proxy/Clones.sol"; + +import {IMellowFlexibleDepositGateway} from "../../interfaces/mellow/IMellowFlexibleDepositGateway.sol"; +import {IMellowDepositQueue} from "../../integrations/mellow/IMellowDepositQueue.sol"; +import {IMellowFlexibleVault} from "../../integrations/mellow/IMellowFlexibleVault.sol"; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; + +import {MellowFlexibleDepositor} from "./MellowFlexibleDepositor.sol"; + +/// @title Mellow Flexible Vaults deposit gateway +/// @notice Acts as an intermediary between Gearbox Credit Acocunts and the Mellow deposit queue to avoid unexpected balance changes, +/// and allow partial claiming of matured deposits. +contract MellowFlexibleDepositGateway is IMellowFlexibleDepositGateway { + using SafeERC20 for IERC20; + + bytes32 public constant override contractType = "GATEWAY::MELLOW_DEPOSIT_QUEUE"; + uint256 public constant override version = 3_10; + + /// @notice The deposit queue contract + address public immutable mellowDepositQueue; + + /// @notice The asset deposited into the vault through the queue + address public immutable asset; + + /// @notice The vault token received from deposits + address public immutable vaultToken; + + /// @notice The master depositor contract + address public immutable masterDepositor; + + /// @notice Mapping of accounts to corresponding depositor contracts, + /// which interact directly with the queue + mapping(address => address) public accountToDepositor; + + constructor(address _mellowDepositQueue) { + mellowDepositQueue = _mellowDepositQueue; + asset = IMellowDepositQueue(mellowDepositQueue).asset(); + vaultToken = IMellowFlexibleVault(IMellowDepositQueue(mellowDepositQueue).vault()).shareManager(); + masterDepositor = address(new MellowFlexibleDepositor(mellowDepositQueue, asset, vaultToken)); + } + + /// @notice Deposits assets into the vault through the queue + /// @param assets The amount of assets to deposit + /// @param referral The referral address for the deposit + function deposit(uint256 assets, address referral, bytes32[] calldata) external { + address depositor = _getDepositorForAccount(msg.sender); + IERC20(asset).safeTransferFrom(msg.sender, depositor, assets); + MellowFlexibleDepositor(depositor).deposit(assets, referral); + } + + /// @notice Cancels a pending deposit request + function cancelDepositRequest() external { + address depositor = _getDepositorForAccount(msg.sender); + MellowFlexibleDepositor(depositor).cancelDepositRequest(); + } + + /// @notice Claims a specific amount from mature deposits + function claim(uint256 amount) external { + address depositor = _getDepositorForAccount(msg.sender); + MellowFlexibleDepositor(depositor).claim(amount); + } + + /// @notice Returns the amount of assets pending for a deposit + function getPendingAssets(address account) external view returns (uint256) { + address depositor = accountToDepositor[account]; + if (depositor == address(0)) { + return 0; + } + return MellowFlexibleDepositor(depositor).getPendingAssets(); + } + + /// @notice Returns the amount of shares claimable from mature deposits + function getClaimableShares(address account) external view returns (uint256) { + address depositor = accountToDepositor[account]; + if (depositor == address(0)) { + return 0; + } + return MellowFlexibleDepositor(depositor).getClaimableShares(); + } + + /// @dev Internal function to get the depositor contract for an account or create a new one if it doesn't exist + function _getDepositorForAccount(address account) internal returns (address) { + address depositor = accountToDepositor[account]; + if (depositor == address(0)) { + depositor = Clones.clone(masterDepositor); + MellowFlexibleDepositor(depositor).setAccount(account); + accountToDepositor[account] = depositor; + } + return depositor; + } +} diff --git a/contracts/helpers/mellow/MellowFlexibleDepositPhantomToken.sol b/contracts/helpers/mellow/MellowFlexibleDepositPhantomToken.sol new file mode 100644 index 00000000..aa6742ee --- /dev/null +++ b/contracts/helpers/mellow/MellowFlexibleDepositPhantomToken.sol @@ -0,0 +1,91 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Gearbox Protocol. Generalized leverage for DeFi protocols +// (c) Gearbox Foundation, 2024. +pragma solidity ^0.8.23; + +import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {PhantomERC20} from "../PhantomERC20.sol"; +import {IERC20Metadata} from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; +import {MultiCall} from "@gearbox-protocol/core-v3/contracts/interfaces/ICreditFacadeV3.sol"; +import {IPhantomToken} from "@gearbox-protocol/core-v3/contracts/interfaces/base/IPhantomToken.sol"; + +import {IMellowRateOracle, OracleReport} from "../../integrations/mellow/IMellowRateOracle.sol"; +import {IMellowFlexibleDepositGateway} from "../../interfaces/mellow/IMellowFlexibleDepositGateway.sol"; +import {WAD} from "@gearbox-protocol/core-v3/contracts/libraries/Constants.sol"; + +uint256 constant PRICE_NUMERATOR = 1e36; + +/// @title Mellow Flexible Vaults deposit phantom token +/// @notice Phantom ERC-20 token that represents the balance of the pending and claimable deposits in Mellow Flexible Vaults +contract MellowFlexibleDepositPhantomToken is PhantomERC20, Ownable, IPhantomToken { + bytes32 public constant override contractType = "PHANTOM_TOKEN::MELLOW_DEPOSIT"; + + uint256 public constant override version = 3_10; + + address public immutable depositQueueGateway; + + address public immutable asset; + + address public immutable mellowRateOracle; + + /// @notice Constructor + constructor(address _depositQueueGateway, address _mellowRateOracle) + PhantomERC20( + IMellowFlexibleDepositGateway(_depositQueueGateway).vaultToken(), + string.concat( + "Mellow pending deposit from ", + IERC20Metadata(IMellowFlexibleDepositGateway(_depositQueueGateway).asset()).name(), + " to ", + IERC20Metadata(IMellowFlexibleDepositGateway(_depositQueueGateway).vaultToken()).name() + ), + string.concat( + "mpd", + IERC20Metadata(IMellowFlexibleDepositGateway(_depositQueueGateway).asset()).symbol(), + "_", + IERC20Metadata(IMellowFlexibleDepositGateway(_depositQueueGateway).vaultToken()).symbol() + ), + IERC20Metadata(IMellowFlexibleDepositGateway(_depositQueueGateway).vaultToken()).decimals() + ) + { + depositQueueGateway = _depositQueueGateway; + mellowRateOracle = _mellowRateOracle; + asset = IMellowFlexibleDepositGateway(_depositQueueGateway).asset(); + } + + /// @notice Returns the amount of shares pending/claimable for a deposit + /// @param account The account for which the calculation is performed + function balanceOf(address account) public view returns (uint256 balance) { + uint256 assetPrice = _getLastAcceptedPrice(); + + uint256 pendingAssets = IMellowFlexibleDepositGateway(depositQueueGateway).getPendingAssets(account); + + uint256 claimableShares = IMellowFlexibleDepositGateway(depositQueueGateway).getClaimableShares(account); + + return pendingAssets * assetPrice / WAD + claimableShares; + } + + /// @notice Retrieves the last non-suspicious report from Mellow's OracleSubmitter for the queue's asset + function _getLastAcceptedPrice() internal view returns (uint256) { + uint256 reportNum = IMellowRateOracle(mellowRateOracle).reports(asset); + + for (uint256 i = reportNum; i > 0; i--) { + OracleReport memory report = IMellowRateOracle(mellowRateOracle).reportAt(asset, i - 1); + + if (!report.isSuspicious || IMellowRateOracle(mellowRateOracle).acceptedAt(asset, i - 1) != 0) { + return uint256(report.priceD18); + } + } + + return 0; + } + + /// @notice Returns phantom token's target contract and underlying + function getPhantomTokenInfo() external view override returns (address, address) { + return (depositQueueGateway, underlying); + } + + function serialize() external view override returns (bytes memory) { + return abi.encode(depositQueueGateway, underlying); + } +} diff --git a/contracts/helpers/mellow/MellowFlexibleDepositor.sol b/contracts/helpers/mellow/MellowFlexibleDepositor.sol new file mode 100644 index 00000000..4479208a --- /dev/null +++ b/contracts/helpers/mellow/MellowFlexibleDepositor.sol @@ -0,0 +1,135 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Gearbox Protocol. Generalized leverage for DeFi protocols +// (c) Gearbox Foundation, 2025. +pragma solidity ^0.8.23; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; + +import {IMellowDepositQueue} from "../../integrations/mellow/IMellowDepositQueue.sol"; + +/// @title MellowFlexibleDepositor +/// @notice As Mellow's deposit queue only accepts a single deposit per account, this contract is used +/// as a disposable proxy by the queue in order to make deposits on behalf of users. This also simplifies gateway logic, +/// as Mellow's own per-address deposit indexing can be used to track balances. +contract MellowFlexibleDepositor { + using SafeERC20 for IERC20; + + /// @notice Thrown when attempting to claim when there are not enough shares to claim. + error NotEnoughToClaimException(); + + /// @notice Thrown when attempting to deposit when a deposit is already in progress. + error DepositInProgressException(); + + /// @notice Thrown when attempting to cancel a deposit when no deposit is in progress. + error DepositNotInProgressException(); + + /// @notice Thrown when attempting to call a function from a caller other than the gateway. + error CallerNotGatewayException(); + + /// @notice The account to make deposits on behalf of. + address public account; + + /// @notice The gateway that is using this depositor + address public immutable gateway; + + /// @notice Mellow deposit queue. + address public immutable mellowDepositQueue; + + /// @notice The deposited asset. + address public immutable asset; + + /// @notice The LP token of the vault. + address public immutable vaultToken; + + modifier gatewayOnly() { + if (msg.sender != gateway) { + revert CallerNotGatewayException(); + } + _; + } + + constructor(address _mellowDepositQueue, address _asset, address _vaultToken) { + gateway = msg.sender; + mellowDepositQueue = _mellowDepositQueue; + asset = _asset; + vaultToken = _vaultToken; + } + + /// @notice Sets the account for this depositor + /// @dev Intended to be called only once by the gateway on creation + function setAccount(address _account) external gatewayOnly { + account = _account; + } + + /// @notice Deposits assets on behalf of the account. + /// @param assets The amount of assets to deposit. + /// @param referral The referral address. + function deposit(uint256 assets, address referral) external gatewayOnly { + if (_getPendingAssets() > 0) { + revert DepositInProgressException(); + } + + IERC20(asset).forceApprove(mellowDepositQueue, assets); + IMellowDepositQueue(mellowDepositQueue).deposit(uint224(assets), referral, new bytes32[](0)); + } + + /// @notice Cancels the deposit request. + function cancelDepositRequest() external gatewayOnly { + uint256 pendingAssets = _getNonClaimablePendingAssets(); + + if (pendingAssets == 0) { + revert DepositNotInProgressException(); + } + + IMellowDepositQueue(mellowDepositQueue).cancelDepositRequest(); + IERC20(asset).safeTransfer(account, pendingAssets); + } + + /// @notice Claims a specific amount from a matured deposit on behalf of the account. + function claim(uint256 amount) external gatewayOnly { + (uint256 inQueue, uint256 onDepositor) = _getClaimableShares(); + + if (inQueue != 0) { + IMellowDepositQueue(mellowDepositQueue).claim(address(this)); + onDepositor = IERC20(vaultToken).balanceOf(address(this)); + } + + if (onDepositor < amount) { + revert NotEnoughToClaimException(); + } + + IERC20(vaultToken).safeTransfer(account, amount); + } + + /// @notice Returns the amount of assets pending for a deposit + function getPendingAssets() external view returns (uint256) { + return _getNonClaimablePendingAssets(); + } + + /// @notice Returns the amount of shares claimable from mature deposits + function getClaimableShares() external view returns (uint256 shares) { + (uint256 inQueue, uint256 onDepositor) = _getClaimableShares(); + return inQueue + onDepositor; + } + + function _getNonClaimablePendingAssets() internal view returns (uint256) { + uint256 claimable = IMellowDepositQueue(mellowDepositQueue).claimableOf(address(this)); + if (claimable > 0) return 0; + return _getPendingAssets(); + } + + /// @dev Internal function to get the amount of assets pending for a deposit + function _getPendingAssets() internal view returns (uint256) { + (, uint256 assets) = IMellowDepositQueue(mellowDepositQueue).requestOf(address(this)); + return assets; + } + + /// @dev Internal function to get the amount of shares claimable from mature deposits, still in the queue and already on the depositor + function _getClaimableShares() internal view returns (uint256 inQueue, uint256 onDepositor) { + return ( + IMellowDepositQueue(mellowDepositQueue).claimableOf(address(this)), + IERC20(vaultToken).balanceOf(address(this)) + ); + } +} diff --git a/contracts/helpers/mellow/MellowFlexibleRedeemGateway.sol b/contracts/helpers/mellow/MellowFlexibleRedeemGateway.sol new file mode 100644 index 00000000..ef15181d --- /dev/null +++ b/contracts/helpers/mellow/MellowFlexibleRedeemGateway.sol @@ -0,0 +1,89 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Gearbox Protocol. Generalized leverage for DeFi protocols +// (c) Gearbox Foundation, 2025. +pragma solidity ^0.8.23; + +import {Clones} from "@openzeppelin/contracts/proxy/Clones.sol"; + +import {IMellowFlexibleRedeemGateway} from "../../interfaces/mellow/IMellowFlexibleRedeemGateway.sol"; +import {IMellowRedeemQueue} from "../../integrations/mellow/IMellowRedeemQueue.sol"; +import {IMellowFlexibleVault} from "../../integrations/mellow/IMellowFlexibleVault.sol"; +import {MellowFlexibleRedeemer} from "./MellowFlexibleRedeemer.sol"; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; + +/// @title Mellow Flexible Vaults redemption gateway +/// @notice Acts as an intermediary between Gearbox Credit Acocunts and the Mellow redemption queue to allow partial claiming of matured redemptions. +contract MellowFlexibleRedeemGateway is IMellowFlexibleRedeemGateway { + using SafeERC20 for IERC20; + + bytes32 public constant override contractType = "GATEWAY::MELLOW_REDEEM_QUEUE"; + uint256 public constant override version = 3_10; + + /// @notice The redemption queue contract + address public immutable mellowRedeemQueue; + + /// @notice The asset redeemed from the vault through the queue + address public immutable asset; + + /// @notice The vault token that is redeemed + address public immutable vaultToken; + + /// @notice The master redeemer contract + address public immutable masterRedeemer; + + /// @notice Mapping of accounts to corresponding redeemer contracts, + /// which interact directly with the queue + mapping(address => address) public accountToRedeemer; + + constructor(address _mellowRedeemQueue) { + mellowRedeemQueue = _mellowRedeemQueue; + asset = IMellowRedeemQueue(mellowRedeemQueue).asset(); + vaultToken = IMellowFlexibleVault(IMellowRedeemQueue(mellowRedeemQueue).vault()).shareManager(); + masterRedeemer = address(new MellowFlexibleRedeemer(mellowRedeemQueue, asset, vaultToken)); + } + + /// @notice Initiates a redemption through the queue with exact amount of shares + /// @param shares The amount of shares to redeem + function redeem(uint256 shares) external { + address redeemer = _getRedeemerForAccount(msg.sender); + IERC20(vaultToken).safeTransferFrom(msg.sender, redeemer, shares); + MellowFlexibleRedeemer(redeemer).redeem(shares); + } + + /// @notice Claims a specific amount from mature redemptions + function claim(uint256 amount) external { + address redeemer = _getRedeemerForAccount(msg.sender); + MellowFlexibleRedeemer(redeemer).claim(amount); + } + + /// @notice Returns the amount of shares pending for a redemption + function getPendingShares(address account) external view returns (uint256) { + address redeemer = accountToRedeemer[account]; + if (redeemer == address(0)) { + return 0; + } + return MellowFlexibleRedeemer(redeemer).getPendingShares(); + } + + /// @notice Returns the amount of assets claimable from mature redemptions + function getClaimableAssets(address account) external view returns (uint256) { + address redeemer = accountToRedeemer[account]; + if (redeemer == address(0)) { + return 0; + } + return MellowFlexibleRedeemer(redeemer).getClaimableAssets(); + } + + /// @dev Internal function to get the redeemer contract for an account or create a new one if it doesn't exist + function _getRedeemerForAccount(address account) internal returns (address) { + address redeemer = accountToRedeemer[account]; + if (redeemer == address(0)) { + redeemer = Clones.clone(masterRedeemer); + MellowFlexibleRedeemer(redeemer).setAccount(account); + accountToRedeemer[account] = redeemer; + } + return redeemer; + } +} diff --git a/contracts/helpers/mellow/MellowFlexibleRedeemPhantomToken.sol b/contracts/helpers/mellow/MellowFlexibleRedeemPhantomToken.sol new file mode 100644 index 00000000..49e8f130 --- /dev/null +++ b/contracts/helpers/mellow/MellowFlexibleRedeemPhantomToken.sol @@ -0,0 +1,91 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Gearbox Protocol. Generalized leverage for DeFi protocols +// (c) Gearbox Foundation, 2024. +pragma solidity ^0.8.23; + +import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {PhantomERC20} from "../PhantomERC20.sol"; +import {IERC20Metadata} from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; +import {MultiCall} from "@gearbox-protocol/core-v3/contracts/interfaces/ICreditFacadeV3.sol"; +import {IPhantomToken} from "@gearbox-protocol/core-v3/contracts/interfaces/base/IPhantomToken.sol"; + +import {IMellowRateOracle, OracleReport} from "../../integrations/mellow/IMellowRateOracle.sol"; +import {IMellowFlexibleRedeemGateway} from "../../interfaces/mellow/IMellowFlexibleRedeemGateway.sol"; +import {WAD} from "@gearbox-protocol/core-v3/contracts/libraries/Constants.sol"; + +uint256 constant PRICE_NUMERATOR = 1e36; + +/// @title Mellow Flexible Vaults redemption phantom token +/// @notice Phantom ERC-20 token that represents the balance of the pending and claimable redemptions in Mellow Flexible Vaults +contract MellowFlexibleRedeemPhantomToken is PhantomERC20, Ownable, IPhantomToken { + bytes32 public constant override contractType = "PHANTOM_TOKEN::MELLOW_REDEEM"; + + uint256 public constant override version = 3_10; + + address public immutable redeemQueueGateway; + + address public immutable asset; + + address public immutable mellowRateOracle; + + /// @notice Constructor + constructor(address _redeemQueueGateway, address _mellowRateOracle) + PhantomERC20( + IMellowFlexibleRedeemGateway(_redeemQueueGateway).asset(), + string.concat( + "Mellow pending redemption from ", + IERC20Metadata(IMellowFlexibleRedeemGateway(_redeemQueueGateway).vaultToken()).name(), + " to ", + IERC20Metadata(IMellowFlexibleRedeemGateway(_redeemQueueGateway).asset()).name() + ), + string.concat( + "mpr", + IERC20Metadata(IMellowFlexibleRedeemGateway(_redeemQueueGateway).vaultToken()).symbol(), + "_", + IERC20Metadata(IMellowFlexibleRedeemGateway(_redeemQueueGateway).asset()).symbol() + ), + IERC20Metadata(IMellowFlexibleRedeemGateway(_redeemQueueGateway).asset()).decimals() + ) + { + redeemQueueGateway = _redeemQueueGateway; + mellowRateOracle = _mellowRateOracle; + asset = IMellowFlexibleRedeemGateway(_redeemQueueGateway).asset(); + } + + /// @notice Returns the amount of assets pending/claimable for redemption + /// @param account The account for which the calculation is performed + function balanceOf(address account) public view returns (uint256 balance) { + uint256 sharesRate = _getLastAcceptedRate(); + + uint256 pendingShares = IMellowFlexibleRedeemGateway(redeemQueueGateway).getPendingShares(account); + + uint256 claimableAssets = IMellowFlexibleRedeemGateway(redeemQueueGateway).getClaimableAssets(account); + + return pendingShares * sharesRate / WAD + claimableAssets; + } + + /// @notice Retrieves the last non-suspicious report from Mellow's OracleSubmitter for the queue's asset + function _getLastAcceptedRate() internal view returns (uint256) { + uint256 reportNum = IMellowRateOracle(mellowRateOracle).reports(asset); + + for (uint256 i = reportNum; i > 0; i--) { + OracleReport memory report = IMellowRateOracle(mellowRateOracle).reportAt(asset, i - 1); + + if (!report.isSuspicious || IMellowRateOracle(mellowRateOracle).acceptedAt(asset, i - 1) != 0) { + return PRICE_NUMERATOR / report.priceD18; + } + } + + return 0; + } + + /// @notice Returns phantom token's target contract and underlying + function getPhantomTokenInfo() external view override returns (address, address) { + return (redeemQueueGateway, underlying); + } + + function serialize() external view override returns (bytes memory) { + return abi.encode(redeemQueueGateway, underlying); + } +} diff --git a/contracts/helpers/mellow/MellowFlexibleRedeemer.sol b/contracts/helpers/mellow/MellowFlexibleRedeemer.sol new file mode 100644 index 00000000..aac0ee71 --- /dev/null +++ b/contracts/helpers/mellow/MellowFlexibleRedeemer.sol @@ -0,0 +1,130 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Gearbox Protocol. Generalized leverage for DeFi protocols +// (c) Gearbox Foundation, 2025. +pragma solidity ^0.8.23; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; + +import {IMellowRedeemQueue, Request} from "../../integrations/mellow/IMellowRedeemQueue.sol"; + +/// @title Mellow Flexible Vaults redeemer +/// @notice Having a separate redeemer address simplifies gateway logic, +/// as Mellow's own per-address redemption indexing can be used to track balances. +contract MellowFlexibleRedeemer { + using SafeERC20 for IERC20; + + /// @notice Thrown when attempting to redeem when there are too many requests already, to avoid large + /// gas expenditure to retrieve pending/claimable amounts. + error TooManyRequestsException(); + + /// @notice Thrown when attempting to claim when there are not enough assets to claim. + error NotEnoughToClaimException(); + + /// @notice Thrown when attempting to call a function from a caller other than the gateway. + error CallerNotGatewayException(); + + /// @notice The account to make redemptions on behalf of. + address public account; + + /// @notice The gateway that is using this redeemer + address public immutable gateway; + + /// @notice Mellow redeem queue. + address public immutable mellowRedeemQueue; + + /// @notice The deposited asset. + address public immutable asset; + + /// @notice The LP token of the vault. + address public immutable vaultToken; + + modifier gatewayOnly() { + if (msg.sender != gateway) { + revert CallerNotGatewayException(); + } + _; + } + + constructor(address _mellowRedeemQueue, address _asset, address _vaultToken) { + gateway = msg.sender; + mellowRedeemQueue = _mellowRedeemQueue; + asset = _asset; + vaultToken = _vaultToken; + } + + /// @notice Sets the account for this redeemer + /// @dev Intended to be called only once by the gateway on creation + function setAccount(address _account) external gatewayOnly { + account = _account; + } + + /// @notice Initiates a redemption through the queue with exact amount of shares + /// @param shares The amount of shares to redeem + function redeem(uint256 shares) external gatewayOnly { + if (_getNumRequests() >= 5) { + revert TooManyRequestsException(); + } + + IMellowRedeemQueue(mellowRedeemQueue).redeem(shares); + } + + /// @notice Claims a specific amount from mature redemptions + function claim(uint256 amount) external gatewayOnly { + (uint256 inQueue, uint256 onRedeemer, uint32[] memory timestamps) = _getClaimableAssetsAndTimestamps(); + if (inQueue != 0) { + IMellowRedeemQueue(mellowRedeemQueue).claim(address(this), timestamps); + onRedeemer = IERC20(asset).balanceOf(address(this)); + } + + if (onRedeemer < amount) { + revert NotEnoughToClaimException(); + } + + IERC20(asset).safeTransfer(account, amount); + } + + /// @dev Internal function to get the number of existing requests for the account + function _getNumRequests() internal view returns (uint256) { + Request[] memory requests = + IMellowRedeemQueue(mellowRedeemQueue).requestsOf(address(this), 0, type(uint256).max); + return requests.length; + } + + /// @notice Returns the amount of shares pending for a redemption + function getPendingShares() external view returns (uint256 pendingShares) { + Request[] memory requests = + IMellowRedeemQueue(mellowRedeemQueue).requestsOf(address(this), 0, type(uint256).max); + for (uint256 i = 0; i < requests.length; i++) { + if (!requests[i].isClaimable) { + pendingShares += requests[i].shares; + } + } + } + + /// @notice Returns the amount of assets claimable from mature redemptions + function getClaimableAssets() external view returns (uint256 claimableAssets) { + (uint256 inQueue, uint256 onRedeemer,) = _getClaimableAssetsAndTimestamps(); + return inQueue + onRedeemer; + } + + /// @dev Internal function to get the amount of assets claimable from mature redemptions, still in the queue and already on the redeemer. Also + /// returns the timestamps of active requests, which Mellow uses to index redemptions. + function _getClaimableAssetsAndTimestamps() + internal + view + returns (uint256 inQueue, uint256 onRedeemer, uint32[] memory timestamps) + { + Request[] memory requests = + IMellowRedeemQueue(mellowRedeemQueue).requestsOf(address(this), 0, type(uint256).max); + timestamps = new uint32[](requests.length); + for (uint256 i = 0; i < requests.length; i++) { + if (requests[i].isClaimable) { + inQueue += requests[i].assets; + } + timestamps[i] = uint32(requests[i].timestamp); + } + + onRedeemer = IERC20(asset).balanceOf(address(this)); + } +} diff --git a/contracts/integrations/mellow/IMellowDepositQueue.sol b/contracts/integrations/mellow/IMellowDepositQueue.sol new file mode 100644 index 00000000..9e461dae --- /dev/null +++ b/contracts/integrations/mellow/IMellowDepositQueue.sol @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.23; + +interface IMellowDepositQueue { + function vault() external view returns (address); + function asset() external view returns (address); + function claimableOf(address account) external view returns (uint256); + function requestOf(address account) external view returns (uint256 timestamp, uint256 assets); + function deposit(uint224 assets, address referral, bytes32[] calldata merkleProof) external; + function cancelDepositRequest() external; + function claim(address account) external; +} diff --git a/contracts/integrations/mellow/IMellowFlexibleVault.sol b/contracts/integrations/mellow/IMellowFlexibleVault.sol new file mode 100644 index 00000000..aee21d45 --- /dev/null +++ b/contracts/integrations/mellow/IMellowFlexibleVault.sol @@ -0,0 +1,7 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.23; + +interface IMellowFlexibleVault { + function shareManager() external view returns (address); + function oracle() external view returns (address); +} diff --git a/contracts/integrations/mellow/IMellowRateOracle.sol b/contracts/integrations/mellow/IMellowRateOracle.sol new file mode 100644 index 00000000..990ca528 --- /dev/null +++ b/contracts/integrations/mellow/IMellowRateOracle.sol @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.23; + +struct OracleReport { + uint224 priceD18; + uint32 timestamp; + bool isSuspicious; +} + +interface IMellowRateOracle { + function getRate() external view returns (uint256); + function reports(address asset) external view returns (uint256); + function reportAt(address asset, uint256 index) external view returns (OracleReport memory); + function acceptedAt(address asset, uint256 index) external view returns (uint32); + function securityParams() + external + view + returns ( + uint224 maxAbsoluteDeviation, + uint224 suspiciousAbsoluteDeviation, + uint64 maxRelativeDeviationD18, + uint64 suspiciousRelativeDeviationD18, + uint32 timeout, + uint32 depositInterval, + uint32 redeemInterval + ); +} diff --git a/contracts/integrations/mellow/IMellowRedeemQueue.sol b/contracts/integrations/mellow/IMellowRedeemQueue.sol new file mode 100644 index 00000000..3d82ad8d --- /dev/null +++ b/contracts/integrations/mellow/IMellowRedeemQueue.sol @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.23; + +struct Request { + uint256 timestamp; + uint256 shares; + bool isClaimable; + uint256 assets; +} + +interface IMellowRedeemQueue { + function vault() external view returns (address); + function asset() external view returns (address); + function requestsOf(address account, uint256 offset, uint256 limit) + external + view + returns (Request[] memory requests); + function redeem(uint256 shares) external; + function claim(address receiver, uint32[] calldata timestamps) external returns (uint256 assets); +} diff --git a/contracts/interfaces/mellow/IMellowDepositQueueAdapter.sol b/contracts/interfaces/mellow/IMellowDepositQueueAdapter.sol new file mode 100644 index 00000000..eb71153b --- /dev/null +++ b/contracts/interfaces/mellow/IMellowDepositQueueAdapter.sol @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Gearbox Protocol. Generalized leverage for DeFi protocols +// (c) Gearbox Foundation, 2024. +pragma solidity ^0.8.23; + +import {IPhantomTokenAdapter} from "../IPhantomTokenAdapter.sol"; + +/// @title Mellow Flexible Vault Deposit Queue adapter interface +/// @notice Interface for the adapter to interact with Mellow's Flexible Vaults Deposit Queue +interface IMellowDepositQueueAdapter is IPhantomTokenAdapter { + error InvalidDepositQueueGatewayException(); + + function asset() external view returns (address); + + function phantomToken() external view returns (address); + + function referral() external view returns (address); + + function deposit(uint256 assets, address, bytes32[] calldata) external returns (bool); + + function depositDiff(uint256 leftoverAmount) external returns (bool); + + function cancelDepositRequest() external returns (bool); + + function claim(uint256 amount) external returns (bool); +} diff --git a/contracts/interfaces/mellow/IMellowFlexibleDepositGateway.sol b/contracts/interfaces/mellow/IMellowFlexibleDepositGateway.sol new file mode 100644 index 00000000..1ae99772 --- /dev/null +++ b/contracts/interfaces/mellow/IMellowFlexibleDepositGateway.sol @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Gearbox Protocol. Generalized leverage for DeFi protocols +// (c) Gearbox Foundation, 2024. +pragma solidity ^0.8.23; + +import {IVersion} from "@gearbox-protocol/core-v3/contracts/interfaces/base/IVersion.sol"; + +/// @title Mellow Flexible Deposit Queue Gateway interface +/// @notice Interface for the gateway to interact with Mellow's Flexible Vaults Deposit Queue +interface IMellowFlexibleDepositGateway is IVersion { + function vaultToken() external view returns (address); + function asset() external view returns (address); + function mellowDepositQueue() external view returns (address); + function accountToDepositor(address account) external view returns (address); + function getPendingAssets(address account) external view returns (uint256); + function getClaimableShares(address account) external view returns (uint256); + + function deposit(uint256 assets, address referral, bytes32[] calldata) external; + function cancelDepositRequest() external; + function claim(uint256 amount) external; +} diff --git a/contracts/interfaces/mellow/IMellowFlexibleRedeemGateway.sol b/contracts/interfaces/mellow/IMellowFlexibleRedeemGateway.sol new file mode 100644 index 00000000..80633895 --- /dev/null +++ b/contracts/interfaces/mellow/IMellowFlexibleRedeemGateway.sol @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Gearbox Protocol. Generalized leverage for DeFi protocols +// (c) Gearbox Foundation, 2024. +pragma solidity ^0.8.23; + +import {IVersion} from "@gearbox-protocol/core-v3/contracts/interfaces/base/IVersion.sol"; + +/// @title Mellow Flexible Deposit Queue Gateway interface +/// @notice Interface for the gateway to interact with Mellow's Flexible Vaults Deposit Queue +interface IMellowFlexibleRedeemGateway is IVersion { + function vaultToken() external view returns (address); + function asset() external view returns (address); + function mellowRedeemQueue() external view returns (address); + function accountToRedeemer(address account) external view returns (address); + function getPendingShares(address account) external view returns (uint256); + function getClaimableAssets(address account) external view returns (uint256); + + function redeem(uint256 shares) external; + function claim(uint256 amount) external; +} diff --git a/contracts/interfaces/mellow/IMellowRedeemQueueAdapter.sol b/contracts/interfaces/mellow/IMellowRedeemQueueAdapter.sol new file mode 100644 index 00000000..826c7102 --- /dev/null +++ b/contracts/interfaces/mellow/IMellowRedeemQueueAdapter.sol @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Gearbox Protocol. Generalized leverage for DeFi protocols +// (c) Gearbox Foundation, 2024. +pragma solidity ^0.8.23; + +import {IAdapter} from "@gearbox-protocol/core-v3/contracts/interfaces/base/IAdapter.sol"; + +interface IMellowRedeemQueueAdapter is IAdapter { + error InvalidRedeemQueueGatewayException(); + error IncorrectStakedPhantomTokenException(); + + function redeem(uint256 shares) external returns (bool); + + function redeemDiff(uint256 leftoverAmount) external returns (bool); + + function claim(uint256 amount) external returns (bool); + + function withdrawPhantomToken(address pt, uint256 amount) external returns (bool); + + function depositPhantomToken(address pt, uint256 amount) external returns (bool); + + function vaultToken() external view returns (address); + + function phantomToken() external view returns (address); +} diff --git a/contracts/test/unit/adapters/mellow/MellowDepositQueueAdapter.unit.t.sol b/contracts/test/unit/adapters/mellow/MellowDepositQueueAdapter.unit.t.sol new file mode 100644 index 00000000..f5b2ac62 --- /dev/null +++ b/contracts/test/unit/adapters/mellow/MellowDepositQueueAdapter.unit.t.sol @@ -0,0 +1,224 @@ +// SPDX-License-Identifier: UNLICENSED +// Gearbox Protocol. Generalized leverage for DeFi protocols +// (c) Gearbox Foundation, 2024. +pragma solidity ^0.8.23; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {MellowDepositQueueAdapter} from "../../../../adapters/mellow/MellowDepositQueueAdapter.sol"; +import {MellowFlexibleDepositGateway} from "../../../../helpers/mellow/MellowFlexibleDepositGateway.sol"; +import {MellowFlexibleDepositPhantomToken} from "../../../../helpers/mellow/MellowFlexibleDepositPhantomToken.sol"; +import {IMellowFlexibleDepositGateway} from "../../../../interfaces/mellow/IMellowFlexibleDepositGateway.sol"; +import {IMellowDepositQueueAdapter} from "../../../../interfaces/mellow/IMellowDepositQueueAdapter.sol"; +import {IMellowDepositQueue} from "../../../../integrations/mellow/IMellowDepositQueue.sol"; +import {IMellowFlexibleVault} from "../../../../integrations/mellow/IMellowFlexibleVault.sol"; +import {IPhantomTokenAdapter} from "../../../../interfaces/IPhantomTokenAdapter.sol"; +import {AdapterUnitTestHelper} from "../AdapterUnitTestHelper.sol"; +import {NotImplementedException} from "@gearbox-protocol/core-v3/contracts/interfaces/IExceptions.sol"; + +/// @title Mellow deposit queue adapter unit test +/// @notice U:[MDQ]: Unit tests for Mellow deposit queue adapter +contract MellowDepositQueueAdapterUnitTest is AdapterUnitTestHelper { + MellowDepositQueueAdapter adapter; + + address gateway; + address asset; + address phantomToken; + address referral; + address depositQueue; + address mellowRateOracle; + address vault; + address vaultToken; + + function setUp() public { + _setUp(); + + asset = tokens[0]; + vaultToken = tokens[1]; + referral = makeAddr("REFERRAL"); + depositQueue = makeAddr("DEPOSIT_QUEUE"); + mellowRateOracle = makeAddr("MELLOW_RATE_ORACLE"); + vault = makeAddr("VAULT"); + + vm.mockCall(depositQueue, abi.encodeWithSignature("asset()"), abi.encode(asset)); + vm.mockCall(depositQueue, abi.encodeWithSignature("vault()"), abi.encode(vault)); + vm.mockCall(vault, abi.encodeWithSignature("shareManager()"), abi.encode(vaultToken)); + + gateway = address(new MellowFlexibleDepositGateway(depositQueue)); + + vm.mockCall(gateway, abi.encodeWithSignature("asset()"), abi.encode(asset)); + vm.mockCall(gateway, abi.encodeWithSignature("vaultToken()"), abi.encode(vaultToken)); + + vm.mockCall(asset, abi.encodeWithSignature("name()"), abi.encode("Test Asset")); + vm.mockCall(asset, abi.encodeWithSignature("symbol()"), abi.encode("TST")); + vm.mockCall(vaultToken, abi.encodeWithSignature("name()"), abi.encode("Test Vault")); + vm.mockCall(vaultToken, abi.encodeWithSignature("symbol()"), abi.encode("vTST")); + vm.mockCall(vaultToken, abi.encodeWithSignature("decimals()"), abi.encode(uint8(18))); + + phantomToken = address(new MellowFlexibleDepositPhantomToken(gateway, mellowRateOracle)); + + creditManager.setMask(phantomToken, 1 << 10); + + adapter = new MellowDepositQueueAdapter(address(creditManager), gateway, referral, phantomToken); + } + + /// @notice U:[MDQ-1]: Constructor works as expected + function test_U_MDQ_01_constructor_works_as_expected() public { + address wrongGateway = makeAddr("WRONG_GATEWAY"); + vm.mockCall(wrongGateway, abi.encodeWithSignature("asset()"), abi.encode(asset)); + vm.mockCall(wrongGateway, abi.encodeWithSignature("vaultToken()"), abi.encode(vaultToken)); + address wrongPhantomToken = address(new MellowFlexibleDepositPhantomToken(wrongGateway, mellowRateOracle)); + + creditManager.setMask(wrongPhantomToken, 1 << 11); + + vm.expectRevert(IMellowDepositQueueAdapter.InvalidDepositQueueGatewayException.selector); + new MellowDepositQueueAdapter(address(creditManager), gateway, referral, wrongPhantomToken); + + phantomToken = address(new MellowFlexibleDepositPhantomToken(gateway, mellowRateOracle)); + creditManager.setMask(phantomToken, 1 << 12); + + _readsTokenMask(asset); + _readsTokenMask(phantomToken); + + adapter = new MellowDepositQueueAdapter(address(creditManager), gateway, referral, phantomToken); + + assertEq(adapter.creditManager(), address(creditManager), "Incorrect creditManager"); + assertEq(adapter.targetContract(), gateway, "Incorrect targetContract"); + assertEq(adapter.asset(), asset, "Incorrect asset"); + assertEq(adapter.phantomToken(), phantomToken, "Incorrect phantomToken"); + assertEq(adapter.referral(), referral, "Incorrect referral"); + } + + /// @notice U:[MDQ-2]: Wrapper functions revert on wrong caller + function test_U_MDQ_02_wrapper_functions_revert_on_wrong_caller() public { + _revertsOnNonFacadeCaller(); + adapter.deposit(1000, address(0), new bytes32[](0)); + + _revertsOnNonFacadeCaller(); + adapter.depositDiff(1000); + + _revertsOnNonFacadeCaller(); + adapter.cancelDepositRequest(); + + _revertsOnNonFacadeCaller(); + adapter.claim(1000); + + _revertsOnNonFacadeCaller(); + adapter.withdrawPhantomToken(address(0), 0); + + _revertsOnNonFacadeCaller(); + adapter.depositPhantomToken(address(0), 0); + } + + /// @notice U:[MDQ-3]: `deposit` works as expected + function test_U_MDQ_03_deposit_works_as_expected() public { + bytes32[] memory merkleProof = new bytes32[](0); + + _executesSwap({ + tokenIn: asset, + callData: abi.encodeCall(IMellowFlexibleDepositGateway.deposit, (1000, referral, merkleProof)), + requiresApproval: true + }); + + vm.prank(creditFacade); + bool useSafePrices = adapter.deposit(1000, makeAddr("IGNORED_REFERRAL"), merkleProof); + assertTrue(useSafePrices); + } + + /// @notice U:[MDQ-4]: `depositDiff` works as expected + function test_U_MDQ_04_depositDiff_works_as_expected() public diffTestCases { + deal({token: asset, to: creditAccount, give: diffMintedAmount}); + + _readsActiveAccount(); + + bytes32[] memory merkleProof = new bytes32[](0); + _executesSwap({ + tokenIn: asset, + callData: abi.encodeCall(IMellowFlexibleDepositGateway.deposit, (diffInputAmount, referral, merkleProof)), + requiresApproval: true + }); + + vm.prank(creditFacade); + bool useSafePrices = adapter.depositDiff(diffLeftoverAmount); + assertTrue(useSafePrices); + } + + /// @notice U:[MDQ-5]: `depositDiff` returns false when amount <= leftoverAmount + function test_U_MDQ_05_depositDiff_returns_false_when_nothing_to_deposit() public { + deal({token: asset, to: creditAccount, give: 100}); + + _readsActiveAccount(); + + vm.prank(creditFacade); + bool useSafePrices = adapter.depositDiff(100); + assertFalse(useSafePrices); + + vm.prank(creditFacade); + useSafePrices = adapter.depositDiff(101); + assertFalse(useSafePrices); + } + + /// @notice U:[MDQ-6]: `cancelDepositRequest` works as expected + function test_U_MDQ_06_cancelDepositRequest_works_as_expected() public { + _executesSwap({ + tokenIn: address(0), + callData: abi.encodeCall(IMellowFlexibleDepositGateway.cancelDepositRequest, ()), + requiresApproval: false + }); + + vm.prank(creditFacade); + bool useSafePrices = adapter.cancelDepositRequest(); + assertFalse(useSafePrices); + } + + /// @notice U:[MDQ-7]: `claim` works as expected + function test_U_MDQ_07_claim_works_as_expected() public { + _executesSwap({ + tokenIn: address(0), + callData: abi.encodeCall(IMellowFlexibleDepositGateway.claim, (1000)), + requiresApproval: false + }); + + vm.prank(creditFacade); + bool useSafePrices = adapter.claim(1000); + assertFalse(useSafePrices); + } + + /// @notice U:[MDQ-8]: `withdrawPhantomToken` works as expected + function test_U_MDQ_08_withdrawPhantomToken_works_as_expected() public { + // Test with incorrect token + vm.expectRevert(IPhantomTokenAdapter.IncorrectStakedPhantomTokenException.selector); + vm.prank(creditFacade); + adapter.withdrawPhantomToken(address(0), 1000); + + // Test with correct token + _executesSwap({ + tokenIn: address(0), + callData: abi.encodeCall(IMellowFlexibleDepositGateway.claim, (1000)), + requiresApproval: false + }); + + vm.prank(creditFacade); + bool useSafePrices = adapter.withdrawPhantomToken(phantomToken, 1000); + assertFalse(useSafePrices); + } + + /// @notice U:[MDQ-9]: `depositPhantomToken` reverts as expected + function test_U_MDQ_09_depositPhantomToken_reverts() public { + vm.prank(creditFacade); + vm.expectRevert(NotImplementedException.selector); + adapter.depositPhantomToken(phantomToken, 1000); + } + + /// @notice U:[MDQ-10]: `serialize` works as expected + function test_U_MDQ_10_serialize_works_as_expected() public view { + bytes memory serializedData = adapter.serialize(); + (address cm, address tc, address a, address pt, address r) = + abi.decode(serializedData, (address, address, address, address, address)); + + assertEq(cm, address(creditManager), "Incorrect creditManager in serialized data"); + assertEq(tc, gateway, "Incorrect targetContract in serialized data"); + assertEq(a, asset, "Incorrect asset in serialized data"); + assertEq(pt, phantomToken, "Incorrect phantomToken in serialized data"); + assertEq(r, referral, "Incorrect referral in serialized data"); + } +} diff --git a/contracts/test/unit/adapters/mellow/MellowRedeemQueueAdapter.unit.t.sol b/contracts/test/unit/adapters/mellow/MellowRedeemQueueAdapter.unit.t.sol new file mode 100644 index 00000000..9e03042f --- /dev/null +++ b/contracts/test/unit/adapters/mellow/MellowRedeemQueueAdapter.unit.t.sol @@ -0,0 +1,204 @@ +// SPDX-License-Identifier: UNLICENSED +// Gearbox Protocol. Generalized leverage for DeFi protocols +// (c) Gearbox Foundation, 2024. +pragma solidity ^0.8.23; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {MellowRedeemQueueAdapter} from "../../../../adapters/mellow/MellowRedeemQueueAdapter.sol"; +import {MellowFlexibleRedeemGateway} from "../../../../helpers/mellow/MellowFlexibleRedeemGateway.sol"; +import {MellowFlexibleRedeemPhantomToken} from "../../../../helpers/mellow/MellowFlexibleRedeemPhantomToken.sol"; +import {IMellowFlexibleRedeemGateway} from "../../../../interfaces/mellow/IMellowFlexibleRedeemGateway.sol"; +import {IMellowRedeemQueueAdapter} from "../../../../interfaces/mellow/IMellowRedeemQueueAdapter.sol"; +import {IMellowRedeemQueue} from "../../../../integrations/mellow/IMellowRedeemQueue.sol"; +import {IMellowFlexibleVault} from "../../../../integrations/mellow/IMellowFlexibleVault.sol"; +import {IPhantomTokenAdapter} from "../../../../interfaces/IPhantomTokenAdapter.sol"; +import {AdapterUnitTestHelper} from "../AdapterUnitTestHelper.sol"; +import {NotImplementedException} from "@gearbox-protocol/core-v3/contracts/interfaces/IExceptions.sol"; + +/// @title Mellow redeem queue adapter unit test +/// @notice U:[MRQ]: Unit tests for Mellow redeem queue adapter +contract MellowRedeemQueueAdapterUnitTest is AdapterUnitTestHelper { + MellowRedeemQueueAdapter adapter; + + address gateway; + address vaultToken; + address phantomToken; + address redeemQueue; + address asset; + address mellowRateOracle; + address vault; + + function setUp() public { + _setUp(); + + vaultToken = tokens[0]; + asset = tokens[1]; + redeemQueue = makeAddr("REDEEM_QUEUE"); + mellowRateOracle = makeAddr("MELLOW_RATE_ORACLE"); + vault = makeAddr("VAULT"); + + vm.mockCall(redeemQueue, abi.encodeWithSignature("asset()"), abi.encode(asset)); + vm.mockCall(redeemQueue, abi.encodeWithSignature("vault()"), abi.encode(vault)); + vm.mockCall(vault, abi.encodeWithSignature("shareManager()"), abi.encode(vaultToken)); + + gateway = address(new MellowFlexibleRedeemGateway(redeemQueue)); + + vm.mockCall(gateway, abi.encodeWithSignature("asset()"), abi.encode(asset)); + vm.mockCall(gateway, abi.encodeWithSignature("vaultToken()"), abi.encode(vaultToken)); + + vm.mockCall(asset, abi.encodeWithSignature("name()"), abi.encode("Test Asset")); + vm.mockCall(asset, abi.encodeWithSignature("symbol()"), abi.encode("TST")); + vm.mockCall(vaultToken, abi.encodeWithSignature("name()"), abi.encode("Test Vault")); + vm.mockCall(vaultToken, abi.encodeWithSignature("symbol()"), abi.encode("vTST")); + vm.mockCall(vaultToken, abi.encodeWithSignature("decimals()"), abi.encode(uint8(18))); + + phantomToken = address(new MellowFlexibleRedeemPhantomToken(gateway, mellowRateOracle)); + + creditManager.setMask(phantomToken, 1 << 10); + + adapter = new MellowRedeemQueueAdapter(address(creditManager), gateway, phantomToken); + } + + /// @notice U:[MRQ-1]: Constructor works as expected + function test_U_MRQ_01_constructor_works_as_expected() public { + // Should revert if phantom token points to wrong gateway + address wrongGateway = makeAddr("WRONG_GATEWAY"); + vm.mockCall(wrongGateway, abi.encodeWithSignature("asset()"), abi.encode(asset)); + vm.mockCall(wrongGateway, abi.encodeWithSignature("vaultToken()"), abi.encode(vaultToken)); + address wrongPhantomToken = address(new MellowFlexibleRedeemPhantomToken(wrongGateway, mellowRateOracle)); + + // Register wrong phantom token in credit manager to avoid token not recognized error + creditManager.setMask(wrongPhantomToken, 1 << 11); + + vm.expectRevert(IMellowRedeemQueueAdapter.InvalidRedeemQueueGatewayException.selector); + new MellowRedeemQueueAdapter(address(creditManager), gateway, wrongPhantomToken); + + // Deploy new phantom token and register it + phantomToken = address(new MellowFlexibleRedeemPhantomToken(gateway, mellowRateOracle)); + creditManager.setMask(phantomToken, 1 << 12); + + _readsTokenMask(vaultToken); + _readsTokenMask(phantomToken); + + adapter = new MellowRedeemQueueAdapter(address(creditManager), gateway, phantomToken); + + assertEq(adapter.creditManager(), address(creditManager), "Incorrect creditManager"); + assertEq(adapter.targetContract(), gateway, "Incorrect targetContract"); + assertEq(adapter.vaultToken(), vaultToken, "Incorrect vaultToken"); + assertEq(adapter.phantomToken(), phantomToken, "Incorrect phantomToken"); + } + + /// @notice U:[MRQ-2]: Wrapper functions revert on wrong caller + function test_U_MRQ_02_wrapper_functions_revert_on_wrong_caller() public { + _revertsOnNonFacadeCaller(); + adapter.redeem(1000); + + _revertsOnNonFacadeCaller(); + adapter.redeemDiff(1000); + + _revertsOnNonFacadeCaller(); + adapter.claim(1000); + + _revertsOnNonFacadeCaller(); + adapter.withdrawPhantomToken(address(0), 0); + + _revertsOnNonFacadeCaller(); + adapter.depositPhantomToken(address(0), 0); + } + + /// @notice U:[MRQ-3]: `redeem` works as expected + function test_U_MRQ_03_redeem_works_as_expected() public { + _executesSwap({ + tokenIn: vaultToken, + callData: abi.encodeCall(IMellowFlexibleRedeemGateway.redeem, (1000)), + requiresApproval: true + }); + + vm.prank(creditFacade); + bool useSafePrices = adapter.redeem(1000); + assertTrue(useSafePrices); + } + + /// @notice U:[MRQ-4]: `redeemDiff` works as expected + function test_U_MRQ_04_redeemDiff_works_as_expected() public diffTestCases { + deal({token: vaultToken, to: creditAccount, give: diffMintedAmount}); + + _readsActiveAccount(); + + _executesSwap({ + tokenIn: vaultToken, + callData: abi.encodeCall(IMellowFlexibleRedeemGateway.redeem, (diffInputAmount)), + requiresApproval: true + }); + + vm.prank(creditFacade); + bool useSafePrices = adapter.redeemDiff(diffLeftoverAmount); + assertTrue(useSafePrices); + } + + /// @notice U:[MRQ-5]: `redeemDiff` returns false when amount <= leftoverAmount + function test_U_MRQ_05_redeemDiff_returns_false_when_nothing_to_redeem() public { + deal({token: vaultToken, to: creditAccount, give: 100}); + + _readsActiveAccount(); + + vm.prank(creditFacade); + bool useSafePrices = adapter.redeemDiff(100); + assertFalse(useSafePrices); + + vm.prank(creditFacade); + useSafePrices = adapter.redeemDiff(101); + assertFalse(useSafePrices); + } + + /// @notice U:[MRQ-6]: `claim` works as expected + function test_U_MRQ_06_claim_works_as_expected() public { + _executesSwap({ + tokenIn: address(0), + callData: abi.encodeCall(IMellowFlexibleRedeemGateway.claim, (1000)), + requiresApproval: false + }); + + vm.prank(creditFacade); + bool useSafePrices = adapter.claim(1000); + assertFalse(useSafePrices); + } + + /// @notice U:[MRQ-7]: `withdrawPhantomToken` works as expected + function test_U_MRQ_07_withdrawPhantomToken_works_as_expected() public { + // Test with incorrect token + vm.expectRevert(IPhantomTokenAdapter.IncorrectStakedPhantomTokenException.selector); + vm.prank(creditFacade); + adapter.withdrawPhantomToken(address(0), 1000); + + // Test with correct token + _executesSwap({ + tokenIn: address(0), + callData: abi.encodeCall(IMellowFlexibleRedeemGateway.claim, (1000)), + requiresApproval: false + }); + + vm.prank(creditFacade); + bool useSafePrices = adapter.withdrawPhantomToken(phantomToken, 1000); + assertFalse(useSafePrices); + } + + /// @notice U:[MRQ-8]: `depositPhantomToken` reverts as expected + function test_U_MRQ_08_depositPhantomToken_reverts() public { + vm.prank(creditFacade); + vm.expectRevert(NotImplementedException.selector); + adapter.depositPhantomToken(phantomToken, 1000); + } + + /// @notice U:[MRQ-9]: `serialize` works as expected + function test_U_MRQ_09_serialize_works_as_expected() public view { + bytes memory serializedData = adapter.serialize(); + (address cm, address tc, address vt, address pt) = + abi.decode(serializedData, (address, address, address, address)); + + assertEq(cm, address(creditManager), "Incorrect creditManager in serialized data"); + assertEq(tc, gateway, "Incorrect targetContract in serialized data"); + assertEq(vt, vaultToken, "Incorrect vaultToken in serialized data"); + assertEq(pt, phantomToken, "Incorrect phantomToken in serialized data"); + } +} diff --git a/contracts/test/unit/helpers/mellow/MellowFlexibleDepositor.unit.t.sol b/contracts/test/unit/helpers/mellow/MellowFlexibleDepositor.unit.t.sol new file mode 100644 index 00000000..9dbee081 --- /dev/null +++ b/contracts/test/unit/helpers/mellow/MellowFlexibleDepositor.unit.t.sol @@ -0,0 +1,231 @@ +// SPDX-License-Identifier: UNLICENSED +// Gearbox Protocol. Generalized leverage for DeFi protocols +// (c) Gearbox Foundation, 2025. +pragma solidity ^0.8.23; + +import {Test} from "forge-std/Test.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {MellowFlexibleDepositor} from "../../../../helpers/mellow/MellowFlexibleDepositor.sol"; +import {IMellowDepositQueue} from "../../../../integrations/mellow/IMellowDepositQueue.sol"; +import {ERC20Mock} from "@gearbox-protocol/core-v3/contracts/test/mocks/token/ERC20Mock.sol"; + +/// @title Mellow flexible depositor unit test +/// @notice U:[MFD]: Unit tests for Mellow flexible depositor +contract MellowFlexibleDepositorUnitTest is Test { + MellowFlexibleDepositor depositor; + + address gateway; + address mellowDepositQueue; + address asset; + address vaultToken; + address account; + address referral; + + function setUp() public { + gateway = address(this); + mellowDepositQueue = makeAddr("DEPOSIT_QUEUE"); + asset = address(new ERC20Mock("USDC", "USDC", 6)); + vaultToken = address(new ERC20Mock("Mellow Vault", "mVault", 18)); + account = makeAddr("ACCOUNT"); + referral = makeAddr("REFERRAL"); + + depositor = new MellowFlexibleDepositor(mellowDepositQueue, asset, vaultToken); + depositor.setAccount(account); + } + + /// @notice U:[MFD-1]: Constructor and setAccount work as expected + function test_U_MFD_01_constructor_and_setAccount_work() public { + assertEq(depositor.gateway(), gateway); + assertEq(depositor.mellowDepositQueue(), mellowDepositQueue); + assertEq(depositor.asset(), asset); + assertEq(depositor.vaultToken(), vaultToken); + assertEq(depositor.account(), account); + + vm.expectRevert(MellowFlexibleDepositor.CallerNotGatewayException.selector); + vm.prank(makeAddr("NOT_GATEWAY")); + depositor.setAccount(makeAddr("NEW_ACCOUNT")); + } + + /// @notice U:[MFD-2]: deposit works as expected + function test_U_MFD_02_deposit_works() public { + vm.mockCall( + mellowDepositQueue, + abi.encodeCall(IMellowDepositQueue.requestOf, (address(depositor))), + abi.encode(uint256(0), uint256(0)) + ); + + deal(asset, address(depositor), 1e6); + + bytes32[] memory merkleProof = new bytes32[](0); + vm.expectCall( + mellowDepositQueue, abi.encodeCall(IMellowDepositQueue.deposit, (uint224(1e6), referral, merkleProof)) + ); + + depositor.deposit(1e6, referral); + } + + /// @notice U:[MFD-3]: deposit reverts when deposit in progress + function test_U_MFD_03_deposit_reverts_when_in_progress() public { + vm.mockCall( + mellowDepositQueue, + abi.encodeCall(IMellowDepositQueue.requestOf, (address(depositor))), + abi.encode(uint256(0), uint256(1000)) + ); + + vm.mockCall( + mellowDepositQueue, abi.encodeCall(IMellowDepositQueue.claimableOf, (account)), abi.encode(uint256(0)) + ); + + vm.expectRevert(MellowFlexibleDepositor.DepositInProgressException.selector); + depositor.deposit(1e6, referral); + } + + /// @notice U:[MFD-4]: cancelDepositRequest works as expected + function test_U_MFD_04_cancelDepositRequest_works() public { + vm.mockCall( + mellowDepositQueue, + abi.encodeCall(IMellowDepositQueue.requestOf, (address(depositor))), + abi.encode(uint256(0), uint256(1000)) + ); + + vm.mockCall( + mellowDepositQueue, + abi.encodeCall(IMellowDepositQueue.claimableOf, (address(depositor))), + abi.encode(uint256(0)) + ); + + vm.expectCall(mellowDepositQueue, abi.encodeCall(IMellowDepositQueue.cancelDepositRequest, ())); + + deal(asset, address(depositor), 1000); + + depositor.cancelDepositRequest(); + + assertEq(IERC20(asset).balanceOf(account), 1000); + assertEq(IERC20(asset).balanceOf(address(depositor)), 0); + } + + /// @notice U:[MFD-5]: cancelDepositRequest reverts when no deposit in progress + function test_U_MFD_05_cancelDepositRequest_reverts_when_not_in_progress() public { + vm.mockCall( + mellowDepositQueue, + abi.encodeCall(IMellowDepositQueue.requestOf, (address(depositor))), + abi.encode(uint256(0), uint256(0)) + ); + + vm.mockCall( + mellowDepositQueue, + abi.encodeCall(IMellowDepositQueue.claimableOf, (address(depositor))), + abi.encode(uint256(0)) + ); + + vm.expectRevert(MellowFlexibleDepositor.DepositNotInProgressException.selector); + depositor.cancelDepositRequest(); + } + + /// @notice U:[MFD-6]: claim works with claimable shares + function test_U_MFD_06_claim_works() public { + vm.mockCall( + mellowDepositQueue, + abi.encodeCall(IMellowDepositQueue.claimableOf, (address(depositor))), + abi.encode(uint256(300)) + ); + + vm.expectCall(mellowDepositQueue, abi.encodeCall(IMellowDepositQueue.claim, (address(depositor)))); + + deal(vaultToken, address(depositor), 300); + + depositor.claim(250); + + assertEq(IERC20(vaultToken).balanceOf(account), 250); + assertEq(IERC20(vaultToken).balanceOf(address(depositor)), 50); + } + + /// @notice U:[MFD-7]: claim works with existing balance + function test_U_MFD_07_claim_with_existing_balance() public { + vm.mockCall( + mellowDepositQueue, + abi.encodeCall(IMellowDepositQueue.claimableOf, (address(depositor))), + abi.encode(uint256(0)) + ); + + deal(vaultToken, address(depositor), 500); + + depositor.claim(300); + + assertEq(IERC20(vaultToken).balanceOf(account), 300); + assertEq(IERC20(vaultToken).balanceOf(address(depositor)), 200); + } + + /// @notice U:[MFD-8]: claim reverts when not enough to claim + function test_U_MFD_08_claim_reverts_not_enough() public { + vm.mockCall( + mellowDepositQueue, + abi.encodeCall(IMellowDepositQueue.claimableOf, (address(depositor))), + abi.encode(uint256(0)) + ); + + deal(vaultToken, address(depositor), 100); + + vm.expectRevert(MellowFlexibleDepositor.NotEnoughToClaimException.selector); + depositor.claim(200); + } + + /// @notice U:[MFD-9]: getPendingAssets works as expected + function test_U_MFD_09_getPendingAssets_works() public { + vm.mockCall( + mellowDepositQueue, + abi.encodeCall(IMellowDepositQueue.claimableOf, (address(depositor))), + abi.encode(uint256(0)) + ); + + vm.mockCall( + mellowDepositQueue, + abi.encodeCall(IMellowDepositQueue.requestOf, (address(depositor))), + abi.encode(uint256(0), uint256(1000)) + ); + + uint256 pending = depositor.getPendingAssets(); + assertEq(pending, 1000); + + vm.mockCall( + mellowDepositQueue, + abi.encodeCall(IMellowDepositQueue.claimableOf, (address(depositor))), + abi.encode(uint256(100)) + ); + + pending = depositor.getPendingAssets(); + assertEq(pending, 0); + } + + /// @notice U:[MFD-10]: getClaimableShares works as expected + function test_U_MFD_10_getClaimableShares_works() public { + vm.mockCall( + mellowDepositQueue, + abi.encodeCall(IMellowDepositQueue.claimableOf, (address(depositor))), + abi.encode(uint256(100)) + ); + + deal(vaultToken, address(depositor), 50); + + uint256 claimable = depositor.getClaimableShares(); + assertEq(claimable, 150); + } + + /// @notice U:[MFD-11]: Access control works as expected + function test_U_MFD_11_access_control_works() public { + address notGateway = makeAddr("NOT_GATEWAY"); + + vm.startPrank(notGateway); + + vm.expectRevert(MellowFlexibleDepositor.CallerNotGatewayException.selector); + depositor.deposit(1000, referral); + + vm.expectRevert(MellowFlexibleDepositor.CallerNotGatewayException.selector); + depositor.cancelDepositRequest(); + + vm.expectRevert(MellowFlexibleDepositor.CallerNotGatewayException.selector); + depositor.claim(100); + + vm.stopPrank(); + } +} diff --git a/contracts/test/unit/helpers/mellow/MellowFlexibleRedeemer.unit.t.sol b/contracts/test/unit/helpers/mellow/MellowFlexibleRedeemer.unit.t.sol new file mode 100644 index 00000000..8bd98e55 --- /dev/null +++ b/contracts/test/unit/helpers/mellow/MellowFlexibleRedeemer.unit.t.sol @@ -0,0 +1,250 @@ +// SPDX-License-Identifier: UNLICENSED +// Gearbox Protocol. Generalized leverage for DeFi protocols +// (c) Gearbox Foundation, 2025. +pragma solidity ^0.8.23; + +import {Test} from "forge-std/Test.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {MellowFlexibleRedeemer} from "../../../../helpers/mellow/MellowFlexibleRedeemer.sol"; +import {IMellowRedeemQueue, Request} from "../../../../integrations/mellow/IMellowRedeemQueue.sol"; + +// Removed ERC20Mock import as we're using mock addresses + +/// @title Mellow flexible redeemer unit test +/// @notice U:[MFR]: Unit tests for Mellow flexible redeemer +contract MellowFlexibleRedeemerUnitTest is Test { + MellowFlexibleRedeemer redeemer; + + address gateway; + address mellowRedeemQueue; + address asset; + address vaultToken; + address account; + + function setUp() public { + gateway = address(this); + mellowRedeemQueue = makeAddr("REDEEM_QUEUE"); + asset = makeAddr("ASSET"); + vaultToken = makeAddr("VAULT_TOKEN"); + account = makeAddr("ACCOUNT"); + + redeemer = new MellowFlexibleRedeemer(mellowRedeemQueue, asset, vaultToken); + redeemer.setAccount(account); + } + + /// @notice U:[MFR-1]: Constructor and setAccount work as expected + function test_U_MFR_01_constructor_and_setAccount_work() public { + assertEq(redeemer.gateway(), gateway); + assertEq(redeemer.mellowRedeemQueue(), mellowRedeemQueue); + assertEq(redeemer.asset(), asset); + assertEq(redeemer.vaultToken(), vaultToken); + assertEq(redeemer.account(), account); + + vm.expectRevert(MellowFlexibleRedeemer.CallerNotGatewayException.selector); + vm.prank(makeAddr("NOT_GATEWAY")); + redeemer.setAccount(makeAddr("NEW_ACCOUNT")); + } + + /// @notice U:[MFR-2]: redeem works as expected + function test_U_MFR_02_redeem_works() public { + Request[] memory requests = new Request[](1); + + vm.mockCall( + mellowRedeemQueue, + abi.encodeCall(IMellowRedeemQueue.requestsOf, (address(redeemer), 0, type(uint256).max)), + abi.encode(requests) + ); + + vm.expectCall(mellowRedeemQueue, abi.encodeCall(IMellowRedeemQueue.redeem, (1000))); + + redeemer.redeem(1000); + } + + /// @notice U:[MFR-3]: redeem reverts with too many requests + function test_U_MFR_03_redeem_reverts_too_many_requests() public { + Request[] memory requests = new Request[](5); + + vm.mockCall( + mellowRedeemQueue, + abi.encodeCall(IMellowRedeemQueue.requestsOf, (address(redeemer), 0, type(uint256).max)), + abi.encode(requests) + ); + + vm.expectRevert(MellowFlexibleRedeemer.TooManyRequestsException.selector); + redeemer.redeem(1000); + } + + /// @notice U:[MFR-4]: claim works with claimable assets + function test_U_MFR_04_claim_works() public { + Request[] memory requests = new Request[](0); + + vm.mockCall( + mellowRedeemQueue, + abi.encodeCall(IMellowRedeemQueue.requestsOf, (address(redeemer), 0, type(uint256).max)), + abi.encode(requests) + ); + + vm.mockCall(asset, abi.encodeCall(IERC20.balanceOf, (address(redeemer))), abi.encode(uint256(330))); + + vm.mockCall(asset, abi.encodeCall(IERC20.transfer, (account, 300)), abi.encode(true)); + + vm.expectCall(asset, abi.encodeCall(IERC20.transfer, (account, 300))); + + redeemer.claim(300); + } + + /// @notice U:[MFR-4A]: claim works with claimable assets using mockCalls + function test_U_MFR_04A_claim_with_queue_assets() public { + Request[] memory requests = new Request[](2); + requests[0] = Request({timestamp: 1000, shares: 100, assets: 110, isClaimable: true}); + requests[1] = Request({timestamp: 2000, shares: 200, assets: 220, isClaimable: true}); + + vm.mockCall( + mellowRedeemQueue, + abi.encodeCall(IMellowRedeemQueue.requestsOf, (address(redeemer), 0, type(uint256).max)), + abi.encode(requests) + ); + + uint32[] memory timestamps = new uint32[](2); + timestamps[0] = 1000; + timestamps[1] = 2000; + + vm.mockCall( + mellowRedeemQueue, + abi.encodeCall(IMellowRedeemQueue.claim, (address(redeemer), timestamps)), + abi.encode(uint256(330)) + ); + + bytes[] memory balanceReturns = new bytes[](2); + balanceReturns[0] = abi.encode(uint256(0)); + balanceReturns[1] = abi.encode(uint256(330)); + + vm.mockCalls(asset, abi.encodeCall(IERC20.balanceOf, (address(redeemer))), balanceReturns); + + vm.mockCall(asset, abi.encodeCall(IERC20.transfer, (account, 300)), abi.encode(true)); + + vm.expectCall(mellowRedeemQueue, abi.encodeCall(IMellowRedeemQueue.claim, (address(redeemer), timestamps))); + vm.expectCall(asset, abi.encodeCall(IERC20.transfer, (account, 300))); + + redeemer.claim(300); + } + + /// @notice U:[MFR-5]: claim works with existing balance + function test_U_MFR_05_claim_with_existing_balance() public { + Request[] memory requests = new Request[](0); + + vm.mockCall( + mellowRedeemQueue, + abi.encodeCall(IMellowRedeemQueue.requestsOf, (address(redeemer), 0, type(uint256).max)), + abi.encode(requests) + ); + + vm.mockCall(asset, abi.encodeCall(IERC20.balanceOf, (address(redeemer))), abi.encode(500)); + + redeemer.claim(300); + } + + /// @notice U:[MFR-6]: claim reverts when not enough to claim + function test_U_MFR_06_claim_reverts_not_enough() public { + Request[] memory requests = new Request[](0); + + vm.mockCall( + mellowRedeemQueue, + abi.encodeCall(IMellowRedeemQueue.requestsOf, (address(redeemer), 0, type(uint256).max)), + abi.encode(requests) + ); + + vm.mockCall(asset, abi.encodeCall(IERC20.balanceOf, (address(redeemer))), abi.encode(100)); + + vm.expectRevert(MellowFlexibleRedeemer.NotEnoughToClaimException.selector); + redeemer.claim(200); + } + + /// @notice U:[MFR-7]: getPendingShares works as expected + function test_U_MFR_07_getPendingShares_works() public { + Request[] memory requests = new Request[](3); + requests[0] = Request({timestamp: 1000, shares: 100, assets: 110, isClaimable: true}); + requests[1] = Request({timestamp: 2000, shares: 200, assets: 220, isClaimable: false}); + requests[2] = Request({timestamp: 3000, shares: 300, assets: 330, isClaimable: false}); + + vm.mockCall( + mellowRedeemQueue, + abi.encodeCall(IMellowRedeemQueue.requestsOf, (address(redeemer), 0, type(uint256).max)), + abi.encode(requests) + ); + + uint256 pending = redeemer.getPendingShares(); + assertEq(pending, 500); + } + + /// @notice U:[MFR-8]: getClaimableAssets works as expected + function test_U_MFR_08_getClaimableAssets_works() public { + Request[] memory requests = new Request[](2); + requests[0] = Request({timestamp: 1000, shares: 100, assets: 110, isClaimable: true}); + requests[1] = Request({timestamp: 2000, shares: 200, assets: 220, isClaimable: false}); + + vm.mockCall( + mellowRedeemQueue, + abi.encodeCall(IMellowRedeemQueue.requestsOf, (address(redeemer), 0, type(uint256).max)), + abi.encode(requests) + ); + + vm.mockCall(asset, abi.encodeCall(IERC20.balanceOf, (address(redeemer))), abi.encode(50)); + + uint256 claimable = redeemer.getClaimableAssets(); + assertEq(claimable, 160); + } + + /// @notice U:[MFR-9]: claim correctly filters claimable requests + function test_U_MFR_09_claim_filters_claimable_requests() public { + Request[] memory requests = new Request[](3); + requests[0] = Request({timestamp: 1000, shares: 100, assets: 110, isClaimable: true}); + requests[1] = Request({timestamp: 2000, shares: 200, assets: 220, isClaimable: false}); + requests[2] = Request({timestamp: 3000, shares: 300, assets: 330, isClaimable: true}); + + vm.mockCall( + mellowRedeemQueue, + abi.encodeCall(IMellowRedeemQueue.requestsOf, (address(redeemer), 0, type(uint256).max)), + abi.encode(requests) + ); + + uint32[] memory timestamps = new uint32[](3); + timestamps[0] = 1000; + timestamps[1] = 2000; + timestamps[2] = 3000; + + vm.mockCall( + mellowRedeemQueue, + abi.encodeCall(IMellowRedeemQueue.claim, (address(redeemer), timestamps)), + abi.encode(uint256(440)) + ); + + bytes[] memory balanceReturns = new bytes[](2); + balanceReturns[0] = abi.encode(uint256(0)); + balanceReturns[1] = abi.encode(uint256(440)); + + vm.mockCalls(asset, abi.encodeCall(IERC20.balanceOf, (address(redeemer))), balanceReturns); + + vm.mockCall(asset, abi.encodeCall(IERC20.transfer, (account, 440)), abi.encode(true)); + + vm.expectCall(mellowRedeemQueue, abi.encodeCall(IMellowRedeemQueue.claim, (address(redeemer), timestamps))); + vm.expectCall(asset, abi.encodeCall(IERC20.transfer, (account, 440))); + + redeemer.claim(440); + } + + /// @notice U:[MFR-10]: Access control works as expected + function test_U_MFR_10_access_control_works() public { + address notGateway = makeAddr("NOT_GATEWAY"); + + vm.startPrank(notGateway); + + vm.expectRevert(MellowFlexibleRedeemer.CallerNotGatewayException.selector); + redeemer.redeem(1000); + + vm.expectRevert(MellowFlexibleRedeemer.CallerNotGatewayException.selector); + redeemer.claim(100); + + vm.stopPrank(); + } +} diff --git a/specs/adapters/mellow/MellowDepositQueueAdapter.spec.md b/specs/adapters/mellow/MellowDepositQueueAdapter.spec.md new file mode 100644 index 00000000..4549c323 --- /dev/null +++ b/specs/adapters/mellow/MellowDepositQueueAdapter.spec.md @@ -0,0 +1,21 @@ +# Mellow Deposit Queue Adapter + +## Contract overview + +This contract is an adapter that allows Gearbox CAs to interact with the Mellow Flexible Vault deposit queue, in order to deposit a specific asset into a Mellow flexible vault. + +The adapter has a gateway as its target, which enables safely collateralizing pending deposits in order to enter Mellow vaults with leverage. The gateway also enables some additional functions, such as partial deposit claiming. + +## Protocol overivew + +Mellow is an LRT protocol that allows curators to create vaults for ETH or ETH-based derivatives, where users can deposit their ETH for additional yield. + +Mellow's deposit queue allows users to deposit into a vault. The deposit is delayed due to Mellow's internal mechanics. The user first needs to initiate a deposit with `deposit`, which will transfer the deposited asset from the user to the vault. Then the deposit matures based on the vault's deposit interval + 1 day. It is then claimable with `claim`, which will mint vault shares to the user. Deposits can also be cancelled until they mature with `cancelDepositRequest`, which will transfer the deposited assets back. + +## Contract features + +MellowDepositQueueAdapter allows user to request deposits via the gateway, and then claim them once they mature. + +In order to request withdrawals, users can call `deposit` or `depositDiff`, in order to claim them, users call `claim` with the required amount. + +As withdrawals in the process of maturation are tracked via a phantom token, the adapter also has a `withdrawPhantomToken` as a standard phantom token unwrapping function - this function calls `claim` internally. diff --git a/specs/adapters/mellow/MellowFlexibleDepositGateway.spec.md b/specs/adapters/mellow/MellowFlexibleDepositGateway.spec.md new file mode 100644 index 00000000..cc3bf427 --- /dev/null +++ b/specs/adapters/mellow/MellowFlexibleDepositGateway.spec.md @@ -0,0 +1,35 @@ +# Mellow Flexible Deposit Gateway + +## Contract overview + +This contract acts as an intermediary between the adapter and the Mellow Deposit Queue, in order to facilitate delayed deposits for Gearbox CAs. There are several elements that enable collateralizing deposits: + +- The gateway itself, which acts as an entry point for CAs +- `MellowFlexibleDepositor`, which is a disposable account created for each CA and actually requests / claims deposits. Since most protocols with delayed deposits / withdrawals track relevant data by address, having a separate account for each CA greatly simplifies deposit tracking logic, while still leaving space for adding more functionality to the process. +- `MellowFlexibleDepositPhantomToken`, which is a phantom token that tracks deposits in progress and allows using them as collateral. The balance of the phantom token is the sum of pending and claimable deposits, which are computed in `MellowFlexibleDepositor`. + +## Protocol overview + +Mellow is an LRT protocol that allows curators to create vaults for ETH or ETH-based derivatives, where users can deposit their ETH for additional yield. + +Mellow's deposit queue allows users to deposit into a vault. The deposit is delayed due to Mellow's internal mechanics. The user first needs to initiate a deposit with `deposit`, which will transfer the deposited asset from the user to the vault. Then the deposit matures based on the vault's deposit interval + 1 day. It is then claimable with `claim`, which will mint vault shares to the user. Deposits can also be cancelled until they mature with `cancelDepositRequest`, which will transfer the deposited assets back. The deposit queue only allows 1 pending deposit to be active at given time. + +## Contract features + +### MellowFlexibleDepositGateway + +MellowFlexibleDepositGateway allows users to initiate and claim deposits via `deposit` and `claim`. These functions fetch the `MellowFlexibleDepositor` address associated with the calling CA (or create a new one), and call the respective functions in them (with the actual business logic mostly contained in `MellowFlexibleDepositor` itself). The gateway also exposes `getPendingAssets` and `getClaimableShares`, which are used by the phantom token to compute total outstanding deposit value in vault share terms. + +### MellowFlexibleDepositor + +MellowFlexibleDepositor is created for each CA and initiates / completes deposits on behalf of this CA. Respective state-changing functions (such as `deposit` / `claim`) are gateway-only, so that the gateway is the sole entry-point for CAs. + +For `deposit`, the contract assumes that the deposited asset is already transferred to it, so it only approves funds to the deposit queue and requests a deposit. To avoid any unexpected side effects, the depositor does not allow creating a new deposit if there is one pending. + +For `claim`, the contract computes the amount of matured deposits already on the contract (if they were previously partially claimed), and the amount of the matured deposit that is not yet claimed, if present. The contract then claims the deposit still in the deposit queue. Then, the depositor sends the required amount to the account. + +The depositor also provides `getPendingAssets` and `getClaimableShares`, to retrieve the amount of pending assets in the deposit, and the amount of claimable (but not pending) shares. + +### MellowFlexibleDepositPhantomToken + +The phantom token tracks deposits-in-progress in its balance in order for them to be usable as collateral. The balance represents the expected amount of shares and is computed as the sum of pending assets multiplied by the asset-to-share rate, and the claimable shares. diff --git a/specs/adapters/mellow/MellowFlexibleRedeemGateway.spec.md b/specs/adapters/mellow/MellowFlexibleRedeemGateway.spec.md new file mode 100644 index 00000000..d2052b9c --- /dev/null +++ b/specs/adapters/mellow/MellowFlexibleRedeemGateway.spec.md @@ -0,0 +1,35 @@ +# Mellow Flexible Redeem Gateway + +## Contract overview + +This contract acts as an intermediary between the adapter and the Mellow Redeem Queue, in order to facilitate delayed redemptions for Gearbox CAs. There are several elements that enable collateralizing redemptions: + +- The gateway itself, which acts as an entry point for CAs +- `MellowFlexibleRedeemer`, which is a disposable account created for each CA and actually requests / claims redemptions. Since most protocols with delayed redemptions track relevant data by address, having a separate account for each CA greatly simplifies redemption tracking logic, while still leaving space for adding more functionality to the process. +- `MellowFlexibleRedeemPhantomToken`, which is a phantom token that tracks redemptions in progress and allows using them as collateral. The balance of the phantom token is the sum of pending and claimable redemptions, which are computed in `MellowFlexibleRedeemer`. + +## Protocol overview + +Mellow is an LRT protocol that allows curators to create vaults for ETH or ETH-based derivatives, where users can deposit their ETH for additional yield. + +Mellow's redemption queue allows users to withdraw from a vault. The redemption is delayed due to Mellow's internal mechanics. The user first needs to initiate a withdrawal with `redeem`, which will burn vault shares from the CA. Then the redemption matures based on the vault's redemption interval + 1 day. It is then claimable with `claim`, which will transfer the asset to the user. + +## Contract features + +### MellowFlexibleRedeemGateway + +MellowFlexibleRedeemGateway allows users to initiate and claim redemptions via `redeem` and `claim`. These functions fetch the `MellowFlexibleRedeemer` address associated with the calling CA (or create a new one), and call the respective functions in them (with the actual business logic mostly contained in `MellowFlexibleRedeemer` itself). The gateway also exposes `getPendingShares` and `getClaimableAssets`, which are used by the phantom token to compute total outstanding redemption value in withdrawn asset terms. + +### MellowFlexibleRedeemer + +MellowFlexibleDepositor is created for each CA and initiates / completes redemptions on behalf of this CA. Respective state-changing functions (such as `redeem` / `claim`) are gateway-only, so that the gateway is the sole entry-point for CAs. + +For `redeem`, the contract approves funds to the deposit queue and requests a deposit. To avoid any unexpected side effects, the depositor does not allow creating a new deposit if there is one pending. In order to limit the gas expense for `getPendingShares` / `getClaimableAssets`, the number of active redemptions is limited to 5. + +For `claim`, the contract computes the amount of matured redemptions already on the contract (if they were previously partially claimed), and the amount of the matured redemptions that are not yet claimed, if present. The contract then claims the redemptions still in the queue. Then, the redeemer sends the required amount to the account. + +The redeemer also provides `getPendingShares` and `getClaimableAssets`, to retrieve the amount of pending shares in all redemptions, and the amount of claimable (but not pending) assets. + +### MellowFlexibleDepositPhantomToken + +The phantom token tracks redemptions-in-progress in its balance in order for them to be usable as collateral. The balance represents the expected amount of assets from all redemptions and is computed as the sum of pending shares multiplied by the share-to-asset rate, and the claimable assets. diff --git a/specs/adapters/mellow/MellowRedeemQueueAdapter.spec.md b/specs/adapters/mellow/MellowRedeemQueueAdapter.spec.md new file mode 100644 index 00000000..6eb0fcda --- /dev/null +++ b/specs/adapters/mellow/MellowRedeemQueueAdapter.spec.md @@ -0,0 +1,21 @@ +# Mellow Redeem Queue Adapter + +## Contract overview + +This contract is an adapter that allows Gearbox CAs to interact with the Mellow Flexible Vault redemption queue, in order to withdraw a specific asset from a Mellow flexible vault. + +The adapter has a gateway as its target, which enables safely collateralizing pending redemptions in order to enter Mellow vaults with leverage. The gateway also enables some additional functions, such as partial redemption claims. + +## Protocol overivew + +Mellow is an LRT protocol that allows curators to create vaults for ETH or ETH-based derivatives, where users can deposit their ETH for additional yield. + +Mellow's redemption queue allows users to withdraw from a vault. The redemption is delayed due to Mellow's internal mechanics. The user first needs to initiate a withdrawal with `redeem`, which will burn vault shares from the CA. Then the redemption matures based on the vault's redemption interval + 1 day. It is then claimable with `claim`, which will transfer the asset to the user. + +## Contract features + +MellowRedeemQueueAdapter allows a user to request redemptions via the gateway, and then claim them once they mature. + +In order to request withdrawals, users can call `redeem` or `redeemDiff`, in order to claim them, users call `claim` with the required amount. + +As redemptions in the process of maturation are tracked via a phantom token, the adapter also has a `withdrawPhantomToken` as a standard phantom token unwrapping function - this function calls `claim` internally.