diff --git a/config/constants/addresses.ts b/config/constants/addresses.ts index eadb6bee..567dcbf2 100644 --- a/config/constants/addresses.ts +++ b/config/constants/addresses.ts @@ -7,9 +7,17 @@ export type RedemptionVaultType = | 'redemptionVault' | 'redemptionVaultBuidl' | 'redemptionVaultSwapper' - | 'redemptionVaultUstb'; + | 'redemptionVaultMToken' + | 'redemptionVaultUstb' + | 'redemptionVaultAave' + | 'redemptionVaultMorpho'; -export type DepositVaultType = 'depositVault' | 'depositVaultUstb'; +export type DepositVaultType = + | 'depositVault' + | 'depositVaultUstb' + | 'depositVaultAave' + | 'depositVaultMorpho' + | 'depositVaultMToken'; export type LayerZeroTokenAddresses = { oft?: string; @@ -26,11 +34,9 @@ export type TokenAddresses = { customFeed?: string; dataFeed?: string; token?: string; - depositVault?: string; - depositVaultUstb?: string; layerZero?: LayerZeroTokenAddresses; axelar?: AxelarTokenAddresses; -} & Partial>; +} & Partial>; export type VaultType = RedemptionVaultType | DepositVaultType; diff --git a/contracts/DepositVault.sol b/contracts/DepositVault.sol index 7a4860f4..9103a391 100644 --- a/contracts/DepositVault.sol +++ b/contracts/DepositVault.sol @@ -535,9 +535,8 @@ contract DepositVault is ManageableVault, IDepositVault { calcResult = _calcAndValidateDeposit(user, tokenIn, amountToken, false); - _tokenTransferFromUser( + _requestTransferTokensToTokensReceiver( tokenIn, - tokensReceiver, calcResult.amountTokenWithoutFee, calcResult.tokenDecimals ); @@ -609,7 +608,7 @@ contract DepositVault is ManageableVault, IDepositVault { } /** - * @dev internal transfer tokens to tokens receiver + * @dev internal transfer tokens to tokens receiver (instant deposits) * @param tokenIn tokenIn address * @param amountToken amount of tokenIn (decimals 18) * @param tokensDecimals tokens decimals @@ -627,6 +626,25 @@ contract DepositVault is ManageableVault, IDepositVault { ); } + /** + * @dev internal transfer tokens to tokens receiver (deposit requests) + * @param tokenIn tokenIn address + * @param amountToken amount of tokenIn (decimals 18) + * @param tokensDecimals tokens decimals + */ + function _requestTransferTokensToTokensReceiver( + address tokenIn, + uint256 amountToken, + uint256 tokensDecimals + ) internal virtual { + _tokenTransferFromUser( + tokenIn, + tokensReceiver, + amountToken, + tokensDecimals + ); + } + /** * @dev validate deposit and calculate mint amount * @param user user address diff --git a/contracts/DepositVaultWithAave.sol b/contracts/DepositVaultWithAave.sol new file mode 100644 index 00000000..834f59d4 --- /dev/null +++ b/contracts/DepositVaultWithAave.sol @@ -0,0 +1,201 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.9; + +import {IERC20Upgradeable as IERC20} from "@openzeppelin/contracts-upgradeable/token/ERC20/IERC20Upgradeable.sol"; +import {SafeERC20Upgradeable as SafeERC20} from "@openzeppelin/contracts-upgradeable/token/ERC20/utils/SafeERC20Upgradeable.sol"; + +import "./DepositVault.sol"; +import "./interfaces/aave/IAaveV3Pool.sol"; + +/** + * @title DepositVaultWithAave + * @notice Smart contract that handles mToken minting and invests + * proceeds into Aave V3 Pool + * @dev If `aaveDepositsEnabled` is false, regular deposit flow is used + * @author RedDuck Software + */ +contract DepositVaultWithAave is DepositVault { + using DecimalsCorrectionLibrary for uint256; + using SafeERC20 for IERC20; + + /** + * @notice mapping payment token to Aave V3 Pool + */ + mapping(address => IAaveV3Pool) public aavePools; + + /** + * @notice Whether Aave auto-invest deposits are enabled + * @dev if false, regular deposit flow will be used + */ + bool public aaveDepositsEnabled; + + /** + * @notice Whether to fall back to raw token transfer on auto-invest failure + * @dev if false, the transaction will revert when auto-invest fails + */ + bool public autoInvestFallbackEnabled; + + /** + * @dev leaving a storage gap for futures updates + */ + uint256[50] private __gap; + + /** + * @notice Emitted when an Aave V3 Pool is configured for a payment token + * @param caller address of the caller + * @param token payment token address + * @param pool Aave V3 Pool address + */ + event SetAavePool( + address indexed caller, + address indexed token, + address indexed pool + ); + + /** + * @notice Emitted when an Aave V3 Pool is removed for a payment token + * @param caller address of the caller + * @param token payment token address + */ + event RemoveAavePool(address indexed caller, address indexed token); + + /** + * @notice Emitted when `aaveDepositsEnabled` flag is updated + * @param enabled Whether Aave deposits are enabled + */ + event SetAaveDepositsEnabled(bool indexed enabled); + + /** + * @notice Emitted when `autoInvestFallbackEnabled` flag is updated + * @param enabled Whether fallback to raw transfer is enabled + */ + event SetAutoInvestFallbackEnabled(bool indexed enabled); + + /** + * @notice Sets the Aave V3 Pool for a specific payment token + * @param _token payment token address + * @param _aavePool Aave V3 Pool address for this token + */ + function setAavePool(address _token, address _aavePool) + external + onlyVaultAdmin + { + _validateAddress(_token, true); + _validateAddress(_aavePool, true); + require( + IAaveV3Pool(_aavePool).getReserveAToken(_token) != address(0), + "DVA: token not in pool" + ); + aavePools[_token] = IAaveV3Pool(_aavePool); + emit SetAavePool(msg.sender, _token, _aavePool); + } + + /** + * @notice Removes the Aave V3 Pool for a specific payment token + * @param _token payment token address + */ + function removeAavePool(address _token) external onlyVaultAdmin { + require(address(aavePools[_token]) != address(0), "DVA: pool not set"); + delete aavePools[_token]; + emit RemoveAavePool(msg.sender, _token); + } + + /** + * @notice Updates `aaveDepositsEnabled` value + * @param enabled whether Aave auto-invest deposits are enabled + */ + function setAaveDepositsEnabled(bool enabled) external onlyVaultAdmin { + aaveDepositsEnabled = enabled; + emit SetAaveDepositsEnabled(enabled); + } + + /** + * @notice Updates `autoInvestFallbackEnabled` value + * @param enabled whether fallback to raw transfer is enabled on auto-invest failure + */ + function setAutoInvestFallbackEnabled(bool enabled) + external + onlyVaultAdmin + { + autoInvestFallbackEnabled = enabled; + emit SetAutoInvestFallbackEnabled(enabled); + } + + /** + * @dev overrides instant deposit transfer hook to auto-invest into Aave + */ + function _instantTransferTokensToTokensReceiver( + address tokenIn, + uint256 amountToken, + uint256 tokensDecimals + ) internal override { + IAaveV3Pool pool = aavePools[tokenIn]; + if (!aaveDepositsEnabled || address(pool) == address(0)) { + return + super._instantTransferTokensToTokensReceiver( + tokenIn, + amountToken, + tokensDecimals + ); + } + + _autoInvest(tokenIn, amountToken, tokensDecimals, pool); + } + + /** + * @dev overrides request deposit transfer hook to auto-invest into Aave + */ + function _requestTransferTokensToTokensReceiver( + address tokenIn, + uint256 amountToken, + uint256 tokensDecimals + ) internal override { + IAaveV3Pool pool = aavePools[tokenIn]; + if (!aaveDepositsEnabled || address(pool) == address(0)) { + return + super._requestTransferTokensToTokensReceiver( + tokenIn, + amountToken, + tokensDecimals + ); + } + + _autoInvest(tokenIn, amountToken, tokensDecimals, pool); + } + + /** + * @dev Transfers tokens from user to this contract and supplies them + * to the Aave V3 Pool. On failure, either falls back to raw transfer + * or reverts based on `autoInvestFallbackEnabled`. + * @param tokenIn token address + * @param amountToken amount of tokens to transfer in base18 + * @param tokensDecimals decimals of tokens + * @param pool Aave V3 Pool + */ + function _autoInvest( + address tokenIn, + uint256 amountToken, + uint256 tokensDecimals, + IAaveV3Pool pool + ) private { + uint256 transferredAmount = _tokenTransferFromUser( + tokenIn, + address(this), + amountToken, + tokensDecimals + ); + + IERC20(tokenIn).safeIncreaseAllowance(address(pool), transferredAmount); + + try + pool.supply(tokenIn, transferredAmount, tokensReceiver, 0) + {} catch { + if (autoInvestFallbackEnabled) { + IERC20(tokenIn).safeApprove(address(pool), 0); + IERC20(tokenIn).safeTransfer(tokensReceiver, transferredAmount); + } else { + revert("DVA: auto-invest failed"); + } + } + } +} diff --git a/contracts/DepositVaultWithMToken.sol b/contracts/DepositVaultWithMToken.sol new file mode 100644 index 00000000..fb60093e --- /dev/null +++ b/contracts/DepositVaultWithMToken.sol @@ -0,0 +1,240 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.9; + +import {IERC20Upgradeable as IERC20} from "@openzeppelin/contracts-upgradeable/token/ERC20/IERC20Upgradeable.sol"; +import {SafeERC20Upgradeable as SafeERC20} from "@openzeppelin/contracts-upgradeable/token/ERC20/utils/SafeERC20Upgradeable.sol"; + +import "./DepositVault.sol"; +import "./interfaces/IDepositVault.sol"; + +/** + * @title DepositVaultWithMToken + * @notice Smart contract that handles mToken minting and invests + * proceeds into another mToken's DepositVault + * @dev If `mTokenDepositsEnabled` is false, regular deposit flow is used + * @author RedDuck Software + */ +contract DepositVaultWithMToken is DepositVault { + using DecimalsCorrectionLibrary for uint256; + using SafeERC20 for IERC20; + + /** + * @notice Target mToken DepositVault for auto-invest + */ + IDepositVault public mTokenDepositVault; + + /** + * @notice Whether mToken auto-invest deposits are enabled + * @dev if false, regular deposit flow will be used + */ + bool public mTokenDepositsEnabled; + + /** + * @notice Whether to fall back to raw token transfer on auto-invest failure + * @dev if false, the transaction will revert when auto-invest fails + */ + bool public autoInvestFallbackEnabled; + + /** + * @dev leaving a storage gap for futures updates + */ + uint256[50] private __gap; + + /** + * @notice Emitted when the mToken DepositVault address is updated + * @param caller address of the caller + * @param newVault new mToken DepositVault address + */ + event SetMTokenDepositVault( + address indexed caller, + address indexed newVault + ); + + /** + * @notice Emitted when `mTokenDepositsEnabled` flag is updated + * @param enabled Whether mToken deposits are enabled + */ + event SetMTokenDepositsEnabled(bool indexed enabled); + + /** + * @notice Emitted when `autoInvestFallbackEnabled` flag is updated + * @param enabled Whether fallback to raw transfer is enabled + */ + event SetAutoInvestFallbackEnabled(bool indexed enabled); + + /** + * @notice upgradeable pattern contract`s initializer + * @param _ac address of MidasAccessControll contract + * @param _mTokenInitParams init params for mToken + * @param _receiversInitParams init params for receivers + * @param _instantInitParams init params for instant operations + * @param _sanctionsList address of sanctionsList contract + * @param _variationTolerance percent of prices diviation 1% = 100 + * @param _minAmount basic min amount for operations in mToken + * @param _minMTokenAmountForFirstDeposit min amount for first deposit in mToken + * @param _maxSupplyCap max supply cap for mToken + * @param _mTokenDepositVault target mToken DepositVault address + */ + function initialize( + address _ac, + MTokenInitParams calldata _mTokenInitParams, + ReceiversInitParams calldata _receiversInitParams, + InstantInitParams calldata _instantInitParams, + address _sanctionsList, + uint256 _variationTolerance, + uint256 _minAmount, + uint256 _minMTokenAmountForFirstDeposit, + uint256 _maxSupplyCap, + address _mTokenDepositVault + ) external { + initialize( + _ac, + _mTokenInitParams, + _receiversInitParams, + _instantInitParams, + _sanctionsList, + _variationTolerance, + _minAmount, + _minMTokenAmountForFirstDeposit, + _maxSupplyCap + ); + + _validateAddress(_mTokenDepositVault, true); + mTokenDepositVault = IDepositVault(_mTokenDepositVault); + } + + /** + * @notice Sets the target mToken DepositVault address + * @param _mTokenDepositVault new mToken DepositVault address + */ + function setMTokenDepositVault(address _mTokenDepositVault) + external + onlyVaultAdmin + { + require( + _mTokenDepositVault != address(mTokenDepositVault), + "DVMT: already set" + ); + _validateAddress(_mTokenDepositVault, true); + mTokenDepositVault = IDepositVault(_mTokenDepositVault); + emit SetMTokenDepositVault(msg.sender, _mTokenDepositVault); + } + + /** + * @notice Updates `mTokenDepositsEnabled` value + * @param enabled whether mToken auto-invest deposits are enabled + */ + function setMTokenDepositsEnabled(bool enabled) external onlyVaultAdmin { + mTokenDepositsEnabled = enabled; + emit SetMTokenDepositsEnabled(enabled); + } + + /** + * @notice Updates `autoInvestFallbackEnabled` value + * @param enabled whether fallback to raw transfer is enabled on auto-invest failure + */ + function setAutoInvestFallbackEnabled(bool enabled) + external + onlyVaultAdmin + { + autoInvestFallbackEnabled = enabled; + emit SetAutoInvestFallbackEnabled(enabled); + } + + /** + * @dev overrides instant deposit transfer hook to auto-invest into target mToken DV + */ + function _instantTransferTokensToTokensReceiver( + address tokenIn, + uint256 amountToken, + uint256 tokensDecimals + ) internal override { + if (!mTokenDepositsEnabled) { + return + super._instantTransferTokensToTokensReceiver( + tokenIn, + amountToken, + tokensDecimals + ); + } + + _autoInvest(tokenIn, amountToken, tokensDecimals); + } + + /** + * @dev overrides request deposit transfer hook to auto-invest into target mToken DV + */ + function _requestTransferTokensToTokensReceiver( + address tokenIn, + uint256 amountToken, + uint256 tokensDecimals + ) internal override { + if (!mTokenDepositsEnabled) { + return + super._requestTransferTokensToTokensReceiver( + tokenIn, + amountToken, + tokensDecimals + ); + } + + _autoInvest(tokenIn, amountToken, tokensDecimals); + } + + /** + * @dev Transfers tokens from user to this contract and deposits them + * into the target mToken DepositVault. On failure, either falls back + * to raw transfer or reverts based on `autoInvestFallbackEnabled`. + * @param tokenIn token address + * @param amountToken amount of tokens to transfer in base18 + * @param tokensDecimals decimals of tokens + */ + function _autoInvest( + address tokenIn, + uint256 amountToken, + uint256 tokensDecimals + ) private { + require( + ManageableVault(address(mTokenDepositVault)).waivedFeeRestriction( + address(this) + ), + "DVMT: fees not waived on target" + ); + + uint256 transferredAmount = _tokenTransferFromUser( + tokenIn, + address(this), + amountToken, + tokensDecimals + ); + + IERC20(tokenIn).safeIncreaseAllowance( + address(mTokenDepositVault), + transferredAmount + ); + + IERC20 targetMToken = IERC20(address(mTokenDepositVault.mToken())); + uint256 balanceBefore = targetMToken.balanceOf(address(this)); + + try + mTokenDepositVault.depositInstant( + tokenIn, + amountToken, + 0, + bytes32(0) + ) + { + uint256 mTokenReceived = targetMToken.balanceOf(address(this)) - + balanceBefore; + require(mTokenReceived > 0, "DVMT: zero mToken received"); + targetMToken.safeTransfer(tokensReceiver, mTokenReceived); + } catch { + if (autoInvestFallbackEnabled) { + IERC20(tokenIn).safeApprove(address(mTokenDepositVault), 0); + IERC20(tokenIn).safeTransfer(tokensReceiver, transferredAmount); + } else { + revert("DVMT: auto-invest failed"); + } + } + } +} diff --git a/contracts/DepositVaultWithMorpho.sol b/contracts/DepositVaultWithMorpho.sol new file mode 100644 index 00000000..d515da38 --- /dev/null +++ b/contracts/DepositVaultWithMorpho.sol @@ -0,0 +1,209 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.9; + +import {IERC20Upgradeable as IERC20} from "@openzeppelin/contracts-upgradeable/token/ERC20/IERC20Upgradeable.sol"; +import {SafeERC20Upgradeable as SafeERC20} from "@openzeppelin/contracts-upgradeable/token/ERC20/utils/SafeERC20Upgradeable.sol"; + +import "./DepositVault.sol"; +import {IMorphoVault} from "./interfaces/morpho/IMorphoVault.sol"; + +/** + * @title DepositVaultWithMorpho + * @notice Smart contract that handles mToken minting and invests + * proceeds into Morpho Vaults + * @dev If `morphoDepositsEnabled` is false, regular deposit flow is used + * @author RedDuck Software + */ +contract DepositVaultWithMorpho is DepositVault { + using DecimalsCorrectionLibrary for uint256; + using SafeERC20 for IERC20; + + /** + * @notice mapping payment token to Morpho Vault + */ + mapping(address => IMorphoVault) public morphoVaults; + + /** + * @notice Whether Morpho auto-invest deposits are enabled + * @dev if false, regular deposit flow will be used + */ + bool public morphoDepositsEnabled; + + /** + * @notice Whether to fall back to raw token transfer on auto-invest failure + * @dev if false, the transaction will revert when auto-invest fails + */ + bool public autoInvestFallbackEnabled; + + /** + * @dev leaving a storage gap for futures updates + */ + uint256[50] private __gap; + + /** + * @notice Emitted when a Morpho Vault is configured for a payment token + * @param caller address of the caller + * @param token payment token address + * @param vault Morpho Vault address + */ + event SetMorphoVault( + address indexed caller, + address indexed token, + address indexed vault + ); + + /** + * @notice Emitted when a Morpho Vault is removed for a payment token + * @param caller address of the caller + * @param token payment token address + */ + event RemoveMorphoVault(address indexed caller, address indexed token); + + /** + * @notice Emitted when `morphoDepositsEnabled` flag is updated + * @param enabled Whether Morpho deposits are enabled + */ + event SetMorphoDepositsEnabled(bool indexed enabled); + + /** + * @notice Emitted when `autoInvestFallbackEnabled` flag is updated + * @param enabled Whether fallback to raw transfer is enabled + */ + event SetAutoInvestFallbackEnabled(bool indexed enabled); + + /** + * @notice Sets the Morpho Vault for a specific payment token + * @param _token payment token address + * @param _morphoVault Morpho Vault (ERC-4626) address for this token + */ + function setMorphoVault(address _token, address _morphoVault) + external + onlyVaultAdmin + { + _validateAddress(_token, true); + _validateAddress(_morphoVault, true); + require( + IMorphoVault(_morphoVault).asset() == _token, + "DVM: asset mismatch" + ); + morphoVaults[_token] = IMorphoVault(_morphoVault); + emit SetMorphoVault(msg.sender, _token, _morphoVault); + } + + /** + * @notice Removes the Morpho Vault for a specific payment token + * @param _token payment token address + */ + function removeMorphoVault(address _token) external onlyVaultAdmin { + require( + address(morphoVaults[_token]) != address(0), + "DVM: vault not set" + ); + delete morphoVaults[_token]; + emit RemoveMorphoVault(msg.sender, _token); + } + + /** + * @notice Updates `morphoDepositsEnabled` value + * @param enabled whether Morpho auto-invest deposits are enabled + */ + function setMorphoDepositsEnabled(bool enabled) external onlyVaultAdmin { + morphoDepositsEnabled = enabled; + emit SetMorphoDepositsEnabled(enabled); + } + + /** + * @notice Updates `autoInvestFallbackEnabled` value + * @param enabled whether fallback to raw transfer is enabled on auto-invest failure + */ + function setAutoInvestFallbackEnabled(bool enabled) + external + onlyVaultAdmin + { + autoInvestFallbackEnabled = enabled; + emit SetAutoInvestFallbackEnabled(enabled); + } + + /** + * @dev overrides instant deposit transfer hook to auto-invest into Morpho + */ + function _instantTransferTokensToTokensReceiver( + address tokenIn, + uint256 amountToken, + uint256 tokensDecimals + ) internal override { + IMorphoVault vault = morphoVaults[tokenIn]; + if (!morphoDepositsEnabled || address(vault) == address(0)) { + return + super._instantTransferTokensToTokensReceiver( + tokenIn, + amountToken, + tokensDecimals + ); + } + + _autoInvest(tokenIn, amountToken, tokensDecimals, vault); + } + + /** + * @dev overrides request deposit transfer hook to auto-invest into Morpho + */ + function _requestTransferTokensToTokensReceiver( + address tokenIn, + uint256 amountToken, + uint256 tokensDecimals + ) internal override { + IMorphoVault vault = morphoVaults[tokenIn]; + if (!morphoDepositsEnabled || address(vault) == address(0)) { + return + super._requestTransferTokensToTokensReceiver( + tokenIn, + amountToken, + tokensDecimals + ); + } + + _autoInvest(tokenIn, amountToken, tokensDecimals, vault); + } + + /** + * @dev Transfers tokens from user to this contract and deposits them + * into the Morpho Vault. On failure, either falls back to raw transfer + * or reverts based on `autoInvestFallbackEnabled`. + * @param tokenIn token address + * @param amountToken amount of tokens to transfer in base18 + * @param tokensDecimals decimals of tokens + * @param vault Morpho Vault + */ + function _autoInvest( + address tokenIn, + uint256 amountToken, + uint256 tokensDecimals, + IMorphoVault vault + ) private { + uint256 transferredAmount = _tokenTransferFromUser( + tokenIn, + address(this), + amountToken, + tokensDecimals + ); + + IERC20(tokenIn).safeIncreaseAllowance( + address(vault), + transferredAmount + ); + + try vault.deposit(transferredAmount, tokensReceiver) returns ( + uint256 shares + ) { + require(shares > 0, "DVM: zero shares"); + } catch { + if (autoInvestFallbackEnabled) { + IERC20(tokenIn).safeApprove(address(vault), 0); + IERC20(tokenIn).safeTransfer(tokensReceiver, transferredAmount); + } else { + revert("DVM: auto-invest failed"); + } + } + } +} diff --git a/contracts/RedemptionVaultWithAave.sol b/contracts/RedemptionVaultWithAave.sol new file mode 100644 index 00000000..2ac74b85 --- /dev/null +++ b/contracts/RedemptionVaultWithAave.sol @@ -0,0 +1,201 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.9; + +import {IERC20Upgradeable as IERC20} from "@openzeppelin/contracts-upgradeable/token/ERC20/IERC20Upgradeable.sol"; + +import "./RedemptionVault.sol"; + +import "./interfaces/aave/IAaveV3Pool.sol"; +import "./libraries/DecimalsCorrectionLibrary.sol"; + +/** + * @title RedemptionVaultWithAave + * @notice Smart contract that handles redemptions using Aave V3 Pool withdrawals + * @dev When the vault has insufficient payment token balance, it withdraws from + * an Aave V3 Pool by burning its aTokens to obtain the underlying asset. + * @author RedDuck Software + */ +contract RedemptionVaultWithAave is RedemptionVault { + using DecimalsCorrectionLibrary for uint256; + + /** + * @notice mapping payment token to Aave V3 Pool + */ + mapping(address => IAaveV3Pool) public aavePools; + + /** + * @dev leaving a storage gap for futures updates + */ + uint256[50] private __gap; + + /** + * @notice Emitted when an Aave V3 Pool is configured for a payment token + * @param caller address of the caller + * @param token payment token address + * @param pool Aave V3 Pool address + */ + event SetAavePool( + address indexed caller, + address indexed token, + address indexed pool + ); + + /** + * @notice Emitted when an Aave V3 Pool is removed for a payment token + * @param caller address of the caller + * @param token payment token address + */ + event RemoveAavePool(address indexed caller, address indexed token); + + /** + * @notice Sets the Aave V3 Pool for a specific payment token + * @param _token payment token address + * @param _aavePool Aave V3 Pool address for this token + */ + function setAavePool(address _token, address _aavePool) + external + onlyVaultAdmin + { + _validateAddress(_token, true); + _validateAddress(_aavePool, true); + require( + IAaveV3Pool(_aavePool).getReserveAToken(_token) != address(0), + "RVA: token not in pool" + ); + aavePools[_token] = IAaveV3Pool(_aavePool); + emit SetAavePool(msg.sender, _token, _aavePool); + } + + /** + * @notice Removes the Aave V3 Pool for a specific payment token + * @param _token payment token address + */ + function removeAavePool(address _token) external onlyVaultAdmin { + require(address(aavePools[_token]) != address(0), "RVA: pool not set"); + delete aavePools[_token]; + emit RemoveAavePool(msg.sender, _token); + } + + /** + * @dev Redeem mToken to the selected payment token if daily limit and allowance are not exceeded. + * If the contract doesn't have enough payment token, the Aave V3 withdrawal flow will be + * triggered to withdraw the missing amount from the Aave Pool. + * Burns mToken from the user. + * Transfers fee in mToken to feeReceiver. + * Transfers tokenOut to user. + * @param tokenOut token out address + * @param amountMTokenIn amount of mToken to redeem + * @param minReceiveAmount minimum expected amount of tokenOut to receive (decimals 18) + * @param recipient address that will receive the tokenOut + */ + function _redeemInstant( + address tokenOut, + uint256 amountMTokenIn, + uint256 minReceiveAmount, + address recipient + ) + internal + override + returns ( + CalcAndValidateRedeemResult memory calcResult, + uint256 amountTokenOutWithoutFee + ) + { + address user = msg.sender; + + calcResult = _calcAndValidateRedeem( + user, + tokenOut, + amountMTokenIn, + true, + false + ); + + _requireAndUpdateLimit(amountMTokenIn); + + uint256 tokenDecimals = _tokenDecimals(tokenOut); + + uint256 amountMTokenInCopy = amountMTokenIn; + address tokenOutCopy = tokenOut; + uint256 minReceiveAmountCopy = minReceiveAmount; + + (uint256 amountMTokenInUsd, uint256 mTokenRate) = _convertMTokenToUsd( + amountMTokenInCopy + ); + (uint256 amountTokenOut, uint256 tokenOutRate) = _convertUsdToToken( + amountMTokenInUsd, + tokenOutCopy + ); + + _requireAndUpdateAllowance(tokenOutCopy, amountTokenOut); + + mToken.burn(user, calcResult.amountMTokenWithoutFee); + if (calcResult.feeAmount > 0) + _tokenTransferFromUser( + address(mToken), + feeReceiver, + calcResult.feeAmount, + 18 + ); + + uint256 amountTokenOutWithoutFeeFrom18 = ((calcResult + .amountMTokenWithoutFee * mTokenRate) / tokenOutRate) + .convertFromBase18(tokenDecimals); + + amountTokenOutWithoutFee = amountTokenOutWithoutFeeFrom18 + .convertToBase18(tokenDecimals); + + require( + amountTokenOutWithoutFee >= minReceiveAmountCopy, + "RVA: minReceiveAmount > actual" + ); + + _checkAndRedeemAave(tokenOutCopy, amountTokenOutWithoutFeeFrom18); + + _tokenTransferToUser( + tokenOutCopy, + recipient, + amountTokenOutWithoutFee, + tokenDecimals + ); + } + + /** + * @notice Check if contract has enough tokenOut balance for redeem; + * if not, withdraw the missing amount from the Aave V3 Pool + * @dev The Aave Pool burns the vault's aTokens and transfers the underlying + * asset directly to this contract. No approval is needed because the Pool + * burns aTokens from msg.sender (this contract) internally. + * @param tokenOut tokenOut address + * @param amountTokenOut amount of tokenOut needed + */ + function _checkAndRedeemAave(address tokenOut, uint256 amountTokenOut) + internal + { + uint256 contractBalanceTokenOut = IERC20(tokenOut).balanceOf( + address(this) + ); + if (contractBalanceTokenOut >= amountTokenOut) return; + + IAaveV3Pool pool = aavePools[tokenOut]; + require(address(pool) != address(0), "RVA: no pool for token"); + + uint256 missingAmount = amountTokenOut - contractBalanceTokenOut; + + address aToken = pool.getReserveAToken(tokenOut); + require(aToken != address(0), "RVA: token not in Aave pool"); + + uint256 aTokenBalance = IERC20(aToken).balanceOf(address(this)); + require( + aTokenBalance >= missingAmount, + "RVA: insufficient aToken balance" + ); + + uint256 withdrawnAmount = pool.withdraw( + tokenOut, + missingAmount, + address(this) + ); + require(withdrawnAmount >= missingAmount, "RVA: withdrawn < needed"); + } +} diff --git a/contracts/RedemptionVaultWithMToken.sol b/contracts/RedemptionVaultWithMToken.sol new file mode 100644 index 00000000..7fa9024d --- /dev/null +++ b/contracts/RedemptionVaultWithMToken.sol @@ -0,0 +1,262 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.9; + +import {IERC20Upgradeable as IERC20} from "@openzeppelin/contracts-upgradeable/token/ERC20/IERC20Upgradeable.sol"; +import {SafeERC20Upgradeable as SafeERC20} from "@openzeppelin/contracts-upgradeable/token/ERC20/utils/SafeERC20Upgradeable.sol"; + +import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; + +import "./RedemptionVault.sol"; +import "./interfaces/IRedemptionVault.sol"; +import "./libraries/DecimalsCorrectionLibrary.sol"; + +/** + * @title RedemptionVaultWithMToken + * @notice Smart contract that handles redemptions using mToken RedemptionVault withdrawals + * @dev Storage layout is preserved for safe upgrades from RedemptionVaultWithSwapper + * @author RedDuck Software + */ +contract RedemptionVaultWithMToken is RedemptionVault { + using DecimalsCorrectionLibrary for uint256; + using SafeERC20 for IERC20; + + /** + * @dev Storage gap preserved from RedemptionVaultWithSwapper layout + */ + uint256[50] private ___gap; + + /** + * @notice mToken RedemptionVault used for fallback redemptions + * @custom:oz-renamed-from mTbillRedemptionVault + */ + IRedemptionVault public redemptionVault; + + /** + * @dev Deprecated storage slot preserved for storage layout compatibility. Must not be removed + * @custom:oz-renamed-from liquidityProvider + */ + // solhint-disable-next-line var-name-mixedcase + address public liquidityProvider_deprecated; + + /** + * @dev leaving a storage gap for futures updates + */ + uint256[50] private __gap; + + /** + * @notice Emitted when the redemption vault address is updated + * @param caller address of the caller + * @param newVault new redemption vault address + */ + event SetRedemptionVault(address indexed caller, address indexed newVault); + + /** + * @notice upgradeable pattern contract`s initializer + * @param _ac address of MidasAccessControll contract + * @param _mTokenInitParams init params for mToken + * @param _receiversInitParams init params for receivers + * @param _instantInitParams init params for instant operations + * @param _sanctionsList address of sanctionsList contract + * @param _variationTolerance percent of prices diviation 1% = 100 + * @param _minAmount basic min amount for operations + * @param _fiatRedemptionInitParams params fiatAdditionalFee, fiatFlatFee, minFiatRedeemAmount + * @param _requestRedeemer address is designated for standard redemptions, allowing tokens to be pulled from this address + * @param _redemptionVault address of the mTokenA RedemptionVault + */ + function initialize( + address _ac, + MTokenInitParams calldata _mTokenInitParams, + ReceiversInitParams calldata _receiversInitParams, + InstantInitParams calldata _instantInitParams, + address _sanctionsList, + uint256 _variationTolerance, + uint256 _minAmount, + FiatRedeptionInitParams calldata _fiatRedemptionInitParams, + address _requestRedeemer, + address _redemptionVault + ) external initializer { + __RedemptionVault_init( + _ac, + _mTokenInitParams, + _receiversInitParams, + _instantInitParams, + _sanctionsList, + _variationTolerance, + _minAmount, + _fiatRedemptionInitParams, + _requestRedeemer + ); + _validateAddress(_redemptionVault, true); + redemptionVault = IRedemptionVault(_redemptionVault); + } + + /** + * @notice Sets the mTokenA RedemptionVault address + * @param _redemptionVault new RedemptionVault address + */ + function setRedemptionVault(address _redemptionVault) + external + onlyVaultAdmin + { + require( + _redemptionVault != address(redemptionVault), + "RVMT: already set" + ); + _validateAddress(_redemptionVault, true); + + redemptionVault = IRedemptionVault(_redemptionVault); + + emit SetRedemptionVault(msg.sender, _redemptionVault); + } + + /** + * @dev Redeem mToken to the selected payment token if daily limit and allowance are not exceeded. + * If the contract doesn't have enough payment token, the mToken RedemptionVault flow + * will be triggered to redeem the missing amount. + * Burns mToken from the user. + * Transfers fee in mToken to feeReceiver. + * Transfers tokenOut to user. + * @param tokenOut token out address + * @param amountMTokenIn amount of mToken to redeem + * @param minReceiveAmount minimum expected amount of tokenOut to receive (decimals 18) + * @param recipient address that will receive the tokenOut + */ + function _redeemInstant( + address tokenOut, + uint256 amountMTokenIn, + uint256 minReceiveAmount, + address recipient + ) + internal + override + returns ( + CalcAndValidateRedeemResult memory calcResult, + uint256 amountTokenOutWithoutFee + ) + { + address user = msg.sender; + + calcResult = _calcAndValidateRedeem( + user, + tokenOut, + amountMTokenIn, + true, + false + ); + + _requireAndUpdateLimit(amountMTokenIn); + + uint256 tokenDecimals = _tokenDecimals(tokenOut); + + uint256 amountMTokenInCopy = amountMTokenIn; + address tokenOutCopy = tokenOut; + uint256 minReceiveAmountCopy = minReceiveAmount; + + (uint256 amountMTokenInUsd, uint256 mTokenRate) = _convertMTokenToUsd( + amountMTokenInCopy + ); + (uint256 amountTokenOut, uint256 tokenOutRate) = _convertUsdToToken( + amountMTokenInUsd, + tokenOutCopy + ); + + _requireAndUpdateAllowance(tokenOutCopy, amountTokenOut); + + mToken.burn(user, calcResult.amountMTokenWithoutFee); + if (calcResult.feeAmount > 0) + _tokenTransferFromUser( + address(mToken), + feeReceiver, + calcResult.feeAmount, + 18 + ); + + uint256 amountTokenOutWithoutFeeFrom18 = ((calcResult + .amountMTokenWithoutFee * mTokenRate) / tokenOutRate) + .convertFromBase18(tokenDecimals); + + amountTokenOutWithoutFee = amountTokenOutWithoutFeeFrom18 + .convertToBase18(tokenDecimals); + + require( + amountTokenOutWithoutFee >= minReceiveAmountCopy, + "RVMT: minReceiveAmount > actual" + ); + + _checkAndRedeemMToken( + tokenOutCopy, + amountTokenOutWithoutFeeFrom18, + tokenOutRate + ); + + _tokenTransferToUser( + tokenOutCopy, + recipient, + amountTokenOutWithoutFee, + tokenDecimals + ); + } + + /** + * @notice Check if contract has enough tokenOut balance for redeem; + * if not, redeem the missing amount via mToken RedemptionVault + * @dev The other vault burns this contract's mToken and transfers the + * underlying asset to this contract + * @param tokenOut tokenOut address + * @param amountTokenOut amount of tokenOut needed (native decimals) + * @param tokenOutRate tokenOut price rate (decimals 18) + */ + function _checkAndRedeemMToken( + address tokenOut, + uint256 amountTokenOut, + uint256 tokenOutRate + ) internal { + uint256 tokenDecimals = _tokenDecimals(tokenOut); + uint256 amountTokenOutBase18 = amountTokenOut.convertToBase18( + tokenDecimals + ); + uint256 contractBalanceBase18 = IERC20(tokenOut) + .balanceOf(address(this)) + .convertToBase18(tokenDecimals); + if (contractBalanceBase18 >= amountTokenOutBase18) return; + + require( + ManageableVault(address(redemptionVault)).waivedFeeRestriction( + address(this) + ), + "RVMT: fees not waived on target" + ); + + uint256 missingAmountBase18 = amountTokenOutBase18 - + contractBalanceBase18; + uint256 mTokenARate = redemptionVault + .mTokenDataFeed() + .getDataInBase18(); + + // Ceil so the inner vault's floored output is still >= missingAmountBase18. + uint256 mTokenAAmount = Math.mulDiv( + missingAmountBase18, + tokenOutRate, + mTokenARate, + Math.Rounding.Up + ); + + address mTokenA = address(redemptionVault.mToken()); + + require( + IERC20(mTokenA).balanceOf(address(this)) >= mTokenAAmount, + "RVMT: balance < needed" + ); + + IERC20(mTokenA).safeIncreaseAllowance( + address(redemptionVault), + mTokenAAmount + ); + + redemptionVault.redeemInstant( + tokenOut, + mTokenAAmount, + missingAmountBase18 + ); + } +} diff --git a/contracts/RedemptionVaultWithMorpho.sol b/contracts/RedemptionVaultWithMorpho.sol new file mode 100644 index 00000000..bb4db3d8 --- /dev/null +++ b/contracts/RedemptionVaultWithMorpho.sol @@ -0,0 +1,197 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.9; + +import {IERC20Upgradeable as IERC20} from "@openzeppelin/contracts-upgradeable/token/ERC20/IERC20Upgradeable.sol"; + +import "./RedemptionVault.sol"; + +import {IMorphoVault} from "./interfaces/morpho/IMorphoVault.sol"; +import "./libraries/DecimalsCorrectionLibrary.sol"; + +/** + * @title RedemptionVaultWithMorpho + * @notice Smart contract that handles redemptions using Morpho Vault withdrawals + * @dev When the vault has insufficient payment token balance, it withdraws from + * a Morpho Vault (ERC-4626) by burning its vault shares to obtain the underlying asset. + * Works with both Morpho Vaults V1 (MetaMorpho) and V2. + * @author RedDuck Software + */ +contract RedemptionVaultWithMorpho is RedemptionVault { + using DecimalsCorrectionLibrary for uint256; + + /** + * @notice mapping payment token to Morpho Vault + */ + mapping(address => IMorphoVault) public morphoVaults; + + /** + * @dev leaving a storage gap for futures updates + */ + uint256[50] private __gap; + + /** + * @notice Emitted when a Morpho Vault is configured for a payment token + * @param caller address of the caller + * @param token payment token address + * @param vault Morpho Vault address + */ + event SetMorphoVault( + address indexed caller, + address indexed token, + address indexed vault + ); + + /** + * @notice Emitted when a Morpho Vault is removed for a payment token + * @param caller address of the caller + * @param token payment token address + */ + event RemoveMorphoVault(address indexed caller, address indexed token); + + /** + * @notice Sets the Morpho Vault for a specific payment token + * @param _token payment token address + * @param _morphoVault Morpho Vault (ERC-4626) address for this token + */ + function setMorphoVault(address _token, address _morphoVault) + external + onlyVaultAdmin + { + _validateAddress(_token, true); + _validateAddress(_morphoVault, true); + require( + IMorphoVault(_morphoVault).asset() == _token, + "RVM: asset mismatch" + ); + morphoVaults[_token] = IMorphoVault(_morphoVault); + emit SetMorphoVault(msg.sender, _token, _morphoVault); + } + + /** + * @notice Removes the Morpho Vault for a specific payment token + * @param _token payment token address + */ + function removeMorphoVault(address _token) external onlyVaultAdmin { + require( + address(morphoVaults[_token]) != address(0), + "RVM: vault not set" + ); + delete morphoVaults[_token]; + emit RemoveMorphoVault(msg.sender, _token); + } + + /** + * @dev Redeem mToken to the selected payment token if daily limit and allowance are not exceeded. + * If the contract doesn't have enough payment token, the Morpho Vault withdrawal flow will be + * triggered to withdraw the missing amount from the Morpho Vault. + * Burns mToken from the user. + * Transfers fee in mToken to feeReceiver. + * Transfers tokenOut to user. + * @param tokenOut token out address + * @param amountMTokenIn amount of mToken to redeem + * @param minReceiveAmount minimum expected amount of tokenOut to receive (decimals 18) + * @param recipient address that will receive the tokenOut + */ + function _redeemInstant( + address tokenOut, + uint256 amountMTokenIn, + uint256 minReceiveAmount, + address recipient + ) + internal + override + returns ( + CalcAndValidateRedeemResult memory calcResult, + uint256 amountTokenOutWithoutFee + ) + { + address user = msg.sender; + + calcResult = _calcAndValidateRedeem( + user, + tokenOut, + amountMTokenIn, + true, + false + ); + + _requireAndUpdateLimit(amountMTokenIn); + + uint256 tokenDecimals = _tokenDecimals(tokenOut); + + uint256 amountMTokenInCopy = amountMTokenIn; + address tokenOutCopy = tokenOut; + uint256 minReceiveAmountCopy = minReceiveAmount; + + (uint256 amountMTokenInUsd, uint256 mTokenRate) = _convertMTokenToUsd( + amountMTokenInCopy + ); + (uint256 amountTokenOut, uint256 tokenOutRate) = _convertUsdToToken( + amountMTokenInUsd, + tokenOutCopy + ); + + _requireAndUpdateAllowance(tokenOutCopy, amountTokenOut); + + mToken.burn(user, calcResult.amountMTokenWithoutFee); + if (calcResult.feeAmount > 0) + _tokenTransferFromUser( + address(mToken), + feeReceiver, + calcResult.feeAmount, + 18 + ); + + uint256 amountTokenOutWithoutFeeFrom18 = ((calcResult + .amountMTokenWithoutFee * mTokenRate) / tokenOutRate) + .convertFromBase18(tokenDecimals); + + amountTokenOutWithoutFee = amountTokenOutWithoutFeeFrom18 + .convertToBase18(tokenDecimals); + + require( + amountTokenOutWithoutFee >= minReceiveAmountCopy, + "RVM: minReceiveAmount > actual" + ); + + _checkAndRedeemMorpho(tokenOutCopy, amountTokenOutWithoutFeeFrom18); + + _tokenTransferToUser( + tokenOutCopy, + recipient, + amountTokenOutWithoutFee, + tokenDecimals + ); + } + + /** + * @notice Check if contract has enough tokenOut balance for redeem; + * if not, withdraw the missing amount from the Morpho Vault + * @dev The Morpho Vault burns the vault's shares and transfers the underlying + * asset directly to this contract. No approval is needed because the vault + * burns shares from msg.sender (this contract) when msg.sender == owner. + * @param tokenOut tokenOut address + * @param amountTokenOut amount of tokenOut needed + */ + function _checkAndRedeemMorpho(address tokenOut, uint256 amountTokenOut) + internal + { + uint256 contractBalanceTokenOut = IERC20(tokenOut).balanceOf( + address(this) + ); + if (contractBalanceTokenOut >= amountTokenOut) return; + + IMorphoVault vault = morphoVaults[tokenOut]; + require(address(vault) != address(0), "RVM: no vault for token"); + + uint256 missingAmount = amountTokenOut - contractBalanceTokenOut; + + uint256 sharesNeeded = vault.previewWithdraw(missingAmount); + require( + vault.balanceOf(address(this)) >= sharesNeeded, + "RVM: insufficient shares" + ); + + vault.withdraw(missingAmount, address(this), address(this)); + } +} diff --git a/contracts/RedemptionVaultWithSwapper.sol b/contracts/RedemptionVaultWithSwapper.sol index 6b0e2b76..b6de1bc8 100644 --- a/contracts/RedemptionVaultWithSwapper.sol +++ b/contracts/RedemptionVaultWithSwapper.sol @@ -3,6 +3,7 @@ pragma solidity 0.8.9; import {IERC20Upgradeable as IERC20} from "@openzeppelin/contracts-upgradeable/token/ERC20/IERC20Upgradeable.sol"; import {SafeERC20Upgradeable as SafeERC20} from "@openzeppelin/contracts-upgradeable/token/ERC20/utils/SafeERC20Upgradeable.sol"; +import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; import "./RedemptionVault.sol"; import "./interfaces/IRedemptionVault.sol"; @@ -246,7 +247,12 @@ contract RedemptionVaultWithSwapper is .mTokenDataFeed() .getDataInBase18(); uint256 mTokenRate = mTokenDataFeed.getDataInBase18(); - mTokenAmount = (mToken1Amount * mTokenRate) / mTbillRate; + mTokenAmount = Math.mulDiv( + mToken1Amount, + mTokenRate, + mTbillRate, + Math.Rounding.Up + ); _tokenTransferFromTo( address(mTbillRedemptionVault.mToken()), diff --git a/contracts/interfaces/aave/IAaveV3Pool.sol b/contracts/interfaces/aave/IAaveV3Pool.sol new file mode 100644 index 00000000..c239da0f --- /dev/null +++ b/contracts/interfaces/aave/IAaveV3Pool.sol @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.9; + +/** + * @title IAaveV3Pool + * @notice Minimal interface for the Aave V3 Pool (v3.2+) + * @dev Full interface: https://github.com/aave-dao/aave-v3-origin/blob/main/src/contracts/interfaces/IPool.sol + */ +interface IAaveV3Pool { + /** + * @notice Withdraws an `amount` of underlying asset from the reserve, burning the equivalent aTokens owned + * E.g. User has 100 aUSDC, calls withdraw() and receives 100 USDC, burning the 100 aUSDC + * @param asset The address of the underlying asset to withdraw + * @param amount The underlying amount to be withdrawn + * - Send the value type(uint256).max in order to withdraw the whole aToken balance + * @param to The address that will receive the underlying, same as msg.sender if the user + * wants to receive it on his own wallet, or a different address if the beneficiary is a + * different wallet + * @return The final amount withdrawn + */ + function withdraw( + address asset, + uint256 amount, + address to + ) external returns (uint256); + + /** + * @notice Supplies an `amount` of underlying asset into the reserve, receiving in return overlying aTokens. + * - E.g. User supplies 100 USDC and gets in return 100 aUSDC + * @param asset The address of the underlying asset to supply + * @param amount The amount to be supplied + * @param onBehalfOf The address that will receive the aTokens, same as msg.sender if the user + * wants to receive them on his own wallet, or a different address if the beneficiary of aTokens + * is a different wallet + * @param referralCode Code used to register the integrator originating the operation, for potential rewards. + * 0 if the action is executed directly by the user, without any middle-man + */ + function supply( + address asset, + uint256 amount, + address onBehalfOf, + uint16 referralCode + ) external; + + /** + * @notice Returns the aToken address of a reserve + * @param asset The address of the underlying asset of the reserve + * @return The aToken address of the reserve + */ + function getReserveAToken(address asset) external view returns (address); +} diff --git a/contracts/interfaces/morpho/IMorphoVault.sol b/contracts/interfaces/morpho/IMorphoVault.sol new file mode 100644 index 00000000..34e78c67 --- /dev/null +++ b/contracts/interfaces/morpho/IMorphoVault.sol @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.9; + +import "@openzeppelin/contracts/interfaces/IERC4626.sol"; + +/** + * @title IMorphoVault + * @notice Morpho Vault interface extending the ERC-4626 Tokenized Vault Standard + * @dev Works with both Morpho Vaults V1 (MetaMorpho) and V2 + * V1 repo: https://github.com/morpho-org/metamorpho-v1.1 + * V2 repo: https://github.com/morpho-org/vault-v2 + */ +interface IMorphoVault is IERC4626 { + +} diff --git a/contracts/mocks/AaveV3PoolMock.sol b/contracts/mocks/AaveV3PoolMock.sol new file mode 100644 index 00000000..9d962e7f --- /dev/null +++ b/contracts/mocks/AaveV3PoolMock.sol @@ -0,0 +1,72 @@ +// solhint-disable +// SPDX-License-Identifier: MIT +pragma solidity 0.8.9; + +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import "./ERC20Mock.sol"; + +contract AaveV3PoolMock { + using SafeERC20 for IERC20; + + mapping(address => address) public reserveATokens; + uint256 public withdrawReturnBps = 10_000; + bool public shouldRevertSupply; + + function setReserveAToken(address asset, address aToken) external { + reserveATokens[asset] = aToken; + } + + function setWithdrawReturnBps(uint256 bps) external { + require(bps <= 10_000, "AaveV3PoolMock: InvalidBps"); + withdrawReturnBps = bps; + } + + function withdraw( + address asset, + uint256 amount, + address to + ) external returns (uint256) { + address aToken = reserveATokens[asset]; + require(aToken != address(0), "AaveV3PoolMock: NoReserve"); + + uint256 poolBalance = IERC20(asset).balanceOf(address(this)); + require(poolBalance >= amount, "AaveV3PoolMock: InsufficientLiquidity"); + + uint256 returnedAmount = (amount * withdrawReturnBps) / 10_000; + ERC20Mock(aToken).burn(msg.sender, amount); + IERC20(asset).safeTransfer(to, returnedAmount); + + return returnedAmount; + } + + function setShouldRevertSupply(bool _shouldRevert) external { + shouldRevertSupply = _shouldRevert; + } + + function supply( + address asset, + uint256 amount, + address onBehalfOf, + uint16 /* referralCode */ + ) external { + require(!shouldRevertSupply, "AaveV3PoolMock: SupplyReverted"); + address aToken = reserveATokens[asset]; + require(aToken != address(0), "AaveV3PoolMock: NoReserve"); + + IERC20(asset).safeTransferFrom(msg.sender, address(this), amount); + ERC20Mock(aToken).mint(onBehalfOf, amount); + } + + function withdrawAdmin( + address token, + address to, + uint256 amount + ) external { + IERC20(token).safeTransfer(to, amount); + } + + function getReserveAToken(address asset) external view returns (address) { + return reserveATokens[asset]; + } +} diff --git a/contracts/mocks/ERC20Mock.sol b/contracts/mocks/ERC20Mock.sol index 34e8873f..0d1f2175 100644 --- a/contracts/mocks/ERC20Mock.sol +++ b/contracts/mocks/ERC20Mock.sol @@ -14,6 +14,10 @@ contract ERC20Mock is ERC20 { _mint(to, amount); } + function burn(address from, uint256 amount) external { + _burn(from, amount); + } + function decimals() public view override returns (uint8) { return _decimals; } diff --git a/contracts/mocks/MorphoVaultMock.sol b/contracts/mocks/MorphoVaultMock.sol new file mode 100644 index 00000000..8c8f58d9 --- /dev/null +++ b/contracts/mocks/MorphoVaultMock.sol @@ -0,0 +1,114 @@ +// solhint-disable +// SPDX-License-Identifier: MIT +pragma solidity 0.8.9; + +import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; + +contract MorphoVaultMock is ERC20 { + using SafeERC20 for IERC20; + + address public immutable underlyingAsset; + + uint256 public exchangeRateNumerator; + uint256 public constant RATE_PRECISION = 1e18; + bool public shouldRevertDeposit; + + constructor(address _underlyingAsset) ERC20("MorphoVaultMock", "mvMOCK") { + underlyingAsset = _underlyingAsset; + exchangeRateNumerator = RATE_PRECISION; + } + + function mint(address to, uint256 amount) external { + _mint(to, amount); + } + + function setExchangeRate(uint256 _numerator) external { + exchangeRateNumerator = _numerator; + } + + function setShouldRevertDeposit(bool _shouldRevert) external { + shouldRevertDeposit = _shouldRevert; + } + + function withdrawAdmin( + address token, + address to, + uint256 amount + ) external { + IERC20(token).safeTransfer(to, amount); + } + + function asset() external view returns (address) { + return underlyingAsset; + } + + function deposit(uint256 assets, address receiver) + external + returns (uint256 shares) + { + require(!shouldRevertDeposit, "MorphoVaultMock: DepositReverted"); + shares = previewDeposit(assets); + + IERC20(underlyingAsset).safeTransferFrom( + msg.sender, + address(this), + assets + ); + _mint(receiver, shares); + + return shares; + } + + function previewDeposit(uint256 assets) + public + view + returns (uint256 shares) + { + shares = (assets * RATE_PRECISION) / exchangeRateNumerator; + } + + function withdraw( + uint256 assets, + address receiver, + address owner + ) external returns (uint256 shares) { + shares = previewWithdraw(assets); + + require( + balanceOf(owner) >= shares, + "MorphoVaultMock: InsufficientShares" + ); + + uint256 vaultBalance = IERC20(underlyingAsset).balanceOf(address(this)); + require( + vaultBalance >= assets, + "MorphoVaultMock: InsufficientLiquidity" + ); + + _burn(owner, shares); + IERC20(underlyingAsset).safeTransfer(receiver, assets); + + return shares; + } + + function previewWithdraw(uint256 assets) + public + view + returns (uint256 shares) + { + // round up + shares = + (assets * RATE_PRECISION + exchangeRateNumerator - 1) / + exchangeRateNumerator; + } + + function convertToAssets(uint256 shares) + external + view + returns (uint256 assets) + { + assets = (shares * exchangeRateNumerator) / RATE_PRECISION; + } +} diff --git a/contracts/products/mFONE/MFOneRedemptionVaultWithMToken.sol b/contracts/products/mFONE/MFOneRedemptionVaultWithMToken.sol new file mode 100644 index 00000000..943efbbf --- /dev/null +++ b/contracts/products/mFONE/MFOneRedemptionVaultWithMToken.sol @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.9; + +import "../../RedemptionVaultWithMToken.sol"; +import "./MFOneMidasAccessControlRoles.sol"; + +/** + * @title MFOneRedemptionVaultWithMToken + * @notice Smart contract that handles mF-ONE redemptions using mToken + * liquid strategy. Upgrade-compatible replacement for + * MFOneRedemptionVaultWithSwapper. + * @author RedDuck Software + */ +contract MFOneRedemptionVaultWithMToken is + RedemptionVaultWithMToken, + MFOneMidasAccessControlRoles +{ + /** + * @dev leaving a storage gap for futures updates + */ + uint256[50] private __gap; + + /** + * @inheritdoc ManageableVault + */ + function vaultRole() public pure override returns (bytes32) { + return M_FONE_REDEMPTION_VAULT_ADMIN_ROLE; + } +} diff --git a/contracts/products/mSL/MSlRedemptionVaultWithMToken.sol b/contracts/products/mSL/MSlRedemptionVaultWithMToken.sol new file mode 100644 index 00000000..feef42cb --- /dev/null +++ b/contracts/products/mSL/MSlRedemptionVaultWithMToken.sol @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.9; + +import "../../RedemptionVaultWithMToken.sol"; +import "./MSlMidasAccessControlRoles.sol"; + +/** + * @title MSlRedemptionVaultWithMToken + * @notice Smart contract that handles mSL redemptions using mToken + * liquid strategy. Upgrade-compatible replacement for + * MSlRedemptionVaultWithSwapper. + * @author RedDuck Software + */ +contract MSlRedemptionVaultWithMToken is + RedemptionVaultWithMToken, + MSlMidasAccessControlRoles +{ + /** + * @dev leaving a storage gap for futures updates + */ + uint256[50] private __gap; + + /** + * @inheritdoc ManageableVault + */ + function vaultRole() public pure override returns (bytes32) { + return M_SL_REDEMPTION_VAULT_ADMIN_ROLE; + } +} diff --git a/contracts/testers/DepositVaultWithAaveTest.sol b/contracts/testers/DepositVaultWithAaveTest.sol new file mode 100644 index 00000000..a1c0c958 --- /dev/null +++ b/contracts/testers/DepositVaultWithAaveTest.sol @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.9; + +import "../DepositVaultWithAave.sol"; + +contract DepositVaultWithAaveTest is DepositVaultWithAave { + bool private _overrideGetTokenRate; + uint256 private _getTokenRateValue; + + function _disableInitializers() internal override {} + + function tokenTransferFromToTester( + address token, + address from, + address to, + uint256 amount, + uint256 tokenDecimals + ) external { + _tokenTransferFromTo(token, from, to, amount, tokenDecimals); + } + + function tokenTransferToUserTester( + address token, + address to, + uint256 amount, + uint256 tokenDecimals + ) external { + _tokenTransferToUser(token, to, amount, tokenDecimals); + } + + function setOverrideGetTokenRate(bool val) external { + _overrideGetTokenRate = val; + } + + function setGetTokenRateValue(uint256 val) external { + _getTokenRateValue = val; + } + + function calcAndValidateDeposit( + address user, + address tokenIn, + uint256 amountToken, + bool isInstant + ) external returns (CalcAndValidateDepositResult memory) { + return _calcAndValidateDeposit(user, tokenIn, amountToken, isInstant); + } + + function convertTokenToUsdTest(address tokenIn, uint256 amount) + external + returns (uint256 amountInUsd, uint256 rate) + { + return _convertTokenToUsd(tokenIn, amount); + } + + function convertUsdToMTokenTest(uint256 amountUsd) + external + returns (uint256 amountMToken, uint256 mTokenRate) + { + return _convertUsdToMToken(amountUsd); + } + + function _getTokenRate(address dataFeed, bool stable) + internal + view + override + returns (uint256) + { + if (_overrideGetTokenRate) { + return _getTokenRateValue; + } + + return super._getTokenRate(dataFeed, stable); + } +} diff --git a/contracts/testers/DepositVaultWithMTokenTest.sol b/contracts/testers/DepositVaultWithMTokenTest.sol new file mode 100644 index 00000000..c07b431c --- /dev/null +++ b/contracts/testers/DepositVaultWithMTokenTest.sol @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.9; + +import "../DepositVaultWithMToken.sol"; + +contract DepositVaultWithMTokenTest is DepositVaultWithMToken { + bool private _overrideGetTokenRate; + uint256 private _getTokenRateValue; + + function _disableInitializers() internal override {} + + function tokenTransferFromToTester( + address token, + address from, + address to, + uint256 amount, + uint256 tokenDecimals + ) external { + _tokenTransferFromTo(token, from, to, amount, tokenDecimals); + } + + function tokenTransferToUserTester( + address token, + address to, + uint256 amount, + uint256 tokenDecimals + ) external { + _tokenTransferToUser(token, to, amount, tokenDecimals); + } + + function setOverrideGetTokenRate(bool val) external { + _overrideGetTokenRate = val; + } + + function setGetTokenRateValue(uint256 val) external { + _getTokenRateValue = val; + } + + function calcAndValidateDeposit( + address user, + address tokenIn, + uint256 amountToken, + bool isInstant + ) external returns (CalcAndValidateDepositResult memory) { + return _calcAndValidateDeposit(user, tokenIn, amountToken, isInstant); + } + + function convertTokenToUsdTest(address tokenIn, uint256 amount) + external + returns (uint256 amountInUsd, uint256 rate) + { + return _convertTokenToUsd(tokenIn, amount); + } + + function convertUsdToMTokenTest(uint256 amountUsd) + external + returns (uint256 amountMToken, uint256 mTokenRate) + { + return _convertUsdToMToken(amountUsd); + } + + function _getTokenRate(address dataFeed, bool stable) + internal + view + override + returns (uint256) + { + if (_overrideGetTokenRate) { + return _getTokenRateValue; + } + + return super._getTokenRate(dataFeed, stable); + } +} diff --git a/contracts/testers/DepositVaultWithMorphoTest.sol b/contracts/testers/DepositVaultWithMorphoTest.sol new file mode 100644 index 00000000..dd282045 --- /dev/null +++ b/contracts/testers/DepositVaultWithMorphoTest.sol @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.9; + +import "../DepositVaultWithMorpho.sol"; + +contract DepositVaultWithMorphoTest is DepositVaultWithMorpho { + bool private _overrideGetTokenRate; + uint256 private _getTokenRateValue; + + function _disableInitializers() internal override {} + + function tokenTransferFromToTester( + address token, + address from, + address to, + uint256 amount, + uint256 tokenDecimals + ) external { + _tokenTransferFromTo(token, from, to, amount, tokenDecimals); + } + + function tokenTransferToUserTester( + address token, + address to, + uint256 amount, + uint256 tokenDecimals + ) external { + _tokenTransferToUser(token, to, amount, tokenDecimals); + } + + function setOverrideGetTokenRate(bool val) external { + _overrideGetTokenRate = val; + } + + function setGetTokenRateValue(uint256 val) external { + _getTokenRateValue = val; + } + + function calcAndValidateDeposit( + address user, + address tokenIn, + uint256 amountToken, + bool isInstant + ) external returns (CalcAndValidateDepositResult memory) { + return _calcAndValidateDeposit(user, tokenIn, amountToken, isInstant); + } + + function convertTokenToUsdTest(address tokenIn, uint256 amount) + external + returns (uint256 amountInUsd, uint256 rate) + { + return _convertTokenToUsd(tokenIn, amount); + } + + function convertUsdToMTokenTest(uint256 amountUsd) + external + returns (uint256 amountMToken, uint256 mTokenRate) + { + return _convertUsdToMToken(amountUsd); + } + + function _getTokenRate(address dataFeed, bool stable) + internal + view + override + returns (uint256) + { + if (_overrideGetTokenRate) { + return _getTokenRateValue; + } + + return super._getTokenRate(dataFeed, stable); + } +} diff --git a/contracts/testers/RedemptionVaultWithAaveTest.sol b/contracts/testers/RedemptionVaultWithAaveTest.sol new file mode 100644 index 00000000..2a044421 --- /dev/null +++ b/contracts/testers/RedemptionVaultWithAaveTest.sol @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.9; + +import "../RedemptionVaultWithAave.sol"; + +contract RedemptionVaultWithAaveTest is RedemptionVaultWithAave { + function _disableInitializers() internal override {} + + function checkAndRedeemAave(address token, uint256 amount) external { + _checkAndRedeemAave(token, amount); + } +} diff --git a/contracts/testers/RedemptionVaultWithMTokenTest.sol b/contracts/testers/RedemptionVaultWithMTokenTest.sol new file mode 100644 index 00000000..118a32dc --- /dev/null +++ b/contracts/testers/RedemptionVaultWithMTokenTest.sol @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.9; + +import "../RedemptionVaultWithMToken.sol"; + +contract RedemptionVaultWithMTokenTest is RedemptionVaultWithMToken { + function _disableInitializers() internal override {} + + function checkAndRedeemMToken( + address token, + uint256 amount, + uint256 rate + ) external { + _checkAndRedeemMToken(token, amount, rate); + } +} diff --git a/contracts/testers/RedemptionVaultWithMorphoTest.sol b/contracts/testers/RedemptionVaultWithMorphoTest.sol new file mode 100644 index 00000000..58f17791 --- /dev/null +++ b/contracts/testers/RedemptionVaultWithMorphoTest.sol @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.9; + +import "../RedemptionVaultWithMorpho.sol"; + +contract RedemptionVaultWithMorphoTest is RedemptionVaultWithMorpho { + function _disableInitializers() internal override {} + + function checkAndRedeemMorpho(address token, uint256 amount) external { + _checkAndRedeemMorpho(token, amount); + } +} diff --git a/helpers/contracts.ts b/helpers/contracts.ts index 1845ed14..13f557cd 100644 --- a/helpers/contracts.ts +++ b/helpers/contracts.ts @@ -4,10 +4,16 @@ import { VaultType } from '../config/constants/addresses'; export type TokenContractNames = { dv: string; dvUstb: string; + dvAave: string; + dvMorpho: string; + dvMToken: string; rv: string; rvSwapper: string; + rvMToken: string; rvBuidl: string; rvUstb: string; + rvAave: string; + rvMorpho: string; dataFeed?: string; dataFeedComposite?: string; dataFeedMultiply?: string; @@ -30,10 +36,16 @@ type CommonContractNames = Omit & { const vaultTypeToContractNameMap: Record = { redemptionVault: 'rv', redemptionVaultSwapper: 'rvSwapper', + redemptionVaultMToken: 'rvMToken', redemptionVaultUstb: 'rvUstb', depositVault: 'dv', depositVaultUstb: 'dvUstb', + depositVaultAave: 'dvAave', + depositVaultMorpho: 'dvMorpho', + depositVaultMToken: 'dvMToken', redemptionVaultBuidl: 'rvBuidl', + redemptionVaultAave: 'rvAave', + redemptionVaultMorpho: 'rvMorpho', }; export const vaultTypeToContractName = ( @@ -124,10 +136,16 @@ export const getCommonContractNames = (): CommonContractNames => { ac: 'MidasAccessControl', dv: 'DepositVault', dvUstb: 'DepositVaultWithUSTB', + dvAave: 'DepositVaultWithAave', + dvMorpho: 'DepositVaultWithMorpho', + dvMToken: 'DepositVaultWithMToken', rv: 'RedemptionVault', rvSwapper: 'RedemptionVaultWithSwapper', + rvMToken: 'RedemptionVaultWithMToken', rvBuidl: 'RedemptionVaultWIthBUIDL', rvUstb: 'RedemptionVaultWithUSTB', + rvAave: 'RedemptionVaultWithAave', + rvMorpho: 'RedemptionVaultWithMorpho', dataFeed: 'DataFeed', customAggregator: 'CustomAggregatorV3CompatibleFeed', customAggregatorGrowth: 'CustomAggregatorV3CompatibleFeedGrowth', @@ -155,10 +173,16 @@ export const getTokenContractNames = ( return { dv: `${tokenPrefix}${commonContractNames.dv}`, dvUstb: `${tokenPrefix}${commonContractNames.dvUstb}`, + dvAave: `${tokenPrefix}${commonContractNames.dvAave}`, + dvMorpho: `${tokenPrefix}${commonContractNames.dvMorpho}`, + dvMToken: `${tokenPrefix}${commonContractNames.dvMToken}`, rv: `${tokenPrefix}${commonContractNames.rv}`, rvSwapper: `${tokenPrefix}${commonContractNames.rvSwapper}`, + rvMToken: `${tokenPrefix}${commonContractNames.rvMToken}`, rvBuidl: `${tokenPrefix}${commonContractNames.rvBuidl}`, rvUstb: `${tokenPrefix}${commonContractNames.rvUstb}`, + rvAave: `${tokenPrefix}${commonContractNames.rvAave}`, + rvMorpho: `${tokenPrefix}${commonContractNames.rvMorpho}`, dataFeed: isTac ? undefined : `${prefix}${commonContractNames.dataFeed}`, customAggregator: isTac ? undefined : `${prefix}CustomAggregatorFeed`, customAggregatorGrowth: isTac diff --git a/scripts/deploy/codegen/common/index.ts b/scripts/deploy/codegen/common/index.ts index 0817ea76..86a64855 100644 --- a/scripts/deploy/codegen/common/index.ts +++ b/scripts/deploy/codegen/common/index.ts @@ -16,8 +16,14 @@ import { getCustomAggregatorContractFromTemplate, getCustomAggregatorGrowthContractFromTemplate, getDataFeedContractFromTemplate, + getDvAaveContractFromTemplate, getDvContractFromTemplate, + getDvMorphoContractFromTemplate, + getDvMTokenContractFromTemplate, + getRvAaveContractFromTemplate, getRvContractFromTemplate, + getRvMorphoContractFromTemplate, + getRvMTokenContractFromTemplate, getRvSwapperContractFromTemplate, getRvUstbContractFromTemplate, getTokenContractFromTemplate, @@ -67,9 +73,15 @@ const generatorPerContract: Partial< > = { token: getTokenContractFromTemplate, dv: getDvContractFromTemplate, + dvAave: getDvAaveContractFromTemplate, + dvMorpho: getDvMorphoContractFromTemplate, + dvMToken: getDvMTokenContractFromTemplate, rv: getRvContractFromTemplate, rvSwapper: getRvSwapperContractFromTemplate, + rvMToken: getRvMTokenContractFromTemplate, rvUstb: getRvUstbContractFromTemplate, + rvAave: getRvAaveContractFromTemplate, + rvMorpho: getRvMorphoContractFromTemplate, dataFeed: getDataFeedContractFromTemplate, customAggregator: getCustomAggregatorContractFromTemplate, customAggregatorGrowth: getCustomAggregatorGrowthContractFromTemplate, diff --git a/scripts/deploy/codegen/common/templates/dv-aave.template.ts b/scripts/deploy/codegen/common/templates/dv-aave.template.ts new file mode 100644 index 00000000..25001389 --- /dev/null +++ b/scripts/deploy/codegen/common/templates/dv-aave.template.ts @@ -0,0 +1,66 @@ +import { MTokenName } from '../../../../../config'; +import { importWithoutCache } from '../../../../../helpers/utils'; + +export const getDvAaveContractFromTemplate = async ( + mToken: MTokenName, + optionalParams?: Record, +) => { + const { vaultUseTokenLevelGreenList = false } = optionalParams || {}; + + const { getTokenContractNames } = await importWithoutCache( + require.resolve('../../../../../helpers/contracts'), + ); + + const { getRolesNamesForToken } = await importWithoutCache( + require.resolve('../../../../../helpers/roles'), + ); + const contractNames = getTokenContractNames(mToken); + const roles = getRolesNamesForToken(mToken); + + return { + name: contractNames.dvAave, + content: ` + // SPDX-License-Identifier: MIT + pragma solidity 0.8.9; + + import "../../DepositVaultWithAave.sol"; + import "./${contractNames.roles}.sol"; + + /** + * @title ${contractNames.dvAave} + * @notice Smart contract that handles ${ + contractNames.token + } minting with Aave V3 auto-invest + * @author RedDuck Software + */ + contract ${contractNames.dvAave} is + DepositVaultWithAave, + ${contractNames.roles} + { + /** + * @dev leaving a storage gap for futures updates + */ + uint256[50] private __gap; + + /** + * @inheritdoc ManageableVault + */ + function vaultRole() public pure override returns (bytes32) { + return ${roles.depositVaultAdmin}; + } + + ${ + vaultUseTokenLevelGreenList + ? ` + /** + * @inheritdoc Greenlistable + */ + function greenlistedRole() public pure override returns (bytes32) { + return ${roles.greenlisted}; + } + ` + : '' + } + }`, + }; +}; diff --git a/scripts/deploy/codegen/common/templates/dv-morpho.template.ts b/scripts/deploy/codegen/common/templates/dv-morpho.template.ts new file mode 100644 index 00000000..edb69743 --- /dev/null +++ b/scripts/deploy/codegen/common/templates/dv-morpho.template.ts @@ -0,0 +1,66 @@ +import { MTokenName } from '../../../../../config'; +import { importWithoutCache } from '../../../../../helpers/utils'; + +export const getDvMorphoContractFromTemplate = async ( + mToken: MTokenName, + optionalParams?: Record, +) => { + const { vaultUseTokenLevelGreenList = false } = optionalParams || {}; + + const { getTokenContractNames } = await importWithoutCache( + require.resolve('../../../../../helpers/contracts'), + ); + + const { getRolesNamesForToken } = await importWithoutCache( + require.resolve('../../../../../helpers/roles'), + ); + const contractNames = getTokenContractNames(mToken); + const roles = getRolesNamesForToken(mToken); + + return { + name: contractNames.dvMorpho, + content: ` + // SPDX-License-Identifier: MIT + pragma solidity 0.8.9; + + import "../../DepositVaultWithMorpho.sol"; + import "./${contractNames.roles}.sol"; + + /** + * @title ${contractNames.dvMorpho} + * @notice Smart contract that handles ${ + contractNames.token + } minting with Morpho auto-invest + * @author RedDuck Software + */ + contract ${contractNames.dvMorpho} is + DepositVaultWithMorpho, + ${contractNames.roles} + { + /** + * @dev leaving a storage gap for futures updates + */ + uint256[50] private __gap; + + /** + * @inheritdoc ManageableVault + */ + function vaultRole() public pure override returns (bytes32) { + return ${roles.depositVaultAdmin}; + } + + ${ + vaultUseTokenLevelGreenList + ? ` + /** + * @inheritdoc Greenlistable + */ + function greenlistedRole() public pure override returns (bytes32) { + return ${roles.greenlisted}; + } + ` + : '' + } + }`, + }; +}; diff --git a/scripts/deploy/codegen/common/templates/dv-mtoken.template.ts b/scripts/deploy/codegen/common/templates/dv-mtoken.template.ts new file mode 100644 index 00000000..fabd91ad --- /dev/null +++ b/scripts/deploy/codegen/common/templates/dv-mtoken.template.ts @@ -0,0 +1,66 @@ +import { MTokenName } from '../../../../../config'; +import { importWithoutCache } from '../../../../../helpers/utils'; + +export const getDvMTokenContractFromTemplate = async ( + mToken: MTokenName, + optionalParams?: Record, +) => { + const { vaultUseTokenLevelGreenList = false } = optionalParams || {}; + + const { getTokenContractNames } = await importWithoutCache( + require.resolve('../../../../../helpers/contracts'), + ); + + const { getRolesNamesForToken } = await importWithoutCache( + require.resolve('../../../../../helpers/roles'), + ); + const contractNames = getTokenContractNames(mToken); + const roles = getRolesNamesForToken(mToken); + + return { + name: contractNames.dvMToken, + content: ` + // SPDX-License-Identifier: MIT + pragma solidity 0.8.9; + + import "../../DepositVaultWithMToken.sol"; + import "./${contractNames.roles}.sol"; + + /** + * @title ${contractNames.dvMToken} + * @notice Smart contract that handles ${ + contractNames.token + } minting with mToken auto-invest + * @author RedDuck Software + */ + contract ${contractNames.dvMToken} is + DepositVaultWithMToken, + ${contractNames.roles} + { + /** + * @dev leaving a storage gap for futures updates + */ + uint256[50] private __gap; + + /** + * @inheritdoc ManageableVault + */ + function vaultRole() public pure override returns (bytes32) { + return ${roles.depositVaultAdmin}; + } + + ${ + vaultUseTokenLevelGreenList + ? ` + /** + * @inheritdoc Greenlistable + */ + function greenlistedRole() public pure override returns (bytes32) { + return ${roles.greenlisted}; + } + ` + : '' + } + }`, + }; +}; diff --git a/scripts/deploy/codegen/common/templates/index.ts b/scripts/deploy/codegen/common/templates/index.ts index 5a46816e..46e31aa7 100644 --- a/scripts/deploy/codegen/common/templates/index.ts +++ b/scripts/deploy/codegen/common/templates/index.ts @@ -1,8 +1,14 @@ export * from './aggregator.template'; export * from './data-feed.template'; export * from './dv.template'; +export * from './dv-aave.template'; +export * from './dv-morpho.template'; +export * from './dv-mtoken.template'; export * from './mtoken.template'; export * from './rv-swapper.template'; +export * from './rv-mtoken.template'; +export * from './rv-aave.template'; +export * from './rv-morpho.template'; export * from './rv-ustb.template'; export * from './rv.template'; export * from './token-roles.template'; diff --git a/scripts/deploy/codegen/common/templates/rv-aave.template.ts b/scripts/deploy/codegen/common/templates/rv-aave.template.ts new file mode 100644 index 00000000..89e520ae --- /dev/null +++ b/scripts/deploy/codegen/common/templates/rv-aave.template.ts @@ -0,0 +1,66 @@ +import { MTokenName } from '../../../../../config'; +import { importWithoutCache } from '../../../../../helpers/utils'; + +export const getRvAaveContractFromTemplate = async ( + mToken: MTokenName, + optionalParams?: Record, +) => { + const { vaultUseTokenLevelGreenList = false } = optionalParams || {}; + + const { getTokenContractNames } = await importWithoutCache( + require.resolve('../../../../../helpers/contracts'), + ); + + const { getRolesNamesForToken } = await importWithoutCache( + require.resolve('../../../../../helpers/roles'), + ); + const contractNames = getTokenContractNames(mToken); + const roles = getRolesNamesForToken(mToken); + + return { + name: contractNames.rvAave, + content: ` + // SPDX-License-Identifier: MIT + pragma solidity 0.8.9; + + import "../../RedemptionVaultWithAave.sol"; + import "./${contractNames.roles}.sol"; + + /** + * @title ${contractNames.rvAave} + * @notice Smart contract that handles ${ + contractNames.token + } redemptions via Aave V3 + * @author RedDuck Software + */ + contract ${contractNames.rvAave} is + RedemptionVaultWithAave, + ${contractNames.roles} + { + /** + * @dev leaving a storage gap for futures updates + */ + uint256[50] private __gap; + + /** + * @inheritdoc ManageableVault + */ + function vaultRole() public pure override returns (bytes32) { + return ${roles.redemptionVaultAdmin}; + } + + ${ + vaultUseTokenLevelGreenList + ? ` + /** + * @inheritdoc Greenlistable + */ + function greenlistedRole() public pure override returns (bytes32) { + return ${roles.greenlisted}; + } + ` + : '' + } + }`, + }; +}; diff --git a/scripts/deploy/codegen/common/templates/rv-morpho.template.ts b/scripts/deploy/codegen/common/templates/rv-morpho.template.ts new file mode 100644 index 00000000..36fc4caa --- /dev/null +++ b/scripts/deploy/codegen/common/templates/rv-morpho.template.ts @@ -0,0 +1,66 @@ +import { MTokenName } from '../../../../../config'; +import { importWithoutCache } from '../../../../../helpers/utils'; + +export const getRvMorphoContractFromTemplate = async ( + mToken: MTokenName, + optionalParams?: Record, +) => { + const { vaultUseTokenLevelGreenList = false } = optionalParams || {}; + + const { getTokenContractNames } = await importWithoutCache( + require.resolve('../../../../../helpers/contracts'), + ); + + const { getRolesNamesForToken } = await importWithoutCache( + require.resolve('../../../../../helpers/roles'), + ); + const contractNames = getTokenContractNames(mToken); + const roles = getRolesNamesForToken(mToken); + + return { + name: contractNames.rvMorpho, + content: ` + // SPDX-License-Identifier: MIT + pragma solidity 0.8.9; + + import "../../RedemptionVaultWithMorpho.sol"; + import "./${contractNames.roles}.sol"; + + /** + * @title ${contractNames.rvMorpho} + * @notice Smart contract that handles ${ + contractNames.token + } redemptions via Morpho Vault + * @author RedDuck Software + */ + contract ${contractNames.rvMorpho} is + RedemptionVaultWithMorpho, + ${contractNames.roles} + { + /** + * @dev leaving a storage gap for futures updates + */ + uint256[50] private __gap; + + /** + * @inheritdoc ManageableVault + */ + function vaultRole() public pure override returns (bytes32) { + return ${roles.redemptionVaultAdmin}; + } + + ${ + vaultUseTokenLevelGreenList + ? ` + /** + * @inheritdoc Greenlistable + */ + function greenlistedRole() public pure override returns (bytes32) { + return ${roles.greenlisted}; + } + ` + : '' + } + }`, + }; +}; diff --git a/scripts/deploy/codegen/common/templates/rv-mtoken.template.ts b/scripts/deploy/codegen/common/templates/rv-mtoken.template.ts new file mode 100644 index 00000000..6baa4c12 --- /dev/null +++ b/scripts/deploy/codegen/common/templates/rv-mtoken.template.ts @@ -0,0 +1,68 @@ +import { MTokenName } from '../../../../../config'; +import { importWithoutCache } from '../../../../../helpers/utils'; + +export const getRvMTokenContractFromTemplate = async ( + mToken: MTokenName, + optionalParams?: Record, +) => { + const { vaultUseTokenLevelGreenList = false } = optionalParams || {}; + + const { getTokenContractNames } = await importWithoutCache( + require.resolve('../../../../../helpers/contracts'), + ); + + const { getRolesNamesForToken } = await importWithoutCache( + require.resolve('../../../../../helpers/roles'), + ); + const contractNames = getTokenContractNames(mToken); + const roles = getRolesNamesForToken(mToken); + + return { + name: contractNames.rvMToken, + content: ` + // SPDX-License-Identifier: MIT + pragma solidity 0.8.9; + + import "../../RedemptionVaultWithMToken.sol"; + import "./${contractNames.roles}.sol"; + + /** + * @title ${contractNames.rvMToken} + * @notice Smart contract that handles ${ + contractNames.token + } redemptions using mToken + * liquid strategy. Upgrade-compatible replacement for + * ${contractNames.rvSwapper}. + * @author RedDuck Software + */ + contract ${contractNames.rvMToken} is + RedemptionVaultWithMToken, + ${contractNames.roles} + { + /** + * @dev leaving a storage gap for futures updates + */ + uint256[50] private __gap; + + /** + * @inheritdoc ManageableVault + */ + function vaultRole() public pure override returns (bytes32) { + return ${roles.redemptionVaultAdmin}; + } + + ${ + vaultUseTokenLevelGreenList + ? ` + /** + * @inheritdoc Greenlistable + */ + function greenlistedRole() public pure override returns (bytes32) { + return ${roles.greenlisted}; + } + ` + : '' + } + }`, + }; +}; diff --git a/scripts/deploy/codegen/common/templates/rv.template.ts b/scripts/deploy/codegen/common/templates/rv.template.ts index 8a66775c..b309a89b 100644 --- a/scripts/deploy/codegen/common/templates/rv.template.ts +++ b/scripts/deploy/codegen/common/templates/rv.template.ts @@ -29,7 +29,7 @@ export const getRvContractFromTemplate = async ( /** * @title ${contractNames.rv} - * @notice Smart contract that handles ${contractNames.token} minting + * @notice Smart contract that handles ${contractNames.token} redemptions * @author RedDuck Software */ contract ${contractNames.rv} is diff --git a/scripts/deploy/codegen/common/ui/deployment-config.ts b/scripts/deploy/codegen/common/ui/deployment-config.ts index 1c893627..6e599ad4 100644 --- a/scripts/deploy/codegen/common/ui/deployment-config.ts +++ b/scripts/deploy/codegen/common/ui/deployment-config.ts @@ -23,8 +23,14 @@ import { DeploymentConfig, PostDeployConfig } from '../../../common/types'; export const configsPerNetworkConfig = { dv: getDvConfigFromUser, + dvAave: getDvAaveConfigFromUser, + dvMorpho: getDvMorphoConfigFromUser, + dvMToken: getDvMTokenConfigFromUser, rv: getRvConfigFromUser, rvSwapper: getRvSwapperConfigFromUser, + rvMToken: getRvMTokenConfigFromUser, + rvAave: getRvAaveConfigFromUser, + rvMorpho: getRvMorphoConfigFromUser, genericConfig: getGenericConfigFromUser, postDeploy: { grantRoles: getPostDeployGrantRolesConfigFromUser, @@ -125,6 +131,235 @@ async function getDvConfigFromUser(hre: HardhatRuntimeEnvironment) { }; } +async function getDvAaveConfigFromUser(hre: HardhatRuntimeEnvironment) { + const config = await group({ + intro: () => + Promise.resolve(intro('Deposit Vault With Aave')).then(() => undefined), + feeReceiver: () => + text({ message: 'Fee Receiver', validate: validateAddress }) + .then(requireNotCancelled) + .then(requireAddress), + tokensReceiver: () => + text({ message: 'Tokens Receiver', validate: validateAddress }) + .then(requireNotCancelled) + .then(requireAddress), + instantDailyLimit: () => + text({ + message: 'Instant Daily Limit', + defaultValue: 'Infinite', + placeholder: 'Infinite', + validate: validateBase18OrInfinite, + }) + .then(requireNotCancelled) + .then(requireBase18OrInfinite), + instantFee: () => + text({ + message: 'Instant Fee', + defaultValue: '0', + placeholder: '0', + validate: validateFloat, + }) + .then(requireNotCancelled) + .then(requireFloatToBigNumberish), + variationTolerance: () => + text({ + message: 'Variation Tolerance', + validate: validateFloat, + }) + .then(requireNotCancelled) + .then(requirePercentageToBigNumberish), + minAmount: () => + text({ + message: 'Min Amount', + defaultValue: '0', + placeholder: '0', + validate: validateBase18, + }) + .then(requireNotCancelled) + .then(requireBase18), + minMTokenAmountForFirstDeposit: () => + text({ + message: 'Min mToken Amount For First Deposit', + defaultValue: '0', + placeholder: '0', + validate: validateBase18, + }) + .then(requireNotCancelled) + .then(requireBase18), + maxSupplyCap: () => + text({ + message: 'Max Supply Cap', + defaultValue: 'Infinite', + placeholder: 'Infinite', + validate: validateBase18OrInfinite, + }) + .then(requireNotCancelled) + .then(requireBase18OrInfinite), + outro: () => Promise.resolve(outro('Done...')).then(() => undefined), + }).then(clearIntroOutro); + + return { + type: 'AAVE' as const, + enableSanctionsList: shouldEnableSanctionsList(hre), + ...config, + }; +} + +async function getDvMorphoConfigFromUser(hre: HardhatRuntimeEnvironment) { + const config = await group({ + intro: () => + Promise.resolve(intro('Deposit Vault With Morpho')).then(() => undefined), + feeReceiver: () => + text({ message: 'Fee Receiver', validate: validateAddress }) + .then(requireNotCancelled) + .then(requireAddress), + tokensReceiver: () => + text({ message: 'Tokens Receiver', validate: validateAddress }) + .then(requireNotCancelled) + .then(requireAddress), + instantDailyLimit: () => + text({ + message: 'Instant Daily Limit', + defaultValue: 'Infinite', + placeholder: 'Infinite', + validate: validateBase18OrInfinite, + }) + .then(requireNotCancelled) + .then(requireBase18OrInfinite), + instantFee: () => + text({ + message: 'Instant Fee', + defaultValue: '0', + placeholder: '0', + validate: validateFloat, + }) + .then(requireNotCancelled) + .then(requireFloatToBigNumberish), + variationTolerance: () => + text({ + message: 'Variation Tolerance', + validate: validateFloat, + }) + .then(requireNotCancelled) + .then(requirePercentageToBigNumberish), + minAmount: () => + text({ + message: 'Min Amount', + defaultValue: '0', + placeholder: '0', + validate: validateBase18, + }) + .then(requireNotCancelled) + .then(requireBase18), + minMTokenAmountForFirstDeposit: () => + text({ + message: 'Min mToken Amount For First Deposit', + defaultValue: '0', + placeholder: '0', + validate: validateBase18, + }) + .then(requireNotCancelled) + .then(requireBase18), + maxSupplyCap: () => + text({ + message: 'Max Supply Cap', + defaultValue: 'Infinite', + placeholder: 'Infinite', + validate: validateBase18OrInfinite, + }) + .then(requireNotCancelled) + .then(requireBase18OrInfinite), + outro: () => Promise.resolve(outro('Done...')).then(() => undefined), + }).then(clearIntroOutro); + + return { + type: 'MORPHO' as const, + enableSanctionsList: shouldEnableSanctionsList(hre), + ...config, + }; +} + +async function getDvMTokenConfigFromUser(hre: HardhatRuntimeEnvironment) { + const config = await group({ + intro: () => + Promise.resolve(intro('Deposit Vault With MToken')).then(() => undefined), + feeReceiver: () => + text({ message: 'Fee Receiver', validate: validateAddress }) + .then(requireNotCancelled) + .then(requireAddress), + tokensReceiver: () => + text({ message: 'Tokens Receiver', validate: validateAddress }) + .then(requireNotCancelled) + .then(requireAddress), + mTokenDepositVault: () => + text({ + message: 'Target mToken DepositVault Address', + validate: validateAddress, + }) + .then(requireNotCancelled) + .then(requireAddress), + instantDailyLimit: () => + text({ + message: 'Instant Daily Limit', + defaultValue: 'Infinite', + placeholder: 'Infinite', + validate: validateBase18OrInfinite, + }) + .then(requireNotCancelled) + .then(requireBase18OrInfinite), + instantFee: () => + text({ + message: 'Instant Fee', + defaultValue: '0', + placeholder: '0', + validate: validateFloat, + }) + .then(requireNotCancelled) + .then(requireFloatToBigNumberish), + variationTolerance: () => + text({ + message: 'Variation Tolerance', + validate: validateFloat, + }) + .then(requireNotCancelled) + .then(requirePercentageToBigNumberish), + minAmount: () => + text({ + message: 'Min Amount', + defaultValue: '0', + placeholder: '0', + validate: validateBase18, + }) + .then(requireNotCancelled) + .then(requireBase18), + minMTokenAmountForFirstDeposit: () => + text({ + message: 'Min mToken Amount For First Deposit', + defaultValue: '0', + placeholder: '0', + validate: validateBase18, + }) + .then(requireNotCancelled) + .then(requireBase18), + maxSupplyCap: () => + text({ + message: 'Max Supply Cap', + defaultValue: 'Infinite', + placeholder: 'Infinite', + validate: validateBase18OrInfinite, + }) + .then(requireNotCancelled) + .then(requireBase18OrInfinite), + outro: () => Promise.resolve(outro('Done...')).then(() => undefined), + }).then(clearIntroOutro); + + return { + type: 'MTOKEN' as const, + enableSanctionsList: shouldEnableSanctionsList(hre), + ...config, + }; +} + async function getRvConfigFromUser( hre: HardhatRuntimeEnvironment, extendGroup?: PromptGroup, @@ -183,17 +418,70 @@ async function getRvConfigFromUser( }; } +async function getRvAaveConfigFromUser(hre: HardhatRuntimeEnvironment) { + const config = await getRvConfigFromUser( + hre, + {}, + 'Redemption Vault With Aave', + ); + + return { + ...config, + type: 'AAVE' as const, + }; +} + +async function getRvMorphoConfigFromUser(hre: HardhatRuntimeEnvironment) { + const config = await getRvConfigFromUser( + hre, + {}, + 'Redemption Vault With Morpho', + ); + + return { + ...config, + type: 'MORPHO' as const, + }; +} + +async function getRvMTokenConfigFromUser(hre: HardhatRuntimeEnvironment) { + const config = await getRvConfigFromUser( + hre, + { + redemptionVault: () => + text({ + message: 'mTokenA Redemption Vault Address', + validate: validateAddress, + }) + .then(requireNotCancelled) + .then(requireAddress), + }, + 'Redemption Vault With MToken', + ); + + return { + ...config, + type: 'MTOKEN' as const, + }; +} + const getVaultForSwapper = ( hre: HardhatRuntimeEnvironment, mToken: MTokenName, ) => { const addresses = getCurrentAddresses(hre); - if (addresses?.[mToken]?.redemptionVaultSwapper) { + if (addresses?.[mToken]?.redemptionVaultMToken) { + return 'redemptionVaultMToken'; + } else if (addresses?.[mToken]?.redemptionVaultSwapper) { return 'redemptionVaultSwapper'; } else if (addresses?.[mToken]?.redemptionVaultUstb) { return 'redemptionVaultUstb'; } else if (addresses?.[mToken]?.redemptionVaultBuidl) { return 'redemptionVaultBuidl'; + } else if (addresses?.[mToken]?.redemptionVaultAave) { + return 'redemptionVaultAave'; + } else if (addresses?.[mToken]?.redemptionVaultMorpho) { + return 'redemptionVaultMorpho'; } else if (addresses?.[mToken]?.redemptionVault) { return 'redemptionVault'; } @@ -390,7 +678,15 @@ export async function getDeploymentConfigFromUser( multiselect< keyof Pick< DeploymentConfig['networkConfigs'][number], - 'rv' | 'rvSwapper' | 'dv' + | 'rv' + | 'rvSwapper' + | 'rvMToken' + | 'rvAave' + | 'rvMorpho' + | 'dv' + | 'dvAave' + | 'dvMorpho' + | 'dvMToken' > >({ message: @@ -401,6 +697,21 @@ export async function getDeploymentConfigFromUser( label: 'Deposit Vault', hint: 'Deposit Vault contract', }, + { + value: 'dvAave', + label: 'Deposit Vault With Aave', + hint: 'Deposit Vault with Aave V3 auto-invest', + }, + { + value: 'dvMorpho', + label: 'Deposit Vault With Morpho', + hint: 'Deposit Vault with Morpho auto-invest', + }, + { + value: 'dvMToken', + label: 'Deposit Vault With MToken', + hint: 'Deposit Vault with mToken auto-invest', + }, { value: 'rv', label: 'Redemption Vault', @@ -411,6 +722,21 @@ export async function getDeploymentConfigFromUser( label: 'Redemption Vault With Swapper', hint: 'Redemption Vault With Swapper contract', }, + { + value: 'rvMToken', + label: 'Redemption Vault With MToken', + hint: 'Redemption Vault With MToken liquid strategy contract', + }, + { + value: 'rvAave', + label: 'Redemption Vault With Aave', + hint: 'Redemption Vault With Aave V3 contract', + }, + { + value: 'rvMorpho', + label: 'Redemption Vault With Morpho', + hint: 'Redemption Vault With Morpho Vault (ERC-4626) contract', + }, ], initialValues: ['dv', 'rvSwapper'], required: true, @@ -517,7 +843,7 @@ const validateBase18 = (value: string) => { try { parseUnits(value, 18); return undefined; - } catch (_) { + } catch { return new Error('Invalid value'); } }; @@ -531,7 +857,7 @@ const validateFloat = (value: string, maxDecimals = 2) => { try { parseUnits(value, maxDecimals); return undefined; - } catch (_) { + } catch { return new Error('Invalid float'); } }; diff --git a/scripts/deploy/codegen/common/ui/deployment-contracts.ts b/scripts/deploy/codegen/common/ui/deployment-contracts.ts index f83773b0..a6e79672 100644 --- a/scripts/deploy/codegen/common/ui/deployment-contracts.ts +++ b/scripts/deploy/codegen/common/ui/deployment-contracts.ts @@ -88,11 +88,41 @@ export const getContractsToGenerateFromUser = async () => { label: 'Redemption Vault With Swapper', hint: 'Redemption Vault With Swapper contract', }, + { + value: 'rvMToken', + label: 'Redemption Vault With MToken', + hint: 'Redemption Vault With MToken liquid strategy contract', + }, { value: 'rvUstb', label: 'Redemption Vault With USTB', hint: 'Redemption Vault With USTB contract', }, + { + value: 'rvAave', + label: 'Redemption Vault With Aave', + hint: 'Redemption Vault With Aave V3 contract', + }, + { + value: 'rvMorpho', + label: 'Redemption Vault With Morpho', + hint: 'Redemption Vault With Morpho Vault (ERC-4626) contract', + }, + { + value: 'dvAave', + label: 'Deposit Vault With Aave', + hint: 'Deposit Vault with Aave V3 auto-invest contract', + }, + { + value: 'dvMorpho', + label: 'Deposit Vault With Morpho', + hint: 'Deposit Vault with Morpho auto-invest contract', + }, + { + value: 'dvMToken', + label: 'Deposit Vault With MToken', + hint: 'Deposit Vault with mToken auto-invest contract', + }, { value: 'dataFeed', label: 'Data Feed', hint: 'Data Feed contract' }, { value: 'customAggregator', diff --git a/scripts/deploy/common/dv.ts b/scripts/deploy/common/dv.ts index 01a0fa3a..32bd76a3 100644 --- a/scripts/deploy/common/dv.ts +++ b/scripts/deploy/common/dv.ts @@ -1,21 +1,25 @@ import { BigNumberish, constants } from 'ethers'; import { HardhatRuntimeEnvironment } from 'hardhat/types'; -import { RvType } from './types'; import { deployAndVerifyProxy, getDeployer, getNetworkConfig } from './utils'; import { MTokenName } from '../../../config'; import { getCurrentAddresses, + RedemptionVaultType, sanctionListContracts, ustbContracts, } from '../../../config/constants/addresses'; import { getTokenContractNames } from '../../../helpers/contracts'; -import { DepositVault, DepositVaultWithUSTB } from '../../../typechain-types'; +import { + DepositVault, + DepositVaultWithMToken, + DepositVaultWithUSTB, +} from '../../../typechain-types'; export type DeployDvConfigCommon = { feeReceiver?: string; - tokensReceiver?: `0x${string}` | RvType; + tokensReceiver?: `0x${string}` | RedemptionVaultType; instantDailyLimit: BigNumberish; instantFee: BigNumberish; enableSanctionsList?: boolean; @@ -42,7 +46,25 @@ export type DeployDvUstbConfig = DeployDvConfigCommon & { type: 'USTB'; }; -export type DeployDvConfig = DeployDvRegularConfig | DeployDvUstbConfig; +export type DeployDvAaveConfig = DeployDvConfigCommon & { + type: 'AAVE'; +}; + +export type DeployDvMorphoConfig = DeployDvConfigCommon & { + type: 'MORPHO'; +}; + +export type DeployDvMTokenConfig = DeployDvConfigCommon & { + type: 'MTOKEN'; + mTokenDepositVault: string; +}; + +export type DeployDvConfig = + | DeployDvRegularConfig + | DeployDvUstbConfig + | DeployDvAaveConfig + | DeployDvMorphoConfig + | DeployDvMTokenConfig; const isAddress = (value: string): value is `0x${string}` => { return value.startsWith('0x'); @@ -51,7 +73,7 @@ const isAddress = (value: string): value is `0x${string}` => { export const deployDepositVault = async ( hre: HardhatRuntimeEnvironment, token: MTokenName, - type: 'dv' | 'dvUstb', + type: 'dv' | 'dvUstb' | 'dvAave' | 'dvMorpho' | 'dvMToken', ) => { const addresses = getCurrentAddresses(hre); const deployer = await getDeployer(hre); @@ -116,6 +138,8 @@ export const deployDepositVault = async ( } extraParams.push(ustbContract); + } else if (networkConfig.type === 'MTOKEN') { + extraParams.push(networkConfig.mTokenDepositVault); } const params = [ @@ -142,12 +166,15 @@ export const deployDepositVault = async ( | Parameters | Parameters< DepositVaultWithUSTB['initialize(address,(address,address),(address,address),(uint256,uint256),address,uint256,uint256,uint256,uint256,address)'] + > + | Parameters< + DepositVaultWithMToken['initialize(address,(address,address),(address,address),(uint256,uint256),address,uint256,uint256,uint256,uint256,address)'] >; await deployAndVerifyProxy(hre, dvContractName, params, undefined, { initializer: - networkConfig.type === 'USTB' - ? 'initialize(address,(address,address),(address,address),(uint256,uint256),address,uint256,uint256,uint256,address)' + networkConfig.type === 'USTB' || networkConfig.type === 'MTOKEN' + ? 'initialize(address,(address,address),(address,address),(uint256,uint256),address,uint256,uint256,uint256,uint256,address)' : 'initialize', }); }; diff --git a/scripts/deploy/common/roles.ts b/scripts/deploy/common/roles.ts index 235c4a45..bf9c7a2b 100644 --- a/scripts/deploy/common/roles.ts +++ b/scripts/deploy/common/roles.ts @@ -7,6 +7,11 @@ import { getNetworkConfig, sendAndWaitForCustomTxSign, } from './utils'; +import { + defaultDepositVaultPriority, + resolveVaultAddress, + roleGrantRedemptionVaultPriority, +} from './vault-resolver'; import { MTokenName } from '../../../config'; import { getCurrentAddresses } from '../../../config/constants/addresses'; @@ -74,12 +79,14 @@ export const grantAllProductRoles = async ( const contractsRoles: string[] = []; const contractsAddresses: string[] = []; - const depositVault = tokenAddresses.depositVault; - const redemptionVault = - tokenAddresses.redemptionVaultSwapper ?? - tokenAddresses.redemptionVaultBuidl ?? - tokenAddresses.redemptionVaultUstb ?? - tokenAddresses.redemptionVault; + const depositVault = resolveVaultAddress( + tokenAddresses, + defaultDepositVaultPriority, + ); + const redemptionVault = resolveVaultAddress( + tokenAddresses, + roleGrantRedemptionVaultPriority, + ); if (depositVault) { contractsRoles.push(tokenRoles.minter); diff --git a/scripts/deploy/common/rv.ts b/scripts/deploy/common/rv.ts index f22d58ea..dfaee5c2 100644 --- a/scripts/deploy/common/rv.ts +++ b/scripts/deploy/common/rv.ts @@ -14,6 +14,7 @@ import { getTokenContractNames } from '../../../helpers/contracts'; import { MBasisRedemptionVaultWithSwapper, RedemptionVault, + RedemptionVaultWithMToken, RedemptionVaultWIthBUIDL, } from '../../../typechain-types'; @@ -67,17 +68,33 @@ export type DeployRvSwapperConfig = { liquidityProvider?: `0x${string}` | 'dummy'; } & DeployRvConfigCommon; +export type DeployRvAaveConfig = { + type: 'AAVE'; +} & DeployRvConfigCommon; + +export type DeployRvMorphoConfig = { + type: 'MORPHO'; +} & DeployRvConfigCommon; + +export type DeployRvMTokenConfig = { + type: 'MTOKEN'; + redemptionVault: string; +} & DeployRvConfigCommon; + export type DeployRvConfig = | DeployRvRegularConfig | DeployRvBuidlConfig - | DeployRvSwapperConfig; + | DeployRvSwapperConfig + | DeployRvAaveConfig + | DeployRvMorphoConfig + | DeployRvMTokenConfig; const DUMMY_ADDRESS = '0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF'; export const deployRedemptionVault = async ( hre: HardhatRuntimeEnvironment, token: MTokenName, - type: 'rv' | 'rvBuidl' | 'rvSwapper', + type: 'rv' | 'rvBuidl' | 'rvSwapper' | 'rvAave' | 'rvMorpho' | 'rvMToken', ) => { const addresses = getCurrentAddresses(hre); const deployer = await getDeployer(hre); @@ -97,7 +114,9 @@ export const deployRedemptionVault = async ( const extraParams: unknown[] = []; - if (networkConfig.type === 'BUIDL') { + if (networkConfig.type === 'MTOKEN') { + extraParams.push(networkConfig.redemptionVault); + } else if (networkConfig.type === 'BUIDL') { extraParams.push(networkConfig.buidlRedemption); extraParams.push(networkConfig.minBuidlToRedeem); extraParams.push(networkConfig.minBuidlBalance); @@ -183,6 +202,9 @@ export const deployRedemptionVault = async ( > | Parameters< MBasisRedemptionVaultWithSwapper['initialize(address,(address,address),(address,address),(uint256,uint256),address,uint256,uint256,(uint256,uint256,uint256),address,address,address)'] + > + | Parameters< + RedemptionVaultWithMToken['initialize(address,(address,address),(address,address),(uint256,uint256),address,uint256,uint256,(uint256,uint256,uint256),address,address)'] >; await deployAndVerifyProxy(hre, contractName, params, undefined, { @@ -191,6 +213,8 @@ export const deployRedemptionVault = async ( ? 'initialize(address,(address,address),(address,address),(uint256,uint256),address,uint256,uint256,(uint256,uint256,uint256),address,address,address)' : networkConfig.type === 'BUIDL' ? 'initialize(address,(address,address),(address,address),(uint256,uint256),address,uint256,uint256,(uint256,uint256,uint256),address,address,uint256,uint256)' + : networkConfig.type === 'MTOKEN' + ? 'initialize(address,(address,address),(address,address),(uint256,uint256),address,uint256,uint256,(uint256,uint256,uint256),address,address)' : 'initialize', }); }; diff --git a/scripts/deploy/common/types.ts b/scripts/deploy/common/types.ts index ee481921..6424b0f4 100644 --- a/scripts/deploy/common/types.ts +++ b/scripts/deploy/common/types.ts @@ -8,13 +8,22 @@ import { DeployDataFeedConfig, SetRoundDataConfig, } from './data-feed'; -import { DeployDvRegularConfig, DeployDvUstbConfig } from './dv'; +import { + DeployDvAaveConfig, + DeployDvMorphoConfig, + DeployDvMTokenConfig, + DeployDvRegularConfig, + DeployDvUstbConfig, +} from './dv'; import { GrantAllTokenRolesConfig, GrantDefaultAdminRoleToAcAdminConfig, } from './roles'; import { + DeployRvAaveConfig, DeployRvBuidlConfig, + DeployRvMorphoConfig, + DeployRvMTokenConfig, DeployRvRegularConfig, DeployRvSwapperConfig, } from './rv'; @@ -93,9 +102,15 @@ export type DeploymentConfig = { { dv?: DeployDvRegularConfig; dvUstb?: DeployDvUstbConfig; + dvAave?: DeployDvAaveConfig; + dvMorpho?: DeployDvMorphoConfig; + dvMToken?: DeployDvMTokenConfig; rv?: DeployRvRegularConfig; rvBuidl?: DeployRvBuidlConfig; rvSwapper?: DeployRvSwapperConfig; + rvAave?: DeployRvAaveConfig; + rvMorpho?: DeployRvMorphoConfig; + rvMToken?: DeployRvMTokenConfig; postDeploy?: PostDeployConfig; } >; @@ -132,9 +147,4 @@ export type NetworkDeploymentConfig = Record< } >; -export type RvType = - | 'redemptionVault' - | 'redemptionVaultBuidl' - | 'redemptionVaultSwapper'; - export type DeployFunction = (hre: HardhatRuntimeEnvironment) => Promise; diff --git a/scripts/deploy/common/vault-resolver.ts b/scripts/deploy/common/vault-resolver.ts new file mode 100644 index 00000000..305e7bc9 --- /dev/null +++ b/scripts/deploy/common/vault-resolver.ts @@ -0,0 +1,45 @@ +import { + DepositVaultType, + RedemptionVaultType, + TokenAddresses, +} from '../../../config/constants/addresses'; + +export const defaultDepositVaultPriority: DepositVaultType[] = [ + 'depositVault', + 'depositVaultUstb', + 'depositVaultAave', + 'depositVaultMorpho', + 'depositVaultMToken', +]; + +export const routingRedemptionVaultPriority: RedemptionVaultType[] = [ + 'redemptionVaultMToken', + 'redemptionVaultSwapper', + 'redemptionVaultUstb', + 'redemptionVaultAave', + 'redemptionVaultMorpho', + 'redemptionVault', + 'redemptionVaultBuidl', +]; + +export const roleGrantRedemptionVaultPriority: RedemptionVaultType[] = [ + 'redemptionVaultMToken', + 'redemptionVaultSwapper', + 'redemptionVaultBuidl', + 'redemptionVaultUstb', + 'redemptionVaultAave', + 'redemptionVaultMorpho', + 'redemptionVault', +]; + +export const resolveVaultAddress = ( + tokenAddresses: TokenAddresses, + priorities: readonly (DepositVaultType | RedemptionVaultType)[], +): string | undefined => { + for (const key of priorities) { + const value = tokenAddresses[key]; + if (value) return value; + } + + return undefined; +}; diff --git a/scripts/deploy/deploy_DVAave.ts b/scripts/deploy/deploy_DVAave.ts new file mode 100644 index 00000000..3d2b8563 --- /dev/null +++ b/scripts/deploy/deploy_DVAave.ts @@ -0,0 +1,13 @@ +import { HardhatRuntimeEnvironment } from 'hardhat/types'; + +import { deployDepositVault } from './common'; +import { DeployFunction } from './common/types'; + +import { getMTokenOrThrow } from '../../helpers/utils'; + +const func: DeployFunction = async (hre: HardhatRuntimeEnvironment) => { + const mToken = getMTokenOrThrow(hre); + await deployDepositVault(hre, mToken, 'dvAave'); +}; + +export default func; diff --git a/scripts/deploy/deploy_DVMToken.ts b/scripts/deploy/deploy_DVMToken.ts new file mode 100644 index 00000000..aa7de28a --- /dev/null +++ b/scripts/deploy/deploy_DVMToken.ts @@ -0,0 +1,13 @@ +import { HardhatRuntimeEnvironment } from 'hardhat/types'; + +import { deployDepositVault } from './common'; +import { DeployFunction } from './common/types'; + +import { getMTokenOrThrow } from '../../helpers/utils'; + +const func: DeployFunction = async (hre: HardhatRuntimeEnvironment) => { + const mToken = getMTokenOrThrow(hre); + await deployDepositVault(hre, mToken, 'dvMToken'); +}; + +export default func; diff --git a/scripts/deploy/deploy_DVMorpho.ts b/scripts/deploy/deploy_DVMorpho.ts new file mode 100644 index 00000000..b9b147a1 --- /dev/null +++ b/scripts/deploy/deploy_DVMorpho.ts @@ -0,0 +1,13 @@ +import { HardhatRuntimeEnvironment } from 'hardhat/types'; + +import { deployDepositVault } from './common'; +import { DeployFunction } from './common/types'; + +import { getMTokenOrThrow } from '../../helpers/utils'; + +const func: DeployFunction = async (hre: HardhatRuntimeEnvironment) => { + const mToken = getMTokenOrThrow(hre); + await deployDepositVault(hre, mToken, 'dvMorpho'); +}; + +export default func; diff --git a/scripts/deploy/deploy_RVAave.ts b/scripts/deploy/deploy_RVAave.ts new file mode 100644 index 00000000..829c59be --- /dev/null +++ b/scripts/deploy/deploy_RVAave.ts @@ -0,0 +1,13 @@ +import { HardhatRuntimeEnvironment } from 'hardhat/types'; + +import { deployRedemptionVault } from './common'; +import { DeployFunction } from './common/types'; + +import { getMTokenOrThrow } from '../../helpers/utils'; + +const func: DeployFunction = async (hre: HardhatRuntimeEnvironment) => { + const mToken = getMTokenOrThrow(hre); + await deployRedemptionVault(hre, mToken, 'rvAave'); +}; + +export default func; diff --git a/scripts/deploy/deploy_RVMToken.ts b/scripts/deploy/deploy_RVMToken.ts new file mode 100644 index 00000000..d29eeba7 --- /dev/null +++ b/scripts/deploy/deploy_RVMToken.ts @@ -0,0 +1,13 @@ +import { HardhatRuntimeEnvironment } from 'hardhat/types'; + +import { deployRedemptionVault } from './common'; +import { DeployFunction } from './common/types'; + +import { getMTokenOrThrow } from '../../helpers/utils'; + +const func: DeployFunction = async (hre: HardhatRuntimeEnvironment) => { + const mToken = getMTokenOrThrow(hre); + await deployRedemptionVault(hre, mToken, 'rvMToken'); +}; + +export default func; diff --git a/scripts/deploy/deploy_RVMorpho.ts b/scripts/deploy/deploy_RVMorpho.ts new file mode 100644 index 00000000..189ad368 --- /dev/null +++ b/scripts/deploy/deploy_RVMorpho.ts @@ -0,0 +1,13 @@ +import { HardhatRuntimeEnvironment } from 'hardhat/types'; + +import { deployRedemptionVault } from './common'; +import { DeployFunction } from './common/types'; + +import { getMTokenOrThrow } from '../../helpers/utils'; + +const func: DeployFunction = async (hre: HardhatRuntimeEnvironment) => { + const mToken = getMTokenOrThrow(hre); + await deployRedemptionVault(hre, mToken, 'rvMorpho'); +}; + +export default func; diff --git a/scripts/deploy/misc/acre/deploy_AcreAdapter.ts b/scripts/deploy/misc/acre/deploy_AcreAdapter.ts index e718ef69..ccaba3aa 100644 --- a/scripts/deploy/misc/acre/deploy_AcreAdapter.ts +++ b/scripts/deploy/misc/acre/deploy_AcreAdapter.ts @@ -48,6 +48,12 @@ const func: DeployFunction = async (hre: HardhatRuntimeEnvironment) => { { value: 'depositVaultUstb', }, + { + value: 'depositVaultAave', + }, + { + value: 'depositVaultMorpho', + }, ], initialValue: 'depositVault', }), @@ -64,6 +70,18 @@ const func: DeployFunction = async (hre: HardhatRuntimeEnvironment) => { { value: 'redemptionVaultBuidl', }, + { + value: 'redemptionVaultUstb', + }, + { + value: 'redemptionVaultAave', + }, + { + value: 'redemptionVaultMorpho', + }, + { + value: 'redemptionVaultMToken', + }, ], initialValue: 'redemptionVaultSwapper', }), diff --git a/scripts/deploy/misc/axelar/deploy_Executable.ts b/scripts/deploy/misc/axelar/deploy_Executable.ts index 36b1a805..610efb2d 100644 --- a/scripts/deploy/misc/axelar/deploy_Executable.ts +++ b/scripts/deploy/misc/axelar/deploy_Executable.ts @@ -8,6 +8,11 @@ import { } from '../../../../helpers/utils'; import { DeployFunction } from '../../common/types'; import { deployAndVerifyProxy } from '../../common/utils'; +import { + defaultDepositVaultPriority, + resolveVaultAddress, + routingRedemptionVaultPriority, +} from '../../common/vault-resolver'; const func: DeployFunction = async (hre: HardhatRuntimeEnvironment) => { const mToken = getMTokenOrThrow(hre); @@ -24,6 +29,22 @@ const func: DeployFunction = async (hre: HardhatRuntimeEnvironment) => { throw new Error('mToken axelar tokenId is not found'); } + const depositVault = resolveVaultAddress( + mTokenAddresses, + defaultDepositVaultPriority, + ); + if (!depositVault) { + throw new Error('Deposit vault not found'); + } + + const redemptionVault = resolveVaultAddress( + mTokenAddresses, + routingRedemptionVaultPriority, + ); + if (!redemptionVault) { + throw new Error('Redemption vault not found'); + } + const pTokenId = addresses?.paymentTokens?.[pToken]?.axelar?.tokenId; if (!pTokenId) { @@ -33,11 +54,8 @@ const func: DeployFunction = async (hre: HardhatRuntimeEnvironment) => { await deployAndVerifyProxy(hre, 'MidasAxelarVaultExecutable', [], undefined, { unsafeAllow: ['state-variable-immutable', 'constructor'], constructorArgs: [ - mTokenAddresses.depositVault!, - mTokenAddresses.redemptionVaultSwapper ?? - mTokenAddresses.redemptionVaultUstb ?? - mTokenAddresses.redemptionVault ?? - mTokenAddresses.redemptionVaultBuidl!, + depositVault, + redemptionVault, pTokenId, mTokenAddresses.axelar?.tokenId, axelarItsAddress, diff --git a/scripts/deploy/misc/layerzero/deploy_Composer.ts b/scripts/deploy/misc/layerzero/deploy_Composer.ts index d25839e9..715fc135 100644 --- a/scripts/deploy/misc/layerzero/deploy_Composer.ts +++ b/scripts/deploy/misc/layerzero/deploy_Composer.ts @@ -7,6 +7,11 @@ import { } from '../../../../helpers/utils'; import { DeployFunction } from '../../common/types'; import { deployAndVerifyProxy } from '../../common/utils'; +import { + defaultDepositVaultPriority, + resolveVaultAddress, + routingRedemptionVaultPriority, +} from '../../common/vault-resolver'; const func: DeployFunction = async (hre: HardhatRuntimeEnvironment) => { const mToken = getMTokenOrThrow(hre); @@ -23,6 +28,22 @@ const func: DeployFunction = async (hre: HardhatRuntimeEnvironment) => { throw new Error('mToken layerzero adapter not found'); } + const depositVault = resolveVaultAddress( + mTokenAddresses, + defaultDepositVaultPriority, + ); + if (!depositVault) { + throw new Error('Deposit vault not found'); + } + + const redemptionVault = resolveVaultAddress( + mTokenAddresses, + routingRedemptionVaultPriority, + ); + if (!redemptionVault) { + throw new Error('Redemption vault not found'); + } + const pTokenOft = addresses?.paymentTokens?.[pToken]?.layerZero?.oft; if (!pTokenOft) { @@ -32,11 +53,8 @@ const func: DeployFunction = async (hre: HardhatRuntimeEnvironment) => { await deployAndVerifyProxy(hre, 'MidasLzVaultComposerSync', [], undefined, { unsafeAllow: ['state-variable-immutable'], constructorArgs: [ - mTokenAddresses.depositVault!, - mTokenAddresses.redemptionVaultSwapper ?? - mTokenAddresses.redemptionVaultUstb ?? - mTokenAddresses.redemptionVault ?? - mTokenAddresses.redemptionVaultBuidl!, + depositVault, + redemptionVault, pTokenOft, mTokenAddresses.layerZero.oft, ], diff --git a/scripts/deploy/post-deploy/set_SanctionsList.ts b/scripts/deploy/post-deploy/set_SanctionsList.ts index 2cbba522..d43a1a20 100644 --- a/scripts/deploy/post-deploy/set_SanctionsList.ts +++ b/scripts/deploy/post-deploy/set_SanctionsList.ts @@ -3,6 +3,7 @@ import { HardhatRuntimeEnvironment } from 'hardhat/types'; import { getCurrentAddresses, sanctionListContracts, + VaultType, } from '../../../config/constants/addresses'; import { getChainOrThrow, getMTokenOrThrow } from '../../../helpers/utils'; import { DeployFunction } from '../common/types'; @@ -25,12 +26,20 @@ const func: DeployFunction = async (hre: HardhatRuntimeEnvironment) => { return; } - const vaultEntries: Array<{ type: string; address?: string }> = [ + const vaultEntries: Array<{ type: VaultType; address?: string }> = [ { type: 'depositVault', address: tokenAddresses.depositVault }, { type: 'depositVaultUstb', address: tokenAddresses.depositVaultUstb, }, + { + type: 'depositVaultAave', + address: tokenAddresses.depositVaultAave, + }, + { + type: 'depositVaultMorpho', + address: tokenAddresses.depositVaultMorpho, + }, { type: 'redemptionVault', address: tokenAddresses.redemptionVault, @@ -47,6 +56,18 @@ const func: DeployFunction = async (hre: HardhatRuntimeEnvironment) => { type: 'redemptionVaultUstb', address: tokenAddresses.redemptionVaultUstb, }, + { + type: 'redemptionVaultAave', + address: tokenAddresses.redemptionVaultAave, + }, + { + type: 'redemptionVaultMorpho', + address: tokenAddresses.redemptionVaultMorpho, + }, + { + type: 'redemptionVaultMToken', + address: tokenAddresses.redemptionVaultMToken, + }, ]; for (const { type, address } of vaultEntries) { diff --git a/scripts/upgrades/upgrade_RedemptionVaultMToken.ts b/scripts/upgrades/upgrade_RedemptionVaultMToken.ts new file mode 100644 index 00000000..1943ca53 --- /dev/null +++ b/scripts/upgrades/upgrade_RedemptionVaultMToken.ts @@ -0,0 +1,54 @@ +import { HardhatRuntimeEnvironment } from 'hardhat/types'; + +import { getCurrentAddresses } from '../../config/constants/addresses'; +import { getTokenContractNames } from '../../helpers/contracts'; +import { getMTokenOrThrow } from '../../helpers/utils'; +import { DeployFunction } from '../deploy/common/types'; +import { getDeployer } from '../deploy/common/utils'; + +/** + * Upgrades a RedemptionVaultWithSwapper proxy to use the + * RedemptionVaultWithMToken implementation. + * + * Usage: + * npx hardhat runscript scripts/upgrades/upgrade_RedemptionVaultMToken.ts --mtoken mFONE --network + * + * The script uses `prepareUpgrade` which validates storage layout + * compatibility and deploys the new implementation (if changed). + */ +const func: DeployFunction = async (hre: HardhatRuntimeEnvironment) => { + const mToken = getMTokenOrThrow(hre); + + const addresses = getCurrentAddresses(hre); + const proxyAddress = addresses?.[mToken]?.redemptionVaultSwapper; + + if (!proxyAddress) { + throw new Error( + `No redemptionVaultSwapper address found for ${mToken} on chain ${hre.network.config.chainId}`, + ); + } + + const deployer = await getDeployer(hre); + const contractName = getTokenContractNames(mToken).rvMToken; + + console.log( + `Upgrading ${mToken} Swapper proxy (${proxyAddress}) -> ${contractName}`, + ); + + const deployment = await hre.upgrades.prepareUpgrade( + proxyAddress, + await hre.ethers.getContractFactory(contractName, deployer), + { + redeployImplementation: 'onchange', + }, + ); + + if (typeof deployment !== 'string') { + await deployment.wait(5); + console.log('New implementation deployed at:', deployment.to); + } else { + console.log('Implementation address:', deployment); + } +}; + +export default func; diff --git a/test/common/deposit-vault-aave.helpers.ts b/test/common/deposit-vault-aave.helpers.ts new file mode 100644 index 00000000..4ca9402c --- /dev/null +++ b/test/common/deposit-vault-aave.helpers.ts @@ -0,0 +1,310 @@ +import { expect } from 'chai'; +import { BigNumberish } from 'ethers'; + +import { + AccountOrContract, + OptionalCommonParams, + balanceOfBase18, + getAccount, +} from './common.helpers'; +import { + depositInstantTest, + depositRequestTest, +} from './deposit-vault.helpers'; +import { defaultDeploy } from './fixtures'; + +import { + AaveV3PoolMock, + ERC20, + ERC20__factory, + IERC20Metadata, +} from '../../typechain-types'; + +type CommonParamsDeposit = { + aavePoolMock: AaveV3PoolMock; +} & Pick< + Awaited>, + 'depositVaultWithAave' | 'owner' | 'mTBILL' | 'mTokenToUsdDataFeed' +>; + +type CommonParamsSetAaveDepositsEnabled = Pick< + Awaited>, + 'depositVaultWithAave' | 'owner' +>; + +type CommonParamsSetAavePool = Pick< + Awaited>, + 'depositVaultWithAave' | 'owner' +>; + +export const setAaveDepositsEnabledTest = async ( + { depositVaultWithAave, owner }: CommonParamsSetAaveDepositsEnabled, + enabled: boolean, + opt?: OptionalCommonParams, +) => { + if (opt?.revertMessage) { + await expect( + depositVaultWithAave + .connect(opt?.from ?? owner) + .setAaveDepositsEnabled(enabled), + ).revertedWith(opt?.revertMessage); + return; + } + + await expect( + depositVaultWithAave + .connect(opt?.from ?? owner) + .setAaveDepositsEnabled(enabled), + ).to.emit( + depositVaultWithAave, + depositVaultWithAave.interface.events['SetAaveDepositsEnabled(bool)'].name, + ).to.not.reverted; + + const aaveEnabledAfter = await depositVaultWithAave.aaveDepositsEnabled(); + expect(aaveEnabledAfter).eq(enabled); +}; + +export const setAavePoolTest = async ( + { depositVaultWithAave, owner }: CommonParamsSetAavePool, + token: string, + pool: string, + opt?: OptionalCommonParams, +) => { + if (opt?.revertMessage) { + await expect( + depositVaultWithAave.connect(opt?.from ?? owner).setAavePool(token, pool), + ).revertedWith(opt?.revertMessage); + return; + } + + await expect( + depositVaultWithAave.connect(opt?.from ?? owner).setAavePool(token, pool), + ).to.emit( + depositVaultWithAave, + depositVaultWithAave.interface.events[ + 'SetAavePool(address,address,address)' + ].name, + ).to.not.reverted; + + const poolAfter = await depositVaultWithAave.aavePools(token); + expect(poolAfter).eq(pool); +}; + +export const removeAavePoolTest = async ( + { depositVaultWithAave, owner }: CommonParamsSetAavePool, + token: string, + opt?: OptionalCommonParams, +) => { + if (opt?.revertMessage) { + await expect( + depositVaultWithAave.connect(opt?.from ?? owner).removeAavePool(token), + ).revertedWith(opt?.revertMessage); + return; + } + + await expect( + depositVaultWithAave.connect(opt?.from ?? owner).removeAavePool(token), + ).to.emit( + depositVaultWithAave, + depositVaultWithAave.interface.events['RemoveAavePool(address,address)'] + .name, + ).to.not.reverted; + + const poolAfter = await depositVaultWithAave.aavePools(token); + expect(poolAfter).eq('0x0000000000000000000000000000000000000000'); +}; + +export const depositInstantWithAaveTest = async ( + { + depositVaultWithAave, + owner, + mTBILL, + mTokenToUsdDataFeed, + aavePoolMock, + waivedFee, + minAmount, + customRecipient, + expectedAaveDeposited = true, + }: CommonParamsDeposit & { + expectedAaveDeposited?: boolean; + waivedFee?: boolean; + minAmount?: BigNumberish; + customRecipient?: AccountOrContract; + }, + tokenIn: ERC20 | IERC20Metadata | string, + amountUsdIn: number, + opt?: OptionalCommonParams, +) => { + tokenIn = getAccount(tokenIn); + + if (opt?.revertMessage) { + await depositInstantTest( + { + depositVault: depositVaultWithAave, + owner, + mTBILL, + mTokenToUsdDataFeed, + waivedFee, + minAmount, + customRecipient, + checkTokensReceiver: !expectedAaveDeposited, + }, + tokenIn, + amountUsdIn, + opt, + ); + return; + } + + const tokensReceiver = await depositVaultWithAave.tokensReceiver(); + const aaveEnabledBefore = await depositVaultWithAave.aaveDepositsEnabled(); + + const aTokenAddress = await aavePoolMock.getReserveAToken(tokenIn); + const aTokenContract = ERC20__factory.connect(aTokenAddress, owner); + + const aTokenReceiverBalanceBefore = await balanceOfBase18( + aTokenContract, + tokensReceiver, + ); + + await depositInstantTest( + { + depositVault: depositVaultWithAave, + owner, + mTBILL, + mTokenToUsdDataFeed, + waivedFee, + minAmount, + customRecipient, + checkTokensReceiver: !expectedAaveDeposited, + }, + tokenIn, + amountUsdIn, + opt, + ); + + const aaveEnabledAfter = await depositVaultWithAave.aaveDepositsEnabled(); + expect(aaveEnabledAfter).eq(aaveEnabledBefore); + + if (aaveEnabledAfter && expectedAaveDeposited) { + const aTokenReceiverBalanceAfter = await balanceOfBase18( + aTokenContract, + tokensReceiver, + ); + const aTokenReceived = aTokenReceiverBalanceAfter.sub( + aTokenReceiverBalanceBefore, + ); + expect(aTokenReceived).to.be.gt(0); + } +}; + +export const setAutoInvestFallbackEnabledAaveTest = async ( + { depositVaultWithAave, owner }: CommonParamsSetAaveDepositsEnabled, + enabled: boolean, + opt?: OptionalCommonParams, +) => { + if (opt?.revertMessage) { + await expect( + depositVaultWithAave + .connect(opt?.from ?? owner) + .setAutoInvestFallbackEnabled(enabled), + ).revertedWith(opt?.revertMessage); + return; + } + + await expect( + depositVaultWithAave + .connect(opt?.from ?? owner) + .setAutoInvestFallbackEnabled(enabled), + ).to.emit( + depositVaultWithAave, + depositVaultWithAave.interface.events['SetAutoInvestFallbackEnabled(bool)'] + .name, + ).to.not.reverted; + + const fallbackEnabledAfter = + await depositVaultWithAave.autoInvestFallbackEnabled(); + expect(fallbackEnabledAfter).eq(enabled); +}; + +export const depositRequestWithAaveTest = async ( + { + depositVaultWithAave, + owner, + mTBILL, + mTokenToUsdDataFeed, + aavePoolMock, + waivedFee, + customRecipient, + expectedAaveDeposited = true, + }: CommonParamsDeposit & { + expectedAaveDeposited?: boolean; + waivedFee?: boolean; + customRecipient?: AccountOrContract; + }, + tokenIn: ERC20 | IERC20Metadata | string, + amountUsdIn: number, + opt?: OptionalCommonParams, +) => { + tokenIn = getAccount(tokenIn); + + if (opt?.revertMessage) { + await depositRequestTest( + { + depositVault: depositVaultWithAave, + owner, + mTBILL, + mTokenToUsdDataFeed, + waivedFee, + customRecipient, + checkTokensReceiver: !expectedAaveDeposited, + }, + tokenIn, + amountUsdIn, + opt, + ); + return {}; + } + + const tokensReceiver = await depositVaultWithAave.tokensReceiver(); + const aaveEnabledBefore = await depositVaultWithAave.aaveDepositsEnabled(); + + const aTokenAddress = await aavePoolMock.getReserveAToken(tokenIn); + const aTokenContract = ERC20__factory.connect(aTokenAddress, owner); + + const aTokenReceiverBalanceBefore = await balanceOfBase18( + aTokenContract, + tokensReceiver, + ); + + const result = await depositRequestTest( + { + depositVault: depositVaultWithAave, + owner, + mTBILL, + mTokenToUsdDataFeed, + waivedFee, + customRecipient, + checkTokensReceiver: !expectedAaveDeposited, + }, + tokenIn, + amountUsdIn, + opt, + ); + + const aaveEnabledAfter = await depositVaultWithAave.aaveDepositsEnabled(); + expect(aaveEnabledAfter).eq(aaveEnabledBefore); + + if (aaveEnabledAfter && expectedAaveDeposited) { + const aTokenReceiverBalanceAfter = await balanceOfBase18( + aTokenContract, + tokensReceiver, + ); + const aTokenReceived = aTokenReceiverBalanceAfter.sub( + aTokenReceiverBalanceBefore, + ); + expect(aTokenReceived).to.be.gt(0); + } + + return result; +}; diff --git a/test/common/deposit-vault-morpho.helpers.ts b/test/common/deposit-vault-morpho.helpers.ts new file mode 100644 index 00000000..c6c4c8ca --- /dev/null +++ b/test/common/deposit-vault-morpho.helpers.ts @@ -0,0 +1,318 @@ +import { expect } from 'chai'; +import { BigNumberish } from 'ethers'; + +import { + AccountOrContract, + OptionalCommonParams, + balanceOfBase18, + getAccount, +} from './common.helpers'; +import { + depositInstantTest, + depositRequestTest, +} from './deposit-vault.helpers'; +import { defaultDeploy } from './fixtures'; + +import { + ERC20, + ERC20__factory, + IERC20Metadata, + MorphoVaultMock, +} from '../../typechain-types'; + +type CommonParamsDeposit = { + morphoVaultMock: MorphoVaultMock; +} & Pick< + Awaited>, + 'depositVaultWithMorpho' | 'owner' | 'mTBILL' | 'mTokenToUsdDataFeed' +>; + +type CommonParamsSetMorphoDepositsEnabled = Pick< + Awaited>, + 'depositVaultWithMorpho' | 'owner' +>; + +type CommonParamsSetMorphoVault = Pick< + Awaited>, + 'depositVaultWithMorpho' | 'owner' +>; + +export const setMorphoDepositsEnabledTest = async ( + { depositVaultWithMorpho, owner }: CommonParamsSetMorphoDepositsEnabled, + enabled: boolean, + opt?: OptionalCommonParams, +) => { + if (opt?.revertMessage) { + await expect( + depositVaultWithMorpho + .connect(opt?.from ?? owner) + .setMorphoDepositsEnabled(enabled), + ).revertedWith(opt?.revertMessage); + return; + } + + await expect( + depositVaultWithMorpho + .connect(opt?.from ?? owner) + .setMorphoDepositsEnabled(enabled), + ).to.emit( + depositVaultWithMorpho, + depositVaultWithMorpho.interface.events['SetMorphoDepositsEnabled(bool)'] + .name, + ).to.not.reverted; + + const morphoEnabledAfter = + await depositVaultWithMorpho.morphoDepositsEnabled(); + expect(morphoEnabledAfter).eq(enabled); +}; + +export const setMorphoVaultTest = async ( + { depositVaultWithMorpho, owner }: CommonParamsSetMorphoVault, + token: string, + vault: string, + opt?: OptionalCommonParams, +) => { + if (opt?.revertMessage) { + await expect( + depositVaultWithMorpho + .connect(opt?.from ?? owner) + .setMorphoVault(token, vault), + ).revertedWith(opt?.revertMessage); + return; + } + + await expect( + depositVaultWithMorpho + .connect(opt?.from ?? owner) + .setMorphoVault(token, vault), + ).to.emit( + depositVaultWithMorpho, + depositVaultWithMorpho.interface.events[ + 'SetMorphoVault(address,address,address)' + ].name, + ).to.not.reverted; + + const vaultAfter = await depositVaultWithMorpho.morphoVaults(token); + expect(vaultAfter).eq(vault); +}; + +export const removeMorphoVaultTest = async ( + { depositVaultWithMorpho, owner }: CommonParamsSetMorphoVault, + token: string, + opt?: OptionalCommonParams, +) => { + if (opt?.revertMessage) { + await expect( + depositVaultWithMorpho + .connect(opt?.from ?? owner) + .removeMorphoVault(token), + ).revertedWith(opt?.revertMessage); + return; + } + + await expect( + depositVaultWithMorpho.connect(opt?.from ?? owner).removeMorphoVault(token), + ).to.emit( + depositVaultWithMorpho, + depositVaultWithMorpho.interface.events[ + 'RemoveMorphoVault(address,address)' + ].name, + ).to.not.reverted; + + const vaultAfter = await depositVaultWithMorpho.morphoVaults(token); + expect(vaultAfter).eq('0x0000000000000000000000000000000000000000'); +}; + +export const depositInstantWithMorphoTest = async ( + { + depositVaultWithMorpho, + owner, + mTBILL, + mTokenToUsdDataFeed, + morphoVaultMock, + waivedFee, + minAmount, + customRecipient, + expectedMorphoDeposited = true, + }: CommonParamsDeposit & { + expectedMorphoDeposited?: boolean; + waivedFee?: boolean; + minAmount?: BigNumberish; + customRecipient?: AccountOrContract; + }, + tokenIn: ERC20 | IERC20Metadata | string, + amountUsdIn: number, + opt?: OptionalCommonParams, +) => { + tokenIn = getAccount(tokenIn); + + if (opt?.revertMessage) { + await depositInstantTest( + { + depositVault: depositVaultWithMorpho, + owner, + mTBILL, + mTokenToUsdDataFeed, + waivedFee, + minAmount, + customRecipient, + checkTokensReceiver: !expectedMorphoDeposited, + }, + tokenIn, + amountUsdIn, + opt, + ); + return; + } + + const tokensReceiver = await depositVaultWithMorpho.tokensReceiver(); + const morphoEnabledBefore = + await depositVaultWithMorpho.morphoDepositsEnabled(); + + const morphoSharesReceiverBefore = await balanceOfBase18( + ERC20__factory.connect(morphoVaultMock.address, owner), + tokensReceiver, + ); + + await depositInstantTest( + { + depositVault: depositVaultWithMorpho, + owner, + mTBILL, + mTokenToUsdDataFeed, + waivedFee, + minAmount, + customRecipient, + checkTokensReceiver: !expectedMorphoDeposited, + }, + tokenIn, + amountUsdIn, + opt, + ); + + const morphoEnabledAfter = + await depositVaultWithMorpho.morphoDepositsEnabled(); + expect(morphoEnabledAfter).eq(morphoEnabledBefore); + + if (morphoEnabledAfter && expectedMorphoDeposited) { + const morphoSharesReceiverAfter = await balanceOfBase18( + ERC20__factory.connect(morphoVaultMock.address, owner), + tokensReceiver, + ); + const sharesReceived = morphoSharesReceiverAfter.sub( + morphoSharesReceiverBefore, + ); + expect(sharesReceived).to.be.gt(0); + } +}; + +export const setAutoInvestFallbackEnabledMorphoTest = async ( + { depositVaultWithMorpho, owner }: CommonParamsSetMorphoDepositsEnabled, + enabled: boolean, + opt?: OptionalCommonParams, +) => { + if (opt?.revertMessage) { + await expect( + depositVaultWithMorpho + .connect(opt?.from ?? owner) + .setAutoInvestFallbackEnabled(enabled), + ).revertedWith(opt?.revertMessage); + return; + } + + await expect( + depositVaultWithMorpho + .connect(opt?.from ?? owner) + .setAutoInvestFallbackEnabled(enabled), + ).to.emit( + depositVaultWithMorpho, + depositVaultWithMorpho.interface.events[ + 'SetAutoInvestFallbackEnabled(bool)' + ].name, + ).to.not.reverted; + + const fallbackEnabledAfter = + await depositVaultWithMorpho.autoInvestFallbackEnabled(); + expect(fallbackEnabledAfter).eq(enabled); +}; + +export const depositRequestWithMorphoTest = async ( + { + depositVaultWithMorpho, + owner, + mTBILL, + mTokenToUsdDataFeed, + morphoVaultMock, + waivedFee, + customRecipient, + expectedMorphoDeposited = true, + }: CommonParamsDeposit & { + expectedMorphoDeposited?: boolean; + waivedFee?: boolean; + customRecipient?: AccountOrContract; + }, + tokenIn: ERC20 | IERC20Metadata | string, + amountUsdIn: number, + opt?: OptionalCommonParams, +) => { + tokenIn = getAccount(tokenIn); + + if (opt?.revertMessage) { + await depositRequestTest( + { + depositVault: depositVaultWithMorpho, + owner, + mTBILL, + mTokenToUsdDataFeed, + waivedFee, + customRecipient, + checkTokensReceiver: !expectedMorphoDeposited, + }, + tokenIn, + amountUsdIn, + opt, + ); + return {}; + } + + const tokensReceiver = await depositVaultWithMorpho.tokensReceiver(); + const morphoEnabledBefore = + await depositVaultWithMorpho.morphoDepositsEnabled(); + + const morphoSharesReceiverBefore = await balanceOfBase18( + ERC20__factory.connect(morphoVaultMock.address, owner), + tokensReceiver, + ); + + const result = await depositRequestTest( + { + depositVault: depositVaultWithMorpho, + owner, + mTBILL, + mTokenToUsdDataFeed, + waivedFee, + customRecipient, + checkTokensReceiver: !expectedMorphoDeposited, + }, + tokenIn, + amountUsdIn, + opt, + ); + + const morphoEnabledAfter = + await depositVaultWithMorpho.morphoDepositsEnabled(); + expect(morphoEnabledAfter).eq(morphoEnabledBefore); + + if (morphoEnabledAfter && expectedMorphoDeposited) { + const morphoSharesReceiverAfter = await balanceOfBase18( + ERC20__factory.connect(morphoVaultMock.address, owner), + tokensReceiver, + ); + const sharesReceived = morphoSharesReceiverAfter.sub( + morphoSharesReceiverBefore, + ); + expect(sharesReceived).to.be.gt(0); + } + + return result; +}; diff --git a/test/common/deposit-vault-mtoken.helpers.ts b/test/common/deposit-vault-mtoken.helpers.ts new file mode 100644 index 00000000..78667460 --- /dev/null +++ b/test/common/deposit-vault-mtoken.helpers.ts @@ -0,0 +1,303 @@ +import { expect } from 'chai'; +import { BigNumberish } from 'ethers'; + +import { + AccountOrContract, + OptionalCommonParams, + balanceOfBase18, + getAccount, +} from './common.helpers'; +import { + depositInstantTest, + depositRequestTest, +} from './deposit-vault.helpers'; +import { defaultDeploy } from './fixtures'; + +import { + DepositVault__factory, + ERC20, + ERC20__factory, + IERC20Metadata, +} from '../../typechain-types'; + +type CommonParamsDeposit = Pick< + Awaited>, + 'depositVaultWithMToken' | 'owner' | 'mTBILL' | 'mTokenToUsdDataFeed' +>; + +type CommonParamsSetMTokenDepositsEnabled = Pick< + Awaited>, + 'depositVaultWithMToken' | 'owner' +>; + +type CommonParamsSetMTokenDepositVault = Pick< + Awaited>, + 'depositVaultWithMToken' | 'owner' +>; + +export const setMTokenDepositsEnabledTest = async ( + { depositVaultWithMToken, owner }: CommonParamsSetMTokenDepositsEnabled, + enabled: boolean, + opt?: OptionalCommonParams, +) => { + if (opt?.revertMessage) { + await expect( + depositVaultWithMToken + .connect(opt?.from ?? owner) + .setMTokenDepositsEnabled(enabled), + ).revertedWith(opt?.revertMessage); + return; + } + + await expect( + depositVaultWithMToken + .connect(opt?.from ?? owner) + .setMTokenDepositsEnabled(enabled), + ).to.emit( + depositVaultWithMToken, + depositVaultWithMToken.interface.events['SetMTokenDepositsEnabled(bool)'] + .name, + ).to.not.reverted; + + const mTokenEnabledAfter = + await depositVaultWithMToken.mTokenDepositsEnabled(); + expect(mTokenEnabledAfter).eq(enabled); +}; + +export const setMTokenDepositVaultTest = async ( + { depositVaultWithMToken, owner }: CommonParamsSetMTokenDepositVault, + newVault: string, + opt?: OptionalCommonParams, +) => { + if (opt?.revertMessage) { + await expect( + depositVaultWithMToken + .connect(opt?.from ?? owner) + .setMTokenDepositVault(newVault), + ).revertedWith(opt?.revertMessage); + return; + } + + await expect( + depositVaultWithMToken + .connect(opt?.from ?? owner) + .setMTokenDepositVault(newVault), + ).to.emit( + depositVaultWithMToken, + depositVaultWithMToken.interface.events[ + 'SetMTokenDepositVault(address,address)' + ].name, + ).to.not.reverted; + + const vaultAfter = await depositVaultWithMToken.mTokenDepositVault(); + expect(vaultAfter).eq(newVault); +}; + +export const depositInstantWithMTokenTest = async ( + { + depositVaultWithMToken, + owner, + mTBILL, + mTokenToUsdDataFeed, + waivedFee, + minAmount, + customRecipient, + expectedMTokenDeposited = true, + }: CommonParamsDeposit & { + expectedMTokenDeposited?: boolean; + waivedFee?: boolean; + minAmount?: BigNumberish; + customRecipient?: AccountOrContract; + }, + tokenIn: ERC20 | IERC20Metadata | string, + amountUsdIn: number, + opt?: OptionalCommonParams, +) => { + tokenIn = getAccount(tokenIn); + + if (opt?.revertMessage) { + await depositInstantTest( + { + depositVault: depositVaultWithMToken, + owner, + mTBILL, + mTokenToUsdDataFeed, + waivedFee, + minAmount, + customRecipient, + checkTokensReceiver: !expectedMTokenDeposited, + }, + tokenIn, + amountUsdIn, + opt, + ); + return; + } + + const tokensReceiver = await depositVaultWithMToken.tokensReceiver(); + const mTokenEnabledBefore = + await depositVaultWithMToken.mTokenDepositsEnabled(); + + const targetDvAddress = await depositVaultWithMToken.mTokenDepositVault(); + const targetDv = DepositVault__factory.connect(targetDvAddress, owner); + const targetMTokenAddress = await targetDv.mToken(); + + const targetMTokenContract = ERC20__factory.connect( + targetMTokenAddress, + owner, + ); + + const targetMTokenReceiverBefore = await balanceOfBase18( + targetMTokenContract, + tokensReceiver, + ); + + await depositInstantTest( + { + depositVault: depositVaultWithMToken, + owner, + mTBILL, + mTokenToUsdDataFeed, + waivedFee, + minAmount, + customRecipient, + checkTokensReceiver: !expectedMTokenDeposited, + }, + tokenIn, + amountUsdIn, + opt, + ); + + const mTokenEnabledAfter = + await depositVaultWithMToken.mTokenDepositsEnabled(); + expect(mTokenEnabledAfter).eq(mTokenEnabledBefore); + + if (mTokenEnabledAfter && expectedMTokenDeposited) { + const targetMTokenReceiverAfter = await balanceOfBase18( + targetMTokenContract, + tokensReceiver, + ); + const mTokenReceived = targetMTokenReceiverAfter.sub( + targetMTokenReceiverBefore, + ); + expect(mTokenReceived).to.be.gt(0); + } +}; + +export const setAutoInvestFallbackEnabledMTokenTest = async ( + { depositVaultWithMToken, owner }: CommonParamsSetMTokenDepositsEnabled, + enabled: boolean, + opt?: OptionalCommonParams, +) => { + if (opt?.revertMessage) { + await expect( + depositVaultWithMToken + .connect(opt?.from ?? owner) + .setAutoInvestFallbackEnabled(enabled), + ).revertedWith(opt?.revertMessage); + return; + } + + await expect( + depositVaultWithMToken + .connect(opt?.from ?? owner) + .setAutoInvestFallbackEnabled(enabled), + ).to.emit( + depositVaultWithMToken, + depositVaultWithMToken.interface.events[ + 'SetAutoInvestFallbackEnabled(bool)' + ].name, + ).to.not.reverted; + + const fallbackEnabledAfter = + await depositVaultWithMToken.autoInvestFallbackEnabled(); + expect(fallbackEnabledAfter).eq(enabled); +}; + +export const depositRequestWithMTokenTest = async ( + { + depositVaultWithMToken, + owner, + mTBILL, + mTokenToUsdDataFeed, + waivedFee, + customRecipient, + expectedMTokenDeposited = true, + }: CommonParamsDeposit & { + expectedMTokenDeposited?: boolean; + waivedFee?: boolean; + customRecipient?: AccountOrContract; + }, + tokenIn: ERC20 | IERC20Metadata | string, + amountUsdIn: number, + opt?: OptionalCommonParams, +) => { + tokenIn = getAccount(tokenIn); + + if (opt?.revertMessage) { + await depositRequestTest( + { + depositVault: depositVaultWithMToken, + owner, + mTBILL, + mTokenToUsdDataFeed, + waivedFee, + customRecipient, + checkTokensReceiver: !expectedMTokenDeposited, + }, + tokenIn, + amountUsdIn, + opt, + ); + return {}; + } + + const tokensReceiver = await depositVaultWithMToken.tokensReceiver(); + const mTokenEnabledBefore = + await depositVaultWithMToken.mTokenDepositsEnabled(); + + const targetDvAddress = await depositVaultWithMToken.mTokenDepositVault(); + const targetDv = DepositVault__factory.connect(targetDvAddress, owner); + const targetMTokenAddress = await targetDv.mToken(); + const targetMTokenContract = ERC20__factory.connect( + targetMTokenAddress, + owner, + ); + + const targetMTokenReceiverBefore = await balanceOfBase18( + targetMTokenContract, + tokensReceiver, + ); + + const result = await depositRequestTest( + { + depositVault: depositVaultWithMToken, + owner, + mTBILL, + mTokenToUsdDataFeed, + waivedFee, + customRecipient, + checkTokensReceiver: !expectedMTokenDeposited, + }, + tokenIn, + amountUsdIn, + opt, + ); + + const mTokenEnabledAfter = + await depositVaultWithMToken.mTokenDepositsEnabled(); + expect(mTokenEnabledAfter).eq(mTokenEnabledBefore); + + if (mTokenEnabledAfter && expectedMTokenDeposited) { + const targetMTokenReceiverAfter = await balanceOfBase18( + targetMTokenContract, + tokensReceiver, + ); + const mTokenReceived = targetMTokenReceiverAfter.sub( + targetMTokenReceiverBefore, + ); + expect(mTokenReceived).to.be.gt(0); + } + + return result; +}; diff --git a/test/common/deposit-vault-ustb.helpers.ts b/test/common/deposit-vault-ustb.helpers.ts index 0fab11f7..e3916300 100644 --- a/test/common/deposit-vault-ustb.helpers.ts +++ b/test/common/deposit-vault-ustb.helpers.ts @@ -1,19 +1,16 @@ import { expect } from 'chai'; import { BigNumber, BigNumberish } from 'ethers'; -import { parseUnits } from 'ethers/lib/utils'; import { AccountOrContract, OptionalCommonParams, getAccount, - tokenAmountFromBase18, } from './common.helpers'; -import { depositInstantTest, getFeePercent } from './deposit-vault.helpers'; +import { depositInstantTest } from './deposit-vault.helpers'; import { defaultDeploy } from './fixtures'; import { ERC20, - ERC20__factory, IERC20Metadata, ISuperstateToken, USTBMock, @@ -126,28 +123,11 @@ export const depositInstantWithUstbTest = async ( return; } - const amountIn = parseUnits(amountUsdIn.toFixed(18).replace(/\.?0+$/, '')); - const tokensReceiver = await depositVaultWithUSTB.tokensReceiver(); const ustbEnabledBefore = await depositVaultWithUSTB.ustbDepositsEnabled(); const ustbSupplyBefore = await ustbToken.totalSupply(); const ustbReceiverBalanceBefore = await ustbToken.balanceOf(tokensReceiver); - const feePercent = await getFeePercent( - owner.address, - tokenIn, - depositVaultWithUSTB, - true, - ); - - const hundredPercent = await depositVaultWithUSTB.ONE_HUNDRED_PERCENT(); - const fee = amountIn.mul(feePercent).div(hundredPercent); - - const amountInWithoutFee = await tokenAmountFromBase18( - ERC20__factory.connect(tokenIn, owner), - amountIn.sub(fee), - ); - await depositInstantTest( { depositVault: depositVaultWithUSTB, @@ -171,11 +151,17 @@ export const depositInstantWithUstbTest = async ( expect(ustbEnabledAfter).eq(ustbEnabledBefore); if (ustbEnabledAfter && expectedUstbDeposited) { - expectedUstbMinted ??= amountInWithoutFee; - - expect(ustbSupplyAfter.sub(ustbSupplyBefore)).eq(expectedUstbMinted); - expect(ustbReceiverBalanceAfter.sub(ustbReceiverBalanceBefore)).eq( - expectedUstbMinted, + const ustbMinted = ustbSupplyAfter.sub(ustbSupplyBefore); + const ustbReceived = ustbReceiverBalanceAfter.sub( + ustbReceiverBalanceBefore, ); + + if (expectedUstbMinted !== undefined) { + expect(ustbMinted).eq(expectedUstbMinted); + expect(ustbReceived).eq(expectedUstbMinted); + } else { + expect(ustbMinted).to.be.gt(0); + expect(ustbReceived).eq(ustbMinted); + } } }; diff --git a/test/common/deposit-vault.helpers.ts b/test/common/deposit-vault.helpers.ts index de0dbc49..26ea8a44 100644 --- a/test/common/deposit-vault.helpers.ts +++ b/test/common/deposit-vault.helpers.ts @@ -15,6 +15,9 @@ import { DataFeedTest__factory, DepositVault, DepositVaultTest, + DepositVaultWithAaveTest, + DepositVaultWithMorphoTest, + DepositVaultWithMTokenTest, DepositVaultWithUSTBTest, ERC20, ERC20__factory, @@ -22,7 +25,13 @@ import { } from '../../typechain-types'; type CommonParamsDeposit = { - depositVault: DepositVault | DepositVaultTest | DepositVaultWithUSTBTest; + depositVault: + | DepositVault + | DepositVaultTest + | DepositVaultWithAaveTest + | DepositVaultWithMorphoTest + | DepositVaultWithMTokenTest + | DepositVaultWithUSTBTest; mTBILL: MToken; } & Pick< Awaited>, @@ -196,9 +205,11 @@ export const depositRequestTest = async ( mTokenToUsdDataFeed, waivedFee, customRecipient, + checkTokensReceiver = true, }: CommonParamsDeposit & { waivedFee?: boolean; customRecipient?: AccountOrContract; + checkTokensReceiver?: boolean; }, tokenIn: ERC20 | string, amountUsdIn: number, @@ -315,9 +326,11 @@ export const depositRequestTest = async ( expect(request.tokenIn).eq(tokenContract.address); expect(latestRequestIdAfter).eq(latestRequestIdBefore.add(1)); - expect(balanceAfterContract).eq( - balanceBeforeContract.add(amountInWithoutFee), - ); + if (checkTokensReceiver) { + expect(balanceAfterContract).eq( + balanceBeforeContract.add(amountInWithoutFee), + ); + } expect(feeReceiverBalanceAfterContract).eq( feeReceiverBalanceBeforeContract.add(fee), ); @@ -714,7 +727,13 @@ export const setMaxSupplyCapTest = async ( export const getFeePercent = async ( sender: string, token: string, - depositVault: DepositVault | DepositVaultTest | DepositVaultWithUSTBTest, + depositVault: + | DepositVault + | DepositVaultTest + | DepositVaultWithAaveTest + | DepositVaultWithMorphoTest + | DepositVaultWithMTokenTest + | DepositVaultWithUSTBTest, isInstant: boolean, ) => { const tokenConfig = await depositVault.tokensConfig(token); @@ -733,7 +752,13 @@ export const getFeePercent = async ( export const calcExpectedMintAmount = async ( sender: SignerWithAddress, token: string, - depositVault: DepositVault | DepositVaultTest | DepositVaultWithUSTBTest, + depositVault: + | DepositVault + | DepositVaultTest + | DepositVaultWithAaveTest + | DepositVaultWithMorphoTest + | DepositVaultWithMTokenTest + | DepositVaultWithUSTBTest, mTokenRate: BigNumber, amountIn: BigNumber, isInstant: boolean, diff --git a/test/common/fixtures.ts b/test/common/fixtures.ts index f960a944..752c2b41 100644 --- a/test/common/fixtures.ts +++ b/test/common/fixtures.ts @@ -38,7 +38,15 @@ import { RedemptionVaultWithBUIDLTest__factory, RedemptionVaultWithUSTBTest__factory, RedemptionVaultWithSwapperTest__factory, + RedemptionVaultWithAaveTest__factory, + RedemptionVaultWithMorphoTest__factory, + RedemptionVaultWithMTokenTest__factory, + AaveV3PoolMock__factory, + MorphoVaultMock__factory, CustomAggregatorV3CompatibleFeedDiscountedTester__factory, + DepositVaultWithAaveTest__factory, + DepositVaultWithMorphoTest__factory, + DepositVaultWithMTokenTest__factory, DepositVaultWithUSTBTest__factory, USTBMock__factory, CustomAggregatorV3CompatibleFeedGrowthTester__factory, @@ -244,6 +252,7 @@ export const defaultDeploy = async () => { usdc: await new ERC20Mock__factory(owner).deploy(8), usdt: await new ERC20Mock__factory(owner).deploy(18), dai: await new ERC20Mock__factory(owner).deploy(9), + usdc6: await new ERC20Mock__factory(owner).deploy(6), }; const otherCoins = { @@ -376,6 +385,197 @@ export const defaultDeploy = async () => { redemptionVaultWithUSTB.address, ); + /* Redemption Vault With Aave */ + + const aUSDC = await new ERC20Mock__factory(owner).deploy(8); // aToken mock, same decimals as USDC + const aavePoolMock = await new AaveV3PoolMock__factory(owner).deploy(); + await aavePoolMock.setReserveAToken(stableCoins.usdc.address, aUSDC.address); + await stableCoins.usdc.mint(aavePoolMock.address, parseUnits('1000000')); + + const redemptionVaultWithAave = + await new RedemptionVaultWithAaveTest__factory(owner).deploy(); + + await redemptionVaultWithAave.initialize( + accessControl.address, + { + mToken: mTBILL.address, + mTokenDataFeed: mTokenToUsdDataFeed.address, + }, + { + feeReceiver: feeReceiver.address, + tokensReceiver: tokensReceiver.address, + }, + { + instantFee: 100, + instantDailyLimit: parseUnits('100000'), + }, + mockedSanctionsList.address, + 1, + 1000, + { + fiatAdditionalFee: 100, + fiatFlatFee: parseUnits('1'), + minFiatRedeemAmount: 1000, + }, + requestRedeemer.address, + ); + await redemptionVaultWithAave.setAavePool( + stableCoins.usdc.address, + aavePoolMock.address, + ); + await accessControl.grantRole( + mTBILL.M_TBILL_BURN_OPERATOR_ROLE(), + redemptionVaultWithAave.address, + ); + + /* Redemption Vault With Morpho */ + + const morphoVaultMock = await new MorphoVaultMock__factory(owner).deploy( + stableCoins.usdc.address, + ); + await stableCoins.usdc.mint(morphoVaultMock.address, parseUnits('1000000')); + + const redemptionVaultWithMorpho = + await new RedemptionVaultWithMorphoTest__factory(owner).deploy(); + + await redemptionVaultWithMorpho.initialize( + accessControl.address, + { + mToken: mTBILL.address, + mTokenDataFeed: mTokenToUsdDataFeed.address, + }, + { + feeReceiver: feeReceiver.address, + tokensReceiver: tokensReceiver.address, + }, + { + instantFee: 100, + instantDailyLimit: parseUnits('100000'), + }, + mockedSanctionsList.address, + 1, + 1000, + { + fiatAdditionalFee: 100, + fiatFlatFee: parseUnits('1'), + minFiatRedeemAmount: 1000, + }, + requestRedeemer.address, + ); + await redemptionVaultWithMorpho.setMorphoVault( + stableCoins.usdc.address, + morphoVaultMock.address, + ); + await accessControl.grantRole( + mTBILL.M_TBILL_BURN_OPERATOR_ROLE(), + redemptionVaultWithMorpho.address, + ); + + /* Deposit Vault With Aave */ + + const depositVaultWithAave = await new DepositVaultWithAaveTest__factory( + owner, + ).deploy(); + + await depositVaultWithAave.initialize( + accessControl.address, + { + mToken: mTBILL.address, + mTokenDataFeed: mTokenToUsdDataFeed.address, + }, + { + feeReceiver: feeReceiver.address, + tokensReceiver: tokensReceiver.address, + }, + { + instantFee: 100, + instantDailyLimit: parseUnits('100000'), + }, + mockedSanctionsList.address, + 1, + parseUnits('100'), + 0, + constants.MaxUint256, + ); + await depositVaultWithAave.setAavePool( + stableCoins.usdc.address, + aavePoolMock.address, + ); + + await accessControl.grantRole( + mTBILL.M_TBILL_MINT_OPERATOR_ROLE(), + depositVaultWithAave.address, + ); + + /* Deposit Vault With Morpho */ + + const depositVaultWithMorpho = await new DepositVaultWithMorphoTest__factory( + owner, + ).deploy(); + + await depositVaultWithMorpho.initialize( + accessControl.address, + { + mToken: mTBILL.address, + mTokenDataFeed: mTokenToUsdDataFeed.address, + }, + { + feeReceiver: feeReceiver.address, + tokensReceiver: tokensReceiver.address, + }, + { + instantFee: 100, + instantDailyLimit: parseUnits('100000'), + }, + mockedSanctionsList.address, + 1, + parseUnits('100'), + 0, + constants.MaxUint256, + ); + + await accessControl.grantRole( + mTBILL.M_TBILL_MINT_OPERATOR_ROLE(), + depositVaultWithMorpho.address, + ); + + /* Deposit Vault With MToken (deposits into mTBILL DV) */ + + const depositVaultWithMToken = await new DepositVaultWithMTokenTest__factory( + owner, + ).deploy(); + + await depositVaultWithMToken[ + 'initialize(address,(address,address),(address,address),(uint256,uint256),address,uint256,uint256,uint256,uint256,address)' + ]( + accessControl.address, + { + mToken: mTBILL.address, + mTokenDataFeed: mTokenToUsdDataFeed.address, + }, + { + feeReceiver: feeReceiver.address, + tokensReceiver: tokensReceiver.address, + }, + { + instantFee: 100, + instantDailyLimit: parseUnits('100000'), + }, + mockedSanctionsList.address, + 1, + parseUnits('100'), + 0, + constants.MaxUint256, + depositVault.address, + ); + + await accessControl.grantRole( + mTBILL.M_TBILL_MINT_OPERATOR_ROLE(), + depositVaultWithMToken.address, + ); + + await depositVault.addWaivedFeeAccount(depositVaultWithMToken.address); + /* Redemption Vault With Swapper */ const redemptionVaultWithSwapper = @@ -416,6 +616,67 @@ export const defaultDeploy = async () => { redemptionVaultWithSwapper.address, ); + /* Redemption Vault With MToken (mFONE -> mTBILL) */ + + const mFONE = await new MTBILLTest__factory(owner).deploy(); + await mFONE.initialize(accessControl.address); + + const mockedAggregatorMFone = await new AggregatorV3Mock__factory( + owner, + ).deploy(); + await mockedAggregatorMFone.setRoundData( + parseUnits('2', mockedAggregatorDecimals), + ); + const mFoneToUsdDataFeed = await new DataFeedTest__factory(owner).deploy(); + await mFoneToUsdDataFeed.initialize( + accessControl.address, + mockedAggregatorMFone.address, + 3 * 24 * 3600, + parseUnits('0.1', mockedAggregatorDecimals), + parseUnits('10000', mockedAggregatorDecimals), + ); + + const redemptionVaultWithMToken = + await new RedemptionVaultWithMTokenTest__factory(owner).deploy(); + + await redemptionVaultWithMToken[ + 'initialize(address,(address,address),(address,address),(uint256,uint256),address,uint256,uint256,(uint256,uint256,uint256),address,address)' + ]( + accessControl.address, + { + mToken: mFONE.address, + mTokenDataFeed: mFoneToUsdDataFeed.address, + }, + { + feeReceiver: feeReceiver.address, + tokensReceiver: tokensReceiver.address, + }, + { + instantFee: 100, + instantDailyLimit: parseUnits('100000'), + }, + mockedSanctionsList.address, + 1, + 1000, + { + fiatAdditionalFee: 100, + fiatFlatFee: parseUnits('1'), + minFiatRedeemAmount: 1000, + }, + requestRedeemer.address, + redemptionVault.address, + ); + + await accessControl.grantRole( + mFONE.M_TBILL_BURN_OPERATOR_ROLE(), + redemptionVaultWithMToken.address, + ); + await redemptionVault.addWaivedFeeAccount(redemptionVaultWithMToken.address); + await accessControl.grantRole( + mTBILL.M_TBILL_BURN_OPERATOR_ROLE(), + redemptionVaultWithMToken.address, + ); + const customFeed = await new CustomAggregatorV3CompatibleFeedTester__factory( owner, ).deploy(); @@ -596,12 +857,24 @@ export const defaultDeploy = async () => { buidlRedemption, redemptionVaultWithBUIDL, redemptionVaultWithUSTB, + redemptionVaultWithAave, + aavePoolMock, + aUSDC, + redemptionVaultWithMorpho, + morphoVaultMock, liquidityProvider, + mFONE, + mockedAggregatorMFone, + mFoneToUsdDataFeed, + redemptionVaultWithMToken, otherCoins, ustbToken, ustbRedemption, customRecipient, depositVaultWithUSTB, + depositVaultWithAave, + depositVaultWithMorpho, + depositVaultWithMToken, dataFeedGrowth, compositeDataFeed, }; diff --git a/test/common/manageable-vault.helpers.ts b/test/common/manageable-vault.helpers.ts index b8009e87..2ac6ba78 100644 --- a/test/common/manageable-vault.helpers.ts +++ b/test/common/manageable-vault.helpers.ts @@ -8,12 +8,18 @@ import { defaultDeploy } from './fixtures'; import { DepositVault, + DepositVaultWithAave, + DepositVaultWithMorpho, + DepositVaultWithMToken, DepositVaultWithUSTB, ERC20, ERC20__factory, IERC20, RedemptionVault, RedemptionVaultWIthBUIDL, + RedemptionVaultWithAave, + RedemptionVaultWithMorpho, + RedemptionVaultWithMToken, RedemptionVaultWithSwapper, RedemptionVaultWithUSTB, } from '../../typechain-types'; @@ -21,15 +27,26 @@ import { type CommonParamsChangePaymentToken = { vault: | DepositVault + | DepositVaultWithAave + | DepositVaultWithMorpho + | DepositVaultWithMToken | DepositVaultWithUSTB | RedemptionVault | RedemptionVaultWIthBUIDL + | RedemptionVaultWithAave + | RedemptionVaultWithMorpho + | RedemptionVaultWithMToken | RedemptionVaultWithSwapper | RedemptionVaultWithUSTB; owner: SignerWithAddress; }; type CommonParams = { - depositVault: DepositVault | DepositVaultWithUSTB; + depositVault: + | DepositVault + | DepositVaultWithAave + | DepositVaultWithMorpho + | DepositVaultWithMToken + | DepositVaultWithUSTB; } & Pick>, 'owner'>; export const setInstantFeeTest = async ( diff --git a/test/common/redemption-vault-aave.helpers.ts b/test/common/redemption-vault-aave.helpers.ts new file mode 100644 index 00000000..c09bdc2f --- /dev/null +++ b/test/common/redemption-vault-aave.helpers.ts @@ -0,0 +1,165 @@ +import { SignerWithAddress } from '@nomiclabs/hardhat-ethers/signers'; +import { expect } from 'chai'; +import { BigNumber, BigNumberish } from 'ethers'; + +import { AccountOrContract, OptionalCommonParams } from './common.helpers'; +import { redeemInstantTest } from './redemption-vault.helpers'; + +import { + IERC20, + RedemptionVaultWithAave, + MTBILLTest, + DataFeedTest, +} from '../../typechain-types'; + +type CommonParamsSetAavePool = { + redemptionVault: RedemptionVaultWithAave; + owner: SignerWithAddress; +}; + +type RedemptionWithAaveParams = { + redemptionVault: RedemptionVaultWithAave; + owner: SignerWithAddress; + mTBILL: MTBILLTest; + mTokenToUsdDataFeed: DataFeedTest; + usdc: IERC20; + aToken: IERC20; + waivedFee?: boolean; + minAmount?: BigNumberish; + expectedATokenUsed?: BigNumber; + expectedUsdcUsed?: BigNumber; + customRecipient?: AccountOrContract; +}; + +export const setAavePoolTest = async ( + { redemptionVault, owner }: CommonParamsSetAavePool, + token: string, + pool: string, + opt?: OptionalCommonParams, +) => { + if (opt?.revertMessage) { + await expect( + redemptionVault.connect(opt?.from ?? owner).setAavePool(token, pool), + ).revertedWith(opt?.revertMessage); + return; + } + + await expect( + redemptionVault.connect(opt?.from ?? owner).setAavePool(token, pool), + ).to.emit( + redemptionVault, + redemptionVault.interface.events['SetAavePool(address,address,address)'] + .name, + ).to.not.reverted; + + const poolAfter = await redemptionVault.aavePools(token); + expect(poolAfter).eq(pool); +}; + +export const removeAavePoolTest = async ( + { redemptionVault, owner }: CommonParamsSetAavePool, + token: string, + opt?: OptionalCommonParams, +) => { + if (opt?.revertMessage) { + await expect( + redemptionVault.connect(opt?.from ?? owner).removeAavePool(token), + ).revertedWith(opt?.revertMessage); + return; + } + + await expect( + redemptionVault.connect(opt?.from ?? owner).removeAavePool(token), + ).to.emit( + redemptionVault, + redemptionVault.interface.events['RemoveAavePool(address,address)'].name, + ).to.not.reverted; + + const poolAfter = await redemptionVault.aavePools(token); + expect(poolAfter).eq('0x0000000000000000000000000000000000000000'); +}; + +export const redeemInstantWithAaveTest = async ( + params: RedemptionWithAaveParams, + amountTBillIn: number, + opt?: OptionalCommonParams, +) => { + const { + redemptionVault, + owner, + mTBILL, + mTokenToUsdDataFeed, + usdc, + aToken, + expectedATokenUsed, + expectedUsdcUsed, + customRecipient, + } = params; + + if (opt?.revertMessage) { + await redeemInstantTest( + { + redemptionVault, + owner, + mTBILL, + mTokenToUsdDataFeed, + waivedFee: params.waivedFee, + minAmount: params.minAmount, + customRecipient, + }, + usdc, + amountTBillIn, + opt, + ); + return undefined; + } + + const sender = opt?.from ?? owner; + const [vaultUSDCBefore, vaultATokenBefore, userUSDCBefore] = + await Promise.all([ + usdc.balanceOf(redemptionVault.address), + aToken.balanceOf(redemptionVault.address), + usdc.balanceOf(sender.address), + ]); + + await redeemInstantTest( + { + redemptionVault, + owner, + mTBILL, + mTokenToUsdDataFeed, + waivedFee: params.waivedFee, + minAmount: params.minAmount, + customRecipient, + }, + usdc, + amountTBillIn, + opt, + ); + + const [vaultUSDCAfter, vaultATokenAfter, userUSDCAfter] = await Promise.all([ + usdc.balanceOf(redemptionVault.address), + aToken.balanceOf(redemptionVault.address), + usdc.balanceOf(sender.address), + ]); + + const usdcUsed = vaultUSDCBefore.sub(vaultUSDCAfter); + const aTokenUsed = vaultATokenBefore.sub(vaultATokenAfter); + + if (expectedATokenUsed !== undefined) { + expect(aTokenUsed).to.equal(expectedATokenUsed); + } + if (expectedUsdcUsed !== undefined) { + expect(usdcUsed).to.equal(expectedUsdcUsed); + } + + return { + usdcUsed, + aTokenUsed, + userUSDCReceived: userUSDCAfter.sub(userUSDCBefore), + vaultUSDCBefore, + vaultUSDCAfter, + vaultATokenBefore, + vaultATokenAfter, + }; +}; diff --git a/test/common/redemption-vault-morpho.helpers.ts b/test/common/redemption-vault-morpho.helpers.ts new file mode 100644 index 00000000..a065347e --- /dev/null +++ b/test/common/redemption-vault-morpho.helpers.ts @@ -0,0 +1,165 @@ +import { SignerWithAddress } from '@nomiclabs/hardhat-ethers/signers'; +import { expect } from 'chai'; +import { BigNumber, BigNumberish } from 'ethers'; + +import { AccountOrContract, OptionalCommonParams } from './common.helpers'; +import { redeemInstantTest } from './redemption-vault.helpers'; + +import { + IERC20, + RedemptionVaultWithMorpho, + MTBILLTest, + DataFeedTest, +} from '../../typechain-types'; + +type CommonParamsSetMorphoVault = { + redemptionVault: RedemptionVaultWithMorpho; + owner: SignerWithAddress; +}; + +type RedemptionWithMorphoParams = { + redemptionVault: RedemptionVaultWithMorpho; + owner: SignerWithAddress; + mTBILL: MTBILLTest; + mTokenToUsdDataFeed: DataFeedTest; + usdc: IERC20; + morphoVault: IERC20; + waivedFee?: boolean; + minAmount?: BigNumberish; + expectedSharesUsed?: BigNumber; + expectedUsdcUsed?: BigNumber; + customRecipient?: AccountOrContract; +}; + +export const setMorphoVaultTest = async ( + { redemptionVault, owner }: CommonParamsSetMorphoVault, + token: string, + vault: string, + opt?: OptionalCommonParams, +) => { + if (opt?.revertMessage) { + await expect( + redemptionVault.connect(opt?.from ?? owner).setMorphoVault(token, vault), + ).revertedWith(opt?.revertMessage); + return; + } + + await expect( + redemptionVault.connect(opt?.from ?? owner).setMorphoVault(token, vault), + ).to.emit( + redemptionVault, + redemptionVault.interface.events['SetMorphoVault(address,address,address)'] + .name, + ).to.not.reverted; + + const vaultAfter = await redemptionVault.morphoVaults(token); + expect(vaultAfter).eq(vault); +}; + +export const removeMorphoVaultTest = async ( + { redemptionVault, owner }: CommonParamsSetMorphoVault, + token: string, + opt?: OptionalCommonParams, +) => { + if (opt?.revertMessage) { + await expect( + redemptionVault.connect(opt?.from ?? owner).removeMorphoVault(token), + ).revertedWith(opt?.revertMessage); + return; + } + + await expect( + redemptionVault.connect(opt?.from ?? owner).removeMorphoVault(token), + ).to.emit( + redemptionVault, + redemptionVault.interface.events['RemoveMorphoVault(address,address)'].name, + ).to.not.reverted; + + const vaultAfter = await redemptionVault.morphoVaults(token); + expect(vaultAfter).eq('0x0000000000000000000000000000000000000000'); +}; + +export const redeemInstantWithMorphoTest = async ( + params: RedemptionWithMorphoParams, + amountTBillIn: number, + opt?: OptionalCommonParams, +) => { + const { + redemptionVault, + owner, + mTBILL, + mTokenToUsdDataFeed, + usdc, + morphoVault, + expectedSharesUsed, + expectedUsdcUsed, + customRecipient, + } = params; + + if (opt?.revertMessage) { + await redeemInstantTest( + { + redemptionVault, + owner, + mTBILL, + mTokenToUsdDataFeed, + waivedFee: params.waivedFee, + minAmount: params.minAmount, + customRecipient, + }, + usdc, + amountTBillIn, + opt, + ); + return undefined; + } + + const sender = opt?.from ?? owner; + const [vaultUSDCBefore, vaultSharesBefore, userUSDCBefore] = + await Promise.all([ + usdc.balanceOf(redemptionVault.address), + morphoVault.balanceOf(redemptionVault.address), + usdc.balanceOf(sender.address), + ]); + + await redeemInstantTest( + { + redemptionVault, + owner, + mTBILL, + mTokenToUsdDataFeed, + waivedFee: params.waivedFee, + minAmount: params.minAmount, + customRecipient, + }, + usdc, + amountTBillIn, + opt, + ); + + const [vaultUSDCAfter, vaultSharesAfter, userUSDCAfter] = await Promise.all([ + usdc.balanceOf(redemptionVault.address), + morphoVault.balanceOf(redemptionVault.address), + usdc.balanceOf(sender.address), + ]); + + const usdcUsed = vaultUSDCBefore.sub(vaultUSDCAfter); + const sharesUsed = vaultSharesBefore.sub(vaultSharesAfter); + + if (expectedSharesUsed !== undefined) { + expect(sharesUsed).to.equal(expectedSharesUsed); + } + if (expectedUsdcUsed !== undefined) { + expect(usdcUsed).to.equal(expectedUsdcUsed); + } + + return { + usdcUsed, + sharesUsed, + userUSDCReceived: userUSDCAfter.sub(userUSDCBefore), + vaultUSDCBefore, + vaultUSDCAfter, + vaultSharesBefore, + vaultSharesAfter, + }; +}; diff --git a/test/common/redemption-vault-mtoken.helpers.ts b/test/common/redemption-vault-mtoken.helpers.ts new file mode 100644 index 00000000..cfe08def --- /dev/null +++ b/test/common/redemption-vault-mtoken.helpers.ts @@ -0,0 +1,162 @@ +import { SignerWithAddress } from '@nomiclabs/hardhat-ethers/signers'; +import { expect } from 'chai'; +import { BigNumberish } from 'ethers'; +import { parseUnits } from 'ethers/lib/utils'; + +import { + AccountOrContract, + OptionalCommonParams, + getAccount, +} from './common.helpers'; +import { defaultDeploy } from './fixtures'; +import { + calcExpectedTokenOutAmount, + redeemInstantTest, +} from './redemption-vault.helpers'; + +import { + ERC20, + ERC20__factory, + RedemptionVaultWithMToken, +} from '../../typechain-types'; + +type CommonParamsRedeem = Pick< + Awaited>, + | 'owner' + | 'mTBILL' + | 'mFONE' + | 'redemptionVaultWithMToken' + | 'mTokenToUsdDataFeed' + | 'mFoneToUsdDataFeed' +>; + +type CommonParamsSetVault = { + vault: RedemptionVaultWithMToken; + owner: SignerWithAddress; +}; + +export const redeemInstantWithMTokenTest = async ( + { + redemptionVaultWithMToken, + owner, + mTBILL, + mFONE, + mFoneToUsdDataFeed, + useMTokenSleeve, + minAmount, + waivedFee, + customRecipient, + }: CommonParamsRedeem & { + useMTokenSleeve?: boolean; + waivedFee?: boolean; + minAmount?: BigNumberish; + customRecipient?: AccountOrContract; + }, + tokenOut: ERC20 | string, + amountMFoneIn: number, + opt?: OptionalCommonParams, +) => { + tokenOut = getAccount(tokenOut); + + const tokenContract = ERC20__factory.connect(tokenOut, owner); + + const sender = opt?.from ?? owner; + + const amountIn = parseUnits(amountMFoneIn.toString()); + + if (opt?.revertMessage) { + await redeemInstantTest( + { + redemptionVault: redemptionVaultWithMToken, + owner, + mTBILL: mFONE, + mTokenToUsdDataFeed: mFoneToUsdDataFeed, + waivedFee, + minAmount, + customRecipient, + }, + tokenOut, + amountMFoneIn, + opt, + ); + + return; + } + + const balanceBeforeUserMFone = await mFONE.balanceOf(sender.address); + const balanceBeforeVaultMTbill = await mTBILL.balanceOf( + redemptionVaultWithMToken.address, + ); + const supplyBeforeMFone = await mFONE.totalSupply(); + const supplyBeforeMTbill = await mTBILL.totalSupply(); + + const mFoneRate = await mFoneToUsdDataFeed.getDataInBase18(); + + const { amountInWithoutFee } = await calcExpectedTokenOutAmount( + sender, + tokenContract, + redemptionVaultWithMToken, + mFoneRate, + amountIn, + true, + ); + + await redeemInstantTest( + { + redemptionVault: redemptionVaultWithMToken, + owner, + mTBILL: mFONE, + mTokenToUsdDataFeed: mFoneToUsdDataFeed, + waivedFee, + minAmount, + customRecipient, + }, + tokenOut, + amountMFoneIn, + opt, + ); + + const balanceAfterUserMFone = await mFONE.balanceOf(sender.address); + const balanceAfterVaultMTbill = await mTBILL.balanceOf( + redemptionVaultWithMToken.address, + ); + const supplyAfterMFone = await mFONE.totalSupply(); + const supplyAfterMTbill = await mTBILL.totalSupply(); + + // mFONE is always burned from user + expect(balanceAfterUserMFone).eq(balanceBeforeUserMFone.sub(amountIn)); + expect(supplyAfterMFone).eq(supplyBeforeMFone.sub(amountInWithoutFee)); + + if (useMTokenSleeve) { + // mTBILL was redeemed from the vault's holdings + expect(balanceAfterVaultMTbill).lt(balanceBeforeVaultMTbill); + expect(supplyAfterMTbill).lt(supplyBeforeMTbill); + } else { + // Vault had enough tokenOut, mTBILL untouched + expect(balanceAfterVaultMTbill).eq(balanceBeforeVaultMTbill); + expect(supplyAfterMTbill).eq(supplyBeforeMTbill); + } +}; + +export const setRedemptionVaultTest = async ( + { vault, owner }: CommonParamsSetVault, + newVault: string, + opt?: OptionalCommonParams, +) => { + if (opt?.revertMessage) { + await expect( + vault.connect(opt?.from ?? owner).setRedemptionVault(newVault), + ).revertedWith(opt?.revertMessage); + return; + } + + await expect(vault.connect(opt?.from ?? owner).setRedemptionVault(newVault)) + .to.emit( + vault, + vault.interface.events['SetRedemptionVault(address,address)'].name, + ) + .withArgs((opt?.from ?? owner).address, newVault).to.not.reverted; + + const provider = await vault.redemptionVault(); + expect(provider).eq(newVault); +}; diff --git a/test/common/redemption-vault.helpers.ts b/test/common/redemption-vault.helpers.ts index 91142b24..083d8aa1 100644 --- a/test/common/redemption-vault.helpers.ts +++ b/test/common/redemption-vault.helpers.ts @@ -20,6 +20,9 @@ import { MToken, RedemptionVault, RedemptionVaultWIthBUIDL, + RedemptionVaultWithAave, + RedemptionVaultWithMorpho, + RedemptionVaultWithMToken, RedemptionVaultWithSwapper, RedemptionVaultWithUSTB, } from '../../typechain-types'; @@ -33,6 +36,9 @@ type CommonParamsRedeem = { redemptionVault: | RedemptionVault | RedemptionVaultWIthBUIDL + | RedemptionVaultWithAave + | RedemptionVaultWithMorpho + | RedemptionVaultWithMToken | RedemptionVaultWithUSTB | RedemptionVaultWithSwapper; }; @@ -41,6 +47,9 @@ type CommonParams = Pick>, 'owner'> & { redemptionVault: | RedemptionVault | RedemptionVaultWIthBUIDL + | RedemptionVaultWithAave + | RedemptionVaultWithMorpho + | RedemptionVaultWithMToken | RedemptionVaultWithUSTB | RedemptionVaultWithSwapper; }; @@ -931,6 +940,9 @@ export const getFeePercent = async ( redemptionVault: | RedemptionVault | RedemptionVaultWIthBUIDL + | RedemptionVaultWithAave + | RedemptionVaultWithMorpho + | RedemptionVaultWithMToken | RedemptionVaultWithSwapper | RedemptionVaultWithUSTB, isInstant: boolean, @@ -955,6 +967,9 @@ export const calcExpectedTokenOutAmount = async ( redemptionVault: | RedemptionVault | RedemptionVaultWIthBUIDL + | RedemptionVaultWithAave + | RedemptionVaultWithMorpho + | RedemptionVaultWithMToken | RedemptionVaultWithSwapper | RedemptionVaultWithUSTB, mTokenRate: BigNumber, diff --git a/test/integration/DepositVaultWithAave.test.ts b/test/integration/DepositVaultWithAave.test.ts new file mode 100644 index 00000000..947a7ced --- /dev/null +++ b/test/integration/DepositVaultWithAave.test.ts @@ -0,0 +1,234 @@ +import { loadFixture } from '@nomicfoundation/hardhat-network-helpers'; + +import { aaveDepositFixture } from './fixtures/aave.fixture'; +import { + assertAutoInvestDisabled, + assertAutoInvestEnabled, + depositInstantAave, +} from './helpers/deposit.helpers'; + +describe('DepositVaultWithAave - Mainnet Fork Integration Tests', function () { + this.timeout(300000); + + describe('Scenario 1: Auto-invest enabled', function () { + it('should supply USDC into Aave and send aTokens to tokensReceiver', async function () { + const { + vaultAdmin, + testUser, + tokensReceiver, + mTBILL, + depositVaultWithAave, + usdc, + aUsdc, + usdcWhale, + } = await loadFixture(aaveDepositFixture); + + await depositVaultWithAave + .connect(vaultAdmin) + .setAaveDepositsEnabled(true); + + const result = await depositInstantAave({ + depositVault: depositVaultWithAave, + user: testUser, + tokenIn: usdc, + receiptToken: aUsdc, + mToken: mTBILL, + tokensReceiverAddress: tokensReceiver.address, + tokenWhale: usdcWhale, + amountUsd: 100, + }); + + assertAutoInvestEnabled(result); + }); + }); + + describe('Scenario 2: Auto-invest disabled', function () { + it('should send USDC directly to tokensReceiver', async function () { + const { + testUser, + tokensReceiver, + mTBILL, + depositVaultWithAave, + usdc, + aUsdc, + usdcWhale, + } = await loadFixture(aaveDepositFixture); + + const result = await depositInstantAave({ + depositVault: depositVaultWithAave, + user: testUser, + tokenIn: usdc, + receiptToken: aUsdc, + mToken: mTBILL, + tokensReceiverAddress: tokensReceiver.address, + tokenWhale: usdcWhale, + amountUsd: 100, + }); + + assertAutoInvestDisabled(result); + }); + }); + + describe('Scenario 3: Toggle mid-flight', function () { + it('should switch between aToken and USDC delivery when toggled', async function () { + const { + vaultAdmin, + testUser, + tokensReceiver, + mTBILL, + depositVaultWithAave, + usdc, + aUsdc, + usdcWhale, + } = await loadFixture(aaveDepositFixture); + + await depositVaultWithAave + .connect(vaultAdmin) + .setAaveDepositsEnabled(true); + + const result1 = await depositInstantAave({ + depositVault: depositVaultWithAave, + user: testUser, + tokenIn: usdc, + receiptToken: aUsdc, + mToken: mTBILL, + tokensReceiverAddress: tokensReceiver.address, + tokenWhale: usdcWhale, + amountUsd: 100, + }); + + assertAutoInvestEnabled(result1); + + await depositVaultWithAave + .connect(vaultAdmin) + .setAaveDepositsEnabled(false); + + const result2 = await depositInstantAave({ + depositVault: depositVaultWithAave, + user: testUser, + tokenIn: usdc, + receiptToken: aUsdc, + mToken: mTBILL, + tokensReceiverAddress: tokensReceiver.address, + tokenWhale: usdcWhale, + amountUsd: 100, + }); + + assertAutoInvestDisabled(result2); + }); + }); + + describe('Multi-token: USDT with auto-invest', function () { + it('should supply USDT into Aave and send aUSDT to tokensReceiver', async function () { + const { + vaultAdmin, + testUser, + tokensReceiver, + mTBILL, + depositVaultWithAave, + usdt, + aUsdt, + usdtWhale, + } = await loadFixture(aaveDepositFixture); + + await depositVaultWithAave + .connect(vaultAdmin) + .setAaveDepositsEnabled(true); + + const result = await depositInstantAave({ + depositVault: depositVaultWithAave, + user: testUser, + tokenIn: usdt, + receiptToken: aUsdt, + mToken: mTBILL, + tokensReceiverAddress: tokensReceiver.address, + tokenWhale: usdtWhale, + amountUsd: 100, + }); + + assertAutoInvestEnabled(result); + }); + + it('should handle USDC and USDT deposits sequentially', async function () { + const { + vaultAdmin, + testUser, + tokensReceiver, + mTBILL, + depositVaultWithAave, + usdc, + aUsdc, + usdcWhale, + usdt, + aUsdt, + usdtWhale, + } = await loadFixture(aaveDepositFixture); + + await depositVaultWithAave + .connect(vaultAdmin) + .setAaveDepositsEnabled(true); + + const result1 = await depositInstantAave({ + depositVault: depositVaultWithAave, + user: testUser, + tokenIn: usdc, + receiptToken: aUsdc, + mToken: mTBILL, + tokensReceiverAddress: tokensReceiver.address, + tokenWhale: usdcWhale, + amountUsd: 100, + }); + + assertAutoInvestEnabled(result1); + + const result2 = await depositInstantAave({ + depositVault: depositVaultWithAave, + user: testUser, + tokenIn: usdt, + receiptToken: aUsdt, + mToken: mTBILL, + tokensReceiverAddress: tokensReceiver.address, + tokenWhale: usdtWhale, + amountUsd: 100, + }); + + assertAutoInvestEnabled(result2); + }); + }); + + describe('Fallback: No pool configured for token', function () { + it('deposit succeeds with raw tokens when no pool configured (fallback to normal flow)', async function () { + const { + vaultAdmin, + testUser, + tokensReceiver, + mTBILL, + depositVaultWithAave, + usdc, + aUsdc, + usdcWhale, + } = await loadFixture(aaveDepositFixture); + + await depositVaultWithAave + .connect(vaultAdmin) + .setAaveDepositsEnabled(true); + + await depositVaultWithAave + .connect(vaultAdmin) + .removeAavePool(usdc.address); + + const result = await depositInstantAave({ + depositVault: depositVaultWithAave, + user: testUser, + tokenIn: usdc, + receiptToken: aUsdc, + mToken: mTBILL, + tokensReceiverAddress: tokensReceiver.address, + tokenWhale: usdcWhale, + amountUsd: 100, + }); + + assertAutoInvestDisabled(result); + }); + }); +}); diff --git a/test/integration/DepositVaultWithMToken.test.ts b/test/integration/DepositVaultWithMToken.test.ts new file mode 100644 index 00000000..80bbe41b --- /dev/null +++ b/test/integration/DepositVaultWithMToken.test.ts @@ -0,0 +1,120 @@ +import { loadFixture } from '@nomicfoundation/hardhat-network-helpers'; + +import { mTokenDepositFixture } from './fixtures/mtoken.fixture'; +import { + assertAutoInvestDisabled, + assertAutoInvestEnabled, + depositInstantMToken, +} from './helpers/deposit.helpers'; + +describe('DepositVaultWithMToken - Mainnet Fork Integration Tests', function () { + this.timeout(300000); + + describe('Scenario 1: Auto-invest enabled', function () { + it('should deposit USDC into target DV and send mTBILL to tokensReceiver', async function () { + const { + vaultAdmin, + testUser, + tokensReceiver, + mFONE, + mTBILL, + depositVaultWithMToken, + usdc, + usdcWhale, + } = await loadFixture(mTokenDepositFixture); + + await depositVaultWithMToken + .connect(vaultAdmin) + .setMTokenDepositsEnabled(true); + + const result = await depositInstantMToken({ + depositVault: depositVaultWithMToken, + user: testUser, + usdc, + targetMToken: mTBILL, + mToken: mFONE, + tokensReceiverAddress: tokensReceiver.address, + usdcWhale, + amountUsd: 100, + }); + + assertAutoInvestEnabled(result); + }); + }); + + describe('Scenario 2: Auto-invest disabled', function () { + it('should send USDC directly to tokensReceiver', async function () { + const { + testUser, + tokensReceiver, + mFONE, + mTBILL, + depositVaultWithMToken, + usdc, + usdcWhale, + } = await loadFixture(mTokenDepositFixture); + + const result = await depositInstantMToken({ + depositVault: depositVaultWithMToken, + user: testUser, + usdc, + targetMToken: mTBILL, + mToken: mFONE, + tokensReceiverAddress: tokensReceiver.address, + usdcWhale, + amountUsd: 100, + }); + + assertAutoInvestDisabled(result); + }); + }); + + describe('Scenario 3: Toggle mid-flight', function () { + it('should switch between mTBILL and USDC delivery when toggled', async function () { + const { + vaultAdmin, + testUser, + tokensReceiver, + mFONE, + mTBILL, + depositVaultWithMToken, + usdc, + usdcWhale, + } = await loadFixture(mTokenDepositFixture); + + await depositVaultWithMToken + .connect(vaultAdmin) + .setMTokenDepositsEnabled(true); + + const result1 = await depositInstantMToken({ + depositVault: depositVaultWithMToken, + user: testUser, + usdc, + targetMToken: mTBILL, + mToken: mFONE, + tokensReceiverAddress: tokensReceiver.address, + usdcWhale, + amountUsd: 100, + }); + + assertAutoInvestEnabled(result1); + + await depositVaultWithMToken + .connect(vaultAdmin) + .setMTokenDepositsEnabled(false); + + const result2 = await depositInstantMToken({ + depositVault: depositVaultWithMToken, + user: testUser, + usdc, + targetMToken: mTBILL, + mToken: mFONE, + tokensReceiverAddress: tokensReceiver.address, + usdcWhale, + amountUsd: 100, + }); + + assertAutoInvestDisabled(result2); + }); + }); +}); diff --git a/test/integration/DepositVaultWithMorpho.test.ts b/test/integration/DepositVaultWithMorpho.test.ts new file mode 100644 index 00000000..d404c920 --- /dev/null +++ b/test/integration/DepositVaultWithMorpho.test.ts @@ -0,0 +1,234 @@ +import { loadFixture } from '@nomicfoundation/hardhat-network-helpers'; + +import { morphoDepositFixture } from './fixtures/morpho.fixture'; +import { + assertAutoInvestDisabled, + assertAutoInvestEnabled, + depositInstantMorpho, +} from './helpers/deposit.helpers'; + +describe('DepositVaultWithMorpho - Mainnet Fork Integration Tests', function () { + this.timeout(300000); + + describe('Scenario 1: Auto-invest enabled', function () { + it('should deposit USDC into Morpho vault and send shares to tokensReceiver', async function () { + const { + vaultAdmin, + testUser, + tokensReceiver, + mTBILL, + depositVaultWithMorpho, + usdc, + morphoVault, + usdcWhale, + } = await loadFixture(morphoDepositFixture); + + await depositVaultWithMorpho + .connect(vaultAdmin) + .setMorphoDepositsEnabled(true); + + const result = await depositInstantMorpho({ + depositVault: depositVaultWithMorpho, + user: testUser, + tokenIn: usdc, + receiptToken: morphoVault, + mToken: mTBILL, + tokensReceiverAddress: tokensReceiver.address, + tokenWhale: usdcWhale, + amountUsd: 100, + }); + + assertAutoInvestEnabled(result); + }); + }); + + describe('Scenario 2: Auto-invest disabled', function () { + it('should send USDC directly to tokensReceiver', async function () { + const { + testUser, + tokensReceiver, + mTBILL, + depositVaultWithMorpho, + usdc, + morphoVault, + usdcWhale, + } = await loadFixture(morphoDepositFixture); + + const result = await depositInstantMorpho({ + depositVault: depositVaultWithMorpho, + user: testUser, + tokenIn: usdc, + receiptToken: morphoVault, + mToken: mTBILL, + tokensReceiverAddress: tokensReceiver.address, + tokenWhale: usdcWhale, + amountUsd: 100, + }); + + assertAutoInvestDisabled(result); + }); + }); + + describe('Scenario 3: Toggle mid-flight', function () { + it('should switch between Morpho shares and USDC delivery when toggled', async function () { + const { + vaultAdmin, + testUser, + tokensReceiver, + mTBILL, + depositVaultWithMorpho, + usdc, + morphoVault, + usdcWhale, + } = await loadFixture(morphoDepositFixture); + + await depositVaultWithMorpho + .connect(vaultAdmin) + .setMorphoDepositsEnabled(true); + + const result1 = await depositInstantMorpho({ + depositVault: depositVaultWithMorpho, + user: testUser, + tokenIn: usdc, + receiptToken: morphoVault, + mToken: mTBILL, + tokensReceiverAddress: tokensReceiver.address, + tokenWhale: usdcWhale, + amountUsd: 100, + }); + + assertAutoInvestEnabled(result1); + + await depositVaultWithMorpho + .connect(vaultAdmin) + .setMorphoDepositsEnabled(false); + + const result2 = await depositInstantMorpho({ + depositVault: depositVaultWithMorpho, + user: testUser, + tokenIn: usdc, + receiptToken: morphoVault, + mToken: mTBILL, + tokensReceiverAddress: tokensReceiver.address, + tokenWhale: usdcWhale, + amountUsd: 100, + }); + + assertAutoInvestDisabled(result2); + }); + }); + + describe('Multi-token: USDT with different Morpho vault', function () { + it('should deposit USDT into Smokehouse USDT vault and send shares to tokensReceiver', async function () { + const { + vaultAdmin, + testUser, + tokensReceiver, + mTBILL, + depositVaultWithMorpho, + usdt, + morphoUsdtVault, + usdtWhale, + } = await loadFixture(morphoDepositFixture); + + await depositVaultWithMorpho + .connect(vaultAdmin) + .setMorphoDepositsEnabled(true); + + const result = await depositInstantMorpho({ + depositVault: depositVaultWithMorpho, + user: testUser, + tokenIn: usdt, + receiptToken: morphoUsdtVault, + mToken: mTBILL, + tokensReceiverAddress: tokensReceiver.address, + tokenWhale: usdtWhale, + amountUsd: 100, + }); + + assertAutoInvestEnabled(result); + }); + + it('should route USDC to Steakhouse and USDT to Smokehouse', async function () { + const { + vaultAdmin, + testUser, + tokensReceiver, + mTBILL, + depositVaultWithMorpho, + usdc, + morphoVault, + usdcWhale, + usdt, + morphoUsdtVault, + usdtWhale, + } = await loadFixture(morphoDepositFixture); + + await depositVaultWithMorpho + .connect(vaultAdmin) + .setMorphoDepositsEnabled(true); + + const resultUsdc = await depositInstantMorpho({ + depositVault: depositVaultWithMorpho, + user: testUser, + tokenIn: usdc, + receiptToken: morphoVault, + mToken: mTBILL, + tokensReceiverAddress: tokensReceiver.address, + tokenWhale: usdcWhale, + amountUsd: 100, + }); + + assertAutoInvestEnabled(resultUsdc); + + const resultUsdt = await depositInstantMorpho({ + depositVault: depositVaultWithMorpho, + user: testUser, + tokenIn: usdt, + receiptToken: morphoUsdtVault, + mToken: mTBILL, + tokensReceiverAddress: tokensReceiver.address, + tokenWhale: usdtWhale, + amountUsd: 100, + }); + + assertAutoInvestEnabled(resultUsdt); + }); + }); + + describe('Fallback: No vault configured for token', function () { + it('deposit succeeds with raw tokens when no vault configured (fallback to normal flow)', async function () { + const { + vaultAdmin, + testUser, + tokensReceiver, + mTBILL, + depositVaultWithMorpho, + usdc, + morphoVault, + usdcWhale, + } = await loadFixture(morphoDepositFixture); + + await depositVaultWithMorpho + .connect(vaultAdmin) + .setMorphoDepositsEnabled(true); + + await depositVaultWithMorpho + .connect(vaultAdmin) + .removeMorphoVault(usdc.address); + + const result = await depositInstantMorpho({ + depositVault: depositVaultWithMorpho, + user: testUser, + tokenIn: usdc, + receiptToken: morphoVault, + mToken: mTBILL, + tokensReceiverAddress: tokensReceiver.address, + tokenWhale: usdcWhale, + amountUsd: 100, + }); + + assertAutoInvestDisabled(result); + }); + }); +}); diff --git a/test/integration/DepositVaultWithUSTB.test.ts b/test/integration/DepositVaultWithUSTB.test.ts index 2017ba64..cfa043e1 100644 --- a/test/integration/DepositVaultWithUSTB.test.ts +++ b/test/integration/DepositVaultWithUSTB.test.ts @@ -1,8 +1,8 @@ import { loadFixture } from '@nomicfoundation/hardhat-network-helpers'; -import { BigNumber, constants } from 'ethers'; +import { constants } from 'ethers'; import { parseUnits } from 'ethers/lib/utils'; -import { ustbRedemptionVaultFixture } from './fixtures/ustb.fixture'; +import { ustbDepositFixture } from './fixtures/ustb.fixture'; import { approveBase18 } from '../common/common.helpers'; import { @@ -12,7 +12,7 @@ import { } from '../common/deposit-vault-ustb.helpers'; describe('DepositVaultWithUSTB - Mainnet Fork Integration Tests', function () { - this.timeout(120000); + this.timeout(300000); describe('Scenario 1: USTB deposits are enabled and stablecoin config exists', function () { it('should invest USDC into USTB', async function () { @@ -26,7 +26,7 @@ describe('DepositVaultWithUSTB - Mainnet Fork Integration Tests', function () { ustbToken, usdcWhale, mTokenToUsdDataFeed, - } = await loadFixture(ustbRedemptionVaultFixture); + } = await loadFixture(ustbDepositFixture); const usdcAmount = 100; @@ -51,7 +51,6 @@ describe('DepositVaultWithUSTB - Mainnet Fork Integration Tests', function () { mTokenToUsdDataFeed, ustbToken, expectedUstbDeposited: true, - expectedUstbMinted: BigNumber.from(9264844), }, usdc, usdcAmount, @@ -73,7 +72,7 @@ describe('DepositVaultWithUSTB - Mainnet Fork Integration Tests', function () { ustbTokenOwner, usdcWhale, mTokenToUsdDataFeed, - } = await loadFixture(ustbRedemptionVaultFixture); + } = await loadFixture(ustbDepositFixture); const usdcAmount = 100; @@ -129,7 +128,7 @@ describe('DepositVaultWithUSTB - Mainnet Fork Integration Tests', function () { ustbTokenOwner, usdcWhale, mTokenToUsdDataFeed, - } = await loadFixture(ustbRedemptionVaultFixture); + } = await loadFixture(ustbDepositFixture); const usdcAmount = 100; diff --git a/test/integration/RedemptionVaultWithAave.test.ts b/test/integration/RedemptionVaultWithAave.test.ts new file mode 100644 index 00000000..cc1b6f4f --- /dev/null +++ b/test/integration/RedemptionVaultWithAave.test.ts @@ -0,0 +1,436 @@ +import { loadFixture } from '@nomicfoundation/hardhat-network-helpers'; +import { expect } from 'chai'; +import { constants } from 'ethers'; +import { parseUnits } from 'ethers/lib/utils'; +import { ethers } from 'hardhat'; + +import { aaveRedemptionFixture } from './fixtures/aave.fixture'; + +import { mintToken, approveBase18 } from '../common/common.helpers'; +import { redeemInstantWithAaveTest } from '../common/redemption-vault-aave.helpers'; + +describe('RedemptionVaultWithAave - Mainnet Fork Integration Tests', function () { + this.timeout(300000); + + describe('Scenario 1: Vault has sufficient USDC', function () { + it('should redeem mTBILL for USDC directly without Aave withdrawal', async function () { + const { + owner, + testUser, + mTBILL, + redemptionVaultWithAave, + usdc, + aUsdc, + usdcWhale, + mTokenToUsdDataFeed, + } = await loadFixture(aaveRedemptionFixture); + + const mTBILLAmount = 1000; + + // Fund vault with USDC + await usdc + .connect(usdcWhale) + .transfer(redemptionVaultWithAave.address, parseUnits('10000', 6)); + + // Mint mTBILL to user + await mintToken(mTBILL, testUser, mTBILLAmount); + + // Approve vault + await approveBase18( + testUser, + mTBILL, + redemptionVaultWithAave, + mTBILLAmount, + ); + + // Get balances before + const vaultUSDCBefore = await usdc.balanceOf( + redemptionVaultWithAave.address, + ); + + // Perform redemption + const result = await redeemInstantWithAaveTest( + { + redemptionVault: redemptionVaultWithAave, + owner, + mTBILL, + mTokenToUsdDataFeed, + usdc, + aToken: aUsdc, + expectedATokenUsed: parseUnits('0', 6), + expectedUsdcUsed: parseUnits('990', 6), // 990 USDC (1000 - 1% fee) + }, + mTBILLAmount, + { from: testUser }, + ); + + // Verify user received USDC (990 after 1% fee) + expect(result?.userUSDCReceived).to.equal(parseUnits('990', 6)); + + // Verify vault USDC decreased + expect(vaultUSDCBefore.sub(result?.vaultUSDCAfter ?? '0')).to.equal( + parseUnits('990', 6), + ); + + // Verify mTBILL was burned from user + expect(await mTBILL.balanceOf(testUser.address)).to.equal(0); + }); + }); + + describe('Scenario 2: Vault uses Aave for liquidity', function () { + it('should withdraw from Aave when vault has no direct USDC', async function () { + const { + owner, + testUser, + mTBILL, + redemptionVaultWithAave, + usdc, + aUsdc, + aUsdcWhale, + mTokenToUsdDataFeed, + } = await loadFixture(aaveRedemptionFixture); + + const mTBILLAmount = 1000; + + // Fund vault with aUSDC only (no direct USDC) + await aUsdc + .connect(aUsdcWhale) + .transfer(redemptionVaultWithAave.address, parseUnits('10000', 6)); + + // Verify vault has no direct USDC + expect(await usdc.balanceOf(redemptionVaultWithAave.address)).to.equal(0); + + // Verify vault has aTokens + expect(await aUsdc.balanceOf(redemptionVaultWithAave.address)).to.be.gte( + parseUnits('10000', 6), + ); + + // Mint mTBILL to user + await mintToken(mTBILL, testUser, mTBILLAmount); + + // Approve vault + await approveBase18( + testUser, + mTBILL, + redemptionVaultWithAave, + mTBILLAmount, + ); + + // Perform redemption + const result = await redeemInstantWithAaveTest( + { + redemptionVault: redemptionVaultWithAave, + owner, + mTBILL, + mTokenToUsdDataFeed, + usdc, + aToken: aUsdc, + }, + mTBILLAmount, + { from: testUser }, + ); + + // Check that aTokens were used + expect(result?.aTokenUsed).to.be.gt(0); + + // Verify user received USDC + expect(result?.userUSDCReceived).to.equal(parseUnits('990', 6)); + + // Verify mTBILL was burned from user + expect(await mTBILL.balanceOf(testUser.address)).to.equal(0); + }); + }); + + describe('Scenario 3: Partial Aave withdrawal', function () { + it('should only withdraw shortfall from Aave when vault has partial USDC', async function () { + const { + owner, + testUser, + mTBILL, + redemptionVaultWithAave, + usdc, + aUsdc, + usdcWhale, + aUsdcWhale, + mTokenToUsdDataFeed, + } = await loadFixture(aaveRedemptionFixture); + + const mTBILLAmount = 1000; + const partialUSDC = parseUnits('500', 6); // 500 USDC in vault + + // Fund vault with partial USDC + await usdc + .connect(usdcWhale) + .transfer(redemptionVaultWithAave.address, partialUSDC); + + // Fund vault with aUSDC for the rest + await aUsdc + .connect(aUsdcWhale) + .transfer(redemptionVaultWithAave.address, parseUnits('10000', 6)); + + // Mint mTBILL to user + await mintToken(mTBILL, testUser, mTBILLAmount); + + // Approve vault + await approveBase18( + testUser, + mTBILL, + redemptionVaultWithAave, + mTBILLAmount, + ); + + // Perform redemption: 1000 mTBILL @ 1:1 rate, 1% fee = 990 USDC needed + // Vault has 500 USDC, so shortfall = 490 USDC from Aave + const result = await redeemInstantWithAaveTest( + { + redemptionVault: redemptionVaultWithAave, + owner, + mTBILL, + mTokenToUsdDataFeed, + usdc, + aToken: aUsdc, + }, + mTBILLAmount, + { from: testUser }, + ); + + // Verify user received USDC + expect(result?.userUSDCReceived).to.equal(parseUnits('990', 6)); + + // Verify aToken decrease equals the shortfall (990 - 500 = 490) + const expectedShortfall = parseUnits('490', 6); + expect(result?.aTokenUsed).to.be.closeTo( + expectedShortfall, + parseUnits('1', 6), // 1 USDC tolerance for Aave interest accrual + ); + + // Verify some vault USDC was also used + expect(result?.usdcUsed).to.be.gt(0); + + // Verify mTBILL was burned from user + expect(await mTBILL.balanceOf(testUser.address)).to.equal(0); + }); + }); + + describe('Error Cases', function () { + it('should revert when vault has insufficient aToken balance', async function () { + const { + owner, + testUser, + mTBILL, + redemptionVaultWithAave, + usdc, + aUsdc, + mTokenToUsdDataFeed, + } = await loadFixture(aaveRedemptionFixture); + + const mTBILLAmount = 100000; // 100k mTBILL - vault has no USDC and no aTokens + + // Mint mTBILL + await mintToken(mTBILL, testUser, mTBILLAmount); + + // Approve + await approveBase18( + testUser, + mTBILL, + redemptionVaultWithAave, + mTBILLAmount, + ); + + // Should revert because vault has no USDC and no aTokens + await redeemInstantWithAaveTest( + { + redemptionVault: redemptionVaultWithAave, + owner, + mTBILL, + mTokenToUsdDataFeed, + usdc, + aToken: aUsdc, + }, + mTBILLAmount, + { + from: testUser, + revertMessage: 'RVA: insufficient aToken balance', + }, + ); + }); + + it('should revert when trying to redeem a token not in Aave pool', async function () { + const { + owner, + testUser, + mTBILL, + redemptionVaultWithAave, + aUsdc, + mTokenToUsdDataFeed, + } = await loadFixture(aaveRedemptionFixture); + + // Deploy a fake token that isn't registered in Aave + const fakeTokenFactory = await ethers.getContractFactory('ERC20Mock'); + const fakeToken = await fakeTokenFactory.deploy(6); + await fakeToken.deployed(); + + // Add the fake token as a payment token on our vault + await redemptionVaultWithAave + .connect(owner) + .addPaymentToken( + fakeToken.address, + mTokenToUsdDataFeed.address, + 0, + constants.MaxUint256, + true, + ); + + const mTBILLAmount = 1000; + + // Mint mTBILL to user + await mintToken(mTBILL, testUser, mTBILLAmount); + + // Approve vault + await approveBase18( + testUser, + mTBILL, + redemptionVaultWithAave, + mTBILLAmount, + ); + + // Should revert because fakeToken is not in Aave pool + // The vault has no fakeToken balance, so it tries Aave withdrawal + // Aave's getReserveAToken returns address(0) + await redeemInstantWithAaveTest( + { + redemptionVault: redemptionVaultWithAave, + owner, + mTBILL, + mTokenToUsdDataFeed, + usdc: fakeToken, + aToken: aUsdc, + }, + mTBILLAmount, + { + from: testUser, + revertMessage: 'RVA: no pool for token', + }, + ); + }); + }); + + describe('Multi-token: USDT redemption via Aave', function () { + it('should withdraw USDT from Aave when vault has aUSDT but no direct USDT', async function () { + const { + owner, + testUser, + mTBILL, + redemptionVaultWithAave, + usdt, + aUsdt, + aUsdtWhale, + mTokenToUsdDataFeed, + } = await loadFixture(aaveRedemptionFixture); + + const mTBILLAmount = 1000; + + // Fund vault with aUSDT (from aUsdtWhale) + await aUsdt + .connect(aUsdtWhale) + .transfer(redemptionVaultWithAave.address, parseUnits('10000', 6)); + + // Verify vault has no direct USDT + expect(await usdt.balanceOf(redemptionVaultWithAave.address)).to.equal(0); + + // Verify vault has aTokens + expect(await aUsdt.balanceOf(redemptionVaultWithAave.address)).to.be.gte( + parseUnits('10000', 6), + ); + + // Mint mTBILL to user + await mintToken(mTBILL, testUser, mTBILLAmount); + + // Approve vault + await approveBase18( + testUser, + mTBILL, + redemptionVaultWithAave, + mTBILLAmount, + ); + + // redeemInstantWithAaveTest with usdc: usdt, aToken: aUsdt + const result = await redeemInstantWithAaveTest( + { + redemptionVault: redemptionVaultWithAave, + owner, + mTBILL, + mTokenToUsdDataFeed, + usdc: usdt, + aToken: aUsdt, + }, + mTBILLAmount, + { from: testUser }, + ); + + // Verify aTokens used, user received USDT + expect(result?.aTokenUsed).to.be.gt(0); + expect(result?.userUSDCReceived).to.equal(parseUnits('990', 6)); + + // Verify mTBILL was burned from user + expect(await mTBILL.balanceOf(testUser.address)).to.equal(0); + }); + + it('should handle USDC and USDT independently via per-token pool mapping', async function () { + const { + owner, + testUser, + mTBILL, + redemptionVaultWithAave, + usdc, + aUsdc, + usdt, + aUsdt, + aUsdcWhale, + aUsdtWhale, + mTokenToUsdDataFeed, + } = await loadFixture(aaveRedemptionFixture); + + // Fund vault with aUSDC for USDC redemptions + await aUsdc + .connect(aUsdcWhale) + .transfer(redemptionVaultWithAave.address, parseUnits('10000', 6)); + + // Fund vault with aUSDT for USDT redemptions + await aUsdt + .connect(aUsdtWhale) + .transfer(redemptionVaultWithAave.address, parseUnits('10000', 6)); + + // Do a USDC redemption → verify works + const mTBILLAmount = 1000; + await mintToken(mTBILL, testUser, mTBILLAmount); + await approveBase18( + testUser, + mTBILL, + redemptionVaultWithAave, + mTBILLAmount, + ); + + const result = await redeemInstantWithAaveTest( + { + redemptionVault: redemptionVaultWithAave, + owner, + mTBILL, + mTokenToUsdDataFeed, + usdc, + aToken: aUsdc, + }, + mTBILLAmount, + { from: testUser }, + ); + + expect(result?.userUSDCReceived).to.equal(parseUnits('990', 6)); + + // Verify both payment tokens have their pools configured + const aavePool = await redemptionVaultWithAave.aavePools(usdc.address); + const usdtPool = await redemptionVaultWithAave.aavePools(usdt.address); + expect(aavePool).to.not.equal(constants.AddressZero); + expect(usdtPool).to.not.equal(constants.AddressZero); + }); + }); +}); diff --git a/test/integration/RedemptionVaultWithMToken.test.ts b/test/integration/RedemptionVaultWithMToken.test.ts new file mode 100644 index 00000000..53d5da7c --- /dev/null +++ b/test/integration/RedemptionVaultWithMToken.test.ts @@ -0,0 +1,258 @@ +import { loadFixture } from '@nomicfoundation/hardhat-network-helpers'; +import { expect } from 'chai'; +import { parseUnits } from 'ethers/lib/utils'; + +import { mTokenRedemptionFixture } from './fixtures/mtoken.fixture'; + +import { mintToken, approveBase18 } from '../common/common.helpers'; + +describe('RedemptionVaultWithMToken - Mainnet Fork Integration Tests', function () { + this.timeout(300000); + + describe('Scenario 1: Vault has sufficient USDC', function () { + it('should redeem mFONE for USDC directly without touching mTBILL', async function () { + const { + testUser, + mFONE, + mTBILL, + redemptionVaultWithMToken, + usdc, + usdcWhale, + } = await loadFixture(mTokenRedemptionFixture); + + const mFONEAmount = 1000; + + // Fund product RV with USDC so no mTBILL redemption is needed + await usdc + .connect(usdcWhale) + .transfer(redemptionVaultWithMToken.address, parseUnits('10000', 6)); + + // Mint mFONE to user + await mintToken(mFONE, testUser, mFONEAmount); + + // Approve product RV + await approveBase18( + testUser, + mFONE, + redemptionVaultWithMToken, + mFONEAmount, + ); + + // Get balances before + const vaultUSDCBefore = await usdc.balanceOf( + redemptionVaultWithMToken.address, + ); + const vaultMTBILLBefore = await mTBILL.balanceOf( + redemptionVaultWithMToken.address, + ); + const userUSDCBefore = await usdc.balanceOf(testUser.address); + + // Perform redemption + await redemptionVaultWithMToken + .connect(testUser) + ['redeemInstant(address,uint256,uint256)']( + usdc.address, + parseUnits(String(mFONEAmount)), + 0, + ); + + // Get balances after + const vaultUSDCAfter = await usdc.balanceOf( + redemptionVaultWithMToken.address, + ); + const vaultMTBILLAfter = await mTBILL.balanceOf( + redemptionVaultWithMToken.address, + ); + const userUSDCAfter = await usdc.balanceOf(testUser.address); + + // Verify user received USDC (990 after 1% fee) + expect(userUSDCAfter.sub(userUSDCBefore)).to.equal(parseUnits('990', 6)); + + // Verify vault USDC decreased + expect(vaultUSDCBefore.sub(vaultUSDCAfter)).to.equal( + parseUnits('990', 6), + ); + + // Verify mTBILL was NOT used + expect(vaultMTBILLAfter).to.equal(vaultMTBILLBefore); + + // Verify mFONE was burned from user + expect(await mFONE.balanceOf(testUser.address)).to.equal(0); + }); + }); + + describe('Scenario 2: Vault uses mTBILL for liquidity', function () { + it('should redeem mTBILL through target RV when vault has no direct USDC', async function () { + const { testUser, mFONE, mTBILL, redemptionVaultWithMToken, usdc } = + await loadFixture(mTokenRedemptionFixture); + + const mFONEAmount = 1000; + + // Vault has no direct USDC but has mTBILL (loaded in fixture) + expect(await usdc.balanceOf(redemptionVaultWithMToken.address)).to.equal( + 0, + ); + expect( + await mTBILL.balanceOf(redemptionVaultWithMToken.address), + ).to.be.gte(parseUnits('1000')); + + // Mint mFONE to user + await mintToken(mFONE, testUser, mFONEAmount); + + // Approve product RV + await approveBase18( + testUser, + mFONE, + redemptionVaultWithMToken, + mFONEAmount, + ); + + // Get balances before + const vaultMTBILLBefore = await mTBILL.balanceOf( + redemptionVaultWithMToken.address, + ); + const userUSDCBefore = await usdc.balanceOf(testUser.address); + + // Perform redemption + await redemptionVaultWithMToken + .connect(testUser) + ['redeemInstant(address,uint256,uint256)']( + usdc.address, + parseUnits(String(mFONEAmount)), + 0, + ); + + // Get balances after + const vaultMTBILLAfter = await mTBILL.balanceOf( + redemptionVaultWithMToken.address, + ); + const userUSDCAfter = await usdc.balanceOf(testUser.address); + + // Verify mTBILL was used (redeemed through target RV) + expect(vaultMTBILLBefore.sub(vaultMTBILLAfter)).to.be.gt(0); + + // Verify user received USDC (990 after 1% fee) + expect(userUSDCAfter.sub(userUSDCBefore)).to.equal(parseUnits('990', 6)); + + // Verify mFONE was burned from user + expect(await mFONE.balanceOf(testUser.address)).to.equal(0); + }); + }); + + describe('Scenario 3: Partial mTBILL redemption', function () { + it('should only redeem mTBILL for the shortfall when vault has partial USDC', async function () { + const { + testUser, + mFONE, + mTBILL, + redemptionVaultWithMToken, + usdc, + usdcWhale, + } = await loadFixture(mTokenRedemptionFixture); + + const mFONEAmount = 1000; + const partialUSDC = parseUnits('500', 6); + + // Fund product RV with partial USDC + await usdc + .connect(usdcWhale) + .transfer(redemptionVaultWithMToken.address, partialUSDC); + + // Mint mFONE to user + await mintToken(mFONE, testUser, mFONEAmount); + + // Approve product RV + await approveBase18( + testUser, + mFONE, + redemptionVaultWithMToken, + mFONEAmount, + ); + + // Get balances before + const vaultMTBILLBefore = await mTBILL.balanceOf( + redemptionVaultWithMToken.address, + ); + const vaultUSDCBefore = await usdc.balanceOf( + redemptionVaultWithMToken.address, + ); + const userUSDCBefore = await usdc.balanceOf(testUser.address); + + // Perform redemption: 1000 mFONE @ 1:1 rate, 1% fee = 990 USDC needed + // Vault has 500 USDC, shortfall = 490 USDC from mTBILL redemption + await redemptionVaultWithMToken + .connect(testUser) + ['redeemInstant(address,uint256,uint256)']( + usdc.address, + parseUnits(String(mFONEAmount)), + 0, + ); + + // Get balances after + const vaultMTBILLAfter = await mTBILL.balanceOf( + redemptionVaultWithMToken.address, + ); + const vaultUSDCAfter = await usdc.balanceOf( + redemptionVaultWithMToken.address, + ); + const userUSDCAfter = await usdc.balanceOf(testUser.address); + + // Verify user received USDC + expect(userUSDCAfter.sub(userUSDCBefore)).to.equal(parseUnits('990', 6)); + + // Verify mTBILL was used for the shortfall portion + expect(vaultMTBILLBefore.sub(vaultMTBILLAfter)).to.be.gt(0); + + // Verify some vault USDC was also used + expect(vaultUSDCBefore.sub(vaultUSDCAfter)).to.be.gt(0); + + // Verify mFONE was burned from user + expect(await mFONE.balanceOf(testUser.address)).to.equal(0); + }); + }); + + describe('Error Cases', function () { + it('should revert when vault has insufficient mTBILL balance', async function () { + const { + owner, + testUser, + mFONE, + mTBILL, + redemptionVaultWithMToken, + usdc, + } = await loadFixture(mTokenRedemptionFixture); + + // Withdraw all mTBILL from the product RV so it has no fallback + const vaultMTBILL = await mTBILL.balanceOf( + redemptionVaultWithMToken.address, + ); + await redemptionVaultWithMToken + .connect(owner) + .withdrawToken(mTBILL.address, vaultMTBILL, owner.address); + + const mFONEAmount = 1000; + + // Mint mFONE + await mintToken(mFONE, testUser, mFONEAmount); + + // Approve + await approveBase18( + testUser, + mFONE, + redemptionVaultWithMToken, + mFONEAmount, + ); + + // Should revert because vault has no USDC and no mTBILL + await expect( + redemptionVaultWithMToken + .connect(testUser) + ['redeemInstant(address,uint256,uint256)']( + usdc.address, + parseUnits(String(mFONEAmount)), + 0, + ), + ).to.be.revertedWith('RVMT: balance < needed'); + }); + }); +}); diff --git a/test/integration/RedemptionVaultWithMorpho.test.ts b/test/integration/RedemptionVaultWithMorpho.test.ts new file mode 100644 index 00000000..b0b54460 --- /dev/null +++ b/test/integration/RedemptionVaultWithMorpho.test.ts @@ -0,0 +1,391 @@ +import { loadFixture } from '@nomicfoundation/hardhat-network-helpers'; +import { expect } from 'chai'; +import { constants } from 'ethers'; +import { parseUnits } from 'ethers/lib/utils'; +import { ethers } from 'hardhat'; + +import { morphoRedemptionFixture } from './fixtures/morpho.fixture'; +import { MAINNET_ADDRESSES } from './helpers/mainnet-addresses'; + +import { mintToken, approveBase18 } from '../common/common.helpers'; +import { redeemInstantWithMorphoTest } from '../common/redemption-vault-morpho.helpers'; + +describe('RedemptionVaultWithMorpho - Mainnet Fork Integration Tests', function () { + this.timeout(300000); + + describe('Scenario 1: Vault has sufficient USDC', function () { + it('should redeem mTBILL for USDC directly without Morpho withdrawal', async function () { + const { + owner, + testUser, + mTBILL, + redemptionVaultWithMorpho, + usdc, + morphoVault, + usdcWhale, + mTokenToUsdDataFeed, + } = await loadFixture(morphoRedemptionFixture); + + const mTBILLAmount = 1000; + + // Fund vault with USDC + await usdc + .connect(usdcWhale) + .transfer(redemptionVaultWithMorpho.address, parseUnits('10000', 6)); + + // Mint mTBILL to user + await mintToken(mTBILL, testUser, mTBILLAmount); + + // Approve vault + await approveBase18( + testUser, + mTBILL, + redemptionVaultWithMorpho, + mTBILLAmount, + ); + + // Get balances before + const vaultUSDCBefore = await usdc.balanceOf( + redemptionVaultWithMorpho.address, + ); + + // Perform redemption + const result = await redeemInstantWithMorphoTest( + { + redemptionVault: redemptionVaultWithMorpho, + owner, + mTBILL, + mTokenToUsdDataFeed, + usdc, + morphoVault, + expectedSharesUsed: parseUnits('0'), + expectedUsdcUsed: parseUnits('990', 6), // 990 USDC (1000 - 1% fee) + }, + mTBILLAmount, + { from: testUser }, + ); + + // Verify user received USDC (990 after 1% fee) + expect(result?.userUSDCReceived).to.equal(parseUnits('990', 6)); + + // Verify vault USDC decreased + expect(vaultUSDCBefore.sub(result?.vaultUSDCAfter ?? '0')).to.equal( + parseUnits('990', 6), + ); + + // Verify mTBILL was burned from user + expect(await mTBILL.balanceOf(testUser.address)).to.equal(0); + }); + }); + + describe('Scenario 2: Vault uses Morpho for liquidity', function () { + it('should withdraw from Morpho when vault has no direct USDC', async function () { + const { + owner, + testUser, + mTBILL, + redemptionVaultWithMorpho, + usdc, + morphoVault, + morphoShareWhale, + mTokenToUsdDataFeed, + } = await loadFixture(morphoRedemptionFixture); + + const mTBILLAmount = 1000; + + // Fund vault with Morpho shares only (no direct USDC) + const shareAmount = parseUnits('10000', 18); + await morphoVault + .connect(morphoShareWhale) + .transfer(redemptionVaultWithMorpho.address, shareAmount); + + // Verify vault has no direct USDC + expect(await usdc.balanceOf(redemptionVaultWithMorpho.address)).to.equal( + 0, + ); + + // Verify vault has shares + expect( + await morphoVault.balanceOf(redemptionVaultWithMorpho.address), + ).to.be.gte(shareAmount); + + // Mint mTBILL to user + await mintToken(mTBILL, testUser, mTBILLAmount); + + // Approve vault + await approveBase18( + testUser, + mTBILL, + redemptionVaultWithMorpho, + mTBILLAmount, + ); + + // Perform redemption + const result = await redeemInstantWithMorphoTest( + { + redemptionVault: redemptionVaultWithMorpho, + owner, + mTBILL, + mTokenToUsdDataFeed, + usdc, + morphoVault, + }, + mTBILLAmount, + { from: testUser }, + ); + + // Check that shares were used + expect(result?.sharesUsed).to.be.gt(0); + + // Verify user received USDC + expect(result?.userUSDCReceived).to.equal(parseUnits('990', 6)); + + // Verify mTBILL was burned from user + expect(await mTBILL.balanceOf(testUser.address)).to.equal(0); + }); + }); + + describe('Scenario 3: Partial Morpho withdrawal', function () { + it('should only withdraw shortfall from Morpho when vault has partial USDC', async function () { + const { + owner, + testUser, + mTBILL, + redemptionVaultWithMorpho, + usdc, + morphoVault, + usdcWhale, + morphoShareWhale, + mTokenToUsdDataFeed, + } = await loadFixture(morphoRedemptionFixture); + + const mTBILLAmount = 1000; + const partialUSDC = parseUnits('500', 6); // 500 USDC in vault + + // Fund vault with partial USDC + await usdc + .connect(usdcWhale) + .transfer(redemptionVaultWithMorpho.address, partialUSDC); + + // Fund vault with Morpho shares for the rest + const shareAmount = parseUnits('10000', 18); + await morphoVault + .connect(morphoShareWhale) + .transfer(redemptionVaultWithMorpho.address, shareAmount); + + // Mint mTBILL to user + await mintToken(mTBILL, testUser, mTBILLAmount); + + // Approve vault + await approveBase18( + testUser, + mTBILL, + redemptionVaultWithMorpho, + mTBILLAmount, + ); + + // Perform redemption: 1000 mTBILL @ 1:1 rate, 1% fee = 990 USDC needed + // Vault has 500 USDC, so shortfall = 490 USDC from Morpho + const result = await redeemInstantWithMorphoTest( + { + redemptionVault: redemptionVaultWithMorpho, + owner, + mTBILL, + mTokenToUsdDataFeed, + usdc, + morphoVault, + }, + mTBILLAmount, + { from: testUser }, + ); + + // Verify user received USDC + expect(result?.userUSDCReceived).to.equal(parseUnits('990', 6)); + + // Verify shares were used (for the shortfall portion) + expect(result?.sharesUsed).to.be.gt(0); + + // Verify some vault USDC was also used + expect(result?.usdcUsed).to.be.gt(0); + + // Verify mTBILL was burned from user + expect(await mTBILL.balanceOf(testUser.address)).to.equal(0); + }); + }); + + describe('Error Cases', function () { + it('should revert when vault has insufficient shares', async function () { + const { + owner, + testUser, + mTBILL, + redemptionVaultWithMorpho, + usdc, + morphoVault, + mTokenToUsdDataFeed, + } = await loadFixture(morphoRedemptionFixture); + + const mTBILLAmount = 100000; // 100k mTBILL - vault has no USDC and no shares + + // Mint mTBILL + await mintToken(mTBILL, testUser, mTBILLAmount); + + // Approve + await approveBase18( + testUser, + mTBILL, + redemptionVaultWithMorpho, + mTBILLAmount, + ); + + // Should revert because vault has no USDC and no Morpho shares + await redeemInstantWithMorphoTest( + { + redemptionVault: redemptionVaultWithMorpho, + owner, + mTBILL, + mTokenToUsdDataFeed, + usdc, + morphoVault, + }, + mTBILLAmount, + { + from: testUser, + revertMessage: 'RVM: insufficient shares', + }, + ); + }); + + it('should revert when trying to redeem a token not matching vault asset', async function () { + const { + owner, + testUser, + mTBILL, + redemptionVaultWithMorpho, + morphoVault, + mTokenToUsdDataFeed, + } = await loadFixture(morphoRedemptionFixture); + + // Deploy a fake token that isn't the Morpho vault's underlying asset + const fakeTokenFactory = await ethers.getContractFactory('ERC20Mock'); + const fakeToken = await fakeTokenFactory.deploy(6); + await fakeToken.deployed(); + + // Add the fake token as a payment token on our vault + await redemptionVaultWithMorpho + .connect(owner) + .addPaymentToken( + fakeToken.address, + mTokenToUsdDataFeed.address, + 0, + constants.MaxUint256, + true, + ); + + const mTBILLAmount = 1000; + + // Mint mTBILL to user + await mintToken(mTBILL, testUser, mTBILLAmount); + + // Approve vault + await approveBase18( + testUser, + mTBILL, + redemptionVaultWithMorpho, + mTBILLAmount, + ); + + // Should revert because fakeToken is not the Morpho vault's asset + // The vault has no fakeToken balance, so it tries Morpho withdrawal + // which fails because no vault is configured for the fake token + await redeemInstantWithMorphoTest( + { + redemptionVault: redemptionVaultWithMorpho, + owner, + mTBILL, + mTokenToUsdDataFeed, + usdc: fakeToken, + morphoVault, + }, + mTBILLAmount, + { + from: testUser, + revertMessage: 'RVM: no vault for token', + }, + ); + }); + }); + + describe('Multi-token: USDT redemption via different Morpho vault', function () { + it('should withdraw USDT from Smokehouse vault when vault has shares but no direct USDT', async function () { + const { + owner, + testUser, + mTBILL, + redemptionVaultWithMorpho, + usdt, + morphoUsdtVault, + morphoUsdtShareWhale, + mTokenToUsdDataFeed, + } = await loadFixture(morphoRedemptionFixture); + + const mTBILLAmount = 1000; + const shareAmount = parseUnits('10000', 18); + await morphoUsdtVault + .connect(morphoUsdtShareWhale) + .transfer(redemptionVaultWithMorpho.address, shareAmount); + + expect(await usdt.balanceOf(redemptionVaultWithMorpho.address)).to.equal( + 0, + ); + expect( + await morphoUsdtVault.balanceOf(redemptionVaultWithMorpho.address), + ).to.be.gte(shareAmount); + + await mintToken(mTBILL, testUser, mTBILLAmount); + await approveBase18( + testUser, + mTBILL, + redemptionVaultWithMorpho, + mTBILLAmount, + ); + + const result = await redeemInstantWithMorphoTest( + { + redemptionVault: redemptionVaultWithMorpho, + owner, + mTBILL, + mTokenToUsdDataFeed, + usdc: usdt, + morphoVault: morphoUsdtVault, + }, + mTBILLAmount, + { from: testUser }, + ); + + expect(result?.sharesUsed).to.be.gt(0); + expect(result?.userUSDCReceived).to.equal(parseUnits('990', 6)); + expect(await mTBILL.balanceOf(testUser.address)).to.equal(0); + }); + + it('should verify per-token vault mapping is configured for both tokens', async function () { + const { redemptionVaultWithMorpho, usdc, usdt } = await loadFixture( + morphoRedemptionFixture, + ); + + const steakhouseUsdcVault = await redemptionVaultWithMorpho.morphoVaults( + usdc.address, + ); + const smokehouseUsdtVault = await redemptionVaultWithMorpho.morphoVaults( + usdt.address, + ); + + expect(steakhouseUsdcVault.toLowerCase()).to.equal( + MAINNET_ADDRESSES.MORPHO_STEAKHOUSE_USDC_VAULT.toLowerCase(), + ); + expect(smokehouseUsdtVault.toLowerCase()).to.equal( + MAINNET_ADDRESSES.MORPHO_SMOKEHOUSE_USDT_VAULT.toLowerCase(), + ); + }); + }); +}); diff --git a/test/integration/RedemptionVaultWithUSTB.test.ts b/test/integration/RedemptionVaultWithUSTB.test.ts index 59603e2f..0ed4f87c 100644 --- a/test/integration/RedemptionVaultWithUSTB.test.ts +++ b/test/integration/RedemptionVaultWithUSTB.test.ts @@ -2,14 +2,14 @@ import { loadFixture } from '@nomicfoundation/hardhat-network-helpers'; import { expect } from 'chai'; import { parseUnits } from 'ethers/lib/utils'; -import { ustbRedemptionVaultFixture } from './fixtures/ustb.fixture'; +import { ustbRedemptionFixture } from './fixtures/ustb.fixture'; import { transferUSTBFromWhale } from './helpers/ustb-helpers'; import { mintToken, approveBase18 } from '../common/common.helpers'; import { redeemInstantWithUstbTest } from '../common/redemption-vault-ustb.helpers'; describe('RedemptionVaultWithUSTB - Mainnet Fork Integration Tests', function () { - this.timeout(120000); + this.timeout(300000); describe('Scenario 1: Vault has sufficient USDC', function () { it('should redeem mTBILL for USDC directly without USTB', async function () { @@ -22,7 +22,7 @@ describe('RedemptionVaultWithUSTB - Mainnet Fork Integration Tests', function () ustbToken, usdcWhale, mTokenToUsdDataFeed, - } = await loadFixture(ustbRedemptionVaultFixture); + } = await loadFixture(ustbRedemptionFixture); const mTBILLAmount = 1000; @@ -89,7 +89,7 @@ describe('RedemptionVaultWithUSTB - Mainnet Fork Integration Tests', function () usdcWhale, redemptionIdle, mTokenToUsdDataFeed, - } = await loadFixture(ustbRedemptionVaultFixture); + } = await loadFixture(ustbRedemptionFixture); const mTBILLAmount = 5000; @@ -159,7 +159,7 @@ describe('RedemptionVaultWithUSTB - Mainnet Fork Integration Tests', function () redemptionIdle, mTokenToUsdDataFeed, ustbOwner, - } = await loadFixture(ustbRedemptionVaultFixture); + } = await loadFixture(ustbRedemptionFixture); const mTBILLAmount = 5000; @@ -225,7 +225,7 @@ describe('RedemptionVaultWithUSTB - Mainnet Fork Integration Tests', function () ustbToken, ustbWhale, mTokenToUsdDataFeed, - } = await loadFixture(ustbRedemptionVaultFixture); + } = await loadFixture(ustbRedemptionFixture); const mTBILLAmount = 100000000; // 100 million mTBILL diff --git a/test/integration/fixtures/aave.fixture.ts b/test/integration/fixtures/aave.fixture.ts new file mode 100644 index 00000000..25402143 --- /dev/null +++ b/test/integration/fixtures/aave.fixture.ts @@ -0,0 +1,322 @@ +import { parseUnits } from 'ethers/lib/utils'; +import { ethers } from 'hardhat'; + +import { rpcUrls } from '../../../config'; +import { getAllRoles } from '../../../helpers/roles'; +import { + MidasAccessControlTest, + MTBILLTest, + DepositVaultWithAaveTest, + RedemptionVaultWithAaveTest, + DataFeedTest, + AggregatorV3Mock, +} from '../../../typechain-types'; +import { deployProxyContract } from '../../common/deploy.helpers'; +import { impersonateAndFundAccount, resetFork } from '../helpers/fork.helpers'; +import { MAINNET_ADDRESSES } from '../helpers/mainnet-addresses'; + +const FORK_BLOCK_NUMBER = 24441000; + +async function setupAaveBase() { + await resetFork(rpcUrls.main, FORK_BLOCK_NUMBER); + + const [ + owner, + tokensReceiver, + feeReceiver, + requestRedeemer, + vaultAdmin, + testUser, + ] = await ethers.getSigners(); + const allRoles = getAllRoles(); + + const accessControl = await deployProxyContract( + 'MidasAccessControlTest', + [], + ); + + const mTBILL = await deployProxyContract('mTBILLTest', [ + accessControl.address, + ]); + + const rolesArray = [ + allRoles.common.defaultAdmin, + allRoles.tokenRoles.mTBILL.minter, + allRoles.tokenRoles.mTBILL.burner, + allRoles.tokenRoles.mTBILL.pauser, + allRoles.tokenRoles.mTBILL.depositVaultAdmin, + allRoles.tokenRoles.mTBILL.redemptionVaultAdmin, + allRoles.common.greenlistedOperator, + ]; + + for (const role of rolesArray) { + await accessControl.grantRole(role, owner.address); + } + + await accessControl.grantRole( + allRoles.tokenRoles.mTBILL.depositVaultAdmin, + vaultAdmin.address, + ); + + await accessControl.grantRole( + allRoles.tokenRoles.mTBILL.redemptionVaultAdmin, + vaultAdmin.address, + ); + + await accessControl.grantRole(allRoles.common.greenlisted, testUser.address); + + const usdcAggregator = (await ( + await ethers.getContractFactory('AggregatorV3Mock') + ).deploy()) as AggregatorV3Mock; + await usdcAggregator.setRoundData( + parseUnits('1', await usdcAggregator.decimals()), + ); + + const mtbillAggregator = (await ( + await ethers.getContractFactory('AggregatorV3Mock') + ).deploy()) as AggregatorV3Mock; + await mtbillAggregator.setRoundData( + parseUnits('1', await mtbillAggregator.decimals()), + ); + + const usdcDataFeed = await deployProxyContract('DataFeedTest', [ + accessControl.address, + usdcAggregator.address, + 3 * 24 * 3600, + parseUnits('0.1', await usdcAggregator.decimals()), + parseUnits('10000', await usdcAggregator.decimals()), + ]); + + const usdtAggregator = (await ( + await ethers.getContractFactory('AggregatorV3Mock') + ).deploy()) as AggregatorV3Mock; + await usdtAggregator.setRoundData( + parseUnits('1', await usdtAggregator.decimals()), + ); + + const usdtDataFeed = await deployProxyContract('DataFeedTest', [ + accessControl.address, + usdtAggregator.address, + 3 * 24 * 3600, + parseUnits('0.1', await usdtAggregator.decimals()), + parseUnits('10000', await usdtAggregator.decimals()), + ]); + + const mtbillDataFeed = await deployProxyContract( + 'DataFeedTest', + [ + accessControl.address, + mtbillAggregator.address, + 3 * 24 * 3600, + parseUnits('0.1', await mtbillAggregator.decimals()), + parseUnits('10000', await mtbillAggregator.decimals()), + ], + ); + + // Get mainnet contracts + const usdc = await ethers.getContractAt( + 'IERC20Metadata', + MAINNET_ADDRESSES.USDC, + ); + const aUsdc = await ethers.getContractAt('IERC20', MAINNET_ADDRESSES.AUSDC); + const usdt = await ethers.getContractAt( + 'IERC20Metadata', + MAINNET_ADDRESSES.USDT, + ); + const aUsdt = await ethers.getContractAt('IERC20', MAINNET_ADDRESSES.AUSDT); + const aavePool = await ethers.getContractAt( + 'IAaveV3Pool', + MAINNET_ADDRESSES.AAVE_V3_POOL, + ); + + // Impersonate whales + const usdcWhale = await impersonateAndFundAccount( + MAINNET_ADDRESSES.USDC_WHALE_BINANCE, + ); + const aUsdcWhale = await impersonateAndFundAccount( + MAINNET_ADDRESSES.AUSDC_WHALE, + ); + const usdtWhale = await impersonateAndFundAccount( + MAINNET_ADDRESSES.USDT_WHALE_BINANCE, + ); + const aUsdtWhale = await impersonateAndFundAccount( + MAINNET_ADDRESSES.AUSDT_WHALE, + ); + + return { + accessControl, + mTBILL, + dataFeed: usdcDataFeed, + mTokenToUsdDataFeed: mtbillDataFeed, + mockedAggregator: usdcAggregator, + mockedAggregatorMToken: mtbillAggregator, + usdc, + aUsdc, + usdt, + aUsdt, + usdtDataFeed, + aavePool, + owner, + tokensReceiver, + feeReceiver, + requestRedeemer, + vaultAdmin, + testUser, + usdcWhale, + aUsdcWhale, + usdtWhale, + aUsdtWhale, + roles: allRoles, + }; +} + +export async function aaveDepositFixture() { + const base = await setupAaveBase(); + const { accessControl, mTBILL, owner, roles, usdc } = base; + + // Deploy DepositVaultWithAave + const depositVaultWithAave = + await deployProxyContract( + 'DepositVaultWithAaveTest', + [ + accessControl.address, + { + mToken: mTBILL.address, + mTokenDataFeed: base.mTokenToUsdDataFeed.address, + }, + { + feeReceiver: base.feeReceiver.address, + tokensReceiver: base.tokensReceiver.address, + }, + { + instantFee: 100, + instantDailyLimit: ethers.constants.MaxUint256, + }, + ethers.constants.AddressZero, // sanctions list + 200, + parseUnits('0'), + 0, + ethers.constants.MaxUint256, + ], + ); + + // Grant MINTER_ROLE to deposit vault + await accessControl.grantRole( + roles.tokenRoles.mTBILL.minter, + depositVaultWithAave.address, + ); + + // Setup payment token + await depositVaultWithAave.connect(owner).addPaymentToken( + usdc.address, + base.dataFeed.address, + 0, // no fee + ethers.constants.MaxUint256, + true, // is stable + ); + + // Configure Aave pool mapping for USDC + await depositVaultWithAave + .connect(owner) + .setAavePool(usdc.address, MAINNET_ADDRESSES.AAVE_V3_POOL); + + await depositVaultWithAave + .connect(owner) + .addPaymentToken( + base.usdt.address, + base.usdtDataFeed.address, + 0, + ethers.constants.MaxUint256, + true, + ); + await depositVaultWithAave + .connect(owner) + .setAavePool(base.usdt.address, MAINNET_ADDRESSES.AAVE_V3_POOL); + + return { + ...base, + depositVaultWithAave, + }; +} + +export async function aaveRedemptionFixture() { + const base = await setupAaveBase(); + const { accessControl, mTBILL, owner, roles, usdc } = base; + + // Deploy RedemptionVaultWithAave + const redemptionVaultWithAave = + await deployProxyContract( + 'RedemptionVaultWithAaveTest', + [ + accessControl.address, + { + mToken: mTBILL.address, + mTokenDataFeed: base.mTokenToUsdDataFeed.address, + }, + { + feeReceiver: base.feeReceiver.address, + tokensReceiver: base.tokensReceiver.address, + }, + { + instantFee: 100, // 1% + instantDailyLimit: ethers.constants.MaxUint256, + }, + ethers.constants.AddressZero, // sanctions list + 200, // variation tolerance 2% + parseUnits('100', 18), // min amount + { + minFiatRedeemAmount: parseUnits('1000', 18), + fiatAdditionalFee: 100, + fiatFlatFee: parseUnits('10', 18), + }, + base.requestRedeemer.address, + ], + 'initialize(address,(address,address),(address,address),(uint256,uint256),address,uint256,uint256,(uint256,uint256,uint256),address)', + ); + + // Grant BURN_ROLE to redemption vault + await accessControl.grantRole( + roles.tokenRoles.mTBILL.burner, + redemptionVaultWithAave.address, + ); + + // Setup payment token + await redemptionVaultWithAave.connect(owner).addPaymentToken( + usdc.address, + base.dataFeed.address, + 0, // no fee + ethers.constants.MaxUint256, + true, // is stable + ); + + // Configure Aave pool mapping for USDC + await redemptionVaultWithAave + .connect(owner) + .setAavePool(usdc.address, MAINNET_ADDRESSES.AAVE_V3_POOL); + + await redemptionVaultWithAave + .connect(owner) + .addPaymentToken( + base.usdt.address, + base.usdtDataFeed.address, + 0, + ethers.constants.MaxUint256, + true, + ); + await redemptionVaultWithAave + .connect(owner) + .setAavePool(base.usdt.address, MAINNET_ADDRESSES.AAVE_V3_POOL); + + return { + ...base, + redemptionVaultWithAave, + }; +} + +export type AaveDepositContracts = Awaited< + ReturnType +>; + +export type AaveRedemptionContracts = Awaited< + ReturnType +>; diff --git a/test/integration/fixtures/morpho.fixture.ts b/test/integration/fixtures/morpho.fixture.ts new file mode 100644 index 00000000..299984d6 --- /dev/null +++ b/test/integration/fixtures/morpho.fixture.ts @@ -0,0 +1,336 @@ +import { parseUnits } from 'ethers/lib/utils'; +import { ethers } from 'hardhat'; + +import { rpcUrls } from '../../../config'; +import { getAllRoles } from '../../../helpers/roles'; +import { + MidasAccessControlTest, + MTBILLTest, + DepositVaultWithMorphoTest, + RedemptionVaultWithMorphoTest, + DataFeedTest, + AggregatorV3Mock, +} from '../../../typechain-types'; +import { deployProxyContract } from '../../common/deploy.helpers'; +import { impersonateAndFundAccount, resetFork } from '../helpers/fork.helpers'; +import { MAINNET_ADDRESSES } from '../helpers/mainnet-addresses'; + +// Block where Steakhouse USDC Morpho vault is active and has liquidity +const FORK_BLOCK_NUMBER = 24441000; + +async function setupMorphoBase() { + await resetFork(rpcUrls.main, FORK_BLOCK_NUMBER); + + const [ + owner, + tokensReceiver, + feeReceiver, + requestRedeemer, + vaultAdmin, + testUser, + ] = await ethers.getSigners(); + const allRoles = getAllRoles(); + + const accessControl = await deployProxyContract( + 'MidasAccessControlTest', + [], + ); + + const mTBILL = await deployProxyContract('mTBILLTest', [ + accessControl.address, + ]); + + const rolesArray = [ + allRoles.common.defaultAdmin, + allRoles.tokenRoles.mTBILL.minter, + allRoles.tokenRoles.mTBILL.burner, + allRoles.tokenRoles.mTBILL.pauser, + allRoles.tokenRoles.mTBILL.depositVaultAdmin, + allRoles.tokenRoles.mTBILL.redemptionVaultAdmin, + allRoles.common.greenlistedOperator, + ]; + + for (const role of rolesArray) { + await accessControl.grantRole(role, owner.address); + } + + await accessControl.grantRole( + allRoles.tokenRoles.mTBILL.depositVaultAdmin, + vaultAdmin.address, + ); + + await accessControl.grantRole( + allRoles.tokenRoles.mTBILL.redemptionVaultAdmin, + vaultAdmin.address, + ); + + await accessControl.grantRole(allRoles.common.greenlisted, testUser.address); + + const usdcAggregator = (await ( + await ethers.getContractFactory('AggregatorV3Mock') + ).deploy()) as AggregatorV3Mock; + await usdcAggregator.setRoundData( + parseUnits('1', await usdcAggregator.decimals()), + ); + + const mtbillAggregator = (await ( + await ethers.getContractFactory('AggregatorV3Mock') + ).deploy()) as AggregatorV3Mock; + await mtbillAggregator.setRoundData( + parseUnits('1', await mtbillAggregator.decimals()), + ); + + const usdcDataFeed = await deployProxyContract('DataFeedTest', [ + accessControl.address, + usdcAggregator.address, + 3 * 24 * 3600, + parseUnits('0.1', await usdcAggregator.decimals()), + parseUnits('10000', await usdcAggregator.decimals()), + ]); + + const usdtAggregator = (await ( + await ethers.getContractFactory('AggregatorV3Mock') + ).deploy()) as AggregatorV3Mock; + await usdtAggregator.setRoundData( + parseUnits('1', await usdtAggregator.decimals()), + ); + + const usdtDataFeed = await deployProxyContract('DataFeedTest', [ + accessControl.address, + usdtAggregator.address, + 3 * 24 * 3600, + parseUnits('0.1', await usdtAggregator.decimals()), + parseUnits('10000', await usdtAggregator.decimals()), + ]); + + const mtbillDataFeed = await deployProxyContract( + 'DataFeedTest', + [ + accessControl.address, + mtbillAggregator.address, + 3 * 24 * 3600, + parseUnits('0.1', await mtbillAggregator.decimals()), + parseUnits('10000', await mtbillAggregator.decimals()), + ], + ); + + // Get mainnet contracts + const usdc = await ethers.getContractAt( + 'IERC20Metadata', + MAINNET_ADDRESSES.USDC, + ); + const morphoVault = await ethers.getContractAt( + 'IERC20', + MAINNET_ADDRESSES.MORPHO_STEAKHOUSE_USDC_VAULT, + ); + const usdt = await ethers.getContractAt( + 'IERC20Metadata', + MAINNET_ADDRESSES.USDT, + ); + const morphoUsdtVault = await ethers.getContractAt( + 'IERC20', + MAINNET_ADDRESSES.MORPHO_SMOKEHOUSE_USDT_VAULT, + ); + + // Impersonate whales + const usdcWhale = await impersonateAndFundAccount( + MAINNET_ADDRESSES.USDC_WHALE_BINANCE, + ); + const morphoShareWhale = await impersonateAndFundAccount( + MAINNET_ADDRESSES.MORPHO_STEAKHOUSE_USDC_WHALE, + ); + const usdtWhale = await impersonateAndFundAccount( + MAINNET_ADDRESSES.USDT_WHALE_BINANCE, + ); + const morphoUsdtShareWhale = await impersonateAndFundAccount( + MAINNET_ADDRESSES.MORPHO_SMOKEHOUSE_USDT_WHALE, + ); + + return { + accessControl, + mTBILL, + dataFeed: usdcDataFeed, + mTokenToUsdDataFeed: mtbillDataFeed, + mockedAggregator: usdcAggregator, + mockedAggregatorMToken: mtbillAggregator, + usdc, + morphoVault, + usdt, + morphoUsdtVault, + usdtDataFeed, + owner, + tokensReceiver, + feeReceiver, + requestRedeemer, + vaultAdmin, + testUser, + usdcWhale, + morphoShareWhale, + usdtWhale, + morphoUsdtShareWhale, + roles: allRoles, + }; +} + +export async function morphoDepositFixture() { + const base = await setupMorphoBase(); + const { accessControl, mTBILL, owner, roles, usdc } = base; + + // Deploy DepositVaultWithMorpho + const depositVaultWithMorpho = + await deployProxyContract( + 'DepositVaultWithMorphoTest', + [ + accessControl.address, + { + mToken: mTBILL.address, + mTokenDataFeed: base.mTokenToUsdDataFeed.address, + }, + { + feeReceiver: base.feeReceiver.address, + tokensReceiver: base.tokensReceiver.address, + }, + { + instantFee: 100, + instantDailyLimit: ethers.constants.MaxUint256, + }, + ethers.constants.AddressZero, // sanctions list + 200, + parseUnits('0'), + 0, + ethers.constants.MaxUint256, + ], + ); + + // Grant MINTER_ROLE to deposit vault + await accessControl.grantRole( + roles.tokenRoles.mTBILL.minter, + depositVaultWithMorpho.address, + ); + + // Setup payment token + await depositVaultWithMorpho.connect(owner).addPaymentToken( + usdc.address, + base.dataFeed.address, + 0, // no fee + ethers.constants.MaxUint256, + true, // is stable + ); + + // Configure Morpho vault mapping for USDC + await depositVaultWithMorpho + .connect(owner) + .setMorphoVault( + usdc.address, + MAINNET_ADDRESSES.MORPHO_STEAKHOUSE_USDC_VAULT, + ); + + await depositVaultWithMorpho + .connect(owner) + .addPaymentToken( + base.usdt.address, + base.usdtDataFeed.address, + 0, + ethers.constants.MaxUint256, + true, + ); + await depositVaultWithMorpho + .connect(owner) + .setMorphoVault( + base.usdt.address, + MAINNET_ADDRESSES.MORPHO_SMOKEHOUSE_USDT_VAULT, + ); + + return { + ...base, + depositVaultWithMorpho, + }; +} + +export async function morphoRedemptionFixture() { + const base = await setupMorphoBase(); + const { accessControl, mTBILL, owner, roles, usdc } = base; + + // Deploy RedemptionVaultWithMorpho + const redemptionVaultWithMorpho = + await deployProxyContract( + 'RedemptionVaultWithMorphoTest', + [ + accessControl.address, + { + mToken: mTBILL.address, + mTokenDataFeed: base.mTokenToUsdDataFeed.address, + }, + { + feeReceiver: base.feeReceiver.address, + tokensReceiver: base.tokensReceiver.address, + }, + { + instantFee: 100, // 1% + instantDailyLimit: ethers.constants.MaxUint256, + }, + ethers.constants.AddressZero, // sanctions list + 200, // variation tolerance 2% + parseUnits('100', 18), // min amount + { + minFiatRedeemAmount: parseUnits('1000', 18), + fiatAdditionalFee: 100, + fiatFlatFee: parseUnits('10', 18), + }, + base.requestRedeemer.address, + ], + 'initialize(address,(address,address),(address,address),(uint256,uint256),address,uint256,uint256,(uint256,uint256,uint256),address)', + ); + + // Grant BURN_ROLE to redemption vault + await accessControl.grantRole( + roles.tokenRoles.mTBILL.burner, + redemptionVaultWithMorpho.address, + ); + + // Setup payment token + await redemptionVaultWithMorpho.connect(owner).addPaymentToken( + usdc.address, + base.dataFeed.address, + 0, // no fee + ethers.constants.MaxUint256, + true, // is stable + ); + + // Configure Morpho vault mapping for USDC + await redemptionVaultWithMorpho + .connect(owner) + .setMorphoVault( + usdc.address, + MAINNET_ADDRESSES.MORPHO_STEAKHOUSE_USDC_VAULT, + ); + + await redemptionVaultWithMorpho + .connect(owner) + .addPaymentToken( + base.usdt.address, + base.usdtDataFeed.address, + 0, + ethers.constants.MaxUint256, + true, + ); + await redemptionVaultWithMorpho + .connect(owner) + .setMorphoVault( + base.usdt.address, + MAINNET_ADDRESSES.MORPHO_SMOKEHOUSE_USDT_VAULT, + ); + + return { + ...base, + redemptionVaultWithMorpho, + }; +} + +export type MorphoDepositContracts = Awaited< + ReturnType +>; + +export type MorphoRedemptionContracts = Awaited< + ReturnType +>; diff --git a/test/integration/fixtures/mtoken.fixture.ts b/test/integration/fixtures/mtoken.fixture.ts new file mode 100644 index 00000000..151ca528 --- /dev/null +++ b/test/integration/fixtures/mtoken.fixture.ts @@ -0,0 +1,403 @@ +import { parseUnits } from 'ethers/lib/utils'; +import { ethers } from 'hardhat'; + +import { rpcUrls } from '../../../config'; +import { getAllRoles } from '../../../helpers/roles'; +import { + MidasAccessControlTest, + MTBILLTest, + DepositVaultTest, + DepositVaultWithMTokenTest, + RedemptionVaultTest, + RedemptionVaultWithMTokenTest, + DataFeedTest, + AggregatorV3Mock, +} from '../../../typechain-types'; +import { deployProxyContract } from '../../common/deploy.helpers'; +import { impersonateAndFundAccount, resetFork } from '../helpers/fork.helpers'; +import { MAINNET_ADDRESSES } from '../helpers/mainnet-addresses'; + +const FORK_BLOCK_NUMBER = 24441000; + +async function setupMTokenBase() { + await resetFork(rpcUrls.main, FORK_BLOCK_NUMBER); + + const [ + owner, + tokensReceiver, + feeReceiver, + requestRedeemer, + vaultAdmin, + testUser, + targetTokensReceiver, + ] = await ethers.getSigners(); + const allRoles = getAllRoles(); + + const accessControl = await deployProxyContract( + 'MidasAccessControlTest', + [], + ); + + // "Target" mToken (simulating mTBILL) + const mTBILL = await deployProxyContract('mTBILLTest', [ + accessControl.address, + ]); + + // "Product" mToken (simulating mFONE) + const mFONE = await deployProxyContract('mTBILLTest', [ + accessControl.address, + ]); + + const rolesArray = [ + allRoles.common.defaultAdmin, + allRoles.tokenRoles.mTBILL.minter, + allRoles.tokenRoles.mTBILL.burner, + allRoles.tokenRoles.mTBILL.pauser, + allRoles.tokenRoles.mTBILL.depositVaultAdmin, + allRoles.tokenRoles.mTBILL.redemptionVaultAdmin, + allRoles.common.greenlistedOperator, + ]; + + for (const role of rolesArray) { + await accessControl.grantRole(role, owner.address); + } + + await accessControl.grantRole( + allRoles.tokenRoles.mTBILL.depositVaultAdmin, + vaultAdmin.address, + ); + + await accessControl.grantRole( + allRoles.tokenRoles.mTBILL.redemptionVaultAdmin, + vaultAdmin.address, + ); + + await accessControl.grantRole(allRoles.common.greenlisted, testUser.address); + + // USDC data feed + const usdcAggregator = (await ( + await ethers.getContractFactory('AggregatorV3Mock') + ).deploy()) as AggregatorV3Mock; + await usdcAggregator.setRoundData( + parseUnits('1', await usdcAggregator.decimals()), + ); + + const usdcDataFeed = await deployProxyContract('DataFeedTest', [ + accessControl.address, + usdcAggregator.address, + 3 * 24 * 3600, + parseUnits('0.1', await usdcAggregator.decimals()), + parseUnits('10000', await usdcAggregator.decimals()), + ]); + + // Target mToken (mTBILL) data feed + const mtbillAggregator = (await ( + await ethers.getContractFactory('AggregatorV3Mock') + ).deploy()) as AggregatorV3Mock; + await mtbillAggregator.setRoundData( + parseUnits('1', await mtbillAggregator.decimals()), + ); + + const mtbillDataFeed = await deployProxyContract( + 'DataFeedTest', + [ + accessControl.address, + mtbillAggregator.address, + 3 * 24 * 3600, + parseUnits('0.1', await mtbillAggregator.decimals()), + parseUnits('10000', await mtbillAggregator.decimals()), + ], + ); + + // Product mToken (mFONE) data feed + const mfoneAggregator = (await ( + await ethers.getContractFactory('AggregatorV3Mock') + ).deploy()) as AggregatorV3Mock; + await mfoneAggregator.setRoundData( + parseUnits('1', await mfoneAggregator.decimals()), + ); + + const mfoneDataFeed = await deployProxyContract( + 'DataFeedTest', + [ + accessControl.address, + mfoneAggregator.address, + 3 * 24 * 3600, + parseUnits('0.1', await mfoneAggregator.decimals()), + parseUnits('10000', await mfoneAggregator.decimals()), + ], + ); + + // Get mainnet USDC + const usdc = await ethers.getContractAt( + 'IERC20Metadata', + MAINNET_ADDRESSES.USDC, + ); + + // Impersonate USDC whale + const usdcWhale = await impersonateAndFundAccount( + MAINNET_ADDRESSES.USDC_WHALE_BINANCE, + ); + + return { + accessControl, + mTBILL, + mFONE, + dataFeed: usdcDataFeed, + mTokenToUsdDataFeed: mtbillDataFeed, + mFoneToUsdDataFeed: mfoneDataFeed, + mockedAggregator: usdcAggregator, + mockedAggregatorMToken: mtbillAggregator, + mockedAggregatorMFone: mfoneAggregator, + usdc, + owner, + tokensReceiver, + feeReceiver, + requestRedeemer, + vaultAdmin, + testUser, + targetTokensReceiver, + usdcWhale, + roles: allRoles, + }; +} + +export async function mTokenDepositFixture() { + const base = await setupMTokenBase(); + const { accessControl, mTBILL, mFONE, owner, roles, usdc } = base; + + // Deploy target DV (plain DepositVault for mTBILL, 0% fee) + // Uses a separate tokensReceiver so USDC flowing through the target DV + // doesn't contaminate the product DV's tokensReceiver balance assertions + const targetDepositVault = await deployProxyContract( + 'DepositVaultTest', + [ + accessControl.address, + { + mToken: mTBILL.address, + mTokenDataFeed: base.mTokenToUsdDataFeed.address, + }, + { + feeReceiver: base.feeReceiver.address, + tokensReceiver: base.targetTokensReceiver.address, + }, + { + instantFee: 0, + instantDailyLimit: ethers.constants.MaxUint256, + }, + ethers.constants.AddressZero, + 200, + parseUnits('0'), + 0, + ethers.constants.MaxUint256, + ], + ); + + // Grant minter to target DV (so it can mint mTBILL) + await accessControl.grantRole( + roles.tokenRoles.mTBILL.minter, + targetDepositVault.address, + ); + + // Add USDC as payment token on target DV + await targetDepositVault + .connect(owner) + .addPaymentToken( + usdc.address, + base.dataFeed.address, + 0, + ethers.constants.MaxUint256, + true, + ); + + // Deploy product DV (DepositVaultWithMToken for mFONE) + const depositVaultWithMToken = + await deployProxyContract( + 'DepositVaultWithMTokenTest', + [ + accessControl.address, + { + mToken: mFONE.address, + mTokenDataFeed: base.mFoneToUsdDataFeed.address, + }, + { + feeReceiver: base.feeReceiver.address, + tokensReceiver: base.tokensReceiver.address, + }, + { + instantFee: 100, + instantDailyLimit: ethers.constants.MaxUint256, + }, + ethers.constants.AddressZero, + 200, + parseUnits('0'), + 0, + ethers.constants.MaxUint256, + targetDepositVault.address, + ], + 'initialize(address,(address,address),(address,address),(uint256,uint256),address,uint256,uint256,uint256,uint256,address)', + ); + + // Grant minter to product DV (so it can mint mFONE) + await accessControl.grantRole( + roles.tokenRoles.mTBILL.minter, + depositVaultWithMToken.address, + ); + + // Greenlist the product DV so it can call depositInstant on target DV + await accessControl.grantRole( + roles.common.greenlisted, + depositVaultWithMToken.address, + ); + + // Add USDC as payment token on product DV + await depositVaultWithMToken + .connect(owner) + .addPaymentToken( + usdc.address, + base.dataFeed.address, + 0, + ethers.constants.MaxUint256, + true, + ); + + return { + ...base, + targetDepositVault, + depositVaultWithMToken, + }; +} + +export async function mTokenRedemptionFixture() { + const base = await setupMTokenBase(); + const { accessControl, mTBILL, mFONE, owner, roles, usdc } = base; + + // Deploy target RV (plain RedemptionVault for mTBILL) + const targetRedemptionVault = await deployProxyContract( + 'RedemptionVaultTest', + [ + accessControl.address, + { + mToken: mTBILL.address, + mTokenDataFeed: base.mTokenToUsdDataFeed.address, + }, + { + feeReceiver: base.feeReceiver.address, + tokensReceiver: base.tokensReceiver.address, + }, + { + instantFee: 100, + instantDailyLimit: ethers.constants.MaxUint256, + }, + ethers.constants.AddressZero, + 200, + parseUnits('100', 18), + { + minFiatRedeemAmount: parseUnits('1000', 18), + fiatAdditionalFee: 100, + fiatFlatFee: parseUnits('10', 18), + }, + base.requestRedeemer.address, + ], + ); + + // Grant BURN_ROLE to target RV (so it can burn mTBILL) + await accessControl.grantRole( + roles.tokenRoles.mTBILL.burner, + targetRedemptionVault.address, + ); + + // Add USDC as payment token on target RV + await targetRedemptionVault + .connect(owner) + .addPaymentToken( + usdc.address, + base.dataFeed.address, + 0, + ethers.constants.MaxUint256, + true, + ); + + // Fund target RV with USDC (so inner redeemInstant has liquidity) + await usdc + .connect(base.usdcWhale) + .transfer(targetRedemptionVault.address, parseUnits('100000', 6)); + + // Deploy product RV (RedemptionVaultWithMToken for mFONE) + const redemptionVaultWithMToken = + await deployProxyContract( + 'RedemptionVaultWithMTokenTest', + [ + accessControl.address, + { + mToken: mFONE.address, + mTokenDataFeed: base.mFoneToUsdDataFeed.address, + }, + { + feeReceiver: base.feeReceiver.address, + tokensReceiver: base.tokensReceiver.address, + }, + { + instantFee: 100, + instantDailyLimit: ethers.constants.MaxUint256, + }, + ethers.constants.AddressZero, + 200, + parseUnits('100', 18), + { + minFiatRedeemAmount: parseUnits('1000', 18), + fiatAdditionalFee: 100, + fiatFlatFee: parseUnits('10', 18), + }, + base.requestRedeemer.address, + targetRedemptionVault.address, + ], + 'initialize(address,(address,address),(address,address),(uint256,uint256),address,uint256,uint256,(uint256,uint256,uint256),address,address)', + ); + + // Grant BURN_ROLE to product RV (so it can burn mFONE) + await accessControl.grantRole( + roles.tokenRoles.mTBILL.burner, + redemptionVaultWithMToken.address, + ); + + // Add USDC as payment token on product RV + await redemptionVaultWithMToken + .connect(owner) + .addPaymentToken( + usdc.address, + base.dataFeed.address, + 0, + ethers.constants.MaxUint256, + true, + ); + + // Greenlist the product RV on access control (target RV requires greenlist) + await accessControl.grantRole( + roles.common.greenlisted, + redemptionVaultWithMToken.address, + ); + + // Waive fees on target RV for the product RV address + await targetRedemptionVault + .connect(owner) + .addWaivedFeeAccount(redemptionVaultWithMToken.address); + + // Mint mTBILL to product RV (simulating Fordefi deposit) + await accessControl.grantRole(roles.tokenRoles.mTBILL.minter, owner.address); + await mTBILL.mint(redemptionVaultWithMToken.address, parseUnits('50000')); + + return { + ...base, + targetRedemptionVault, + redemptionVaultWithMToken, + }; +} + +export type MTokenDepositContracts = Awaited< + ReturnType +>; + +export type MTokenRedemptionContracts = Awaited< + ReturnType +>; diff --git a/test/integration/fixtures/upgrades.fixture.ts b/test/integration/fixtures/upgrades.fixture.ts index 81751cc2..f84a1580 100644 --- a/test/integration/fixtures/upgrades.fixture.ts +++ b/test/integration/fixtures/upgrades.fixture.ts @@ -1,24 +1,10 @@ -import { - impersonateAccount, - mine, -} from '@nomicfoundation/hardhat-network-helpers'; -import { SignerWithAddress } from '@nomiclabs/hardhat-ethers/signers'; +import { mine } from '@nomicfoundation/hardhat-network-helpers'; import { parseUnits } from 'ethers/lib/utils'; -import { ethers, network } from 'hardhat'; +import { ethers } from 'hardhat'; import { rpcUrls } from '../../../config'; import { MToken } from '../../../typechain-types'; - -async function impersonateAndFundAccount( - address: string, -): Promise { - await impersonateAccount(address); - await network.provider.send('hardhat_setBalance', [ - address, - ethers.utils.hexStripZeros(parseUnits('1000', 18).toHexString()), - ]); - return ethers.getSigner(address); -} +import { impersonateAndFundAccount, resetFork } from '../helpers/fork.helpers'; export async function hyperEvmUpgradeFixture() { const dvProxyAddress = '0x48fb106Ef0c0C1a19EdDC9C5d27A945E66DA1C4E'; @@ -34,19 +20,7 @@ export async function hyperEvmUpgradeFixture() { const proxyAdminAddress = '0xbf25b58cB8DfaD688F7BcB2b87D71C23A6600AaC'; const [customRecipient] = await ethers.getSigners(); - await network.provider.request({ - method: 'hardhat_reset', - params: [ - { - forking: { - jsonRpcUrl: rpcUrls.hyperevm, - blockNumber: 9874404, - }, - }, - ], - }); - - await network.provider.send('evm_setAutomine', [true]); + await resetFork(rpcUrls.hyperevm, 9874404); await mine(); diff --git a/test/integration/fixtures/ustb.fixture.ts b/test/integration/fixtures/ustb.fixture.ts index ef4906dc..3ee3b96f 100644 --- a/test/integration/fixtures/ustb.fixture.ts +++ b/test/integration/fixtures/ustb.fixture.ts @@ -1,7 +1,5 @@ -import { impersonateAccount } from '@nomicfoundation/hardhat-network-helpers'; -import { SignerWithAddress } from '@nomiclabs/hardhat-ethers/signers'; import { parseUnits } from 'ethers/lib/utils'; -import { ethers, network } from 'hardhat'; +import { ethers } from 'hardhat'; import { rpcUrls } from '../../../config'; import { getAllRoles } from '../../../helpers/roles'; @@ -14,36 +12,15 @@ import { DepositVaultWithUSTBTest, } from '../../../typechain-types'; import { deployProxyContract } from '../../common/deploy.helpers'; +import { impersonateAndFundAccount, resetFork } from '../helpers/fork.helpers'; import { MAINNET_ADDRESSES } from '../helpers/mainnet-addresses'; import { setupUSTBAllowlist } from '../helpers/ustb-helpers'; -async function impersonateAndFundAccount( - address: string, -): Promise { - await impersonateAccount(address); - await network.provider.send('hardhat_setBalance', [ - address, - ethers.utils.hexStripZeros(parseUnits('1000', 18).toHexString()), - ]); - return ethers.getSigner(address); -} - // Fork block number where we know all fixture related addresses have funds -export const FORK_BLOCK_NUMBER = 22540000; - -export async function ustbRedemptionVaultFixture() { - await network.provider.request({ - method: 'hardhat_reset', - params: [ - { - forking: { - jsonRpcUrl: rpcUrls.main, - blockNumber: FORK_BLOCK_NUMBER, - }, - }, - ], - }); - await network.provider.send('evm_setAutomine', [true]); +const FORK_BLOCK_NUMBER = 22540000; + +async function setupUstbBase() { + await resetFork(rpcUrls.main, FORK_BLOCK_NUMBER); const [ owner, @@ -123,7 +100,65 @@ export async function ustbRedemptionVaultFixture() { ], ); - // Deploy RedemptionVaultWithUSTB + // Get mainnet contracts + const usdc = await ethers.getContractAt( + 'IERC20Metadata', + MAINNET_ADDRESSES.USDC, + ); + const ustbToken = await ethers.getContractAt( + 'ISuperstateToken', + MAINNET_ADDRESSES.SUPERSTATE_TOKEN_PROXY, + ); + const redemptionIdle = await ethers.getContractAt( + 'IUSTBRedemption', + MAINNET_ADDRESSES.REDEMPTION_IDLE_PROXY, + ); + + // Impersonate whales + const usdcWhale = await impersonateAndFundAccount( + MAINNET_ADDRESSES.USDC_WHALE, + ); + const ustbWhale = await impersonateAndFundAccount( + MAINNET_ADDRESSES.USTB_WHALE, + ); + + const ustbOwner = await impersonateAndFundAccount( + await redemptionIdle.owner(), + ); + + const ustbTokenOwner = await impersonateAndFundAccount( + await ustbToken.owner(), + ); + + return { + accessControl, + mTBILL, + dataFeed: usdcDataFeed, + mTokenToUsdDataFeed: mtbillDataFeed, + mockedAggregator: usdcAggregator, + mockedAggregatorMToken: mtbillAggregator, + usdc, + ustbToken, + redemptionIdle, + owner, + tokensReceiver, + feeReceiver, + requestRedeemer, + vaultAdmin, + testUser, + usdcWhale, + ustbWhale, + ustbOwner, + ustbTokenOwner, + roles: allRoles, + }; +} + +export async function ustbDepositFixture() { + const base = await setupUstbBase(); + const { accessControl, mTBILL, owner, roles, usdc } = base; + + // Deploy DepositVaultWithUSTB const depositVaultWithUSTB = await deployProxyContract( 'DepositVaultWithUSTBTest', @@ -131,11 +166,11 @@ export async function ustbRedemptionVaultFixture() { accessControl.address, { mToken: mTBILL.address, - mTokenDataFeed: mtbillDataFeed.address, + mTokenDataFeed: base.mTokenToUsdDataFeed.address, }, { - feeReceiver: feeReceiver.address, - tokensReceiver: tokensReceiver.address, + feeReceiver: base.feeReceiver.address, + tokensReceiver: base.tokensReceiver.address, }, { instantFee: 100, @@ -151,6 +186,34 @@ export async function ustbRedemptionVaultFixture() { 'initialize(address,(address,address),(address,address),(uint256,uint256),address,uint256,uint256,uint256,uint256,address)', ); + // Grant MINTER_ROLE to vault + await accessControl.grantRole( + roles.tokenRoles.mTBILL.minter, + depositVaultWithUSTB.address, + ); + + // Setup payment token + await depositVaultWithUSTB.connect(owner).addPaymentToken( + usdc.address, + base.dataFeed.address, + 0, // no fee + ethers.constants.MaxUint256, + true, // is stable + ); + + await setupUSTBAllowlist(base.ustbToken, depositVaultWithUSTB.address); + await setupUSTBAllowlist(base.ustbToken, base.tokensReceiver.address); + + return { + ...base, + depositVaultWithUSTB, + }; +} + +export async function ustbRedemptionFixture() { + const base = await setupUstbBase(); + const { accessControl, mTBILL, owner, roles, usdc } = base; + // Deploy RedemptionVaultWithUSTB const redemptionVaultWithUSTB = await deployProxyContract( @@ -159,11 +222,11 @@ export async function ustbRedemptionVaultFixture() { accessControl.address, { mToken: mTBILL.address, - mTokenDataFeed: mtbillDataFeed.address, + mTokenDataFeed: base.mTokenToUsdDataFeed.address, }, { - feeReceiver: feeReceiver.address, - tokensReceiver: tokensReceiver.address, + feeReceiver: base.feeReceiver.address, + tokensReceiver: base.tokensReceiver.address, }, { instantFee: 100, // 1% @@ -177,100 +240,37 @@ export async function ustbRedemptionVaultFixture() { fiatAdditionalFee: 100, fiatFlatFee: parseUnits('10', 18), }, - requestRedeemer.address, + base.requestRedeemer.address, MAINNET_ADDRESSES.REDEMPTION_IDLE_PROXY, ], 'initialize(address,(address,address),(address,address),(uint256,uint256),address,uint256,uint256,(uint256,uint256,uint256),address,address)', ); - // Grant MINTER_ROLE to vault - await accessControl.grantRole( - allRoles.tokenRoles.mTBILL.minter, - depositVaultWithUSTB.address, - ); - // Grant BURN_ROLE to vault await accessControl.grantRole( - allRoles.tokenRoles.mTBILL.burner, + roles.tokenRoles.mTBILL.burner, redemptionVaultWithUSTB.address, ); - // Get mainnet contracts - const usdc = await ethers.getContractAt( - 'IERC20Metadata', - MAINNET_ADDRESSES.USDC, - ); - const ustbToken = await ethers.getContractAt( - 'ISuperstateToken', - MAINNET_ADDRESSES.SUPERSTATE_TOKEN_PROXY, - ); - const redemptionIdle = await ethers.getContractAt( - 'IUSTBRedemption', - MAINNET_ADDRESSES.REDEMPTION_IDLE_PROXY, - ); - - // Impersonate whales - const usdcWhale = await impersonateAndFundAccount( - MAINNET_ADDRESSES.USDC_WHALE, - ); - const ustbWhale = await impersonateAndFundAccount( - MAINNET_ADDRESSES.USTB_WHALE, - ); - // Setup payment token - await depositVaultWithUSTB.connect(owner).addPaymentToken( - usdc.address, - usdcDataFeed.address, - 0, // no fee - ethers.constants.MaxUint256, - true, // is stable - ); - await redemptionVaultWithUSTB.connect(owner).addPaymentToken( usdc.address, - usdcDataFeed.address, + base.dataFeed.address, 0, // no fee ethers.constants.MaxUint256, true, // is stable ); - const ustbOwner = await impersonateAndFundAccount( - await redemptionIdle.owner(), - ); - - const ustbTokenOwner = await impersonateAndFundAccount( - await ustbToken.owner(), - ); - - await setupUSTBAllowlist(ustbToken, depositVaultWithUSTB.address); - await setupUSTBAllowlist(ustbToken, tokensReceiver.address); - return { - accessControl, - mTBILL, - dataFeed: usdcDataFeed, - mTokenToUsdDataFeed: mtbillDataFeed, - mockedAggregator: usdcAggregator, - mockedAggregatorMToken: mtbillAggregator, + ...base, redemptionVaultWithUSTB, - usdc, - ustbToken, - redemptionIdle, - owner, - tokensReceiver, - feeReceiver, - requestRedeemer, - vaultAdmin, - testUser, - usdcWhale, - ustbWhale, - ustbOwner, - roles: allRoles, - ustbTokenOwner, - depositVaultWithUSTB, }; } -export type DeployedContracts = Awaited< - ReturnType +export type UstbDepositContracts = Awaited< + ReturnType +>; + +export type UstbRedemptionContracts = Awaited< + ReturnType >; diff --git a/test/integration/helpers/deposit.helpers.ts b/test/integration/helpers/deposit.helpers.ts new file mode 100644 index 00000000..16a36809 --- /dev/null +++ b/test/integration/helpers/deposit.helpers.ts @@ -0,0 +1,222 @@ +import { SignerWithAddress } from '@nomiclabs/hardhat-ethers/signers'; +import { expect } from 'chai'; +import { BigNumber, constants } from 'ethers'; +import { parseUnits } from 'ethers/lib/utils'; + +import { + DepositVaultWithAaveTest, + DepositVaultWithMorphoTest, + DepositVaultWithMTokenTest, + IERC20, + IERC20Metadata, + IMToken, +} from '../../../typechain-types'; +import { approveBase18 } from '../../common/common.helpers'; + +type DepositInstantAaveParams = { + depositVault: DepositVaultWithAaveTest; + user: SignerWithAddress; + tokenIn: IERC20Metadata; + receiptToken: IERC20; + mToken: IMToken; + tokensReceiverAddress: string; + tokenWhale: SignerWithAddress; + amountUsd: number; + tokenDecimals?: number; +}; + +type DepositInstantMorphoParams = { + depositVault: DepositVaultWithMorphoTest; + user: SignerWithAddress; + tokenIn: IERC20Metadata; + receiptToken: IERC20; + mToken: IMToken; + tokensReceiverAddress: string; + tokenWhale: SignerWithAddress; + amountUsd: number; + tokenDecimals?: number; +}; + +type DepositResult = { + userMTokenReceived: BigNumber; + receiverReceiptTokenReceived: BigNumber; + receiverTokenReceived: BigNumber; +}; + +export async function depositInstantAave({ + depositVault, + user, + tokenIn, + receiptToken, + mToken, + tokensReceiverAddress, + tokenWhale, + amountUsd, + tokenDecimals, +}: DepositInstantAaveParams): Promise { + const decimals = tokenDecimals ?? 6; + await tokenIn + .connect(tokenWhale) + .transfer(user.address, parseUnits(String(amountUsd), decimals)); + await approveBase18(user, tokenIn, depositVault, amountUsd); + + const receiverTokenBefore = await tokenIn.balanceOf(tokensReceiverAddress); + const receiverReceiptBefore = await receiptToken.balanceOf( + tokensReceiverAddress, + ); + const userMTokenBefore = await mToken.balanceOf(user.address); + + await depositVault + .connect(user) + ['depositInstant(address,uint256,uint256,bytes32)']( + tokenIn.address, + parseUnits(String(amountUsd)), + constants.Zero, + constants.HashZero, + ); + + const receiverTokenAfter = await tokenIn.balanceOf(tokensReceiverAddress); + const receiverReceiptAfter = await receiptToken.balanceOf( + tokensReceiverAddress, + ); + const userMTokenAfter = await mToken.balanceOf(user.address); + + return { + userMTokenReceived: userMTokenAfter.sub(userMTokenBefore), + receiverReceiptTokenReceived: receiverReceiptAfter.sub( + receiverReceiptBefore, + ), + receiverTokenReceived: receiverTokenAfter.sub(receiverTokenBefore), + }; +} + +export async function depositInstantMorpho({ + depositVault, + user, + tokenIn, + receiptToken, + mToken, + tokensReceiverAddress, + tokenWhale, + amountUsd, + tokenDecimals, +}: DepositInstantMorphoParams): Promise { + const decimals = tokenDecimals ?? 6; + await tokenIn + .connect(tokenWhale) + .transfer(user.address, parseUnits(String(amountUsd), decimals)); + await approveBase18(user, tokenIn, depositVault, amountUsd); + + const receiverTokenBefore = await tokenIn.balanceOf(tokensReceiverAddress); + const receiverReceiptBefore = await receiptToken.balanceOf( + tokensReceiverAddress, + ); + const userMTokenBefore = await mToken.balanceOf(user.address); + + await depositVault + .connect(user) + ['depositInstant(address,uint256,uint256,bytes32)']( + tokenIn.address, + parseUnits(String(amountUsd)), + constants.Zero, + constants.HashZero, + ); + + const receiverTokenAfter = await tokenIn.balanceOf(tokensReceiverAddress); + const receiverReceiptAfter = await receiptToken.balanceOf( + tokensReceiverAddress, + ); + const userMTokenAfter = await mToken.balanceOf(user.address); + + return { + userMTokenReceived: userMTokenAfter.sub(userMTokenBefore), + receiverReceiptTokenReceived: receiverReceiptAfter.sub( + receiverReceiptBefore, + ), + receiverTokenReceived: receiverTokenAfter.sub(receiverTokenBefore), + }; +} + +type DepositInstantMTokenParams = { + depositVault: DepositVaultWithMTokenTest; + user: SignerWithAddress; + usdc: IERC20Metadata; + targetMToken: IERC20; + mToken: IMToken; + tokensReceiverAddress: string; + usdcWhale: SignerWithAddress; + amountUsd: number; +}; + +export async function depositInstantMToken({ + depositVault, + user, + usdc, + targetMToken, + mToken, + tokensReceiverAddress, + usdcWhale, + amountUsd, +}: DepositInstantMTokenParams): Promise { + await usdc + .connect(usdcWhale) + .transfer(user.address, parseUnits(String(amountUsd), 6)); + await approveBase18(user, usdc, depositVault, amountUsd); + + const receiverUsdcBefore = await usdc.balanceOf(tokensReceiverAddress); + const receiverMTokenBefore = await targetMToken.balanceOf( + tokensReceiverAddress, + ); + const userMTokenBefore = await mToken.balanceOf(user.address); + + await depositVault + .connect(user) + ['depositInstant(address,uint256,uint256,bytes32)']( + usdc.address, + parseUnits(String(amountUsd)), + constants.Zero, + constants.HashZero, + ); + + const receiverUsdcAfter = await usdc.balanceOf(tokensReceiverAddress); + const receiverMTokenAfter = await targetMToken.balanceOf( + tokensReceiverAddress, + ); + const userMTokenAfter = await mToken.balanceOf(user.address); + + return { + userMTokenReceived: userMTokenAfter.sub(userMTokenBefore), + receiverReceiptTokenReceived: receiverMTokenAfter.sub(receiverMTokenBefore), + receiverTokenReceived: receiverUsdcAfter.sub(receiverUsdcBefore), + }; +} + +export function assertAutoInvestEnabled(result: DepositResult) { + expect(result.receiverReceiptTokenReceived).to.be.gt( + 0, + 'tokensReceiver should have received receipt tokens', + ); + expect(result.receiverTokenReceived).to.equal( + 0, + 'tokensReceiver raw token should not change when auto-invest is on', + ); + expect(result.userMTokenReceived).to.be.gt( + 0, + 'user should have received mToken', + ); +} + +export function assertAutoInvestDisabled(result: DepositResult) { + expect(result.receiverTokenReceived).to.be.gt( + 0, + 'tokensReceiver should have received token', + ); + expect(result.receiverReceiptTokenReceived).to.be.lte( + 1, + 'tokensReceiver should not receive new receipt tokens when auto-invest is off', + ); + expect(result.userMTokenReceived).to.be.gt( + 0, + 'user should have received mToken', + ); +} diff --git a/test/integration/helpers/fork.helpers.ts b/test/integration/helpers/fork.helpers.ts new file mode 100644 index 00000000..a6a6cc22 --- /dev/null +++ b/test/integration/helpers/fork.helpers.ts @@ -0,0 +1,23 @@ +import { impersonateAccount } from '@nomicfoundation/hardhat-network-helpers'; +import { SignerWithAddress } from '@nomiclabs/hardhat-ethers/signers'; +import { parseUnits } from 'ethers/lib/utils'; +import { ethers, network } from 'hardhat'; + +export async function resetFork(rpcUrl: string, blockNumber: number) { + await network.provider.request({ + method: 'hardhat_reset', + params: [{ forking: { jsonRpcUrl: rpcUrl, blockNumber } }], + }); + await network.provider.send('evm_setAutomine', [true]); +} + +export async function impersonateAndFundAccount( + address: string, +): Promise { + await impersonateAccount(address); + await network.provider.send('hardhat_setBalance', [ + address, + ethers.utils.hexStripZeros(parseUnits('1000', 18).toHexString()), + ]); + return ethers.getSigner(address); +} diff --git a/test/integration/helpers/mainnet-addresses.ts b/test/integration/helpers/mainnet-addresses.ts index 058e9f2c..312a25ba 100644 --- a/test/integration/helpers/mainnet-addresses.ts +++ b/test/integration/helpers/mainnet-addresses.ts @@ -3,11 +3,26 @@ export const MAINNET_ADDRESSES = { REDEMPTION_IDLE_PROXY: '0x4c21B7577C8FE8b0B0669165ee7C8f67fa1454Cf', SUPERSTATE_TOKEN_PROXY: '0x43415eB6ff9DB7E26A15b704e7A3eDCe97d31C4e', + // Aave V3 contracts + AAVE_V3_POOL: '0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2', + AUSDC: '0x98C23E9d8f34FEFb1B7BD6a91B7FF122F4e16F5c', // aEthUSDC + AUSDT: '0x23878914EFE38d27C4D67Ab83ed1b93A74D4086a', // aEthUSDT + + // Morpho Vault contracts + MORPHO_STEAKHOUSE_USDC_VAULT: '0xBEEF01735c132Ada46AA9aA4c54623cAA92A64CB', // Steakhouse USDC (steakUSDC) + MORPHO_SMOKEHOUSE_USDT_VAULT: '0xa0804346780b4c2e3be118ac957d1db82f9d7484', // Smokehouse USDT (BBQUSDT) + // Tokens USDC: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', USDT: '0xdAC17F958D2ee523a2206206994597C13D831ec7', // Whale addresses USDC_WHALE: '0x47ac0Fb4F2D84898e4D9E7b4DaB3C24507a6D503', + USDC_WHALE_BINANCE: '0xe1940f578743367F38D3f25c2D2d32D6636929B6', // Binance 91 USTB_WHALE: '0x5138D77d51dC57983e5A653CeA6e1C1aa9750A39', + AUSDC_WHALE: '0x7055b17A1b911b6b971172C01FF0Cc27881aeA94', + USDT_WHALE_BINANCE: '0x28C6c06298d514Db089934071355E5743bf21d60', // Binance 14 + AUSDT_WHALE: '0x464C71f6c2F760DdA6093dCB91C24c39e5d6e18c', // Aave collector + MORPHO_STEAKHOUSE_USDC_WHALE: '0xdcee3ae4f82bd085ff147b87a754517d8caaff3b', + MORPHO_SMOKEHOUSE_USDT_WHALE: '0x323114536974D7f5BF9bF1B1D0f2a650C7e21a90', }; diff --git a/test/unit/DepositVaultWithAave.test.ts b/test/unit/DepositVaultWithAave.test.ts new file mode 100644 index 00000000..c7b47a55 --- /dev/null +++ b/test/unit/DepositVaultWithAave.test.ts @@ -0,0 +1,2675 @@ +import { loadFixture } from '@nomicfoundation/hardhat-network-helpers'; +import { expect } from 'chai'; +import { constants } from 'ethers'; +import { parseUnits } from 'ethers/lib/utils'; +import { ethers } from 'hardhat'; + +import { encodeFnSelector } from '../../helpers/utils'; +import { ManageableVaultTester__factory } from '../../typechain-types'; +import { acErrors, blackList, greenList } from '../common/ac.helpers'; +import { + approveBase18, + mintToken, + pauseVault, + pauseVaultFn, +} from '../common/common.helpers'; +import { setRoundData } from '../common/data-feed.helpers'; +import { + depositInstantWithAaveTest, + depositRequestWithAaveTest, + removeAavePoolTest, + setAaveDepositsEnabledTest, + setAavePoolTest, + setAutoInvestFallbackEnabledAaveTest, +} from '../common/deposit-vault-aave.helpers'; +import { + approveRequestTest, + depositRequestTest, + rejectRequestTest, + safeApproveRequestTest, + safeBulkApproveRequestTest, +} from '../common/deposit-vault.helpers'; +import { defaultDeploy } from '../common/fixtures'; +import { + addPaymentTokenTest, + addWaivedFeeAccountTest, + changeTokenAllowanceTest, + removePaymentTokenTest, + removeWaivedFeeAccountTest, + setInstantFeeTest, + setInstantDailyLimitTest, + setMinAmountToDepositTest, + setMinAmountTest, + setVariabilityToleranceTest, + withdrawTest, + changeTokenFeeTest, +} from '../common/manageable-vault.helpers'; +import { sanctionUser } from '../common/with-sanctions-list.helpers'; + +describe('DepositVaultWithAave', function () { + it('deployment', async () => { + const { + depositVaultWithAave, + mTBILL, + tokensReceiver, + feeReceiver, + mTokenToUsdDataFeed, + roles, + aavePoolMock, + stableCoins, + } = await loadFixture(defaultDeploy); + + expect(await depositVaultWithAave.mToken()).eq(mTBILL.address); + expect(await depositVaultWithAave.paused()).eq(false); + expect(await depositVaultWithAave.tokensReceiver()).eq( + tokensReceiver.address, + ); + expect(await depositVaultWithAave.feeReceiver()).eq(feeReceiver.address); + expect(await depositVaultWithAave.ONE_HUNDRED_PERCENT()).eq('10000'); + expect(await depositVaultWithAave.minMTokenAmountForFirstDeposit()).eq('0'); + expect(await depositVaultWithAave.minAmount()).eq(parseUnits('100')); + expect(await depositVaultWithAave.instantFee()).eq('100'); + expect(await depositVaultWithAave.instantDailyLimit()).eq( + parseUnits('100000'), + ); + expect(await depositVaultWithAave.mTokenDataFeed()).eq( + mTokenToUsdDataFeed.address, + ); + expect(await depositVaultWithAave.variationTolerance()).eq(1); + expect(await depositVaultWithAave.vaultRole()).eq( + roles.tokenRoles.mTBILL.depositVaultAdmin, + ); + expect(await depositVaultWithAave.MANUAL_FULLFILMENT_TOKEN()).eq( + ethers.constants.AddressZero, + ); + expect(await depositVaultWithAave.aavePools(stableCoins.usdc.address)).eq( + aavePoolMock.address, + ); + expect(await depositVaultWithAave.aaveDepositsEnabled()).eq(false); + }); + + describe('initialization', () => { + it('should fail: call initialize() when already initialized', async () => { + const { depositVaultWithAave } = await loadFixture(defaultDeploy); + + await expect( + depositVaultWithAave.initialize( + constants.AddressZero, + { + mToken: constants.AddressZero, + mTokenDataFeed: constants.AddressZero, + }, + { + feeReceiver: constants.AddressZero, + tokensReceiver: constants.AddressZero, + }, + { + instantFee: 0, + instantDailyLimit: 0, + }, + constants.AddressZero, + 0, + 0, + 0, + 0, + ), + ).revertedWith('Initializable: contract is already initialized'); + }); + + it('should fail: call with initializing == false', async () => { + const { + owner, + accessControl, + mTBILL, + tokensReceiver, + feeReceiver, + mTokenToUsdDataFeed, + mockedSanctionsList, + } = await loadFixture(defaultDeploy); + + const vault = await new ManageableVaultTester__factory(owner).deploy(); + + await expect( + vault.initializeWithoutInitializer( + accessControl.address, + { + mToken: mTBILL.address, + mTokenDataFeed: mTokenToUsdDataFeed.address, + }, + { + feeReceiver: feeReceiver.address, + tokensReceiver: tokensReceiver.address, + }, + { + instantFee: 100, + instantDailyLimit: parseUnits('100000'), + }, + mockedSanctionsList.address, + 1, + parseUnits('100'), + ), + ).revertedWith('Initializable: contract is not initializing'); + }); + }); + + describe('setAavePool()', () => { + it('should fail: call from address without DEPOSIT_VAULT_ADMIN_ROLE role', async () => { + const { + depositVaultWithAave, + owner, + regularAccounts, + stableCoins, + aavePoolMock, + } = await loadFixture(defaultDeploy); + + await setAavePoolTest( + { depositVaultWithAave, owner }, + stableCoins.usdc.address, + aavePoolMock.address, + { + from: regularAccounts[0], + revertMessage: 'WMAC: hasnt role', + }, + ); + }); + + it('should fail: zero address', async () => { + const { depositVaultWithAave, owner, stableCoins } = await loadFixture( + defaultDeploy, + ); + + await setAavePoolTest( + { depositVaultWithAave, owner }, + stableCoins.usdc.address, + ethers.constants.AddressZero, + { + revertMessage: 'zero address', + }, + ); + }); + + it('should fail: token not in pool', async () => { + const { depositVaultWithAave, owner, stableCoins, aavePoolMock } = + await loadFixture(defaultDeploy); + + await setAavePoolTest( + { depositVaultWithAave, owner }, + stableCoins.dai.address, + aavePoolMock.address, + { + revertMessage: 'DVA: token not in pool', + }, + ); + }); + + it('call from address with DEPOSIT_VAULT_ADMIN_ROLE role', async () => { + const { depositVaultWithAave, owner, stableCoins, aavePoolMock } = + await loadFixture(defaultDeploy); + + await setAavePoolTest( + { depositVaultWithAave, owner }, + stableCoins.usdc.address, + aavePoolMock.address, + ); + }); + }); + + describe('removeAavePool()', () => { + it('should fail: call from address without DEPOSIT_VAULT_ADMIN_ROLE role', async () => { + const { depositVaultWithAave, owner, regularAccounts, stableCoins } = + await loadFixture(defaultDeploy); + + await removeAavePoolTest( + { depositVaultWithAave, owner }, + stableCoins.usdc.address, + { + from: regularAccounts[0], + revertMessage: 'WMAC: hasnt role', + }, + ); + }); + + it('should fail: pool not set', async () => { + const { depositVaultWithAave, owner, stableCoins } = await loadFixture( + defaultDeploy, + ); + + await removeAavePoolTest( + { depositVaultWithAave, owner }, + stableCoins.dai.address, + { + revertMessage: 'DVA: pool not set', + }, + ); + }); + + it('call from address with DEPOSIT_VAULT_ADMIN_ROLE role', async () => { + const { depositVaultWithAave, owner, stableCoins, aavePoolMock } = + await loadFixture(defaultDeploy); + + await setAavePoolTest( + { depositVaultWithAave, owner }, + stableCoins.usdc.address, + aavePoolMock.address, + ); + + await removeAavePoolTest( + { depositVaultWithAave, owner }, + stableCoins.usdc.address, + ); + }); + }); + + describe('setAaveDepositsEnabled()', () => { + it('should fail: call from address without DEPOSIT_VAULT_ADMIN_ROLE role', async () => { + const { depositVaultWithAave, owner, regularAccounts } = + await loadFixture(defaultDeploy); + + await setAaveDepositsEnabledTest({ depositVaultWithAave, owner }, true, { + from: regularAccounts[0], + revertMessage: 'WMAC: hasnt role', + }); + }); + + it('call from address with DEPOSIT_VAULT_ADMIN_ROLE role', async () => { + const { depositVaultWithAave, owner } = await loadFixture(defaultDeploy); + + await setAaveDepositsEnabledTest({ depositVaultWithAave, owner }, true); + }); + + it('toggle on and off', async () => { + const { depositVaultWithAave, owner } = await loadFixture(defaultDeploy); + + await setAaveDepositsEnabledTest({ depositVaultWithAave, owner }, true); + + await setAaveDepositsEnabledTest({ depositVaultWithAave, owner }, false); + }); + }); + + describe('setAutoInvestFallbackEnabled()', () => { + it('should fail: call from address without DEPOSIT_VAULT_ADMIN_ROLE role', async () => { + const { depositVaultWithAave, owner, regularAccounts } = + await loadFixture(defaultDeploy); + + await setAutoInvestFallbackEnabledAaveTest( + { depositVaultWithAave, owner }, + true, + { + from: regularAccounts[0], + revertMessage: 'WMAC: hasnt role', + }, + ); + }); + + it('call from address with DEPOSIT_VAULT_ADMIN_ROLE role', async () => { + const { depositVaultWithAave, owner } = await loadFixture(defaultDeploy); + + await setAutoInvestFallbackEnabledAaveTest( + { depositVaultWithAave, owner }, + true, + ); + }); + + it('toggle on and off', async () => { + const { depositVaultWithAave, owner } = await loadFixture(defaultDeploy); + + await setAutoInvestFallbackEnabledAaveTest( + { depositVaultWithAave, owner }, + true, + ); + + await setAutoInvestFallbackEnabledAaveTest( + { depositVaultWithAave, owner }, + false, + ); + }); + }); + + describe('setMinAmount()', () => { + it('should fail: call from address without DEPOSIT_VAULT_ADMIN_ROLE role', async () => { + const { depositVaultWithAave, regularAccounts, owner } = + await loadFixture(defaultDeploy); + await setMinAmountTest({ vault: depositVaultWithAave, owner }, 10, { + from: regularAccounts[0], + revertMessage: 'WMAC: hasnt role', + }); + }); + + it('call from address with DEPOSIT_VAULT_ADMIN_ROLE role', async () => { + const { depositVaultWithAave, owner } = await loadFixture(defaultDeploy); + await setMinAmountTest({ vault: depositVaultWithAave, owner }, 10); + }); + }); + + describe('setMinMTokenAmountForFirstDeposit()', () => { + it('should fail: call from address without DEPOSIT_VAULT_ADMIN_ROLE role', async () => { + const { depositVaultWithAave, regularAccounts, owner } = + await loadFixture(defaultDeploy); + + await setMinAmountToDepositTest( + { depositVault: depositVaultWithAave, owner }, + 10, + { + from: regularAccounts[0], + revertMessage: 'WMAC: hasnt role', + }, + ); + }); + + it('call from address with DEPOSIT_VAULT_ADMIN_ROLE role', async () => { + const { depositVaultWithAave, owner } = await loadFixture(defaultDeploy); + + await setMinAmountToDepositTest( + { depositVault: depositVaultWithAave, owner }, + 10, + ); + }); + }); + + describe('setVariabilityTolerance()', () => { + it('should fail: call from address without DEPOSIT_VAULT_ADMIN_ROLE role', async () => { + const { depositVaultWithAave, regularAccounts, owner } = + await loadFixture(defaultDeploy); + + await setVariabilityToleranceTest( + { vault: depositVaultWithAave, owner }, + 100, + { + from: regularAccounts[0], + revertMessage: 'WMAC: hasnt role', + }, + ); + }); + + it('call from address with DEPOSIT_VAULT_ADMIN_ROLE role', async () => { + const { depositVaultWithAave, owner } = await loadFixture(defaultDeploy); + + await setVariabilityToleranceTest( + { vault: depositVaultWithAave, owner }, + 100, + ); + }); + }); + + describe('setInstantDailyLimit()', () => { + it('should fail: call from address without DEPOSIT_VAULT_ADMIN_ROLE role', async () => { + const { depositVaultWithAave, regularAccounts, owner } = + await loadFixture(defaultDeploy); + await setInstantDailyLimitTest( + { vault: depositVaultWithAave, owner }, + 10, + { from: regularAccounts[0], revertMessage: 'WMAC: hasnt role' }, + ); + }); + + it('call from address with DEPOSIT_VAULT_ADMIN_ROLE role', async () => { + const { depositVaultWithAave, owner } = await loadFixture(defaultDeploy); + await setInstantDailyLimitTest( + { vault: depositVaultWithAave, owner }, + 10, + ); + }); + }); + + describe('addPaymentToken()', () => { + it('should fail: call from address without DEPOSIT_VAULT_ADMIN_ROLE role', async () => { + const { + depositVaultWithAave, + regularAccounts, + owner, + stableCoins, + dataFeed, + } = await loadFixture(defaultDeploy); + await addPaymentTokenTest( + { vault: depositVaultWithAave, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + constants.MaxUint256, + { from: regularAccounts[0], revertMessage: 'WMAC: hasnt role' }, + ); + }); + + it('call from address with DEPOSIT_VAULT_ADMIN_ROLE role', async () => { + const { depositVaultWithAave, owner, stableCoins, dataFeed } = + await loadFixture(defaultDeploy); + await addPaymentTokenTest( + { vault: depositVaultWithAave, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + }); + }); + + describe('removePaymentToken()', () => { + it('should fail: call from address without DEPOSIT_VAULT_ADMIN_ROLE role', async () => { + const { depositVaultWithAave, regularAccounts, owner, stableCoins } = + await loadFixture(defaultDeploy); + await removePaymentTokenTest( + { vault: depositVaultWithAave, owner }, + stableCoins.usdc, + { from: regularAccounts[0], revertMessage: 'WMAC: hasnt role' }, + ); + }); + + it('call from address with DEPOSIT_VAULT_ADMIN_ROLE role', async () => { + const { depositVaultWithAave, owner, stableCoins, dataFeed } = + await loadFixture(defaultDeploy); + await addPaymentTokenTest( + { vault: depositVaultWithAave, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + await removePaymentTokenTest( + { vault: depositVaultWithAave, owner }, + stableCoins.usdc, + ); + }); + }); + + describe('addWaivedFeeAccount()', () => { + it('should fail: call from address without DEPOSIT_VAULT_ADMIN_ROLE role', async () => { + const { depositVaultWithAave, regularAccounts, owner } = + await loadFixture(defaultDeploy); + await addWaivedFeeAccountTest( + { vault: depositVaultWithAave, owner }, + regularAccounts[0].address, + { from: regularAccounts[0], revertMessage: 'WMAC: hasnt role' }, + ); + }); + + it('call from address with DEPOSIT_VAULT_ADMIN_ROLE role', async () => { + const { depositVaultWithAave, regularAccounts, owner } = + await loadFixture(defaultDeploy); + await addWaivedFeeAccountTest( + { vault: depositVaultWithAave, owner }, + regularAccounts[0].address, + ); + }); + }); + + describe('removeWaivedFeeAccount()', () => { + it('should fail: call from address without DEPOSIT_VAULT_ADMIN_ROLE role', async () => { + const { depositVaultWithAave, regularAccounts, owner } = + await loadFixture(defaultDeploy); + await removeWaivedFeeAccountTest( + { vault: depositVaultWithAave, owner }, + regularAccounts[0].address, + { from: regularAccounts[0], revertMessage: 'WMAC: hasnt role' }, + ); + }); + + it('call from address with DEPOSIT_VAULT_ADMIN_ROLE role', async () => { + const { depositVaultWithAave, regularAccounts, owner } = + await loadFixture(defaultDeploy); + await addWaivedFeeAccountTest( + { vault: depositVaultWithAave, owner }, + regularAccounts[0].address, + ); + await removeWaivedFeeAccountTest( + { vault: depositVaultWithAave, owner }, + regularAccounts[0].address, + ); + }); + }); + + describe('setFee()', () => { + it('should fail: call from address without DEPOSIT_VAULT_ADMIN_ROLE role', async () => { + const { depositVaultWithAave, regularAccounts, owner } = + await loadFixture(defaultDeploy); + await setInstantFeeTest({ vault: depositVaultWithAave, owner }, 100, { + from: regularAccounts[0], + revertMessage: 'WMAC: hasnt role', + }); + }); + + it('call from address with DEPOSIT_VAULT_ADMIN_ROLE role', async () => { + const { depositVaultWithAave, owner } = await loadFixture(defaultDeploy); + await setInstantFeeTest({ vault: depositVaultWithAave, owner }, 100); + }); + }); + + describe('withdrawToken()', () => { + it('should fail: call from address without DEPOSIT_VAULT_ADMIN_ROLE role', async () => { + const { depositVaultWithAave, regularAccounts, owner, stableCoins } = + await loadFixture(defaultDeploy); + await withdrawTest( + { vault: depositVaultWithAave, owner }, + stableCoins.usdc.address, + 0, + owner, + { from: regularAccounts[0], revertMessage: 'WMAC: hasnt role' }, + ); + }); + + it('call from address with DEPOSIT_VAULT_ADMIN_ROLE role', async () => { + const { depositVaultWithAave, owner, stableCoins } = await loadFixture( + defaultDeploy, + ); + await mintToken(stableCoins.usdc, depositVaultWithAave, 1); + await withdrawTest( + { vault: depositVaultWithAave, owner }, + stableCoins.usdc, + 1, + owner, + ); + }); + }); + + describe('changeTokenAllowance()', () => { + it('should fail: call from address without DEPOSIT_VAULT_ADMIN_ROLE role', async () => { + const { depositVaultWithAave, regularAccounts, owner, stableCoins } = + await loadFixture(defaultDeploy); + await changeTokenAllowanceTest( + { vault: depositVaultWithAave, owner }, + stableCoins.usdc.address, + 100, + { from: regularAccounts[0], revertMessage: 'WMAC: hasnt role' }, + ); + }); + + it('call from address with DEPOSIT_VAULT_ADMIN_ROLE role', async () => { + const { depositVaultWithAave, owner, stableCoins, dataFeed } = + await loadFixture(defaultDeploy); + await addPaymentTokenTest( + { vault: depositVaultWithAave, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + await changeTokenAllowanceTest( + { vault: depositVaultWithAave, owner }, + stableCoins.usdc.address, + parseUnits('200'), + ); + }); + }); + + describe('changeTokenFee()', () => { + it('should fail: call from address without DEPOSIT_VAULT_ADMIN_ROLE role', async () => { + const { depositVaultWithAave, regularAccounts, owner, stableCoins } = + await loadFixture(defaultDeploy); + await changeTokenFeeTest( + { vault: depositVaultWithAave, owner }, + stableCoins.usdc.address, + 100, + { from: regularAccounts[0], revertMessage: 'WMAC: hasnt role' }, + ); + }); + + it('call from address with DEPOSIT_VAULT_ADMIN_ROLE role', async () => { + const { depositVaultWithAave, owner, stableCoins, dataFeed } = + await loadFixture(defaultDeploy); + await addPaymentTokenTest( + { vault: depositVaultWithAave, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + await changeTokenFeeTest( + { vault: depositVaultWithAave, owner }, + stableCoins.usdc.address, + 100, + ); + }); + }); + + describe('freeFromMinAmount()', async () => { + it('should fail: call from address without vault admin role', async () => { + const { depositVaultWithAave, regularAccounts } = await loadFixture( + defaultDeploy, + ); + await expect( + depositVaultWithAave + .connect(regularAccounts[0]) + .freeFromMinAmount(regularAccounts[1].address, true), + ).to.be.revertedWith('WMAC: hasnt role'); + }); + it('should not fail', async () => { + const { depositVaultWithAave, regularAccounts } = await loadFixture( + defaultDeploy, + ); + await expect( + depositVaultWithAave.freeFromMinAmount( + regularAccounts[0].address, + true, + ), + ).to.not.reverted; + + expect( + await depositVaultWithAave.isFreeFromMinAmount( + regularAccounts[0].address, + ), + ).to.eq(true); + }); + it('should fail: already in list', async () => { + const { depositVaultWithAave, regularAccounts } = await loadFixture( + defaultDeploy, + ); + await expect( + depositVaultWithAave.freeFromMinAmount( + regularAccounts[0].address, + true, + ), + ).to.not.reverted; + + expect( + await depositVaultWithAave.isFreeFromMinAmount( + regularAccounts[0].address, + ), + ).to.eq(true); + + await expect( + depositVaultWithAave.freeFromMinAmount( + regularAccounts[0].address, + true, + ), + ).to.revertedWith('DV: already free'); + }); + }); + + describe('depositInstant()', async () => { + it('should fail: when there is no token in vault', async () => { + const { + owner, + depositVaultWithAave, + stableCoins, + mTBILL, + mTokenToUsdDataFeed, + regularAccounts, + aavePoolMock, + } = await loadFixture(defaultDeploy); + + await mintToken(stableCoins.usdc, regularAccounts[0], 100); + await approveBase18( + regularAccounts[0], + stableCoins.usdc, + depositVaultWithAave, + 100, + ); + + await depositInstantWithAaveTest( + { + depositVaultWithAave, + owner, + mTBILL, + mTokenToUsdDataFeed, + aavePoolMock, + }, + stableCoins.usdc, + 100, + { + from: regularAccounts[0], + revertMessage: 'MV: token not exists', + }, + ); + }); + + it('should fail: when function paused', async () => { + const { + owner, + depositVaultWithAave, + stableCoins, + mTBILL, + mTokenToUsdDataFeed, + regularAccounts, + dataFeed, + aavePoolMock, + } = await loadFixture(defaultDeploy); + + await mintToken(stableCoins.usdc, regularAccounts[0], 100); + await approveBase18( + regularAccounts[0], + stableCoins.usdc, + depositVaultWithAave, + 100, + ); + await addPaymentTokenTest( + { vault: depositVaultWithAave, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + await setMinAmountTest({ vault: depositVaultWithAave, owner }, 10); + + await pauseVault(depositVaultWithAave, { + from: owner, + }); + + await depositInstantWithAaveTest( + { + depositVaultWithAave, + owner, + mTBILL, + mTokenToUsdDataFeed, + aavePoolMock, + }, + stableCoins.usdc, + 100, + { + from: regularAccounts[0], + revertMessage: 'Pausable: paused', + }, + ); + }); + + it('should fail: user in blacklist', async () => { + const { + owner, + depositVaultWithAave, + stableCoins, + mTBILL, + mTokenToUsdDataFeed, + regularAccounts, + dataFeed, + accessControl, + aavePoolMock, + } = await loadFixture(defaultDeploy); + + await mintToken(stableCoins.usdc, regularAccounts[0], 100); + await approveBase18( + regularAccounts[0], + stableCoins.usdc, + depositVaultWithAave, + 100, + ); + await addPaymentTokenTest( + { vault: depositVaultWithAave, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + await setMinAmountTest({ vault: depositVaultWithAave, owner }, 10); + + await blackList( + { blacklistable: depositVaultWithAave, accessControl, owner }, + regularAccounts[0], + ); + + await depositInstantWithAaveTest( + { + depositVaultWithAave, + owner, + mTBILL, + mTokenToUsdDataFeed, + aavePoolMock, + }, + stableCoins.usdc, + 100, + { + from: regularAccounts[0], + revertMessage: acErrors.WMAC_HAS_ROLE, + }, + ); + }); + + it('should fail: user in sanctions list', async () => { + const { + owner, + depositVaultWithAave, + stableCoins, + mTBILL, + mTokenToUsdDataFeed, + regularAccounts, + dataFeed, + mockedSanctionsList, + aavePoolMock, + } = await loadFixture(defaultDeploy); + + await mintToken(stableCoins.usdc, regularAccounts[0], 100); + await approveBase18( + regularAccounts[0], + stableCoins.usdc, + depositVaultWithAave, + 100, + ); + await addPaymentTokenTest( + { vault: depositVaultWithAave, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + await setMinAmountTest({ vault: depositVaultWithAave, owner }, 10); + + await sanctionUser( + { sanctionsList: mockedSanctionsList }, + regularAccounts[0], + ); + + await depositInstantWithAaveTest( + { + depositVaultWithAave, + owner, + mTBILL, + mTokenToUsdDataFeed, + aavePoolMock, + }, + stableCoins.usdc, + 100, + { + from: regularAccounts[0], + revertMessage: 'WSL: sanctioned', + }, + ); + }); + + it('should fail: when trying to deposit 0 amount', async () => { + const { + owner, + depositVaultWithAave, + stableCoins, + mTBILL, + dataFeed, + mTokenToUsdDataFeed, + aavePoolMock, + } = await loadFixture(defaultDeploy); + await addPaymentTokenTest( + { vault: depositVaultWithAave, owner }, + stableCoins.dai, + dataFeed.address, + 0, + true, + ); + await depositInstantWithAaveTest( + { + depositVaultWithAave, + owner, + mTBILL, + mTokenToUsdDataFeed, + aavePoolMock, + }, + stableCoins.dai, + 0, + { + revertMessage: 'DV: invalid amount', + }, + ); + }); + + it('should fail: when rounding is invalid', async () => { + const { + owner, + depositVaultWithAave, + stableCoins, + mTBILL, + dataFeed, + mTokenToUsdDataFeed, + aavePoolMock, + } = await loadFixture(defaultDeploy); + + await addPaymentTokenTest( + { vault: depositVaultWithAave, owner }, + stableCoins.dai, + dataFeed.address, + 0, + true, + ); + await setMinAmountTest({ vault: depositVaultWithAave, owner }, 10); + await depositInstantWithAaveTest( + { + depositVaultWithAave, + owner, + mTBILL, + mTokenToUsdDataFeed, + aavePoolMock, + }, + stableCoins.dai, + 100.0000000001, + { + revertMessage: 'MV: invalid rounding', + }, + ); + }); + + it('should fail: call with insufficient allowance', async () => { + const { + owner, + depositVaultWithAave, + stableCoins, + mTBILL, + dataFeed, + mTokenToUsdDataFeed, + aavePoolMock, + } = await loadFixture(defaultDeploy); + + await mintToken(stableCoins.dai, owner, 100); + await addPaymentTokenTest( + { vault: depositVaultWithAave, owner }, + stableCoins.dai, + dataFeed.address, + 0, + true, + ); + await setMinAmountTest({ vault: depositVaultWithAave, owner }, 10); + await depositInstantWithAaveTest( + { + depositVaultWithAave, + owner, + mTBILL, + mTokenToUsdDataFeed, + aavePoolMock, + }, + stableCoins.dai, + 100, + { + revertMessage: 'ERC20: insufficient allowance', + }, + ); + }); + + it('should fail: call with insufficient balance', async () => { + const { + owner, + depositVaultWithAave, + stableCoins, + mTBILL, + dataFeed, + mTokenToUsdDataFeed, + aavePoolMock, + } = await loadFixture(defaultDeploy); + + await approveBase18(owner, stableCoins.dai, depositVaultWithAave, 100); + await addPaymentTokenTest( + { vault: depositVaultWithAave, owner }, + stableCoins.dai, + dataFeed.address, + 0, + true, + ); + await setMinAmountTest({ vault: depositVaultWithAave, owner }, 10); + await depositInstantWithAaveTest( + { + depositVaultWithAave, + owner, + mTBILL, + mTokenToUsdDataFeed, + aavePoolMock, + }, + stableCoins.dai, + 100, + { + revertMessage: 'ERC20: transfer amount exceeds balance', + }, + ); + }); + + it('should fail: dataFeed rate 0 ', async () => { + const { + owner, + depositVaultWithAave, + stableCoins, + mTBILL, + dataFeed, + mockedAggregator, + mTokenToUsdDataFeed, + aavePoolMock, + } = await loadFixture(defaultDeploy); + + await approveBase18(owner, stableCoins.dai, depositVaultWithAave, 10); + await addPaymentTokenTest( + { vault: depositVaultWithAave, owner }, + stableCoins.dai, + dataFeed.address, + 0, + true, + ); + await mintToken(stableCoins.dai, owner, 100_000); + await setRoundData({ mockedAggregator }, 0); + await depositInstantWithAaveTest( + { + depositVaultWithAave, + owner, + mTBILL, + mTokenToUsdDataFeed, + aavePoolMock, + }, + stableCoins.dai, + 1, + { + revertMessage: 'DF: feed is deprecated', + }, + ); + }); + + it('should fail: call for amount < minAmountToDepositTest', async () => { + const { + depositVaultWithAave, + mockedAggregator, + owner, + mTBILL, + stableCoins, + dataFeed, + mTokenToUsdDataFeed, + aavePoolMock, + } = await loadFixture(defaultDeploy); + await addPaymentTokenTest( + { vault: depositVaultWithAave, owner }, + stableCoins.dai, + dataFeed.address, + 0, + true, + ); + await setRoundData({ mockedAggregator }, 1); + + await mintToken(stableCoins.dai, owner, 100_000); + await approveBase18( + owner, + stableCoins.dai, + depositVaultWithAave, + 100_000, + ); + + await setMinAmountToDepositTest( + { depositVault: depositVaultWithAave, owner }, + 100_000, + ); + await setInstantDailyLimitTest( + { vault: depositVaultWithAave, owner }, + 150_000, + ); + + await depositInstantWithAaveTest( + { + depositVaultWithAave, + owner, + mTBILL, + mTokenToUsdDataFeed, + aavePoolMock, + }, + stableCoins.dai, + 99_999, + { + revertMessage: 'DV: mint amount < min', + }, + ); + }); + + it('should fail: call for amount < minAmount', async () => { + const { + depositVaultWithAave, + mockedAggregator, + owner, + mTBILL, + stableCoins, + dataFeed, + mTokenToUsdDataFeed, + aavePoolMock, + } = await loadFixture(defaultDeploy); + await addPaymentTokenTest( + { vault: depositVaultWithAave, owner }, + stableCoins.dai, + dataFeed.address, + 0, + true, + ); + await setRoundData({ mockedAggregator }, 1); + + await mintToken(stableCoins.dai, owner, 100_000); + await approveBase18( + owner, + stableCoins.dai, + depositVaultWithAave, + 100_000, + ); + + await setMinAmountToDepositTest( + { depositVault: depositVaultWithAave, owner }, + 100_000, + ); + await setInstantDailyLimitTest( + { vault: depositVaultWithAave, owner }, + 150_000, + ); + + await depositInstantWithAaveTest( + { + depositVaultWithAave, + owner, + mTBILL, + mTokenToUsdDataFeed, + aavePoolMock, + }, + stableCoins.dai, + 99, + { + revertMessage: 'DV: mToken amount < min', + }, + ); + }); + + it('should fail: if exceed allowance of deposit for token', async () => { + const { + depositVaultWithAave, + mockedAggregator, + owner, + mTBILL, + stableCoins, + dataFeed, + mTokenToUsdDataFeed, + aavePoolMock, + } = await loadFixture(defaultDeploy); + await addPaymentTokenTest( + { vault: depositVaultWithAave, owner }, + stableCoins.dai, + dataFeed.address, + 0, + true, + ); + await setRoundData({ mockedAggregator }, 4); + + await mintToken(stableCoins.dai, owner, 100_000); + await changeTokenAllowanceTest( + { vault: depositVaultWithAave, owner }, + stableCoins.dai.address, + 100, + ); + await approveBase18( + owner, + stableCoins.dai, + depositVaultWithAave, + 100_000, + ); + + await depositInstantWithAaveTest( + { + depositVaultWithAave, + owner, + mTBILL, + mTokenToUsdDataFeed, + aavePoolMock, + }, + stableCoins.dai, + 99_999, + { + revertMessage: 'MV: exceed allowance', + }, + ); + }); + + it('should fail: if mint limit exceeded', async () => { + const { + depositVaultWithAave, + mockedAggregator, + owner, + mTBILL, + stableCoins, + dataFeed, + mTokenToUsdDataFeed, + aavePoolMock, + } = await loadFixture(defaultDeploy); + await addPaymentTokenTest( + { vault: depositVaultWithAave, owner }, + stableCoins.dai, + dataFeed.address, + 0, + true, + ); + await setRoundData({ mockedAggregator }, 4); + + await mintToken(stableCoins.dai, owner, 100_000); + await setInstantDailyLimitTest( + { vault: depositVaultWithAave, owner }, + 1000, + ); + + await approveBase18( + owner, + stableCoins.dai, + depositVaultWithAave, + 100_000, + ); + + await depositInstantWithAaveTest( + { + depositVaultWithAave, + owner, + mTBILL, + mTokenToUsdDataFeed, + aavePoolMock, + }, + stableCoins.dai, + 99_999, + { + revertMessage: 'MV: exceed limit', + }, + ); + }); + + it('should fail: if min receive amount greater then actual', async () => { + const { + depositVaultWithAave, + mockedAggregator, + owner, + mTBILL, + stableCoins, + dataFeed, + mTokenToUsdDataFeed, + aavePoolMock, + } = await loadFixture(defaultDeploy); + await addPaymentTokenTest( + { vault: depositVaultWithAave, owner }, + stableCoins.dai, + dataFeed.address, + 0, + true, + ); + await setRoundData({ mockedAggregator }, 4); + + await mintToken(stableCoins.dai, owner, 100_000); + + await approveBase18( + owner, + stableCoins.dai, + depositVaultWithAave, + 100_000, + ); + + await depositInstantWithAaveTest( + { + depositVaultWithAave, + owner, + mTBILL, + mTokenToUsdDataFeed, + aavePoolMock, + minAmount: parseUnits('100000'), + }, + stableCoins.dai, + 99_999, + { + revertMessage: 'DV: minReceiveAmount > actual', + }, + ); + }); + + it('should fail: if some fee = 100%', async () => { + const { + owner, + depositVaultWithAave, + stableCoins, + mTBILL, + dataFeed, + mTokenToUsdDataFeed, + aavePoolMock, + } = await loadFixture(defaultDeploy); + + await mintToken(stableCoins.dai, owner, 100); + await approveBase18(owner, stableCoins.dai, depositVaultWithAave, 100); + await addPaymentTokenTest( + { vault: depositVaultWithAave, owner }, + stableCoins.dai, + dataFeed.address, + 10000, + true, + ); + await depositInstantWithAaveTest( + { + depositVaultWithAave, + owner, + mTBILL, + mTokenToUsdDataFeed, + aavePoolMock, + }, + stableCoins.dai, + 100, + { + revertMessage: 'DV: mToken amount < min', + }, + ); + + await removePaymentTokenTest( + { vault: depositVaultWithAave, owner }, + stableCoins.dai, + ); + await addPaymentTokenTest( + { vault: depositVaultWithAave, owner }, + stableCoins.dai, + dataFeed.address, + 0, + true, + ); + await setInstantFeeTest({ vault: depositVaultWithAave, owner }, 10000); + await depositInstantWithAaveTest( + { + depositVaultWithAave, + owner, + mTBILL, + mTokenToUsdDataFeed, + aavePoolMock, + }, + stableCoins.dai, + 100, + { revertMessage: 'DV: mToken amount < min' }, + ); + }); + + it('deposit 100 USDC when aaveDepositsEnabled is true', async () => { + const { + owner, + depositVaultWithAave, + stableCoins, + mTBILL, + mTokenToUsdDataFeed, + regularAccounts, + dataFeed, + aavePoolMock, + } = await loadFixture(defaultDeploy); + + await setAaveDepositsEnabledTest({ depositVaultWithAave, owner }, true); + + await mintToken(stableCoins.usdc, regularAccounts[0], 100); + await approveBase18( + regularAccounts[0], + stableCoins.usdc, + depositVaultWithAave, + 100, + ); + await addPaymentTokenTest( + { vault: depositVaultWithAave, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + await setMinAmountTest({ vault: depositVaultWithAave, owner }, 10); + + await depositInstantWithAaveTest( + { + depositVaultWithAave, + owner, + mTBILL, + mTokenToUsdDataFeed, + aavePoolMock, + }, + stableCoins.usdc, + 100, + { + from: regularAccounts[0], + }, + ); + }); + + it('when aaveDepositsEnabled is false, normal DV flow', async () => { + const { + owner, + depositVaultWithAave, + stableCoins, + mTBILL, + mTokenToUsdDataFeed, + regularAccounts, + dataFeed, + aavePoolMock, + } = await loadFixture(defaultDeploy); + + await mintToken(stableCoins.usdc, regularAccounts[0], 100); + await approveBase18( + regularAccounts[0], + stableCoins.usdc, + depositVaultWithAave, + 100, + ); + await addPaymentTokenTest( + { vault: depositVaultWithAave, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + await setMinAmountTest({ vault: depositVaultWithAave, owner }, 10); + + await depositInstantWithAaveTest( + { + depositVaultWithAave, + owner, + mTBILL, + mTokenToUsdDataFeed, + aavePoolMock, + expectedAaveDeposited: false, + }, + stableCoins.usdc, + 100, + { + from: regularAccounts[0], + }, + ); + }); + + it('deposit with waived fee', async () => { + const { + owner, + depositVaultWithAave, + stableCoins, + mTBILL, + mTokenToUsdDataFeed, + regularAccounts, + dataFeed, + aavePoolMock, + } = await loadFixture(defaultDeploy); + + await setAaveDepositsEnabledTest({ depositVaultWithAave, owner }, true); + + await addWaivedFeeAccountTest( + { vault: depositVaultWithAave, owner }, + regularAccounts[0].address, + ); + + await mintToken(stableCoins.usdc, regularAccounts[0], 100); + await approveBase18( + regularAccounts[0], + stableCoins.usdc, + depositVaultWithAave, + 100, + ); + await addPaymentTokenTest( + { vault: depositVaultWithAave, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + await setMinAmountTest({ vault: depositVaultWithAave, owner }, 10); + + await depositInstantWithAaveTest( + { + depositVaultWithAave, + owner, + mTBILL, + mTokenToUsdDataFeed, + aavePoolMock, + waivedFee: true, + }, + stableCoins.usdc, + 100, + { + from: regularAccounts[0], + }, + ); + }); + + it('deposit 100 DAI with aave enabled (non-stablecoin feed)', async () => { + const { + owner, + depositVaultWithAave, + stableCoins, + mTBILL, + mTokenToUsdDataFeed, + regularAccounts, + dataFeed, + aavePoolMock, + aUSDC, + } = await loadFixture(defaultDeploy); + + const aDAI = aUSDC; // reuse the aToken mock for DAI in tests + await aavePoolMock.setReserveAToken( + stableCoins.dai.address, + aDAI.address, + ); + + await setAavePoolTest( + { depositVaultWithAave, owner }, + stableCoins.dai.address, + aavePoolMock.address, + ); + + await setAaveDepositsEnabledTest({ depositVaultWithAave, owner }, true); + + await mintToken(stableCoins.dai, regularAccounts[0], 100); + await approveBase18( + regularAccounts[0], + stableCoins.dai, + depositVaultWithAave, + 100, + ); + await addPaymentTokenTest( + { vault: depositVaultWithAave, owner }, + stableCoins.dai, + dataFeed.address, + 0, + false, + ); + await setMinAmountTest({ vault: depositVaultWithAave, owner }, 10); + + await depositInstantWithAaveTest( + { + depositVaultWithAave, + owner, + mTBILL, + mTokenToUsdDataFeed, + aavePoolMock, + }, + stableCoins.dai, + 100, + { + from: regularAccounts[0], + }, + ); + }); + + it('deposit with greenlist enabled and user in greenlist', async () => { + const { + owner, + depositVaultWithAave, + stableCoins, + mTBILL, + greenListableTester, + mTokenToUsdDataFeed, + accessControl, + regularAccounts, + dataFeed, + aavePoolMock, + } = await loadFixture(defaultDeploy); + + await depositVaultWithAave.setGreenlistEnable(true); + + await greenList( + { greenlistable: greenListableTester, accessControl, owner }, + regularAccounts[0], + ); + + await setAaveDepositsEnabledTest({ depositVaultWithAave, owner }, true); + + await mintToken(stableCoins.usdc, regularAccounts[0], 100); + await approveBase18( + regularAccounts[0], + stableCoins.usdc, + depositVaultWithAave, + 100, + ); + await addPaymentTokenTest( + { vault: depositVaultWithAave, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + await setMinAmountTest({ vault: depositVaultWithAave, owner }, 10); + + await depositInstantWithAaveTest( + { + depositVaultWithAave, + owner, + mTBILL, + mTokenToUsdDataFeed, + aavePoolMock, + }, + stableCoins.usdc, + 100, + { + from: regularAccounts[0], + }, + ); + }); + + it('deposit with custom recipient, aaveDepositsEnabled is true', async () => { + const { + owner, + depositVaultWithAave, + stableCoins, + mTBILL, + mTokenToUsdDataFeed, + regularAccounts, + dataFeed, + aavePoolMock, + } = await loadFixture(defaultDeploy); + + await setAaveDepositsEnabledTest({ depositVaultWithAave, owner }, true); + + await mintToken(stableCoins.usdc, regularAccounts[0], 100); + await approveBase18( + regularAccounts[0], + stableCoins.usdc, + depositVaultWithAave, + 100, + ); + await addPaymentTokenTest( + { vault: depositVaultWithAave, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + await setMinAmountTest({ vault: depositVaultWithAave, owner }, 10); + + await depositInstantWithAaveTest( + { + depositVaultWithAave, + owner, + mTBILL, + mTokenToUsdDataFeed, + aavePoolMock, + customRecipient: regularAccounts[1], + }, + stableCoins.usdc, + 100, + { + from: regularAccounts[0], + }, + ); + }); + + it('deposit with custom recipient, aaveDepositsEnabled is false', async () => { + const { + owner, + depositVaultWithAave, + stableCoins, + mTBILL, + mTokenToUsdDataFeed, + regularAccounts, + dataFeed, + aavePoolMock, + } = await loadFixture(defaultDeploy); + + await mintToken(stableCoins.usdc, regularAccounts[0], 100); + await approveBase18( + regularAccounts[0], + stableCoins.usdc, + depositVaultWithAave, + 100, + ); + await addPaymentTokenTest( + { vault: depositVaultWithAave, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + await setMinAmountTest({ vault: depositVaultWithAave, owner }, 10); + + await depositInstantWithAaveTest( + { + depositVaultWithAave, + owner, + mTBILL, + mTokenToUsdDataFeed, + aavePoolMock, + expectedAaveDeposited: false, + customRecipient: regularAccounts[1], + }, + stableCoins.usdc, + 100, + { + from: regularAccounts[0], + }, + ); + }); + + it('should fail: greenlist enabled and user not in greenlist', async () => { + const { + owner, + depositVaultWithAave, + stableCoins, + mTBILL, + mTokenToUsdDataFeed, + regularAccounts, + dataFeed, + aavePoolMock, + } = await loadFixture(defaultDeploy); + + await depositVaultWithAave.setGreenlistEnable(true); + + await mintToken(stableCoins.usdc, regularAccounts[0], 100); + await approveBase18( + regularAccounts[0], + stableCoins.usdc, + depositVaultWithAave, + 100, + ); + await addPaymentTokenTest( + { vault: depositVaultWithAave, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + await setMinAmountTest({ vault: depositVaultWithAave, owner }, 10); + + await depositInstantWithAaveTest( + { + depositVaultWithAave, + owner, + mTBILL, + mTokenToUsdDataFeed, + aavePoolMock, + }, + stableCoins.usdc, + 100, + { + from: regularAccounts[0], + revertMessage: acErrors.WMAC_HASNT_ROLE, + }, + ); + }); + + it('should fail: first deposit mint amount below configured minimum', async () => { + const { + owner, + depositVaultWithAave, + stableCoins, + mTBILL, + mTokenToUsdDataFeed, + dataFeed, + aavePoolMock, + } = await loadFixture(defaultDeploy); + + await setAaveDepositsEnabledTest({ depositVaultWithAave, owner }, true); + await setMinAmountTest({ vault: depositVaultWithAave, owner }, 0); + await setMinAmountToDepositTest( + { depositVault: depositVaultWithAave, owner }, + 200, + ); + + await mintToken(stableCoins.usdc, owner, 100); + await approveBase18(owner, stableCoins.usdc, depositVaultWithAave, 100); + await addPaymentTokenTest( + { vault: depositVaultWithAave, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + + await depositInstantWithAaveTest( + { + depositVaultWithAave, + owner, + mTBILL, + mTokenToUsdDataFeed, + aavePoolMock, + }, + stableCoins.usdc, + 100, + { + revertMessage: 'DV: mint amount < min', + }, + ); + }); + + it('aaveDepositsEnabled but no pool for token: fallback to normal flow', async () => { + const { + owner, + depositVaultWithAave, + stableCoins, + mTBILL, + mTokenToUsdDataFeed, + dataFeed, + aavePoolMock, + regularAccounts, + } = await loadFixture(defaultDeploy); + + await setAaveDepositsEnabledTest({ depositVaultWithAave, owner }, true); + await setMinAmountTest({ vault: depositVaultWithAave, owner }, 10); + + await mintToken(stableCoins.dai, regularAccounts[0], 100); + await approveBase18( + regularAccounts[0], + stableCoins.dai, + depositVaultWithAave, + 100, + ); + await addPaymentTokenTest( + { vault: depositVaultWithAave, owner }, + stableCoins.dai, + dataFeed.address, + 0, + false, + ); + + await depositInstantWithAaveTest( + { + depositVaultWithAave, + owner, + mTBILL, + mTokenToUsdDataFeed, + aavePoolMock, + expectedAaveDeposited: false, + }, + stableCoins.dai, + 100, + { + from: regularAccounts[0], + }, + ); + }); + + it('aaveDepositsEnabled, pool configured but supply reverts, fallback enabled: fallback to normal flow', async () => { + const { + owner, + depositVaultWithAave, + stableCoins, + mTBILL, + mTokenToUsdDataFeed, + dataFeed, + aavePoolMock, + regularAccounts, + } = await loadFixture(defaultDeploy); + + await setAaveDepositsEnabledTest({ depositVaultWithAave, owner }, true); + await setAutoInvestFallbackEnabledAaveTest( + { depositVaultWithAave, owner }, + true, + ); + await setMinAmountTest({ vault: depositVaultWithAave, owner }, 10); + + await mintToken(stableCoins.usdc, regularAccounts[0], 100); + await approveBase18( + regularAccounts[0], + stableCoins.usdc, + depositVaultWithAave, + 100, + ); + await addPaymentTokenTest( + { vault: depositVaultWithAave, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + + await aavePoolMock.setShouldRevertSupply(true); + + await depositInstantWithAaveTest( + { + depositVaultWithAave, + owner, + mTBILL, + mTokenToUsdDataFeed, + aavePoolMock, + expectedAaveDeposited: false, + }, + stableCoins.usdc, + 100, + { + from: regularAccounts[0], + }, + ); + }); + + it('should fail: aaveDepositsEnabled, pool configured but supply reverts, fallback disabled', async () => { + const { + owner, + depositVaultWithAave, + stableCoins, + mTBILL, + mTokenToUsdDataFeed, + dataFeed, + aavePoolMock, + regularAccounts, + } = await loadFixture(defaultDeploy); + + await setAaveDepositsEnabledTest({ depositVaultWithAave, owner }, true); + await setMinAmountTest({ vault: depositVaultWithAave, owner }, 10); + + await mintToken(stableCoins.usdc, regularAccounts[0], 100); + await approveBase18( + regularAccounts[0], + stableCoins.usdc, + depositVaultWithAave, + 100, + ); + await addPaymentTokenTest( + { vault: depositVaultWithAave, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + + await aavePoolMock.setShouldRevertSupply(true); + + await depositInstantWithAaveTest( + { + depositVaultWithAave, + owner, + mTBILL, + mTokenToUsdDataFeed, + aavePoolMock, + expectedAaveDeposited: false, + }, + stableCoins.usdc, + 100, + { + from: regularAccounts[0], + revertMessage: 'DVA: auto-invest failed', + }, + ); + }); + + it('should fail: greenlist enabled and recipient not in greenlist (custom recipient overload)', async () => { + const { + owner, + depositVaultWithAave, + stableCoins, + mTBILL, + mTokenToUsdDataFeed, + greenListableTester, + accessControl, + customRecipient, + aavePoolMock, + } = await loadFixture(defaultDeploy); + + await greenList( + { greenlistable: greenListableTester, accessControl, owner }, + owner, + ); + + await depositVaultWithAave.setGreenlistEnable(true); + + await depositInstantWithAaveTest( + { + depositVaultWithAave, + owner, + mTBILL, + mTokenToUsdDataFeed, + aavePoolMock, + customRecipient, + }, + stableCoins.dai, + 1, + { + revertMessage: 'WMAC: hasnt role', + }, + ); + }); + + it('should fail: recipient in blacklist (custom recipient overload)', async () => { + const { + owner, + depositVaultWithAave, + stableCoins, + mTBILL, + mTokenToUsdDataFeed, + blackListableTester, + accessControl, + regularAccounts, + customRecipient, + aavePoolMock, + } = await loadFixture(defaultDeploy); + + await blackList( + { blacklistable: blackListableTester, accessControl, owner }, + customRecipient, + ); + + await depositInstantWithAaveTest( + { + depositVaultWithAave, + owner, + mTBILL, + mTokenToUsdDataFeed, + aavePoolMock, + customRecipient, + }, + stableCoins.dai, + 1, + { + from: regularAccounts[0], + revertMessage: acErrors.WMAC_HAS_ROLE, + }, + ); + }); + + it('should fail: recipient in sanctions list (custom recipient overload)', async () => { + const { + owner, + depositVaultWithAave, + stableCoins, + mTBILL, + mTokenToUsdDataFeed, + regularAccounts, + mockedSanctionsList, + customRecipient, + aavePoolMock, + } = await loadFixture(defaultDeploy); + + await sanctionUser( + { sanctionsList: mockedSanctionsList }, + customRecipient, + ); + + await depositInstantWithAaveTest( + { + depositVaultWithAave, + owner, + mTBILL, + mTokenToUsdDataFeed, + aavePoolMock, + customRecipient, + }, + stableCoins.dai, + 1, + { + from: regularAccounts[0], + revertMessage: 'WSL: sanctioned', + }, + ); + }); + + it('should fail: when function paused (custom recipient overload)', async () => { + const { + owner, + depositVaultWithAave, + stableCoins, + mTBILL, + dataFeed, + mTokenToUsdDataFeed, + regularAccounts, + customRecipient, + aavePoolMock, + } = await loadFixture(defaultDeploy); + await mintToken(stableCoins.dai, regularAccounts[0], 100); + await approveBase18( + regularAccounts[0], + stableCoins.dai, + depositVaultWithAave, + 100, + ); + await addPaymentTokenTest( + { vault: depositVaultWithAave, owner }, + stableCoins.dai, + dataFeed.address, + 0, + true, + ); + const selector = encodeFnSelector( + 'depositInstant(address,uint256,uint256,bytes32,address)', + ); + await pauseVaultFn(depositVaultWithAave, selector); + await depositInstantWithAaveTest( + { + depositVaultWithAave, + owner, + mTBILL, + mTokenToUsdDataFeed, + aavePoolMock, + customRecipient, + }, + stableCoins.dai, + 100, + { + from: regularAccounts[0], + revertMessage: 'Pausable: fn paused', + }, + ); + }); + }); + + describe('depositRequest()', () => { + it('deposit request 100 USDC', async () => { + const { + owner, + depositVaultWithAave, + stableCoins, + mTBILL, + mTokenToUsdDataFeed, + regularAccounts, + dataFeed, + } = await loadFixture(defaultDeploy); + + await mintToken(stableCoins.usdc, regularAccounts[0], 100); + await approveBase18( + regularAccounts[0], + stableCoins.usdc, + depositVaultWithAave, + 100, + ); + await addPaymentTokenTest( + { vault: depositVaultWithAave, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + await setMinAmountTest({ vault: depositVaultWithAave, owner }, 10); + + await depositRequestTest( + { + depositVault: depositVaultWithAave, + owner, + mTBILL, + mTokenToUsdDataFeed, + }, + stableCoins.usdc, + 100, + { + from: regularAccounts[0], + }, + ); + }); + + it('deposit request 100 USDC with aave auto-invest', async () => { + const { + owner, + depositVaultWithAave, + stableCoins, + mTBILL, + mTokenToUsdDataFeed, + regularAccounts, + dataFeed, + aavePoolMock, + } = await loadFixture(defaultDeploy); + + await setAaveDepositsEnabledTest({ depositVaultWithAave, owner }, true); + await setMinAmountTest({ vault: depositVaultWithAave, owner }, 10); + + await mintToken(stableCoins.usdc, regularAccounts[0], 100); + await approveBase18( + regularAccounts[0], + stableCoins.usdc, + depositVaultWithAave, + 100, + ); + await addPaymentTokenTest( + { vault: depositVaultWithAave, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + + await depositRequestWithAaveTest( + { + depositVaultWithAave, + owner, + mTBILL, + mTokenToUsdDataFeed, + aavePoolMock, + }, + stableCoins.usdc, + 100, + { + from: regularAccounts[0], + }, + ); + }); + + it('deposit request with aave auto-invest, supply reverts, fallback enabled: fallback to normal flow', async () => { + const { + owner, + depositVaultWithAave, + stableCoins, + mTBILL, + mTokenToUsdDataFeed, + regularAccounts, + dataFeed, + aavePoolMock, + } = await loadFixture(defaultDeploy); + + await setAaveDepositsEnabledTest({ depositVaultWithAave, owner }, true); + await setAutoInvestFallbackEnabledAaveTest( + { depositVaultWithAave, owner }, + true, + ); + await setMinAmountTest({ vault: depositVaultWithAave, owner }, 10); + + await mintToken(stableCoins.usdc, regularAccounts[0], 100); + await approveBase18( + regularAccounts[0], + stableCoins.usdc, + depositVaultWithAave, + 100, + ); + await addPaymentTokenTest( + { vault: depositVaultWithAave, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + + await aavePoolMock.setShouldRevertSupply(true); + + await depositRequestWithAaveTest( + { + depositVaultWithAave, + owner, + mTBILL, + mTokenToUsdDataFeed, + aavePoolMock, + expectedAaveDeposited: false, + }, + stableCoins.usdc, + 100, + { + from: regularAccounts[0], + }, + ); + }); + + it('should fail: deposit request with aave auto-invest, supply reverts, fallback disabled', async () => { + const { + owner, + depositVaultWithAave, + stableCoins, + mTBILL, + mTokenToUsdDataFeed, + regularAccounts, + dataFeed, + aavePoolMock, + } = await loadFixture(defaultDeploy); + + await setAaveDepositsEnabledTest({ depositVaultWithAave, owner }, true); + await setMinAmountTest({ vault: depositVaultWithAave, owner }, 10); + + await mintToken(stableCoins.usdc, regularAccounts[0], 100); + await approveBase18( + regularAccounts[0], + stableCoins.usdc, + depositVaultWithAave, + 100, + ); + await addPaymentTokenTest( + { vault: depositVaultWithAave, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + + await aavePoolMock.setShouldRevertSupply(true); + + await depositRequestWithAaveTest( + { + depositVaultWithAave, + owner, + mTBILL, + mTokenToUsdDataFeed, + aavePoolMock, + expectedAaveDeposited: false, + }, + stableCoins.usdc, + 100, + { + from: regularAccounts[0], + revertMessage: 'DVA: auto-invest failed', + }, + ); + }); + }); + + describe('approveRequest()', () => { + it('should fail: call from address without DEPOSIT_VAULT_ADMIN_ROLE role', async () => { + const { + owner, + regularAccounts, + depositVaultWithAave, + stableCoins, + mTBILL, + mTokenToUsdDataFeed, + dataFeed, + } = await loadFixture(defaultDeploy); + + await mintToken(stableCoins.usdc, owner, 100); + await approveBase18(owner, stableCoins.usdc, depositVaultWithAave, 100); + await addPaymentTokenTest( + { vault: depositVaultWithAave, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + await setMinAmountTest({ vault: depositVaultWithAave, owner }, 10); + + const request = await depositRequestTest( + { + depositVault: depositVaultWithAave, + owner, + mTBILL, + mTokenToUsdDataFeed, + }, + stableCoins.usdc, + 100, + ); + + await approveRequestTest( + { + depositVault: depositVaultWithAave, + owner, + mTBILL, + mTokenToUsdDataFeed, + }, + request.requestId!, + request.rate!, + { + from: regularAccounts[0], + revertMessage: 'WMAC: hasnt role', + }, + ); + }); + + it('approve request: happy path', async () => { + const { + owner, + depositVaultWithAave, + stableCoins, + mTBILL, + mTokenToUsdDataFeed, + dataFeed, + } = await loadFixture(defaultDeploy); + + await mintToken(stableCoins.usdc, owner, 100); + await approveBase18(owner, stableCoins.usdc, depositVaultWithAave, 100); + await addPaymentTokenTest( + { vault: depositVaultWithAave, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + await setMinAmountTest({ vault: depositVaultWithAave, owner }, 10); + + const request = await depositRequestTest( + { + depositVault: depositVaultWithAave, + owner, + mTBILL, + mTokenToUsdDataFeed, + }, + stableCoins.usdc, + 100, + ); + + await approveRequestTest( + { + depositVault: depositVaultWithAave, + owner, + mTBILL, + mTokenToUsdDataFeed, + }, + request.requestId!, + request.rate!, + ); + }); + }); + + describe('safeApproveRequest()', () => { + it('should fail: call from address without DEPOSIT_VAULT_ADMIN_ROLE role', async () => { + const { + owner, + regularAccounts, + depositVaultWithAave, + stableCoins, + mTBILL, + mTokenToUsdDataFeed, + dataFeed, + } = await loadFixture(defaultDeploy); + + await mintToken(stableCoins.usdc, owner, 100); + await approveBase18(owner, stableCoins.usdc, depositVaultWithAave, 100); + await addPaymentTokenTest( + { vault: depositVaultWithAave, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + await setMinAmountTest({ vault: depositVaultWithAave, owner }, 10); + + const request = await depositRequestTest( + { + depositVault: depositVaultWithAave, + owner, + mTBILL, + mTokenToUsdDataFeed, + }, + stableCoins.usdc, + 100, + ); + + await safeApproveRequestTest( + { + depositVault: depositVaultWithAave, + owner, + mTBILL, + mTokenToUsdDataFeed, + }, + request.requestId!, + request.rate!, + { + from: regularAccounts[0], + revertMessage: 'WMAC: hasnt role', + }, + ); + }); + + it('safe approve request: happy path', async () => { + const { + owner, + depositVaultWithAave, + stableCoins, + mTBILL, + mTokenToUsdDataFeed, + dataFeed, + } = await loadFixture(defaultDeploy); + + await mintToken(stableCoins.usdc, owner, 100); + await approveBase18(owner, stableCoins.usdc, depositVaultWithAave, 100); + await addPaymentTokenTest( + { vault: depositVaultWithAave, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + await setMinAmountTest({ vault: depositVaultWithAave, owner }, 10); + + const request = await depositRequestTest( + { + depositVault: depositVaultWithAave, + owner, + mTBILL, + mTokenToUsdDataFeed, + }, + stableCoins.usdc, + 100, + ); + + await safeApproveRequestTest( + { + depositVault: depositVaultWithAave, + owner, + mTBILL, + mTokenToUsdDataFeed, + }, + request.requestId!, + request.rate!, + ); + }); + }); + + describe('safeBulkApproveRequest()', () => { + it('should fail: call from address without DEPOSIT_VAULT_ADMIN_ROLE role', async () => { + const { + owner, + regularAccounts, + depositVaultWithAave, + stableCoins, + mTBILL, + mTokenToUsdDataFeed, + dataFeed, + } = await loadFixture(defaultDeploy); + + await mintToken(stableCoins.usdc, owner, 100); + await approveBase18(owner, stableCoins.usdc, depositVaultWithAave, 100); + await addPaymentTokenTest( + { vault: depositVaultWithAave, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + await setMinAmountTest({ vault: depositVaultWithAave, owner }, 10); + + const request = await depositRequestTest( + { + depositVault: depositVaultWithAave, + owner, + mTBILL, + mTokenToUsdDataFeed, + }, + stableCoins.usdc, + 100, + ); + + await safeBulkApproveRequestTest( + { + depositVault: depositVaultWithAave, + owner, + mTBILL, + mTokenToUsdDataFeed, + }, + [{ id: request.requestId! }], + request.rate!, + { + from: regularAccounts[0], + revertMessage: 'WMAC: hasnt role', + }, + ); + }); + + it('safe bulk approve request: happy path', async () => { + const { + owner, + depositVaultWithAave, + stableCoins, + mTBILL, + mTokenToUsdDataFeed, + dataFeed, + } = await loadFixture(defaultDeploy); + + await mintToken(stableCoins.usdc, owner, 100); + await approveBase18(owner, stableCoins.usdc, depositVaultWithAave, 100); + await addPaymentTokenTest( + { vault: depositVaultWithAave, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + await setMinAmountTest({ vault: depositVaultWithAave, owner }, 10); + + const request = await depositRequestTest( + { + depositVault: depositVaultWithAave, + owner, + mTBILL, + mTokenToUsdDataFeed, + }, + stableCoins.usdc, + 100, + ); + + await safeBulkApproveRequestTest( + { + depositVault: depositVaultWithAave, + owner, + mTBILL, + mTokenToUsdDataFeed, + }, + [{ id: request.requestId! }], + request.rate!, + ); + }); + }); + + describe('rejectRequest()', () => { + it('should fail: call from address without DEPOSIT_VAULT_ADMIN_ROLE role', async () => { + const { + owner, + regularAccounts, + depositVaultWithAave, + stableCoins, + mTBILL, + mTokenToUsdDataFeed, + dataFeed, + } = await loadFixture(defaultDeploy); + + await mintToken(stableCoins.usdc, owner, 100); + await approveBase18(owner, stableCoins.usdc, depositVaultWithAave, 100); + await addPaymentTokenTest( + { vault: depositVaultWithAave, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + await setMinAmountTest({ vault: depositVaultWithAave, owner }, 10); + + const request = await depositRequestTest( + { + depositVault: depositVaultWithAave, + owner, + mTBILL, + mTokenToUsdDataFeed, + }, + stableCoins.usdc, + 100, + ); + + await rejectRequestTest( + { + depositVault: depositVaultWithAave, + owner, + mTBILL, + mTokenToUsdDataFeed, + }, + request.requestId!, + { + from: regularAccounts[0], + revertMessage: 'WMAC: hasnt role', + }, + ); + }); + + it('reject request: happy path', async () => { + const { + owner, + depositVaultWithAave, + stableCoins, + mTBILL, + mTokenToUsdDataFeed, + dataFeed, + } = await loadFixture(defaultDeploy); + + await mintToken(stableCoins.usdc, owner, 100); + await approveBase18(owner, stableCoins.usdc, depositVaultWithAave, 100); + await addPaymentTokenTest( + { vault: depositVaultWithAave, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + await setMinAmountTest({ vault: depositVaultWithAave, owner }, 10); + + const request = await depositRequestTest( + { + depositVault: depositVaultWithAave, + owner, + mTBILL, + mTokenToUsdDataFeed, + }, + stableCoins.usdc, + 100, + ); + + await rejectRequestTest( + { + depositVault: depositVaultWithAave, + owner, + mTBILL, + mTokenToUsdDataFeed, + }, + request.requestId!, + ); + }); + }); +}); diff --git a/test/unit/DepositVaultWithMToken.test.ts b/test/unit/DepositVaultWithMToken.test.ts new file mode 100644 index 00000000..788e6f4a --- /dev/null +++ b/test/unit/DepositVaultWithMToken.test.ts @@ -0,0 +1,2648 @@ +import { loadFixture } from '@nomicfoundation/hardhat-network-helpers'; +import { expect } from 'chai'; +import { constants } from 'ethers'; +import { parseUnits } from 'ethers/lib/utils'; +import { ethers } from 'hardhat'; + +import { encodeFnSelector } from '../../helpers/utils'; +import { ManageableVaultTester__factory } from '../../typechain-types'; +import { acErrors, blackList, greenList } from '../common/ac.helpers'; +import { + approveBase18, + mintToken, + pauseVault, + pauseVaultFn, +} from '../common/common.helpers'; +import { setRoundData } from '../common/data-feed.helpers'; +import { + depositInstantWithMTokenTest, + depositRequestWithMTokenTest, + setAutoInvestFallbackEnabledMTokenTest, + setMTokenDepositsEnabledTest, + setMTokenDepositVaultTest, +} from '../common/deposit-vault-mtoken.helpers'; +import { + approveRequestTest, + depositRequestTest, + rejectRequestTest, + safeApproveRequestTest, + safeBulkApproveRequestTest, +} from '../common/deposit-vault.helpers'; +import { defaultDeploy } from '../common/fixtures'; +import { + addPaymentTokenTest, + addWaivedFeeAccountTest, + changeTokenAllowanceTest, + removePaymentTokenTest, + removeWaivedFeeAccountTest, + setInstantFeeTest, + setInstantDailyLimitTest, + setMinAmountToDepositTest, + setMinAmountTest, + setVariabilityToleranceTest, + withdrawTest, + changeTokenFeeTest, +} from '../common/manageable-vault.helpers'; +import { sanctionUser } from '../common/with-sanctions-list.helpers'; + +describe('DepositVaultWithMToken', function () { + it('deployment', async () => { + const { + depositVaultWithMToken, + depositVault, + mTBILL, + tokensReceiver, + feeReceiver, + mTokenToUsdDataFeed, + roles, + } = await loadFixture(defaultDeploy); + + expect(await depositVaultWithMToken.mToken()).eq(mTBILL.address); + expect(await depositVaultWithMToken.paused()).eq(false); + expect(await depositVaultWithMToken.tokensReceiver()).eq( + tokensReceiver.address, + ); + expect(await depositVaultWithMToken.feeReceiver()).eq(feeReceiver.address); + expect(await depositVaultWithMToken.ONE_HUNDRED_PERCENT()).eq('10000'); + expect(await depositVaultWithMToken.minMTokenAmountForFirstDeposit()).eq( + '0', + ); + expect(await depositVaultWithMToken.minAmount()).eq(parseUnits('100')); + expect(await depositVaultWithMToken.instantFee()).eq('100'); + expect(await depositVaultWithMToken.instantDailyLimit()).eq( + parseUnits('100000'), + ); + expect(await depositVaultWithMToken.mTokenDataFeed()).eq( + mTokenToUsdDataFeed.address, + ); + expect(await depositVaultWithMToken.variationTolerance()).eq(1); + expect(await depositVaultWithMToken.vaultRole()).eq( + roles.tokenRoles.mTBILL.depositVaultAdmin, + ); + expect(await depositVaultWithMToken.MANUAL_FULLFILMENT_TOKEN()).eq( + ethers.constants.AddressZero, + ); + expect(await depositVaultWithMToken.mTokenDepositVault()).eq( + depositVault.address, + ); + expect(await depositVaultWithMToken.mTokenDepositsEnabled()).eq(false); + }); + + describe('initialization', () => { + it('should fail: call initialize() when already initialized', async () => { + const { depositVaultWithMToken } = await loadFixture(defaultDeploy); + + await expect( + depositVaultWithMToken[ + 'initialize(address,(address,address),(address,address),(uint256,uint256),address,uint256,uint256,uint256,uint256,address)' + ]( + constants.AddressZero, + { + mToken: constants.AddressZero, + mTokenDataFeed: constants.AddressZero, + }, + { + feeReceiver: constants.AddressZero, + tokensReceiver: constants.AddressZero, + }, + { + instantFee: 0, + instantDailyLimit: 0, + }, + constants.AddressZero, + 0, + 0, + 0, + 0, + constants.AddressZero, + ), + ).revertedWith('Initializable: contract is already initialized'); + }); + + it('should fail: call with initializing == false', async () => { + const { + owner, + accessControl, + mTBILL, + tokensReceiver, + feeReceiver, + mTokenToUsdDataFeed, + mockedSanctionsList, + } = await loadFixture(defaultDeploy); + + const vault = await new ManageableVaultTester__factory(owner).deploy(); + + await expect( + vault.initializeWithoutInitializer( + accessControl.address, + { + mToken: mTBILL.address, + mTokenDataFeed: mTokenToUsdDataFeed.address, + }, + { + feeReceiver: feeReceiver.address, + tokensReceiver: tokensReceiver.address, + }, + { + instantFee: 100, + instantDailyLimit: parseUnits('100000'), + }, + mockedSanctionsList.address, + 1, + parseUnits('100'), + ), + ).revertedWith('Initializable: contract is not initializing'); + }); + }); + + describe('setMTokenDepositVault()', () => { + it('should fail: call from address without DEPOSIT_VAULT_ADMIN_ROLE role', async () => { + const { depositVaultWithMToken, owner, regularAccounts, depositVault } = + await loadFixture(defaultDeploy); + + await setMTokenDepositVaultTest( + { depositVaultWithMToken, owner }, + depositVault.address, + { + from: regularAccounts[0], + revertMessage: 'WMAC: hasnt role', + }, + ); + }); + + it('should fail: zero address', async () => { + const { depositVaultWithMToken, owner } = await loadFixture( + defaultDeploy, + ); + + await setMTokenDepositVaultTest( + { depositVaultWithMToken, owner }, + ethers.constants.AddressZero, + { + revertMessage: 'zero address', + }, + ); + }); + + it('should fail: already set to same address', async () => { + const { depositVaultWithMToken, owner, depositVault } = await loadFixture( + defaultDeploy, + ); + + await setMTokenDepositVaultTest( + { depositVaultWithMToken, owner }, + depositVault.address, + { + revertMessage: 'DVMT: already set', + }, + ); + }); + + it('call from address with DEPOSIT_VAULT_ADMIN_ROLE role', async () => { + const { depositVaultWithMToken, owner, regularAccounts } = + await loadFixture(defaultDeploy); + + await setMTokenDepositVaultTest( + { depositVaultWithMToken, owner }, + regularAccounts[1].address, + ); + }); + }); + + describe('setMTokenDepositsEnabled()', () => { + it('should fail: call from address without DEPOSIT_VAULT_ADMIN_ROLE role', async () => { + const { depositVaultWithMToken, owner, regularAccounts } = + await loadFixture(defaultDeploy); + + await setMTokenDepositsEnabledTest( + { depositVaultWithMToken, owner }, + true, + { + from: regularAccounts[0], + revertMessage: 'WMAC: hasnt role', + }, + ); + }); + + it('call from address with DEPOSIT_VAULT_ADMIN_ROLE role', async () => { + const { depositVaultWithMToken, owner } = await loadFixture( + defaultDeploy, + ); + + await setMTokenDepositsEnabledTest( + { depositVaultWithMToken, owner }, + true, + ); + }); + + it('toggle on and off', async () => { + const { depositVaultWithMToken, owner } = await loadFixture( + defaultDeploy, + ); + + await setMTokenDepositsEnabledTest( + { depositVaultWithMToken, owner }, + true, + ); + + await setMTokenDepositsEnabledTest( + { depositVaultWithMToken, owner }, + false, + ); + }); + }); + + describe('setAutoInvestFallbackEnabled()', () => { + it('should fail: call from address without DEPOSIT_VAULT_ADMIN_ROLE role', async () => { + const { depositVaultWithMToken, owner, regularAccounts } = + await loadFixture(defaultDeploy); + + await setAutoInvestFallbackEnabledMTokenTest( + { depositVaultWithMToken, owner }, + true, + { + from: regularAccounts[0], + revertMessage: 'WMAC: hasnt role', + }, + ); + }); + + it('call from address with DEPOSIT_VAULT_ADMIN_ROLE role', async () => { + const { depositVaultWithMToken, owner } = await loadFixture( + defaultDeploy, + ); + + await setAutoInvestFallbackEnabledMTokenTest( + { depositVaultWithMToken, owner }, + true, + ); + }); + + it('toggle on and off', async () => { + const { depositVaultWithMToken, owner } = await loadFixture( + defaultDeploy, + ); + + await setAutoInvestFallbackEnabledMTokenTest( + { depositVaultWithMToken, owner }, + true, + ); + + await setAutoInvestFallbackEnabledMTokenTest( + { depositVaultWithMToken, owner }, + false, + ); + }); + }); + + describe('setMinAmount()', () => { + it('should fail: call from address without DEPOSIT_VAULT_ADMIN_ROLE role', async () => { + const { depositVaultWithMToken, regularAccounts, owner } = + await loadFixture(defaultDeploy); + await setMinAmountTest({ vault: depositVaultWithMToken, owner }, 10, { + from: regularAccounts[0], + revertMessage: 'WMAC: hasnt role', + }); + }); + + it('call from address with DEPOSIT_VAULT_ADMIN_ROLE role', async () => { + const { depositVaultWithMToken, owner } = await loadFixture( + defaultDeploy, + ); + await setMinAmountTest({ vault: depositVaultWithMToken, owner }, 10); + }); + }); + + describe('setMinMTokenAmountForFirstDeposit()', () => { + it('should fail: call from address without DEPOSIT_VAULT_ADMIN_ROLE role', async () => { + const { depositVaultWithMToken, regularAccounts, owner } = + await loadFixture(defaultDeploy); + + await setMinAmountToDepositTest( + { depositVault: depositVaultWithMToken, owner }, + 10, + { + from: regularAccounts[0], + revertMessage: 'WMAC: hasnt role', + }, + ); + }); + + it('call from address with DEPOSIT_VAULT_ADMIN_ROLE role', async () => { + const { depositVaultWithMToken, owner } = await loadFixture( + defaultDeploy, + ); + + await setMinAmountToDepositTest( + { depositVault: depositVaultWithMToken, owner }, + 10, + ); + }); + }); + + describe('setVariabilityTolerance()', () => { + it('should fail: call from address without DEPOSIT_VAULT_ADMIN_ROLE role', async () => { + const { depositVaultWithMToken, regularAccounts, owner } = + await loadFixture(defaultDeploy); + + await setVariabilityToleranceTest( + { vault: depositVaultWithMToken, owner }, + 100, + { + from: regularAccounts[0], + revertMessage: 'WMAC: hasnt role', + }, + ); + }); + + it('call from address with DEPOSIT_VAULT_ADMIN_ROLE role', async () => { + const { depositVaultWithMToken, owner } = await loadFixture( + defaultDeploy, + ); + + await setVariabilityToleranceTest( + { vault: depositVaultWithMToken, owner }, + 100, + ); + }); + }); + + describe('setInstantDailyLimit()', () => { + it('should fail: call from address without DEPOSIT_VAULT_ADMIN_ROLE role', async () => { + const { depositVaultWithMToken, regularAccounts, owner } = + await loadFixture(defaultDeploy); + await setInstantDailyLimitTest( + { vault: depositVaultWithMToken, owner }, + 10, + { from: regularAccounts[0], revertMessage: 'WMAC: hasnt role' }, + ); + }); + + it('call from address with DEPOSIT_VAULT_ADMIN_ROLE role', async () => { + const { depositVaultWithMToken, owner } = await loadFixture( + defaultDeploy, + ); + await setInstantDailyLimitTest( + { vault: depositVaultWithMToken, owner }, + 10, + ); + }); + }); + + describe('addPaymentToken()', () => { + it('should fail: call from address without DEPOSIT_VAULT_ADMIN_ROLE role', async () => { + const { + depositVaultWithMToken, + regularAccounts, + owner, + stableCoins, + dataFeed, + } = await loadFixture(defaultDeploy); + await addPaymentTokenTest( + { vault: depositVaultWithMToken, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + constants.MaxUint256, + { from: regularAccounts[0], revertMessage: 'WMAC: hasnt role' }, + ); + }); + + it('call from address with DEPOSIT_VAULT_ADMIN_ROLE role', async () => { + const { depositVaultWithMToken, owner, stableCoins, dataFeed } = + await loadFixture(defaultDeploy); + await addPaymentTokenTest( + { vault: depositVaultWithMToken, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + }); + }); + + describe('removePaymentToken()', () => { + it('should fail: call from address without DEPOSIT_VAULT_ADMIN_ROLE role', async () => { + const { depositVaultWithMToken, regularAccounts, owner, stableCoins } = + await loadFixture(defaultDeploy); + await removePaymentTokenTest( + { vault: depositVaultWithMToken, owner }, + stableCoins.usdc, + { from: regularAccounts[0], revertMessage: 'WMAC: hasnt role' }, + ); + }); + + it('call from address with DEPOSIT_VAULT_ADMIN_ROLE role', async () => { + const { depositVaultWithMToken, owner, stableCoins, dataFeed } = + await loadFixture(defaultDeploy); + await addPaymentTokenTest( + { vault: depositVaultWithMToken, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + await removePaymentTokenTest( + { vault: depositVaultWithMToken, owner }, + stableCoins.usdc, + ); + }); + }); + + describe('addWaivedFeeAccount()', () => { + it('should fail: call from address without DEPOSIT_VAULT_ADMIN_ROLE role', async () => { + const { depositVaultWithMToken, regularAccounts, owner } = + await loadFixture(defaultDeploy); + await addWaivedFeeAccountTest( + { vault: depositVaultWithMToken, owner }, + regularAccounts[0].address, + { from: regularAccounts[0], revertMessage: 'WMAC: hasnt role' }, + ); + }); + + it('call from address with DEPOSIT_VAULT_ADMIN_ROLE role', async () => { + const { depositVaultWithMToken, regularAccounts, owner } = + await loadFixture(defaultDeploy); + await addWaivedFeeAccountTest( + { vault: depositVaultWithMToken, owner }, + regularAccounts[0].address, + ); + }); + }); + + describe('removeWaivedFeeAccount()', () => { + it('should fail: call from address without DEPOSIT_VAULT_ADMIN_ROLE role', async () => { + const { depositVaultWithMToken, regularAccounts, owner } = + await loadFixture(defaultDeploy); + await removeWaivedFeeAccountTest( + { vault: depositVaultWithMToken, owner }, + regularAccounts[0].address, + { from: regularAccounts[0], revertMessage: 'WMAC: hasnt role' }, + ); + }); + + it('call from address with DEPOSIT_VAULT_ADMIN_ROLE role', async () => { + const { depositVaultWithMToken, regularAccounts, owner } = + await loadFixture(defaultDeploy); + await addWaivedFeeAccountTest( + { vault: depositVaultWithMToken, owner }, + regularAccounts[0].address, + ); + await removeWaivedFeeAccountTest( + { vault: depositVaultWithMToken, owner }, + regularAccounts[0].address, + ); + }); + }); + + describe('setFee()', () => { + it('should fail: call from address without DEPOSIT_VAULT_ADMIN_ROLE role', async () => { + const { depositVaultWithMToken, regularAccounts, owner } = + await loadFixture(defaultDeploy); + await setInstantFeeTest({ vault: depositVaultWithMToken, owner }, 100, { + from: regularAccounts[0], + revertMessage: 'WMAC: hasnt role', + }); + }); + + it('call from address with DEPOSIT_VAULT_ADMIN_ROLE role', async () => { + const { depositVaultWithMToken, owner } = await loadFixture( + defaultDeploy, + ); + await setInstantFeeTest({ vault: depositVaultWithMToken, owner }, 100); + }); + }); + + describe('withdrawToken()', () => { + it('should fail: call from address without DEPOSIT_VAULT_ADMIN_ROLE role', async () => { + const { depositVaultWithMToken, regularAccounts, owner, stableCoins } = + await loadFixture(defaultDeploy); + await withdrawTest( + { vault: depositVaultWithMToken, owner }, + stableCoins.usdc.address, + 0, + owner, + { from: regularAccounts[0], revertMessage: 'WMAC: hasnt role' }, + ); + }); + + it('call from address with DEPOSIT_VAULT_ADMIN_ROLE role', async () => { + const { depositVaultWithMToken, owner, stableCoins } = await loadFixture( + defaultDeploy, + ); + await mintToken(stableCoins.usdc, depositVaultWithMToken, 1); + await withdrawTest( + { vault: depositVaultWithMToken, owner }, + stableCoins.usdc, + 1, + owner, + ); + }); + }); + + describe('changeTokenAllowance()', () => { + it('should fail: call from address without DEPOSIT_VAULT_ADMIN_ROLE role', async () => { + const { depositVaultWithMToken, regularAccounts, owner, stableCoins } = + await loadFixture(defaultDeploy); + await changeTokenAllowanceTest( + { vault: depositVaultWithMToken, owner }, + stableCoins.usdc.address, + 100, + { from: regularAccounts[0], revertMessage: 'WMAC: hasnt role' }, + ); + }); + + it('call from address with DEPOSIT_VAULT_ADMIN_ROLE role', async () => { + const { depositVaultWithMToken, owner, stableCoins, dataFeed } = + await loadFixture(defaultDeploy); + await addPaymentTokenTest( + { vault: depositVaultWithMToken, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + await changeTokenAllowanceTest( + { vault: depositVaultWithMToken, owner }, + stableCoins.usdc.address, + parseUnits('200'), + ); + }); + }); + + describe('changeTokenFee()', () => { + it('should fail: call from address without DEPOSIT_VAULT_ADMIN_ROLE role', async () => { + const { depositVaultWithMToken, regularAccounts, owner, stableCoins } = + await loadFixture(defaultDeploy); + await changeTokenFeeTest( + { vault: depositVaultWithMToken, owner }, + stableCoins.usdc.address, + 100, + { from: regularAccounts[0], revertMessage: 'WMAC: hasnt role' }, + ); + }); + + it('call from address with DEPOSIT_VAULT_ADMIN_ROLE role', async () => { + const { depositVaultWithMToken, owner, stableCoins, dataFeed } = + await loadFixture(defaultDeploy); + await addPaymentTokenTest( + { vault: depositVaultWithMToken, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + await changeTokenFeeTest( + { vault: depositVaultWithMToken, owner }, + stableCoins.usdc.address, + 100, + ); + }); + }); + + describe('freeFromMinAmount()', async () => { + it('should fail: call from address without vault admin role', async () => { + const { depositVaultWithMToken, regularAccounts } = await loadFixture( + defaultDeploy, + ); + await expect( + depositVaultWithMToken + .connect(regularAccounts[0]) + .freeFromMinAmount(regularAccounts[1].address, true), + ).to.be.revertedWith('WMAC: hasnt role'); + }); + it('should not fail', async () => { + const { depositVaultWithMToken, regularAccounts } = await loadFixture( + defaultDeploy, + ); + await expect( + depositVaultWithMToken.freeFromMinAmount( + regularAccounts[0].address, + true, + ), + ).to.not.reverted; + + expect( + await depositVaultWithMToken.isFreeFromMinAmount( + regularAccounts[0].address, + ), + ).to.eq(true); + }); + it('should fail: already in list', async () => { + const { depositVaultWithMToken, regularAccounts } = await loadFixture( + defaultDeploy, + ); + await expect( + depositVaultWithMToken.freeFromMinAmount( + regularAccounts[0].address, + true, + ), + ).to.not.reverted; + + expect( + await depositVaultWithMToken.isFreeFromMinAmount( + regularAccounts[0].address, + ), + ).to.eq(true); + + await expect( + depositVaultWithMToken.freeFromMinAmount( + regularAccounts[0].address, + true, + ), + ).to.revertedWith('DV: already free'); + }); + }); + + describe('depositInstant()', async () => { + it('should fail: when there is no token in vault', async () => { + const { + owner, + depositVaultWithMToken, + stableCoins, + mTBILL, + mTokenToUsdDataFeed, + regularAccounts, + } = await loadFixture(defaultDeploy); + + await mintToken(stableCoins.usdc, regularAccounts[0], 100); + await approveBase18( + regularAccounts[0], + stableCoins.usdc, + depositVaultWithMToken, + 100, + ); + + await depositInstantWithMTokenTest( + { + depositVaultWithMToken, + owner, + mTBILL, + mTokenToUsdDataFeed, + }, + stableCoins.usdc, + 100, + { + from: regularAccounts[0], + revertMessage: 'MV: token not exists', + }, + ); + }); + + it('should fail: when function paused', async () => { + const { + owner, + depositVaultWithMToken, + stableCoins, + mTBILL, + mTokenToUsdDataFeed, + regularAccounts, + dataFeed, + } = await loadFixture(defaultDeploy); + + await mintToken(stableCoins.usdc, regularAccounts[0], 100); + await approveBase18( + regularAccounts[0], + stableCoins.usdc, + depositVaultWithMToken, + 100, + ); + await addPaymentTokenTest( + { vault: depositVaultWithMToken, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + await setMinAmountTest({ vault: depositVaultWithMToken, owner }, 10); + + await pauseVault(depositVaultWithMToken, { + from: owner, + }); + + await depositInstantWithMTokenTest( + { + depositVaultWithMToken, + owner, + mTBILL, + mTokenToUsdDataFeed, + }, + stableCoins.usdc, + 100, + { + from: regularAccounts[0], + revertMessage: 'Pausable: paused', + }, + ); + }); + + it('should fail: user in blacklist', async () => { + const { + owner, + depositVaultWithMToken, + stableCoins, + mTBILL, + mTokenToUsdDataFeed, + regularAccounts, + dataFeed, + accessControl, + } = await loadFixture(defaultDeploy); + + await mintToken(stableCoins.usdc, regularAccounts[0], 100); + await approveBase18( + regularAccounts[0], + stableCoins.usdc, + depositVaultWithMToken, + 100, + ); + await addPaymentTokenTest( + { vault: depositVaultWithMToken, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + await setMinAmountTest({ vault: depositVaultWithMToken, owner }, 10); + + await blackList( + { blacklistable: depositVaultWithMToken, accessControl, owner }, + regularAccounts[0], + ); + + await depositInstantWithMTokenTest( + { + depositVaultWithMToken, + owner, + mTBILL, + mTokenToUsdDataFeed, + }, + stableCoins.usdc, + 100, + { + from: regularAccounts[0], + revertMessage: acErrors.WMAC_HAS_ROLE, + }, + ); + }); + + it('should fail: user in sanctions list', async () => { + const { + owner, + depositVaultWithMToken, + stableCoins, + mTBILL, + mTokenToUsdDataFeed, + regularAccounts, + dataFeed, + mockedSanctionsList, + } = await loadFixture(defaultDeploy); + + await mintToken(stableCoins.usdc, regularAccounts[0], 100); + await approveBase18( + regularAccounts[0], + stableCoins.usdc, + depositVaultWithMToken, + 100, + ); + await addPaymentTokenTest( + { vault: depositVaultWithMToken, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + await setMinAmountTest({ vault: depositVaultWithMToken, owner }, 10); + + await sanctionUser( + { sanctionsList: mockedSanctionsList }, + regularAccounts[0], + ); + + await depositInstantWithMTokenTest( + { + depositVaultWithMToken, + owner, + mTBILL, + mTokenToUsdDataFeed, + }, + stableCoins.usdc, + 100, + { + from: regularAccounts[0], + revertMessage: 'WSL: sanctioned', + }, + ); + }); + + it('should fail: when trying to deposit 0 amount', async () => { + const { + owner, + depositVaultWithMToken, + stableCoins, + mTBILL, + dataFeed, + mTokenToUsdDataFeed, + } = await loadFixture(defaultDeploy); + await addPaymentTokenTest( + { vault: depositVaultWithMToken, owner }, + stableCoins.dai, + dataFeed.address, + 0, + true, + ); + await depositInstantWithMTokenTest( + { + depositVaultWithMToken, + owner, + mTBILL, + mTokenToUsdDataFeed, + }, + stableCoins.dai, + 0, + { + revertMessage: 'DV: invalid amount', + }, + ); + }); + + it('should fail: when rounding is invalid', async () => { + const { + owner, + depositVaultWithMToken, + stableCoins, + mTBILL, + dataFeed, + mTokenToUsdDataFeed, + } = await loadFixture(defaultDeploy); + + await addPaymentTokenTest( + { vault: depositVaultWithMToken, owner }, + stableCoins.dai, + dataFeed.address, + 0, + true, + ); + await setMinAmountTest({ vault: depositVaultWithMToken, owner }, 10); + await depositInstantWithMTokenTest( + { + depositVaultWithMToken, + owner, + mTBILL, + mTokenToUsdDataFeed, + }, + stableCoins.dai, + 100.0000000001, + { + revertMessage: 'MV: invalid rounding', + }, + ); + }); + + it('should fail: call with insufficient allowance', async () => { + const { + owner, + depositVaultWithMToken, + stableCoins, + mTBILL, + dataFeed, + mTokenToUsdDataFeed, + } = await loadFixture(defaultDeploy); + + await mintToken(stableCoins.dai, owner, 100); + await addPaymentTokenTest( + { vault: depositVaultWithMToken, owner }, + stableCoins.dai, + dataFeed.address, + 0, + true, + ); + await setMinAmountTest({ vault: depositVaultWithMToken, owner }, 10); + await depositInstantWithMTokenTest( + { + depositVaultWithMToken, + owner, + mTBILL, + mTokenToUsdDataFeed, + }, + stableCoins.dai, + 100, + { + revertMessage: 'ERC20: insufficient allowance', + }, + ); + }); + + it('should fail: call with insufficient balance', async () => { + const { + owner, + depositVaultWithMToken, + stableCoins, + mTBILL, + dataFeed, + mTokenToUsdDataFeed, + } = await loadFixture(defaultDeploy); + + await approveBase18(owner, stableCoins.dai, depositVaultWithMToken, 100); + await addPaymentTokenTest( + { vault: depositVaultWithMToken, owner }, + stableCoins.dai, + dataFeed.address, + 0, + true, + ); + await setMinAmountTest({ vault: depositVaultWithMToken, owner }, 10); + await depositInstantWithMTokenTest( + { + depositVaultWithMToken, + owner, + mTBILL, + mTokenToUsdDataFeed, + }, + stableCoins.dai, + 100, + { + revertMessage: 'ERC20: transfer amount exceeds balance', + }, + ); + }); + + it('should fail: dataFeed rate 0', async () => { + const { + owner, + depositVaultWithMToken, + stableCoins, + mTBILL, + dataFeed, + mockedAggregator, + mTokenToUsdDataFeed, + } = await loadFixture(defaultDeploy); + + await approveBase18(owner, stableCoins.dai, depositVaultWithMToken, 10); + await addPaymentTokenTest( + { vault: depositVaultWithMToken, owner }, + stableCoins.dai, + dataFeed.address, + 0, + true, + ); + await mintToken(stableCoins.dai, owner, 100_000); + await setRoundData({ mockedAggregator }, 0); + await depositInstantWithMTokenTest( + { + depositVaultWithMToken, + owner, + mTBILL, + mTokenToUsdDataFeed, + }, + stableCoins.dai, + 1, + { + revertMessage: 'DF: feed is deprecated', + }, + ); + }); + + it('should fail: call for amount < minAmountToDepositTest', async () => { + const { + depositVaultWithMToken, + mockedAggregator, + owner, + mTBILL, + stableCoins, + dataFeed, + mTokenToUsdDataFeed, + } = await loadFixture(defaultDeploy); + await addPaymentTokenTest( + { vault: depositVaultWithMToken, owner }, + stableCoins.dai, + dataFeed.address, + 0, + true, + ); + await setRoundData({ mockedAggregator }, 1); + + await mintToken(stableCoins.dai, owner, 100_000); + await approveBase18( + owner, + stableCoins.dai, + depositVaultWithMToken, + 100_000, + ); + + await setMinAmountToDepositTest( + { depositVault: depositVaultWithMToken, owner }, + 100_000, + ); + await setInstantDailyLimitTest( + { vault: depositVaultWithMToken, owner }, + 150_000, + ); + + await depositInstantWithMTokenTest( + { + depositVaultWithMToken, + owner, + mTBILL, + mTokenToUsdDataFeed, + }, + stableCoins.dai, + 99_999, + { + revertMessage: 'DV: mint amount < min', + }, + ); + }); + + it('should fail: call for amount < minAmount', async () => { + const { + depositVaultWithMToken, + mockedAggregator, + owner, + mTBILL, + stableCoins, + dataFeed, + mTokenToUsdDataFeed, + } = await loadFixture(defaultDeploy); + await addPaymentTokenTest( + { vault: depositVaultWithMToken, owner }, + stableCoins.dai, + dataFeed.address, + 0, + true, + ); + await setRoundData({ mockedAggregator }, 1); + + await mintToken(stableCoins.dai, owner, 100_000); + await approveBase18( + owner, + stableCoins.dai, + depositVaultWithMToken, + 100_000, + ); + + await setMinAmountToDepositTest( + { depositVault: depositVaultWithMToken, owner }, + 100_000, + ); + await setInstantDailyLimitTest( + { vault: depositVaultWithMToken, owner }, + 150_000, + ); + + await depositInstantWithMTokenTest( + { + depositVaultWithMToken, + owner, + mTBILL, + mTokenToUsdDataFeed, + }, + stableCoins.dai, + 99, + { + revertMessage: 'DV: mToken amount < min', + }, + ); + }); + + it('should fail: if exceed allowance of deposit for token', async () => { + const { + depositVaultWithMToken, + mockedAggregator, + owner, + mTBILL, + stableCoins, + dataFeed, + mTokenToUsdDataFeed, + } = await loadFixture(defaultDeploy); + await addPaymentTokenTest( + { vault: depositVaultWithMToken, owner }, + stableCoins.dai, + dataFeed.address, + 0, + true, + ); + await setRoundData({ mockedAggregator }, 4); + + await mintToken(stableCoins.dai, owner, 100_000); + await changeTokenAllowanceTest( + { vault: depositVaultWithMToken, owner }, + stableCoins.dai.address, + 100, + ); + await approveBase18( + owner, + stableCoins.dai, + depositVaultWithMToken, + 100_000, + ); + + await depositInstantWithMTokenTest( + { + depositVaultWithMToken, + owner, + mTBILL, + mTokenToUsdDataFeed, + }, + stableCoins.dai, + 99_999, + { + revertMessage: 'MV: exceed allowance', + }, + ); + }); + + it('should fail: if mint limit exceeded', async () => { + const { + depositVaultWithMToken, + mockedAggregator, + owner, + mTBILL, + stableCoins, + dataFeed, + mTokenToUsdDataFeed, + } = await loadFixture(defaultDeploy); + await addPaymentTokenTest( + { vault: depositVaultWithMToken, owner }, + stableCoins.dai, + dataFeed.address, + 0, + true, + ); + await setRoundData({ mockedAggregator }, 4); + + await mintToken(stableCoins.dai, owner, 100_000); + await setInstantDailyLimitTest( + { vault: depositVaultWithMToken, owner }, + 1000, + ); + + await approveBase18( + owner, + stableCoins.dai, + depositVaultWithMToken, + 100_000, + ); + + await depositInstantWithMTokenTest( + { + depositVaultWithMToken, + owner, + mTBILL, + mTokenToUsdDataFeed, + }, + stableCoins.dai, + 99_999, + { + revertMessage: 'MV: exceed limit', + }, + ); + }); + + it('should fail: if min receive amount greater then actual', async () => { + const { + depositVaultWithMToken, + mockedAggregator, + owner, + mTBILL, + stableCoins, + dataFeed, + mTokenToUsdDataFeed, + } = await loadFixture(defaultDeploy); + await addPaymentTokenTest( + { vault: depositVaultWithMToken, owner }, + stableCoins.dai, + dataFeed.address, + 0, + true, + ); + await setRoundData({ mockedAggregator }, 4); + + await mintToken(stableCoins.dai, owner, 100_000); + + await approveBase18( + owner, + stableCoins.dai, + depositVaultWithMToken, + 100_000, + ); + + await depositInstantWithMTokenTest( + { + depositVaultWithMToken, + owner, + mTBILL, + mTokenToUsdDataFeed, + minAmount: parseUnits('100000'), + }, + stableCoins.dai, + 99_999, + { + revertMessage: 'DV: minReceiveAmount > actual', + }, + ); + }); + + it('should fail: if some fee = 100%', async () => { + const { + owner, + depositVaultWithMToken, + stableCoins, + mTBILL, + dataFeed, + mTokenToUsdDataFeed, + } = await loadFixture(defaultDeploy); + + await mintToken(stableCoins.dai, owner, 100); + await approveBase18(owner, stableCoins.dai, depositVaultWithMToken, 100); + await addPaymentTokenTest( + { vault: depositVaultWithMToken, owner }, + stableCoins.dai, + dataFeed.address, + 10000, + true, + ); + await depositInstantWithMTokenTest( + { + depositVaultWithMToken, + owner, + mTBILL, + mTokenToUsdDataFeed, + }, + stableCoins.dai, + 100, + { + revertMessage: 'DV: mToken amount < min', + }, + ); + + await removePaymentTokenTest( + { vault: depositVaultWithMToken, owner }, + stableCoins.dai, + ); + await addPaymentTokenTest( + { vault: depositVaultWithMToken, owner }, + stableCoins.dai, + dataFeed.address, + 0, + true, + ); + await setInstantFeeTest({ vault: depositVaultWithMToken, owner }, 10000); + await depositInstantWithMTokenTest( + { + depositVaultWithMToken, + owner, + mTBILL, + mTokenToUsdDataFeed, + }, + stableCoins.dai, + 100, + { revertMessage: 'DV: mToken amount < min' }, + ); + }); + + it('deposit 100 USDC when mTokenDepositsEnabled is true', async () => { + const { + owner, + depositVaultWithMToken, + depositVault, + stableCoins, + mTBILL, + mTokenToUsdDataFeed, + regularAccounts, + dataFeed, + } = await loadFixture(defaultDeploy); + + await setMTokenDepositsEnabledTest( + { depositVaultWithMToken, owner }, + true, + ); + + await mintToken(stableCoins.usdc, regularAccounts[0], 100); + await approveBase18( + regularAccounts[0], + stableCoins.usdc, + depositVaultWithMToken, + 100, + ); + await addPaymentTokenTest( + { vault: depositVaultWithMToken, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + await addPaymentTokenTest( + { vault: depositVault, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + await setMinAmountTest({ vault: depositVaultWithMToken, owner }, 10); + await setMinAmountTest({ vault: depositVault, owner }, 0); + + await depositInstantWithMTokenTest( + { + depositVaultWithMToken, + owner, + mTBILL, + mTokenToUsdDataFeed, + }, + stableCoins.usdc, + 100, + { + from: regularAccounts[0], + }, + ); + }); + + it('when mTokenDepositsEnabled is false, normal DV flow', async () => { + const { + owner, + depositVaultWithMToken, + stableCoins, + mTBILL, + mTokenToUsdDataFeed, + regularAccounts, + dataFeed, + } = await loadFixture(defaultDeploy); + + await mintToken(stableCoins.usdc, regularAccounts[0], 100); + await approveBase18( + regularAccounts[0], + stableCoins.usdc, + depositVaultWithMToken, + 100, + ); + await addPaymentTokenTest( + { vault: depositVaultWithMToken, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + await setMinAmountTest({ vault: depositVaultWithMToken, owner }, 10); + + await depositInstantWithMTokenTest( + { + depositVaultWithMToken, + owner, + mTBILL, + mTokenToUsdDataFeed, + expectedMTokenDeposited: false, + }, + stableCoins.usdc, + 100, + { + from: regularAccounts[0], + }, + ); + }); + + it('deposit with waived fee', async () => { + const { + owner, + depositVaultWithMToken, + depositVault, + stableCoins, + mTBILL, + mTokenToUsdDataFeed, + regularAccounts, + dataFeed, + } = await loadFixture(defaultDeploy); + + await setMTokenDepositsEnabledTest( + { depositVaultWithMToken, owner }, + true, + ); + + await addWaivedFeeAccountTest( + { vault: depositVaultWithMToken, owner }, + regularAccounts[0].address, + ); + + await mintToken(stableCoins.usdc, regularAccounts[0], 100); + await approveBase18( + regularAccounts[0], + stableCoins.usdc, + depositVaultWithMToken, + 100, + ); + await addPaymentTokenTest( + { vault: depositVaultWithMToken, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + await addPaymentTokenTest( + { vault: depositVault, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + await setMinAmountTest({ vault: depositVaultWithMToken, owner }, 10); + await setMinAmountTest({ vault: depositVault, owner }, 0); + + await depositInstantWithMTokenTest( + { + depositVaultWithMToken, + owner, + mTBILL, + mTokenToUsdDataFeed, + waivedFee: true, + }, + stableCoins.usdc, + 100, + { + from: regularAccounts[0], + }, + ); + }); + + it('deposit 100 DAI with mToken enabled (non-stablecoin feed)', async () => { + const { + owner, + depositVaultWithMToken, + depositVault, + stableCoins, + mTBILL, + mTokenToUsdDataFeed, + regularAccounts, + dataFeed, + } = await loadFixture(defaultDeploy); + + await setMTokenDepositsEnabledTest( + { depositVaultWithMToken, owner }, + true, + ); + + await mintToken(stableCoins.dai, regularAccounts[0], 100); + await approveBase18( + regularAccounts[0], + stableCoins.dai, + depositVaultWithMToken, + 100, + ); + await addPaymentTokenTest( + { vault: depositVaultWithMToken, owner }, + stableCoins.dai, + dataFeed.address, + 0, + false, + ); + await addPaymentTokenTest( + { vault: depositVault, owner }, + stableCoins.dai, + dataFeed.address, + 0, + false, + ); + await setMinAmountTest({ vault: depositVaultWithMToken, owner }, 10); + await setMinAmountTest({ vault: depositVault, owner }, 0); + + await depositInstantWithMTokenTest( + { + depositVaultWithMToken, + owner, + mTBILL, + mTokenToUsdDataFeed, + }, + stableCoins.dai, + 100, + { + from: regularAccounts[0], + }, + ); + }); + + it('deposit with greenlist enabled and user in greenlist', async () => { + const { + owner, + depositVaultWithMToken, + depositVault, + stableCoins, + mTBILL, + greenListableTester, + mTokenToUsdDataFeed, + accessControl, + regularAccounts, + dataFeed, + } = await loadFixture(defaultDeploy); + + await depositVaultWithMToken.setGreenlistEnable(true); + + await greenList( + { greenlistable: greenListableTester, accessControl, owner }, + regularAccounts[0], + ); + + await setMTokenDepositsEnabledTest( + { depositVaultWithMToken, owner }, + true, + ); + + await mintToken(stableCoins.usdc, regularAccounts[0], 100); + await approveBase18( + regularAccounts[0], + stableCoins.usdc, + depositVaultWithMToken, + 100, + ); + await addPaymentTokenTest( + { vault: depositVaultWithMToken, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + await addPaymentTokenTest( + { vault: depositVault, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + await setMinAmountTest({ vault: depositVaultWithMToken, owner }, 10); + await setMinAmountTest({ vault: depositVault, owner }, 0); + + await depositInstantWithMTokenTest( + { + depositVaultWithMToken, + owner, + mTBILL, + mTokenToUsdDataFeed, + }, + stableCoins.usdc, + 100, + { + from: regularAccounts[0], + }, + ); + }); + + it('deposit with custom recipient, mTokenDepositsEnabled is true', async () => { + const { + owner, + depositVaultWithMToken, + depositVault, + stableCoins, + mTBILL, + mTokenToUsdDataFeed, + regularAccounts, + dataFeed, + } = await loadFixture(defaultDeploy); + + await setMTokenDepositsEnabledTest( + { depositVaultWithMToken, owner }, + true, + ); + + await mintToken(stableCoins.usdc, regularAccounts[0], 100); + await approveBase18( + regularAccounts[0], + stableCoins.usdc, + depositVaultWithMToken, + 100, + ); + await addPaymentTokenTest( + { vault: depositVaultWithMToken, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + await addPaymentTokenTest( + { vault: depositVault, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + await setMinAmountTest({ vault: depositVaultWithMToken, owner }, 10); + await setMinAmountTest({ vault: depositVault, owner }, 0); + + await depositInstantWithMTokenTest( + { + depositVaultWithMToken, + owner, + mTBILL, + mTokenToUsdDataFeed, + customRecipient: regularAccounts[1], + }, + stableCoins.usdc, + 100, + { + from: regularAccounts[0], + }, + ); + }); + + it('deposit with custom recipient, mTokenDepositsEnabled is false', async () => { + const { + owner, + depositVaultWithMToken, + stableCoins, + mTBILL, + mTokenToUsdDataFeed, + regularAccounts, + dataFeed, + } = await loadFixture(defaultDeploy); + + await mintToken(stableCoins.usdc, regularAccounts[0], 100); + await approveBase18( + regularAccounts[0], + stableCoins.usdc, + depositVaultWithMToken, + 100, + ); + await addPaymentTokenTest( + { vault: depositVaultWithMToken, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + await setMinAmountTest({ vault: depositVaultWithMToken, owner }, 10); + + await depositInstantWithMTokenTest( + { + depositVaultWithMToken, + owner, + mTBILL, + mTokenToUsdDataFeed, + expectedMTokenDeposited: false, + customRecipient: regularAccounts[1], + }, + stableCoins.usdc, + 100, + { + from: regularAccounts[0], + }, + ); + }); + + it('should fail: greenlist enabled and user not in greenlist', async () => { + const { + owner, + depositVaultWithMToken, + stableCoins, + mTBILL, + mTokenToUsdDataFeed, + regularAccounts, + dataFeed, + } = await loadFixture(defaultDeploy); + + await depositVaultWithMToken.setGreenlistEnable(true); + + await mintToken(stableCoins.usdc, regularAccounts[0], 100); + await approveBase18( + regularAccounts[0], + stableCoins.usdc, + depositVaultWithMToken, + 100, + ); + await addPaymentTokenTest( + { vault: depositVaultWithMToken, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + await setMinAmountTest({ vault: depositVaultWithMToken, owner }, 10); + + await depositInstantWithMTokenTest( + { + depositVaultWithMToken, + owner, + mTBILL, + mTokenToUsdDataFeed, + }, + stableCoins.usdc, + 100, + { + from: regularAccounts[0], + revertMessage: acErrors.WMAC_HASNT_ROLE, + }, + ); + }); + + it('should fail: first deposit mint amount below configured minimum', async () => { + const { + owner, + depositVaultWithMToken, + stableCoins, + mTBILL, + mTokenToUsdDataFeed, + dataFeed, + } = await loadFixture(defaultDeploy); + + await setMTokenDepositsEnabledTest( + { depositVaultWithMToken, owner }, + true, + ); + await setMinAmountTest({ vault: depositVaultWithMToken, owner }, 0); + await setMinAmountToDepositTest( + { depositVault: depositVaultWithMToken, owner }, + 200, + ); + + await mintToken(stableCoins.usdc, owner, 100); + await approveBase18(owner, stableCoins.usdc, depositVaultWithMToken, 100); + await addPaymentTokenTest( + { vault: depositVaultWithMToken, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + + await depositInstantWithMTokenTest( + { + depositVaultWithMToken, + owner, + mTBILL, + mTokenToUsdDataFeed, + }, + stableCoins.usdc, + 100, + { + revertMessage: 'DV: mint amount < min', + }, + ); + }); + + it('should fail: mToken deposit enabled with token not in target DV', async () => { + const { + owner, + depositVaultWithMToken, + stableCoins, + mTBILL, + mTokenToUsdDataFeed, + dataFeed, + } = await loadFixture(defaultDeploy); + + await setMTokenDepositsEnabledTest( + { depositVaultWithMToken, owner }, + true, + ); + await setMinAmountTest({ vault: depositVaultWithMToken, owner }, 10); + + await mintToken(stableCoins.dai, owner, 100); + await approveBase18(owner, stableCoins.dai, depositVaultWithMToken, 100); + await addPaymentTokenTest( + { vault: depositVaultWithMToken, owner }, + stableCoins.dai, + dataFeed.address, + 0, + false, + ); + + await depositInstantWithMTokenTest( + { + depositVaultWithMToken, + owner, + mTBILL, + mTokenToUsdDataFeed, + }, + stableCoins.dai, + 100, + { + revertMessage: 'DVMT: auto-invest failed', + }, + ); + }); + + it('should fail: greenlist enabled and recipient not in greenlist (custom recipient overload)', async () => { + const { + owner, + depositVaultWithMToken, + stableCoins, + mTBILL, + mTokenToUsdDataFeed, + greenListableTester, + accessControl, + customRecipient, + } = await loadFixture(defaultDeploy); + + await greenList( + { greenlistable: greenListableTester, accessControl, owner }, + owner, + ); + + await depositVaultWithMToken.setGreenlistEnable(true); + + await depositInstantWithMTokenTest( + { + depositVaultWithMToken, + owner, + mTBILL, + mTokenToUsdDataFeed, + customRecipient, + }, + stableCoins.dai, + 1, + { + revertMessage: 'WMAC: hasnt role', + }, + ); + }); + + it('should fail: recipient in blacklist (custom recipient overload)', async () => { + const { + owner, + depositVaultWithMToken, + stableCoins, + mTBILL, + mTokenToUsdDataFeed, + blackListableTester, + accessControl, + regularAccounts, + customRecipient, + } = await loadFixture(defaultDeploy); + + await blackList( + { blacklistable: blackListableTester, accessControl, owner }, + customRecipient, + ); + + await depositInstantWithMTokenTest( + { + depositVaultWithMToken, + owner, + mTBILL, + mTokenToUsdDataFeed, + customRecipient, + }, + stableCoins.dai, + 1, + { + from: regularAccounts[0], + revertMessage: acErrors.WMAC_HAS_ROLE, + }, + ); + }); + + it('should fail: recipient in sanctions list (custom recipient overload)', async () => { + const { + owner, + depositVaultWithMToken, + stableCoins, + mTBILL, + mTokenToUsdDataFeed, + regularAccounts, + mockedSanctionsList, + customRecipient, + } = await loadFixture(defaultDeploy); + + await sanctionUser( + { sanctionsList: mockedSanctionsList }, + customRecipient, + ); + + await depositInstantWithMTokenTest( + { + depositVaultWithMToken, + owner, + mTBILL, + mTokenToUsdDataFeed, + customRecipient, + }, + stableCoins.dai, + 1, + { + from: regularAccounts[0], + revertMessage: 'WSL: sanctioned', + }, + ); + }); + + it('should fail: when function paused (custom recipient overload)', async () => { + const { + owner, + depositVaultWithMToken, + stableCoins, + mTBILL, + dataFeed, + mTokenToUsdDataFeed, + regularAccounts, + customRecipient, + } = await loadFixture(defaultDeploy); + await mintToken(stableCoins.dai, regularAccounts[0], 100); + await approveBase18( + regularAccounts[0], + stableCoins.dai, + depositVaultWithMToken, + 100, + ); + await addPaymentTokenTest( + { vault: depositVaultWithMToken, owner }, + stableCoins.dai, + dataFeed.address, + 0, + true, + ); + const selector = encodeFnSelector( + 'depositInstant(address,uint256,uint256,bytes32,address)', + ); + await pauseVaultFn(depositVaultWithMToken, selector); + await depositInstantWithMTokenTest( + { + depositVaultWithMToken, + owner, + mTBILL, + mTokenToUsdDataFeed, + customRecipient, + }, + stableCoins.dai, + 100, + { + from: regularAccounts[0], + revertMessage: 'Pausable: fn paused', + }, + ); + }); + + it('mTokenDepositsEnabled, auto-invest fails, fallback enabled: fallback to normal flow', async () => { + const { + owner, + depositVaultWithMToken, + stableCoins, + mTBILL, + mTokenToUsdDataFeed, + regularAccounts, + dataFeed, + } = await loadFixture(defaultDeploy); + + await setMTokenDepositsEnabledTest( + { depositVaultWithMToken, owner }, + true, + ); + await setAutoInvestFallbackEnabledMTokenTest( + { depositVaultWithMToken, owner }, + true, + ); + + await mintToken(stableCoins.usdc, regularAccounts[0], 100); + await approveBase18( + regularAccounts[0], + stableCoins.usdc, + depositVaultWithMToken, + 100, + ); + await addPaymentTokenTest( + { vault: depositVaultWithMToken, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + await setMinAmountTest({ vault: depositVaultWithMToken, owner }, 10); + + await depositInstantWithMTokenTest( + { + depositVaultWithMToken, + owner, + mTBILL, + mTokenToUsdDataFeed, + expectedMTokenDeposited: false, + }, + stableCoins.usdc, + 100, + { + from: regularAccounts[0], + }, + ); + }); + + it('should fail: mTokenDepositsEnabled, auto-invest fails, fallback disabled', async () => { + const { + owner, + depositVaultWithMToken, + stableCoins, + mTBILL, + mTokenToUsdDataFeed, + regularAccounts, + dataFeed, + } = await loadFixture(defaultDeploy); + + await setMTokenDepositsEnabledTest( + { depositVaultWithMToken, owner }, + true, + ); + + await mintToken(stableCoins.usdc, regularAccounts[0], 100); + await approveBase18( + regularAccounts[0], + stableCoins.usdc, + depositVaultWithMToken, + 100, + ); + await addPaymentTokenTest( + { vault: depositVaultWithMToken, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + await setMinAmountTest({ vault: depositVaultWithMToken, owner }, 10); + + await depositInstantWithMTokenTest( + { + depositVaultWithMToken, + owner, + mTBILL, + mTokenToUsdDataFeed, + expectedMTokenDeposited: false, + }, + stableCoins.usdc, + 100, + { + from: regularAccounts[0], + revertMessage: 'DVMT: auto-invest failed', + }, + ); + }); + }); + + describe('depositRequest()', () => { + it('deposit request 100 USDC', async () => { + const { + owner, + depositVaultWithMToken, + stableCoins, + mTBILL, + mTokenToUsdDataFeed, + regularAccounts, + dataFeed, + } = await loadFixture(defaultDeploy); + + await mintToken(stableCoins.usdc, regularAccounts[0], 100); + await approveBase18( + regularAccounts[0], + stableCoins.usdc, + depositVaultWithMToken, + 100, + ); + await addPaymentTokenTest( + { vault: depositVaultWithMToken, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + await setMinAmountTest({ vault: depositVaultWithMToken, owner }, 10); + + await depositRequestTest( + { + depositVault: depositVaultWithMToken, + owner, + mTBILL, + mTokenToUsdDataFeed, + }, + stableCoins.usdc, + 100, + { + from: regularAccounts[0], + }, + ); + }); + + it('deposit request 100 USDC with mToken auto-invest', async () => { + const { + owner, + depositVaultWithMToken, + depositVault, + stableCoins, + mTBILL, + mTokenToUsdDataFeed, + regularAccounts, + dataFeed, + } = await loadFixture(defaultDeploy); + + await setMTokenDepositsEnabledTest( + { depositVaultWithMToken, owner }, + true, + ); + + await mintToken(stableCoins.usdc, regularAccounts[0], 100); + await approveBase18( + regularAccounts[0], + stableCoins.usdc, + depositVaultWithMToken, + 100, + ); + await addPaymentTokenTest( + { vault: depositVaultWithMToken, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + await addPaymentTokenTest( + { vault: depositVault, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + await setMinAmountTest({ vault: depositVaultWithMToken, owner }, 10); + await setMinAmountTest({ vault: depositVault, owner }, 0); + + await depositRequestWithMTokenTest( + { + depositVaultWithMToken, + owner, + mTBILL, + mTokenToUsdDataFeed, + }, + stableCoins.usdc, + 100, + { + from: regularAccounts[0], + }, + ); + }); + + it('deposit request with mToken auto-invest fails, fallback enabled: fallback to normal flow', async () => { + const { + owner, + depositVaultWithMToken, + stableCoins, + mTBILL, + mTokenToUsdDataFeed, + regularAccounts, + dataFeed, + } = await loadFixture(defaultDeploy); + + await setMTokenDepositsEnabledTest( + { depositVaultWithMToken, owner }, + true, + ); + await setAutoInvestFallbackEnabledMTokenTest( + { depositVaultWithMToken, owner }, + true, + ); + + await mintToken(stableCoins.usdc, regularAccounts[0], 100); + await approveBase18( + regularAccounts[0], + stableCoins.usdc, + depositVaultWithMToken, + 100, + ); + await addPaymentTokenTest( + { vault: depositVaultWithMToken, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + await setMinAmountTest({ vault: depositVaultWithMToken, owner }, 10); + + await depositRequestWithMTokenTest( + { + depositVaultWithMToken, + owner, + mTBILL, + mTokenToUsdDataFeed, + expectedMTokenDeposited: false, + }, + stableCoins.usdc, + 100, + { + from: regularAccounts[0], + }, + ); + }); + + it('should fail: deposit request with mToken auto-invest fails, fallback disabled', async () => { + const { + owner, + depositVaultWithMToken, + stableCoins, + mTBILL, + mTokenToUsdDataFeed, + regularAccounts, + dataFeed, + } = await loadFixture(defaultDeploy); + + await setMTokenDepositsEnabledTest( + { depositVaultWithMToken, owner }, + true, + ); + + await mintToken(stableCoins.usdc, regularAccounts[0], 100); + await approveBase18( + regularAccounts[0], + stableCoins.usdc, + depositVaultWithMToken, + 100, + ); + await addPaymentTokenTest( + { vault: depositVaultWithMToken, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + await setMinAmountTest({ vault: depositVaultWithMToken, owner }, 10); + + await depositRequestWithMTokenTest( + { + depositVaultWithMToken, + owner, + mTBILL, + mTokenToUsdDataFeed, + expectedMTokenDeposited: false, + }, + stableCoins.usdc, + 100, + { + from: regularAccounts[0], + revertMessage: 'DVMT: auto-invest failed', + }, + ); + }); + }); + + describe('approveRequest()', () => { + it('should fail: call from address without DEPOSIT_VAULT_ADMIN_ROLE role', async () => { + const { + owner, + regularAccounts, + depositVaultWithMToken, + stableCoins, + mTBILL, + mTokenToUsdDataFeed, + dataFeed, + } = await loadFixture(defaultDeploy); + + await mintToken(stableCoins.usdc, owner, 100); + await approveBase18(owner, stableCoins.usdc, depositVaultWithMToken, 100); + await addPaymentTokenTest( + { vault: depositVaultWithMToken, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + await setMinAmountTest({ vault: depositVaultWithMToken, owner }, 10); + + const request = await depositRequestTest( + { + depositVault: depositVaultWithMToken, + owner, + mTBILL, + mTokenToUsdDataFeed, + }, + stableCoins.usdc, + 100, + ); + + await approveRequestTest( + { + depositVault: depositVaultWithMToken, + owner, + mTBILL, + mTokenToUsdDataFeed, + }, + request.requestId!, + request.rate!, + { + from: regularAccounts[0], + revertMessage: 'WMAC: hasnt role', + }, + ); + }); + + it('approve request: happy path', async () => { + const { + owner, + depositVaultWithMToken, + stableCoins, + mTBILL, + mTokenToUsdDataFeed, + dataFeed, + } = await loadFixture(defaultDeploy); + + await mintToken(stableCoins.usdc, owner, 100); + await approveBase18(owner, stableCoins.usdc, depositVaultWithMToken, 100); + await addPaymentTokenTest( + { vault: depositVaultWithMToken, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + await setMinAmountTest({ vault: depositVaultWithMToken, owner }, 10); + + const request = await depositRequestTest( + { + depositVault: depositVaultWithMToken, + owner, + mTBILL, + mTokenToUsdDataFeed, + }, + stableCoins.usdc, + 100, + ); + + await approveRequestTest( + { + depositVault: depositVaultWithMToken, + owner, + mTBILL, + mTokenToUsdDataFeed, + }, + request.requestId!, + request.rate!, + ); + }); + }); + + describe('safeApproveRequest()', () => { + it('should fail: call from address without DEPOSIT_VAULT_ADMIN_ROLE role', async () => { + const { + owner, + regularAccounts, + depositVaultWithMToken, + stableCoins, + mTBILL, + mTokenToUsdDataFeed, + dataFeed, + } = await loadFixture(defaultDeploy); + + await mintToken(stableCoins.usdc, owner, 100); + await approveBase18(owner, stableCoins.usdc, depositVaultWithMToken, 100); + await addPaymentTokenTest( + { vault: depositVaultWithMToken, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + await setMinAmountTest({ vault: depositVaultWithMToken, owner }, 10); + + const request = await depositRequestTest( + { + depositVault: depositVaultWithMToken, + owner, + mTBILL, + mTokenToUsdDataFeed, + }, + stableCoins.usdc, + 100, + ); + + await safeApproveRequestTest( + { + depositVault: depositVaultWithMToken, + owner, + mTBILL, + mTokenToUsdDataFeed, + }, + request.requestId!, + request.rate!, + { + from: regularAccounts[0], + revertMessage: 'WMAC: hasnt role', + }, + ); + }); + + it('safe approve request: happy path', async () => { + const { + owner, + depositVaultWithMToken, + stableCoins, + mTBILL, + mTokenToUsdDataFeed, + dataFeed, + } = await loadFixture(defaultDeploy); + + await mintToken(stableCoins.usdc, owner, 100); + await approveBase18(owner, stableCoins.usdc, depositVaultWithMToken, 100); + await addPaymentTokenTest( + { vault: depositVaultWithMToken, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + await setMinAmountTest({ vault: depositVaultWithMToken, owner }, 10); + + const request = await depositRequestTest( + { + depositVault: depositVaultWithMToken, + owner, + mTBILL, + mTokenToUsdDataFeed, + }, + stableCoins.usdc, + 100, + ); + + await safeApproveRequestTest( + { + depositVault: depositVaultWithMToken, + owner, + mTBILL, + mTokenToUsdDataFeed, + }, + request.requestId!, + request.rate!, + ); + }); + }); + + describe('safeBulkApproveRequest()', () => { + it('should fail: call from address without DEPOSIT_VAULT_ADMIN_ROLE role', async () => { + const { + owner, + regularAccounts, + depositVaultWithMToken, + stableCoins, + mTBILL, + mTokenToUsdDataFeed, + dataFeed, + } = await loadFixture(defaultDeploy); + + await mintToken(stableCoins.usdc, owner, 100); + await approveBase18(owner, stableCoins.usdc, depositVaultWithMToken, 100); + await addPaymentTokenTest( + { vault: depositVaultWithMToken, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + await setMinAmountTest({ vault: depositVaultWithMToken, owner }, 10); + + const request = await depositRequestTest( + { + depositVault: depositVaultWithMToken, + owner, + mTBILL, + mTokenToUsdDataFeed, + }, + stableCoins.usdc, + 100, + ); + + await safeBulkApproveRequestTest( + { + depositVault: depositVaultWithMToken, + owner, + mTBILL, + mTokenToUsdDataFeed, + }, + [{ id: request.requestId! }], + request.rate!, + { + from: regularAccounts[0], + revertMessage: 'WMAC: hasnt role', + }, + ); + }); + + it('safe bulk approve request: happy path', async () => { + const { + owner, + depositVaultWithMToken, + stableCoins, + mTBILL, + mTokenToUsdDataFeed, + dataFeed, + } = await loadFixture(defaultDeploy); + + await mintToken(stableCoins.usdc, owner, 100); + await approveBase18(owner, stableCoins.usdc, depositVaultWithMToken, 100); + await addPaymentTokenTest( + { vault: depositVaultWithMToken, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + await setMinAmountTest({ vault: depositVaultWithMToken, owner }, 10); + + const request = await depositRequestTest( + { + depositVault: depositVaultWithMToken, + owner, + mTBILL, + mTokenToUsdDataFeed, + }, + stableCoins.usdc, + 100, + ); + + await safeBulkApproveRequestTest( + { + depositVault: depositVaultWithMToken, + owner, + mTBILL, + mTokenToUsdDataFeed, + }, + [{ id: request.requestId! }], + request.rate!, + ); + }); + }); + + describe('rejectRequest()', () => { + it('should fail: call from address without DEPOSIT_VAULT_ADMIN_ROLE role', async () => { + const { + owner, + regularAccounts, + depositVaultWithMToken, + stableCoins, + mTBILL, + mTokenToUsdDataFeed, + dataFeed, + } = await loadFixture(defaultDeploy); + + await mintToken(stableCoins.usdc, owner, 100); + await approveBase18(owner, stableCoins.usdc, depositVaultWithMToken, 100); + await addPaymentTokenTest( + { vault: depositVaultWithMToken, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + await setMinAmountTest({ vault: depositVaultWithMToken, owner }, 10); + + const request = await depositRequestTest( + { + depositVault: depositVaultWithMToken, + owner, + mTBILL, + mTokenToUsdDataFeed, + }, + stableCoins.usdc, + 100, + ); + + await rejectRequestTest( + { + depositVault: depositVaultWithMToken, + owner, + mTBILL, + mTokenToUsdDataFeed, + }, + request.requestId!, + { + from: regularAccounts[0], + revertMessage: 'WMAC: hasnt role', + }, + ); + }); + + it('reject request: happy path', async () => { + const { + owner, + depositVaultWithMToken, + stableCoins, + mTBILL, + mTokenToUsdDataFeed, + dataFeed, + } = await loadFixture(defaultDeploy); + + await mintToken(stableCoins.usdc, owner, 100); + await approveBase18(owner, stableCoins.usdc, depositVaultWithMToken, 100); + await addPaymentTokenTest( + { vault: depositVaultWithMToken, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + await setMinAmountTest({ vault: depositVaultWithMToken, owner }, 10); + + const request = await depositRequestTest( + { + depositVault: depositVaultWithMToken, + owner, + mTBILL, + mTokenToUsdDataFeed, + }, + stableCoins.usdc, + 100, + ); + + await rejectRequestTest( + { + depositVault: depositVaultWithMToken, + owner, + mTBILL, + mTokenToUsdDataFeed, + }, + request.requestId!, + ); + }); + }); +}); diff --git a/test/unit/DepositVaultWithMorpho.test.ts b/test/unit/DepositVaultWithMorpho.test.ts new file mode 100644 index 00000000..7d57e174 --- /dev/null +++ b/test/unit/DepositVaultWithMorpho.test.ts @@ -0,0 +1,2819 @@ +import { loadFixture } from '@nomicfoundation/hardhat-network-helpers'; +import { expect } from 'chai'; +import { constants } from 'ethers'; +import { parseUnits } from 'ethers/lib/utils'; +import { ethers } from 'hardhat'; + +import { encodeFnSelector } from '../../helpers/utils'; +import { + ManageableVaultTester__factory, + MorphoVaultMock__factory, +} from '../../typechain-types'; +import { acErrors, blackList, greenList } from '../common/ac.helpers'; +import { + approveBase18, + mintToken, + pauseVault, + pauseVaultFn, +} from '../common/common.helpers'; +import { setRoundData } from '../common/data-feed.helpers'; +import { + depositInstantWithMorphoTest, + depositRequestWithMorphoTest, + removeMorphoVaultTest, + setAutoInvestFallbackEnabledMorphoTest, + setMorphoDepositsEnabledTest, + setMorphoVaultTest, +} from '../common/deposit-vault-morpho.helpers'; +import { + approveRequestTest, + depositRequestTest, + rejectRequestTest, + safeApproveRequestTest, + safeBulkApproveRequestTest, +} from '../common/deposit-vault.helpers'; +import { defaultDeploy } from '../common/fixtures'; +import { + addPaymentTokenTest, + addWaivedFeeAccountTest, + changeTokenAllowanceTest, + changeTokenFeeTest, + removePaymentTokenTest, + removeWaivedFeeAccountTest, + setInstantDailyLimitTest, + setMinAmountToDepositTest, + setInstantFeeTest, + setMinAmountTest, + setVariabilityToleranceTest, + withdrawTest, +} from '../common/manageable-vault.helpers'; +import { sanctionUser } from '../common/with-sanctions-list.helpers'; + +describe('DepositVaultWithMorpho', function () { + it('deployment', async () => { + const { + depositVaultWithMorpho, + mTBILL, + tokensReceiver, + feeReceiver, + mTokenToUsdDataFeed, + roles, + } = await loadFixture(defaultDeploy); + + expect(await depositVaultWithMorpho.mToken()).eq(mTBILL.address); + expect(await depositVaultWithMorpho.paused()).eq(false); + expect(await depositVaultWithMorpho.tokensReceiver()).eq( + tokensReceiver.address, + ); + expect(await depositVaultWithMorpho.feeReceiver()).eq(feeReceiver.address); + expect(await depositVaultWithMorpho.ONE_HUNDRED_PERCENT()).eq('10000'); + expect(await depositVaultWithMorpho.minMTokenAmountForFirstDeposit()).eq( + '0', + ); + expect(await depositVaultWithMorpho.minAmount()).eq(parseUnits('100')); + expect(await depositVaultWithMorpho.instantFee()).eq('100'); + expect(await depositVaultWithMorpho.instantDailyLimit()).eq( + parseUnits('100000'), + ); + expect(await depositVaultWithMorpho.mTokenDataFeed()).eq( + mTokenToUsdDataFeed.address, + ); + expect(await depositVaultWithMorpho.variationTolerance()).eq(1); + expect(await depositVaultWithMorpho.vaultRole()).eq( + roles.tokenRoles.mTBILL.depositVaultAdmin, + ); + expect(await depositVaultWithMorpho.MANUAL_FULLFILMENT_TOKEN()).eq( + ethers.constants.AddressZero, + ); + expect(await depositVaultWithMorpho.morphoDepositsEnabled()).eq(false); + }); + + describe('initialization', () => { + it('should fail: call initialize() when already initialized', async () => { + const { depositVaultWithMorpho } = await loadFixture(defaultDeploy); + + await expect( + depositVaultWithMorpho.initialize( + constants.AddressZero, + { + mToken: constants.AddressZero, + mTokenDataFeed: constants.AddressZero, + }, + { + feeReceiver: constants.AddressZero, + tokensReceiver: constants.AddressZero, + }, + { + instantFee: 0, + instantDailyLimit: 0, + }, + constants.AddressZero, + 0, + 0, + 0, + 0, + ), + ).revertedWith('Initializable: contract is already initialized'); + }); + + it('should fail: call with initializing == false', async () => { + const { + owner, + accessControl, + mTBILL, + tokensReceiver, + feeReceiver, + mTokenToUsdDataFeed, + mockedSanctionsList, + } = await loadFixture(defaultDeploy); + + const vault = await new ManageableVaultTester__factory(owner).deploy(); + + await expect( + vault.initializeWithoutInitializer( + accessControl.address, + { + mToken: mTBILL.address, + mTokenDataFeed: mTokenToUsdDataFeed.address, + }, + { + feeReceiver: feeReceiver.address, + tokensReceiver: tokensReceiver.address, + }, + { + instantFee: 100, + instantDailyLimit: parseUnits('100000'), + }, + mockedSanctionsList.address, + 1, + parseUnits('100'), + ), + ).revertedWith('Initializable: contract is not initializing'); + }); + }); + + describe('setMorphoVault()', () => { + it('should fail: call from address without DEPOSIT_VAULT_ADMIN_ROLE role', async () => { + const { + depositVaultWithMorpho, + owner, + regularAccounts, + stableCoins, + morphoVaultMock, + } = await loadFixture(defaultDeploy); + + await setMorphoVaultTest( + { depositVaultWithMorpho, owner }, + stableCoins.usdc.address, + morphoVaultMock.address, + { + from: regularAccounts[0], + revertMessage: 'WMAC: hasnt role', + }, + ); + }); + + it('should fail: zero token address', async () => { + const { depositVaultWithMorpho, owner, morphoVaultMock } = + await loadFixture(defaultDeploy); + + await setMorphoVaultTest( + { depositVaultWithMorpho, owner }, + ethers.constants.AddressZero, + morphoVaultMock.address, + { + revertMessage: 'zero address', + }, + ); + }); + + it('should fail: zero vault address', async () => { + const { depositVaultWithMorpho, owner, stableCoins } = await loadFixture( + defaultDeploy, + ); + + await setMorphoVaultTest( + { depositVaultWithMorpho, owner }, + stableCoins.usdc.address, + ethers.constants.AddressZero, + { + revertMessage: 'zero address', + }, + ); + }); + + it('should fail: asset mismatch', async () => { + const { depositVaultWithMorpho, owner, stableCoins, morphoVaultMock } = + await loadFixture(defaultDeploy); + + // morphoVaultMock is configured for USDC; passing DAI should fail + await setMorphoVaultTest( + { depositVaultWithMorpho, owner }, + stableCoins.dai.address, + morphoVaultMock.address, + { + revertMessage: 'DVM: asset mismatch', + }, + ); + }); + + it('call from address with DEPOSIT_VAULT_ADMIN_ROLE role', async () => { + const { depositVaultWithMorpho, owner, stableCoins, morphoVaultMock } = + await loadFixture(defaultDeploy); + + await setMorphoVaultTest( + { depositVaultWithMorpho, owner }, + stableCoins.usdc.address, + morphoVaultMock.address, + ); + }); + }); + + describe('removeMorphoVault()', () => { + it('should fail: call from address without DEPOSIT_VAULT_ADMIN_ROLE role', async () => { + const { depositVaultWithMorpho, owner, regularAccounts, stableCoins } = + await loadFixture(defaultDeploy); + + await removeMorphoVaultTest( + { depositVaultWithMorpho, owner }, + stableCoins.usdc.address, + { + from: regularAccounts[0], + revertMessage: 'WMAC: hasnt role', + }, + ); + }); + + it('should fail: vault not set', async () => { + const { depositVaultWithMorpho, owner, stableCoins } = await loadFixture( + defaultDeploy, + ); + + await removeMorphoVaultTest( + { depositVaultWithMorpho, owner }, + stableCoins.usdc.address, + { + revertMessage: 'DVM: vault not set', + }, + ); + }); + + it('call from address with DEPOSIT_VAULT_ADMIN_ROLE role', async () => { + const { depositVaultWithMorpho, owner, stableCoins, morphoVaultMock } = + await loadFixture(defaultDeploy); + + await setMorphoVaultTest( + { depositVaultWithMorpho, owner }, + stableCoins.usdc.address, + morphoVaultMock.address, + ); + + await removeMorphoVaultTest( + { depositVaultWithMorpho, owner }, + stableCoins.usdc.address, + ); + }); + }); + + describe('setMorphoDepositsEnabled()', () => { + it('should fail: call from address without DEPOSIT_VAULT_ADMIN_ROLE role', async () => { + const { depositVaultWithMorpho, owner, regularAccounts } = + await loadFixture(defaultDeploy); + + await setMorphoDepositsEnabledTest( + { depositVaultWithMorpho, owner }, + true, + { + from: regularAccounts[0], + revertMessage: 'WMAC: hasnt role', + }, + ); + }); + + it('call from address with DEPOSIT_VAULT_ADMIN_ROLE role', async () => { + const { depositVaultWithMorpho, owner } = await loadFixture( + defaultDeploy, + ); + + await setMorphoDepositsEnabledTest( + { depositVaultWithMorpho, owner }, + true, + ); + }); + + it('toggle on and off', async () => { + const { depositVaultWithMorpho, owner } = await loadFixture( + defaultDeploy, + ); + + await setMorphoDepositsEnabledTest( + { depositVaultWithMorpho, owner }, + true, + ); + + await setMorphoDepositsEnabledTest( + { depositVaultWithMorpho, owner }, + false, + ); + }); + }); + + describe('setAutoInvestFallbackEnabled()', () => { + it('should fail: call from address without DEPOSIT_VAULT_ADMIN_ROLE role', async () => { + const { depositVaultWithMorpho, owner, regularAccounts } = + await loadFixture(defaultDeploy); + + await setAutoInvestFallbackEnabledMorphoTest( + { depositVaultWithMorpho, owner }, + true, + { + from: regularAccounts[0], + revertMessage: 'WMAC: hasnt role', + }, + ); + }); + + it('call from address with DEPOSIT_VAULT_ADMIN_ROLE role', async () => { + const { depositVaultWithMorpho, owner } = await loadFixture( + defaultDeploy, + ); + + await setAutoInvestFallbackEnabledMorphoTest( + { depositVaultWithMorpho, owner }, + true, + ); + }); + + it('toggle on and off', async () => { + const { depositVaultWithMorpho, owner } = await loadFixture( + defaultDeploy, + ); + + await setAutoInvestFallbackEnabledMorphoTest( + { depositVaultWithMorpho, owner }, + true, + ); + + await setAutoInvestFallbackEnabledMorphoTest( + { depositVaultWithMorpho, owner }, + false, + ); + }); + }); + + describe('setMinAmount()', () => { + it('should fail: call from address without DEPOSIT_VAULT_ADMIN_ROLE role', async () => { + const { depositVaultWithMorpho, regularAccounts, owner } = + await loadFixture(defaultDeploy); + await setMinAmountTest({ vault: depositVaultWithMorpho, owner }, 10, { + from: regularAccounts[0], + revertMessage: 'WMAC: hasnt role', + }); + }); + + it('call from address with DEPOSIT_VAULT_ADMIN_ROLE role', async () => { + const { depositVaultWithMorpho, owner } = await loadFixture( + defaultDeploy, + ); + await setMinAmountTest({ vault: depositVaultWithMorpho, owner }, 10); + }); + }); + + describe('setMinMTokenAmountForFirstDeposit()', () => { + it('should fail: call from address without DEPOSIT_VAULT_ADMIN_ROLE role', async () => { + const { depositVaultWithMorpho, regularAccounts, owner } = + await loadFixture(defaultDeploy); + + await setMinAmountToDepositTest( + { depositVault: depositVaultWithMorpho, owner }, + 10, + { + from: regularAccounts[0], + revertMessage: 'WMAC: hasnt role', + }, + ); + }); + + it('call from address with DEPOSIT_VAULT_ADMIN_ROLE role', async () => { + const { depositVaultWithMorpho, owner } = await loadFixture( + defaultDeploy, + ); + + await setMinAmountToDepositTest( + { depositVault: depositVaultWithMorpho, owner }, + 10, + ); + }); + }); + + describe('setInstantDailyLimit()', () => { + it('should fail: call from address without DEPOSIT_VAULT_ADMIN_ROLE role', async () => { + const { depositVaultWithMorpho, regularAccounts, owner } = + await loadFixture(defaultDeploy); + + await setInstantDailyLimitTest( + { vault: depositVaultWithMorpho, owner }, + 10, + { + from: regularAccounts[0], + revertMessage: 'WMAC: hasnt role', + }, + ); + }); + + it('call from address with DEPOSIT_VAULT_ADMIN_ROLE role', async () => { + const { depositVaultWithMorpho, owner } = await loadFixture( + defaultDeploy, + ); + + await setInstantDailyLimitTest( + { vault: depositVaultWithMorpho, owner }, + 10, + ); + }); + }); + + describe('setVariabilityTolerance()', () => { + it('should fail: call from address without DEPOSIT_VAULT_ADMIN_ROLE role', async () => { + const { depositVaultWithMorpho, regularAccounts, owner } = + await loadFixture(defaultDeploy); + + await setVariabilityToleranceTest( + { vault: depositVaultWithMorpho, owner }, + 100, + { + from: regularAccounts[0], + revertMessage: 'WMAC: hasnt role', + }, + ); + }); + + it('call from address with DEPOSIT_VAULT_ADMIN_ROLE role', async () => { + const { depositVaultWithMorpho, owner } = await loadFixture( + defaultDeploy, + ); + + await setVariabilityToleranceTest( + { vault: depositVaultWithMorpho, owner }, + 100, + ); + }); + }); + + describe('addPaymentToken()', () => { + it('should fail: call from address without DEPOSIT_VAULT_ADMIN_ROLE role', async () => { + const { + depositVaultWithMorpho, + regularAccounts, + owner, + stableCoins, + dataFeed, + } = await loadFixture(defaultDeploy); + await addPaymentTokenTest( + { vault: depositVaultWithMorpho, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + constants.MaxUint256, + { from: regularAccounts[0], revertMessage: 'WMAC: hasnt role' }, + ); + }); + + it('call from address with DEPOSIT_VAULT_ADMIN_ROLE role', async () => { + const { depositVaultWithMorpho, owner, stableCoins, dataFeed } = + await loadFixture(defaultDeploy); + await addPaymentTokenTest( + { vault: depositVaultWithMorpho, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + }); + }); + + describe('removePaymentToken()', () => { + it('should fail: call from address without DEPOSIT_VAULT_ADMIN_ROLE role', async () => { + const { depositVaultWithMorpho, regularAccounts, owner, stableCoins } = + await loadFixture(defaultDeploy); + + await removePaymentTokenTest( + { vault: depositVaultWithMorpho, owner }, + stableCoins.usdc, + { + from: regularAccounts[0], + revertMessage: 'WMAC: hasnt role', + }, + ); + }); + + it('call from address with DEPOSIT_VAULT_ADMIN_ROLE role', async () => { + const { depositVaultWithMorpho, owner, stableCoins, dataFeed } = + await loadFixture(defaultDeploy); + await addPaymentTokenTest( + { vault: depositVaultWithMorpho, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + await removePaymentTokenTest( + { vault: depositVaultWithMorpho, owner }, + stableCoins.usdc, + ); + }); + }); + + describe('addWaivedFeeAccount()', () => { + it('should fail: call from address without DEPOSIT_VAULT_ADMIN_ROLE role', async () => { + const { depositVaultWithMorpho, regularAccounts, owner } = + await loadFixture(defaultDeploy); + await addWaivedFeeAccountTest( + { vault: depositVaultWithMorpho, owner }, + regularAccounts[1].address, + { + from: regularAccounts[0], + revertMessage: 'WMAC: hasnt role', + }, + ); + }); + + it('call from address with DEPOSIT_VAULT_ADMIN_ROLE role', async () => { + const { depositVaultWithMorpho, regularAccounts, owner } = + await loadFixture(defaultDeploy); + await addWaivedFeeAccountTest( + { vault: depositVaultWithMorpho, owner }, + regularAccounts[0].address, + ); + }); + }); + + describe('removeWaivedFeeAccount()', () => { + it('should fail: call from address without DEPOSIT_VAULT_ADMIN_ROLE role', async () => { + const { depositVaultWithMorpho, regularAccounts, owner } = + await loadFixture(defaultDeploy); + + await addWaivedFeeAccountTest( + { vault: depositVaultWithMorpho, owner }, + regularAccounts[1].address, + ); + + await removeWaivedFeeAccountTest( + { vault: depositVaultWithMorpho, owner }, + regularAccounts[1].address, + { + from: regularAccounts[0], + revertMessage: 'WMAC: hasnt role', + }, + ); + }); + + it('call from address with DEPOSIT_VAULT_ADMIN_ROLE role', async () => { + const { depositVaultWithMorpho, regularAccounts, owner } = + await loadFixture(defaultDeploy); + + await addWaivedFeeAccountTest( + { vault: depositVaultWithMorpho, owner }, + regularAccounts[0].address, + ); + await removeWaivedFeeAccountTest( + { vault: depositVaultWithMorpho, owner }, + regularAccounts[0].address, + ); + }); + }); + + describe('withdrawToken()', () => { + it('should fail: call from address without DEPOSIT_VAULT_ADMIN_ROLE role', async () => { + const { depositVaultWithMorpho, owner, stableCoins, regularAccounts } = + await loadFixture(defaultDeploy); + await mintToken(stableCoins.usdc, depositVaultWithMorpho, 1); + await withdrawTest( + { vault: depositVaultWithMorpho, owner }, + stableCoins.usdc.address, + 1, + owner, + { + from: regularAccounts[0], + revertMessage: 'WMAC: hasnt role', + }, + ); + }); + + it('call from address with DEPOSIT_VAULT_ADMIN_ROLE role', async () => { + const { depositVaultWithMorpho, owner, stableCoins } = await loadFixture( + defaultDeploy, + ); + await mintToken(stableCoins.usdc, depositVaultWithMorpho, 100); + const usdcDecimals = await stableCoins.usdc.decimals(); + await withdrawTest( + { vault: depositVaultWithMorpho, owner }, + stableCoins.usdc, + parseUnits('100', usdcDecimals), + owner, + ); + }); + }); + + describe('changeTokenAllowance()', () => { + it('should fail: call from address without DEPOSIT_VAULT_ADMIN_ROLE role', async () => { + const { depositVaultWithMorpho, owner, stableCoins, regularAccounts } = + await loadFixture(defaultDeploy); + await changeTokenAllowanceTest( + { vault: depositVaultWithMorpho, owner }, + stableCoins.usdc.address, + 100, + { + from: regularAccounts[0], + revertMessage: 'WMAC: hasnt role', + }, + ); + }); + + it('call from address with DEPOSIT_VAULT_ADMIN_ROLE role', async () => { + const { depositVaultWithMorpho, owner, stableCoins, dataFeed } = + await loadFixture(defaultDeploy); + await addPaymentTokenTest( + { vault: depositVaultWithMorpho, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + await changeTokenAllowanceTest( + { vault: depositVaultWithMorpho, owner }, + stableCoins.usdc.address, + parseUnits('200'), + ); + }); + }); + + describe('changeTokenFee()', () => { + it('should fail: call from address without DEPOSIT_VAULT_ADMIN_ROLE role', async () => { + const { depositVaultWithMorpho, owner, stableCoins, regularAccounts } = + await loadFixture(defaultDeploy); + await changeTokenFeeTest( + { vault: depositVaultWithMorpho, owner }, + stableCoins.usdc.address, + 100, + { + from: regularAccounts[0], + revertMessage: 'WMAC: hasnt role', + }, + ); + }); + + it('call from address with DEPOSIT_VAULT_ADMIN_ROLE role', async () => { + const { depositVaultWithMorpho, owner, stableCoins, dataFeed } = + await loadFixture(defaultDeploy); + await addPaymentTokenTest( + { vault: depositVaultWithMorpho, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + await changeTokenFeeTest( + { vault: depositVaultWithMorpho, owner }, + stableCoins.usdc.address, + 100, + ); + }); + }); + + describe('freeFromMinAmount()', async () => { + it('should fail: call from address without vault admin role', async () => { + const { depositVaultWithMorpho, regularAccounts } = await loadFixture( + defaultDeploy, + ); + await expect( + depositVaultWithMorpho + .connect(regularAccounts[0]) + .freeFromMinAmount(regularAccounts[1].address, true), + ).to.be.revertedWith('WMAC: hasnt role'); + }); + it('should not fail', async () => { + const { depositVaultWithMorpho, regularAccounts } = await loadFixture( + defaultDeploy, + ); + await expect( + depositVaultWithMorpho.freeFromMinAmount( + regularAccounts[0].address, + true, + ), + ).to.not.reverted; + + expect( + await depositVaultWithMorpho.isFreeFromMinAmount( + regularAccounts[0].address, + ), + ).to.eq(true); + }); + it('should fail: already in list', async () => { + const { depositVaultWithMorpho, regularAccounts } = await loadFixture( + defaultDeploy, + ); + await expect( + depositVaultWithMorpho.freeFromMinAmount( + regularAccounts[0].address, + true, + ), + ).to.not.reverted; + + expect( + await depositVaultWithMorpho.isFreeFromMinAmount( + regularAccounts[0].address, + ), + ).to.eq(true); + + await expect( + depositVaultWithMorpho.freeFromMinAmount( + regularAccounts[0].address, + true, + ), + ).to.revertedWith('DV: already free'); + }); + }); + + describe('depositInstant()', async () => { + it('should fail: when there is no token in vault', async () => { + const { + owner, + depositVaultWithMorpho, + stableCoins, + mTBILL, + mTokenToUsdDataFeed, + regularAccounts, + morphoVaultMock, + } = await loadFixture(defaultDeploy); + + await mintToken(stableCoins.usdc, regularAccounts[0], 100); + await approveBase18( + regularAccounts[0], + stableCoins.usdc, + depositVaultWithMorpho, + 100, + ); + + await depositInstantWithMorphoTest( + { + depositVaultWithMorpho, + owner, + mTBILL, + mTokenToUsdDataFeed, + morphoVaultMock, + }, + stableCoins.usdc, + 100, + { + from: regularAccounts[0], + revertMessage: 'MV: token not exists', + }, + ); + }); + + it('should fail: when function paused', async () => { + const { + owner, + depositVaultWithMorpho, + stableCoins, + mTBILL, + mTokenToUsdDataFeed, + regularAccounts, + dataFeed, + morphoVaultMock, + } = await loadFixture(defaultDeploy); + + await mintToken(stableCoins.usdc, regularAccounts[0], 100); + await approveBase18( + regularAccounts[0], + stableCoins.usdc, + depositVaultWithMorpho, + 100, + ); + await addPaymentTokenTest( + { vault: depositVaultWithMorpho, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + await setMinAmountTest({ vault: depositVaultWithMorpho, owner }, 10); + + await pauseVault(depositVaultWithMorpho, { + from: owner, + }); + + await depositInstantWithMorphoTest( + { + depositVaultWithMorpho, + owner, + mTBILL, + mTokenToUsdDataFeed, + morphoVaultMock, + }, + stableCoins.usdc, + 100, + { + from: regularAccounts[0], + revertMessage: 'Pausable: paused', + }, + ); + }); + + it('should fail: user in blacklist', async () => { + const { + owner, + depositVaultWithMorpho, + stableCoins, + mTBILL, + mTokenToUsdDataFeed, + regularAccounts, + dataFeed, + accessControl, + morphoVaultMock, + } = await loadFixture(defaultDeploy); + + await mintToken(stableCoins.usdc, regularAccounts[0], 100); + await approveBase18( + regularAccounts[0], + stableCoins.usdc, + depositVaultWithMorpho, + 100, + ); + await addPaymentTokenTest( + { vault: depositVaultWithMorpho, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + await setMinAmountTest({ vault: depositVaultWithMorpho, owner }, 10); + + await blackList( + { blacklistable: depositVaultWithMorpho, accessControl, owner }, + regularAccounts[0], + ); + + await depositInstantWithMorphoTest( + { + depositVaultWithMorpho, + owner, + mTBILL, + mTokenToUsdDataFeed, + morphoVaultMock, + }, + stableCoins.usdc, + 100, + { + from: regularAccounts[0], + revertMessage: acErrors.WMAC_HAS_ROLE, + }, + ); + }); + + it('should fail: when trying to deposit 0 amount', async () => { + const { + owner, + depositVaultWithMorpho, + stableCoins, + mTBILL, + dataFeed, + mTokenToUsdDataFeed, + morphoVaultMock, + } = await loadFixture(defaultDeploy); + await addPaymentTokenTest( + { vault: depositVaultWithMorpho, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + await depositInstantWithMorphoTest( + { + depositVaultWithMorpho, + owner, + mTBILL, + mTokenToUsdDataFeed, + morphoVaultMock, + }, + stableCoins.usdc, + 0, + { + revertMessage: 'DV: invalid amount', + }, + ); + }); + + it('should fail: when rounding is invalid', async () => { + const { + owner, + depositVaultWithMorpho, + stableCoins, + mTBILL, + dataFeed, + mTokenToUsdDataFeed, + morphoVaultMock, + } = await loadFixture(defaultDeploy); + + await addPaymentTokenTest( + { vault: depositVaultWithMorpho, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + await setMinAmountTest({ vault: depositVaultWithMorpho, owner }, 10); + await depositInstantWithMorphoTest( + { + depositVaultWithMorpho, + owner, + mTBILL, + mTokenToUsdDataFeed, + morphoVaultMock, + }, + stableCoins.usdc, + 100.0000000001, + { + revertMessage: 'MV: invalid rounding', + }, + ); + }); + + it('should fail: call with insufficient allowance', async () => { + const { + owner, + depositVaultWithMorpho, + stableCoins, + mTBILL, + dataFeed, + mTokenToUsdDataFeed, + morphoVaultMock, + } = await loadFixture(defaultDeploy); + + await mintToken(stableCoins.usdc, owner, 100); + await addPaymentTokenTest( + { vault: depositVaultWithMorpho, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + await setMinAmountTest({ vault: depositVaultWithMorpho, owner }, 10); + await depositInstantWithMorphoTest( + { + depositVaultWithMorpho, + owner, + mTBILL, + mTokenToUsdDataFeed, + morphoVaultMock, + }, + stableCoins.usdc, + 100, + { + revertMessage: 'ERC20: insufficient allowance', + }, + ); + }); + + it('should fail: call with insufficient balance', async () => { + const { + owner, + depositVaultWithMorpho, + stableCoins, + mTBILL, + dataFeed, + mTokenToUsdDataFeed, + morphoVaultMock, + } = await loadFixture(defaultDeploy); + + await approveBase18(owner, stableCoins.usdc, depositVaultWithMorpho, 100); + await addPaymentTokenTest( + { vault: depositVaultWithMorpho, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + await setMinAmountTest({ vault: depositVaultWithMorpho, owner }, 10); + await depositInstantWithMorphoTest( + { + depositVaultWithMorpho, + owner, + mTBILL, + mTokenToUsdDataFeed, + morphoVaultMock, + }, + stableCoins.usdc, + 100, + { + revertMessage: 'ERC20: transfer amount exceeds balance', + }, + ); + }); + + it('should fail: dataFeed rate 0', async () => { + const { + owner, + depositVaultWithMorpho, + stableCoins, + mTBILL, + dataFeed, + mockedAggregator, + mTokenToUsdDataFeed, + morphoVaultMock, + } = await loadFixture(defaultDeploy); + + await approveBase18(owner, stableCoins.usdc, depositVaultWithMorpho, 10); + await addPaymentTokenTest( + { vault: depositVaultWithMorpho, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + await mintToken(stableCoins.usdc, owner, 100_000); + await setRoundData({ mockedAggregator }, 0); + await depositInstantWithMorphoTest( + { + depositVaultWithMorpho, + owner, + mTBILL, + mTokenToUsdDataFeed, + morphoVaultMock, + }, + stableCoins.usdc, + 1, + { + revertMessage: 'DF: feed is deprecated', + }, + ); + }); + + it('should fail: call for amount < minAmountToDepositTest', async () => { + const { + depositVaultWithMorpho, + mockedAggregator, + owner, + mTBILL, + stableCoins, + dataFeed, + mTokenToUsdDataFeed, + morphoVaultMock, + } = await loadFixture(defaultDeploy); + await addPaymentTokenTest( + { vault: depositVaultWithMorpho, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + await setRoundData({ mockedAggregator }, 1); + + await mintToken(stableCoins.usdc, owner, 100_000); + await approveBase18( + owner, + stableCoins.usdc, + depositVaultWithMorpho, + 100_000, + ); + + await setMinAmountToDepositTest( + { depositVault: depositVaultWithMorpho, owner }, + 100_000, + ); + await setInstantDailyLimitTest( + { vault: depositVaultWithMorpho, owner }, + 150_000, + ); + + await depositInstantWithMorphoTest( + { + depositVaultWithMorpho, + owner, + mTBILL, + mTokenToUsdDataFeed, + morphoVaultMock, + }, + stableCoins.usdc, + 99_999, + { + revertMessage: 'DV: mint amount < min', + }, + ); + }); + + it('should fail: call for amount < minAmount', async () => { + const { + depositVaultWithMorpho, + mockedAggregator, + owner, + mTBILL, + stableCoins, + dataFeed, + mTokenToUsdDataFeed, + morphoVaultMock, + } = await loadFixture(defaultDeploy); + await addPaymentTokenTest( + { vault: depositVaultWithMorpho, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + await setRoundData({ mockedAggregator }, 1); + + await mintToken(stableCoins.usdc, owner, 100_000); + await approveBase18( + owner, + stableCoins.usdc, + depositVaultWithMorpho, + 100_000, + ); + + await setMinAmountToDepositTest( + { depositVault: depositVaultWithMorpho, owner }, + 100_000, + ); + await setInstantDailyLimitTest( + { vault: depositVaultWithMorpho, owner }, + 150_000, + ); + + await depositInstantWithMorphoTest( + { + depositVaultWithMorpho, + owner, + mTBILL, + mTokenToUsdDataFeed, + morphoVaultMock, + }, + stableCoins.usdc, + 99, + { + revertMessage: 'DV: mToken amount < min', + }, + ); + }); + + it('should fail: if exceed allowance of deposit for token', async () => { + const { + depositVaultWithMorpho, + mockedAggregator, + owner, + mTBILL, + stableCoins, + dataFeed, + mTokenToUsdDataFeed, + morphoVaultMock, + } = await loadFixture(defaultDeploy); + await addPaymentTokenTest( + { vault: depositVaultWithMorpho, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + await setRoundData({ mockedAggregator }, 4); + + await mintToken(stableCoins.usdc, owner, 100_000); + await changeTokenAllowanceTest( + { vault: depositVaultWithMorpho, owner }, + stableCoins.usdc.address, + 100, + ); + await approveBase18( + owner, + stableCoins.usdc, + depositVaultWithMorpho, + 100_000, + ); + + await depositInstantWithMorphoTest( + { + depositVaultWithMorpho, + owner, + mTBILL, + mTokenToUsdDataFeed, + morphoVaultMock, + }, + stableCoins.usdc, + 99_999, + { + revertMessage: 'MV: exceed allowance', + }, + ); + }); + + it('should fail: if mint limit exceeded', async () => { + const { + depositVaultWithMorpho, + mockedAggregator, + owner, + mTBILL, + stableCoins, + dataFeed, + mTokenToUsdDataFeed, + morphoVaultMock, + } = await loadFixture(defaultDeploy); + await addPaymentTokenTest( + { vault: depositVaultWithMorpho, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + await setRoundData({ mockedAggregator }, 4); + + await mintToken(stableCoins.usdc, owner, 100_000); + await setInstantDailyLimitTest( + { vault: depositVaultWithMorpho, owner }, + 1000, + ); + + await approveBase18( + owner, + stableCoins.usdc, + depositVaultWithMorpho, + 100_000, + ); + + await depositInstantWithMorphoTest( + { + depositVaultWithMorpho, + owner, + mTBILL, + mTokenToUsdDataFeed, + morphoVaultMock, + }, + stableCoins.usdc, + 99_999, + { + revertMessage: 'MV: exceed limit', + }, + ); + }); + + it('should fail: if min receive amount greater then actual', async () => { + const { + depositVaultWithMorpho, + mockedAggregator, + owner, + mTBILL, + stableCoins, + dataFeed, + mTokenToUsdDataFeed, + morphoVaultMock, + } = await loadFixture(defaultDeploy); + await addPaymentTokenTest( + { vault: depositVaultWithMorpho, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + await setRoundData({ mockedAggregator }, 4); + + await mintToken(stableCoins.usdc, owner, 100_000); + + await approveBase18( + owner, + stableCoins.usdc, + depositVaultWithMorpho, + 100_000, + ); + + await depositInstantWithMorphoTest( + { + depositVaultWithMorpho, + owner, + mTBILL, + mTokenToUsdDataFeed, + morphoVaultMock, + minAmount: parseUnits('100000'), + }, + stableCoins.usdc, + 99_999, + { + revertMessage: 'DV: minReceiveAmount > actual', + }, + ); + }); + + it('should fail: if some fee = 100%', async () => { + const { + owner, + depositVaultWithMorpho, + stableCoins, + mTBILL, + dataFeed, + mTokenToUsdDataFeed, + morphoVaultMock, + } = await loadFixture(defaultDeploy); + + await mintToken(stableCoins.usdc, owner, 100); + await approveBase18(owner, stableCoins.usdc, depositVaultWithMorpho, 100); + await addPaymentTokenTest( + { vault: depositVaultWithMorpho, owner }, + stableCoins.usdc, + dataFeed.address, + 10000, + true, + ); + await depositInstantWithMorphoTest( + { + depositVaultWithMorpho, + owner, + mTBILL, + mTokenToUsdDataFeed, + morphoVaultMock, + }, + stableCoins.usdc, + 100, + { + revertMessage: 'DV: mToken amount < min', + }, + ); + + await removePaymentTokenTest( + { vault: depositVaultWithMorpho, owner }, + stableCoins.usdc, + ); + await addPaymentTokenTest( + { vault: depositVaultWithMorpho, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + await setInstantFeeTest({ vault: depositVaultWithMorpho, owner }, 10000); + await depositInstantWithMorphoTest( + { + depositVaultWithMorpho, + owner, + mTBILL, + mTokenToUsdDataFeed, + morphoVaultMock, + }, + stableCoins.usdc, + 100, + { revertMessage: 'DV: mToken amount < min' }, + ); + }); + + it('should fail: greenlist enabled and user not in greenlist', async () => { + const { + owner, + depositVaultWithMorpho, + stableCoins, + mTBILL, + mTokenToUsdDataFeed, + morphoVaultMock, + } = await loadFixture(defaultDeploy); + + await depositVaultWithMorpho.setGreenlistEnable(true); + + await depositInstantWithMorphoTest( + { + depositVaultWithMorpho, + owner, + mTBILL, + mTokenToUsdDataFeed, + morphoVaultMock, + }, + stableCoins.usdc, + 1, + { + revertMessage: 'WMAC: hasnt role', + }, + ); + }); + + it('should fail: user in sanctions list', async () => { + const { + owner, + depositVaultWithMorpho, + stableCoins, + mTBILL, + mTokenToUsdDataFeed, + regularAccounts, + mockedSanctionsList, + morphoVaultMock, + } = await loadFixture(defaultDeploy); + + await sanctionUser( + { sanctionsList: mockedSanctionsList }, + regularAccounts[0], + ); + + await depositInstantWithMorphoTest( + { + depositVaultWithMorpho, + owner, + mTBILL, + mTokenToUsdDataFeed, + morphoVaultMock, + }, + stableCoins.usdc, + 1, + { + from: regularAccounts[0], + revertMessage: 'WSL: sanctioned', + }, + ); + }); + + it('morphoDepositsEnabled but no vault for token: fallback to normal flow', async () => { + const { + owner, + depositVaultWithMorpho, + stableCoins, + mTBILL, + mTokenToUsdDataFeed, + regularAccounts, + dataFeed, + morphoVaultMock, + } = await loadFixture(defaultDeploy); + + await setMorphoDepositsEnabledTest( + { depositVaultWithMorpho, owner }, + true, + ); + + await mintToken(stableCoins.usdc, regularAccounts[0], 100); + await approveBase18( + regularAccounts[0], + stableCoins.usdc, + depositVaultWithMorpho, + 100, + ); + await addPaymentTokenTest( + { vault: depositVaultWithMorpho, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + await setMinAmountTest({ vault: depositVaultWithMorpho, owner }, 10); + + await depositInstantWithMorphoTest( + { + depositVaultWithMorpho, + owner, + mTBILL, + mTokenToUsdDataFeed, + morphoVaultMock, + expectedMorphoDeposited: false, + }, + stableCoins.usdc, + 100, + { + from: regularAccounts[0], + }, + ); + }); + + it('morphoDepositsEnabled, vault configured but deposit reverts, fallback enabled: fallback to normal flow', async () => { + const { + owner, + depositVaultWithMorpho, + stableCoins, + mTBILL, + mTokenToUsdDataFeed, + regularAccounts, + dataFeed, + morphoVaultMock, + } = await loadFixture(defaultDeploy); + + await setMorphoDepositsEnabledTest( + { depositVaultWithMorpho, owner }, + true, + ); + await setAutoInvestFallbackEnabledMorphoTest( + { depositVaultWithMorpho, owner }, + true, + ); + + await setMorphoVaultTest( + { depositVaultWithMorpho, owner }, + stableCoins.usdc.address, + morphoVaultMock.address, + ); + + await mintToken(stableCoins.usdc, regularAccounts[0], 100); + await approveBase18( + regularAccounts[0], + stableCoins.usdc, + depositVaultWithMorpho, + 100, + ); + await addPaymentTokenTest( + { vault: depositVaultWithMorpho, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + await setMinAmountTest({ vault: depositVaultWithMorpho, owner }, 10); + + await morphoVaultMock.setShouldRevertDeposit(true); + + await depositInstantWithMorphoTest( + { + depositVaultWithMorpho, + owner, + mTBILL, + mTokenToUsdDataFeed, + morphoVaultMock, + expectedMorphoDeposited: false, + }, + stableCoins.usdc, + 100, + { + from: regularAccounts[0], + }, + ); + }); + + it('should fail: morphoDepositsEnabled, vault configured but deposit reverts, fallback disabled', async () => { + const { + owner, + depositVaultWithMorpho, + stableCoins, + mTBILL, + mTokenToUsdDataFeed, + regularAccounts, + dataFeed, + morphoVaultMock, + } = await loadFixture(defaultDeploy); + + await setMorphoDepositsEnabledTest( + { depositVaultWithMorpho, owner }, + true, + ); + + await setMorphoVaultTest( + { depositVaultWithMorpho, owner }, + stableCoins.usdc.address, + morphoVaultMock.address, + ); + + await mintToken(stableCoins.usdc, regularAccounts[0], 100); + await approveBase18( + regularAccounts[0], + stableCoins.usdc, + depositVaultWithMorpho, + 100, + ); + await addPaymentTokenTest( + { vault: depositVaultWithMorpho, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + await setMinAmountTest({ vault: depositVaultWithMorpho, owner }, 10); + + await morphoVaultMock.setShouldRevertDeposit(true); + + await depositInstantWithMorphoTest( + { + depositVaultWithMorpho, + owner, + mTBILL, + mTokenToUsdDataFeed, + morphoVaultMock, + expectedMorphoDeposited: false, + }, + stableCoins.usdc, + 100, + { + from: regularAccounts[0], + revertMessage: 'DVM: auto-invest failed', + }, + ); + }); + + it('should fail: when Morpho deposit mints zero shares', async () => { + const { + owner, + depositVaultWithMorpho, + stableCoins, + mTBILL, + mTokenToUsdDataFeed, + dataFeed, + morphoVaultMock, + } = await loadFixture(defaultDeploy); + + await setMorphoDepositsEnabledTest( + { depositVaultWithMorpho, owner }, + true, + ); + await setMorphoVaultTest( + { depositVaultWithMorpho, owner }, + stableCoins.usdc.address, + morphoVaultMock.address, + ); + await setMinAmountTest({ vault: depositVaultWithMorpho, owner }, 0); + await morphoVaultMock.setExchangeRate(parseUnits('1000000000000')); + + await mintToken(stableCoins.usdc, owner, 1); + await approveBase18(owner, stableCoins.usdc, depositVaultWithMorpho, 1); + await addPaymentTokenTest( + { vault: depositVaultWithMorpho, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + + await depositInstantWithMorphoTest( + { + depositVaultWithMorpho, + owner, + mTBILL, + mTokenToUsdDataFeed, + morphoVaultMock, + expectedMorphoDeposited: false, + }, + stableCoins.usdc, + 0.000001, + { + revertMessage: 'DVM: zero shares', + }, + ); + }); + + it('deposit 100 USDC when morphoDepositsEnabled is true', async () => { + const { + owner, + depositVaultWithMorpho, + stableCoins, + mTBILL, + mTokenToUsdDataFeed, + regularAccounts, + dataFeed, + morphoVaultMock, + } = await loadFixture(defaultDeploy); + + await setMorphoDepositsEnabledTest( + { depositVaultWithMorpho, owner }, + true, + ); + + await setMorphoVaultTest( + { depositVaultWithMorpho, owner }, + stableCoins.usdc.address, + morphoVaultMock.address, + ); + + await mintToken(stableCoins.usdc, regularAccounts[0], 100); + await approveBase18( + regularAccounts[0], + stableCoins.usdc, + depositVaultWithMorpho, + 100, + ); + await addPaymentTokenTest( + { vault: depositVaultWithMorpho, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + await setMinAmountTest({ vault: depositVaultWithMorpho, owner }, 10); + + await depositInstantWithMorphoTest( + { + depositVaultWithMorpho, + owner, + mTBILL, + mTokenToUsdDataFeed, + morphoVaultMock, + }, + stableCoins.usdc, + 100, + { + from: regularAccounts[0], + }, + ); + }); + + it('when morphoDepositsEnabled is false, normal DV flow', async () => { + const { + owner, + depositVaultWithMorpho, + stableCoins, + mTBILL, + mTokenToUsdDataFeed, + regularAccounts, + dataFeed, + morphoVaultMock, + } = await loadFixture(defaultDeploy); + + await mintToken(stableCoins.usdc, regularAccounts[0], 100); + await approveBase18( + regularAccounts[0], + stableCoins.usdc, + depositVaultWithMorpho, + 100, + ); + await addPaymentTokenTest( + { vault: depositVaultWithMorpho, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + await setMinAmountTest({ vault: depositVaultWithMorpho, owner }, 10); + + await depositInstantWithMorphoTest( + { + depositVaultWithMorpho, + owner, + mTBILL, + mTokenToUsdDataFeed, + morphoVaultMock, + expectedMorphoDeposited: false, + }, + stableCoins.usdc, + 100, + { + from: regularAccounts[0], + }, + ); + }); + + it('deposit with waived fee', async () => { + const { + owner, + depositVaultWithMorpho, + stableCoins, + mTBILL, + mTokenToUsdDataFeed, + regularAccounts, + dataFeed, + morphoVaultMock, + } = await loadFixture(defaultDeploy); + + await setMorphoDepositsEnabledTest( + { depositVaultWithMorpho, owner }, + true, + ); + + await setMorphoVaultTest( + { depositVaultWithMorpho, owner }, + stableCoins.usdc.address, + morphoVaultMock.address, + ); + + await addWaivedFeeAccountTest( + { vault: depositVaultWithMorpho, owner }, + regularAccounts[0].address, + ); + + await mintToken(stableCoins.usdc, regularAccounts[0], 100); + await approveBase18( + regularAccounts[0], + stableCoins.usdc, + depositVaultWithMorpho, + 100, + ); + await addPaymentTokenTest( + { vault: depositVaultWithMorpho, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + await setMinAmountTest({ vault: depositVaultWithMorpho, owner }, 10); + + await depositInstantWithMorphoTest( + { + depositVaultWithMorpho, + owner, + mTBILL, + mTokenToUsdDataFeed, + morphoVaultMock, + waivedFee: true, + }, + stableCoins.usdc, + 100, + { + from: regularAccounts[0], + }, + ); + }); + + it('deposit with greenlist enabled and user in greenlist', async () => { + const { + owner, + depositVaultWithMorpho, + stableCoins, + mTBILL, + greenListableTester, + mTokenToUsdDataFeed, + accessControl, + regularAccounts, + dataFeed, + morphoVaultMock, + } = await loadFixture(defaultDeploy); + + await depositVaultWithMorpho.setGreenlistEnable(true); + + await greenList( + { greenlistable: greenListableTester, accessControl, owner }, + regularAccounts[0], + ); + + await setMorphoDepositsEnabledTest( + { depositVaultWithMorpho, owner }, + true, + ); + + await setMorphoVaultTest( + { depositVaultWithMorpho, owner }, + stableCoins.usdc.address, + morphoVaultMock.address, + ); + + await mintToken(stableCoins.usdc, regularAccounts[0], 100); + await approveBase18( + regularAccounts[0], + stableCoins.usdc, + depositVaultWithMorpho, + 100, + ); + await addPaymentTokenTest( + { vault: depositVaultWithMorpho, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + await setMinAmountTest({ vault: depositVaultWithMorpho, owner }, 10); + + await depositInstantWithMorphoTest( + { + depositVaultWithMorpho, + owner, + mTBILL, + mTokenToUsdDataFeed, + morphoVaultMock, + }, + stableCoins.usdc, + 100, + { + from: regularAccounts[0], + }, + ); + }); + + it('deposit with custom recipient, morphoDepositsEnabled is true', async () => { + const { + owner, + depositVaultWithMorpho, + stableCoins, + mTBILL, + mTokenToUsdDataFeed, + regularAccounts, + dataFeed, + morphoVaultMock, + } = await loadFixture(defaultDeploy); + + await setMorphoDepositsEnabledTest( + { depositVaultWithMorpho, owner }, + true, + ); + + await setMorphoVaultTest( + { depositVaultWithMorpho, owner }, + stableCoins.usdc.address, + morphoVaultMock.address, + ); + + await mintToken(stableCoins.usdc, regularAccounts[0], 100); + await approveBase18( + regularAccounts[0], + stableCoins.usdc, + depositVaultWithMorpho, + 100, + ); + await addPaymentTokenTest( + { vault: depositVaultWithMorpho, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + await setMinAmountTest({ vault: depositVaultWithMorpho, owner }, 10); + + await depositInstantWithMorphoTest( + { + depositVaultWithMorpho, + owner, + mTBILL, + mTokenToUsdDataFeed, + morphoVaultMock, + customRecipient: regularAccounts[1], + }, + stableCoins.usdc, + 100, + { + from: regularAccounts[0], + }, + ); + }); + + it('deposit 100 DAI with morpho enabled (per-asset vault mapping)', async () => { + const { + owner, + depositVaultWithMorpho, + stableCoins, + mTBILL, + mTokenToUsdDataFeed, + regularAccounts, + dataFeed, + } = await loadFixture(defaultDeploy); + + const daiMorphoVault = await new MorphoVaultMock__factory(owner).deploy( + stableCoins.dai.address, + ); + await stableCoins.dai.mint(daiMorphoVault.address, parseUnits('1000000')); + + await setMorphoDepositsEnabledTest( + { depositVaultWithMorpho, owner }, + true, + ); + + await setMorphoVaultTest( + { depositVaultWithMorpho, owner }, + stableCoins.dai.address, + daiMorphoVault.address, + ); + + await mintToken(stableCoins.dai, regularAccounts[0], 100); + await approveBase18( + regularAccounts[0], + stableCoins.dai, + depositVaultWithMorpho, + 100, + ); + await addPaymentTokenTest( + { vault: depositVaultWithMorpho, owner }, + stableCoins.dai, + dataFeed.address, + 0, + false, + ); + await setMinAmountTest({ vault: depositVaultWithMorpho, owner }, 10); + + await depositInstantWithMorphoTest( + { + depositVaultWithMorpho, + owner, + mTBILL, + mTokenToUsdDataFeed, + morphoVaultMock: daiMorphoVault, + }, + stableCoins.dai, + 100, + { + from: regularAccounts[0], + }, + ); + }); + + it('toggle mid-flight: morpho enabled then disabled', async () => { + const { + owner, + depositVaultWithMorpho, + stableCoins, + mTBILL, + mTokenToUsdDataFeed, + regularAccounts, + dataFeed, + morphoVaultMock, + } = await loadFixture(defaultDeploy); + + await setMorphoVaultTest( + { depositVaultWithMorpho, owner }, + stableCoins.usdc.address, + morphoVaultMock.address, + ); + + await addPaymentTokenTest( + { vault: depositVaultWithMorpho, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + await setMinAmountTest({ vault: depositVaultWithMorpho, owner }, 10); + + // Deposit 1: morpho enabled + await setMorphoDepositsEnabledTest( + { depositVaultWithMorpho, owner }, + true, + ); + + await mintToken(stableCoins.usdc, regularAccounts[0], 200); + await approveBase18( + regularAccounts[0], + stableCoins.usdc, + depositVaultWithMorpho, + 200, + ); + + await depositInstantWithMorphoTest( + { + depositVaultWithMorpho, + owner, + mTBILL, + mTokenToUsdDataFeed, + morphoVaultMock, + }, + stableCoins.usdc, + 100, + { + from: regularAccounts[0], + }, + ); + + // Deposit 2: morpho disabled + await setMorphoDepositsEnabledTest( + { depositVaultWithMorpho, owner }, + false, + ); + + await depositInstantWithMorphoTest( + { + depositVaultWithMorpho, + owner, + mTBILL, + mTokenToUsdDataFeed, + morphoVaultMock, + expectedMorphoDeposited: false, + }, + stableCoins.usdc, + 100, + { + from: regularAccounts[0], + }, + ); + }); + + it('should fail: greenlist enabled and recipient not in greenlist (custom recipient overload)', async () => { + const { + owner, + depositVaultWithMorpho, + stableCoins, + mTBILL, + mTokenToUsdDataFeed, + greenListableTester, + accessControl, + customRecipient, + morphoVaultMock, + } = await loadFixture(defaultDeploy); + + await greenList( + { greenlistable: greenListableTester, accessControl, owner }, + owner, + ); + + await depositVaultWithMorpho.setGreenlistEnable(true); + + await depositInstantWithMorphoTest( + { + depositVaultWithMorpho, + owner, + mTBILL, + mTokenToUsdDataFeed, + morphoVaultMock, + customRecipient, + }, + stableCoins.usdc, + 1, + { + revertMessage: 'WMAC: hasnt role', + }, + ); + }); + + it('should fail: recipient in blacklist (custom recipient overload)', async () => { + const { + owner, + depositVaultWithMorpho, + stableCoins, + mTBILL, + mTokenToUsdDataFeed, + accessControl, + regularAccounts, + customRecipient, + morphoVaultMock, + } = await loadFixture(defaultDeploy); + + await blackList( + { blacklistable: depositVaultWithMorpho, accessControl, owner }, + customRecipient, + ); + + await depositInstantWithMorphoTest( + { + depositVaultWithMorpho, + owner, + mTBILL, + mTokenToUsdDataFeed, + morphoVaultMock, + customRecipient, + }, + stableCoins.usdc, + 1, + { + from: regularAccounts[0], + revertMessage: acErrors.WMAC_HAS_ROLE, + }, + ); + }); + + it('should fail: recipient in sanctions list (custom recipient overload)', async () => { + const { + owner, + depositVaultWithMorpho, + stableCoins, + mTBILL, + mTokenToUsdDataFeed, + regularAccounts, + mockedSanctionsList, + customRecipient, + morphoVaultMock, + } = await loadFixture(defaultDeploy); + + await sanctionUser( + { sanctionsList: mockedSanctionsList }, + customRecipient, + ); + + await depositInstantWithMorphoTest( + { + depositVaultWithMorpho, + owner, + mTBILL, + mTokenToUsdDataFeed, + morphoVaultMock, + customRecipient, + }, + stableCoins.usdc, + 1, + { + from: regularAccounts[0], + revertMessage: 'WSL: sanctioned', + }, + ); + }); + + it('should fail: when function paused (custom recipient overload)', async () => { + const { + owner, + depositVaultWithMorpho, + stableCoins, + mTBILL, + dataFeed, + mTokenToUsdDataFeed, + regularAccounts, + customRecipient, + morphoVaultMock, + } = await loadFixture(defaultDeploy); + await mintToken(stableCoins.usdc, regularAccounts[0], 100); + await approveBase18( + regularAccounts[0], + stableCoins.usdc, + depositVaultWithMorpho, + 100, + ); + await addPaymentTokenTest( + { vault: depositVaultWithMorpho, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + const selector = encodeFnSelector( + 'depositInstant(address,uint256,uint256,bytes32,address)', + ); + await pauseVaultFn(depositVaultWithMorpho, selector); + await depositInstantWithMorphoTest( + { + depositVaultWithMorpho, + owner, + mTBILL, + mTokenToUsdDataFeed, + morphoVaultMock, + customRecipient, + }, + stableCoins.usdc, + 100, + { + from: regularAccounts[0], + revertMessage: 'Pausable: fn paused', + }, + ); + }); + }); + + describe('depositRequest()', () => { + it('deposit request 100 USDC', async () => { + const { + owner, + depositVaultWithMorpho, + stableCoins, + mTBILL, + mTokenToUsdDataFeed, + regularAccounts, + dataFeed, + } = await loadFixture(defaultDeploy); + + await mintToken(stableCoins.usdc, regularAccounts[0], 100); + await approveBase18( + regularAccounts[0], + stableCoins.usdc, + depositVaultWithMorpho, + 100, + ); + await addPaymentTokenTest( + { vault: depositVaultWithMorpho, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + await setMinAmountTest({ vault: depositVaultWithMorpho, owner }, 10); + + await depositRequestTest( + { + depositVault: depositVaultWithMorpho, + owner, + mTBILL, + mTokenToUsdDataFeed, + }, + stableCoins.usdc, + 100, + { + from: regularAccounts[0], + }, + ); + }); + + it('deposit request 100 USDC with morpho auto-invest', async () => { + const { + owner, + depositVaultWithMorpho, + stableCoins, + mTBILL, + mTokenToUsdDataFeed, + regularAccounts, + dataFeed, + morphoVaultMock, + } = await loadFixture(defaultDeploy); + + await setMorphoDepositsEnabledTest( + { depositVaultWithMorpho, owner }, + true, + ); + await setMorphoVaultTest( + { depositVaultWithMorpho, owner }, + stableCoins.usdc.address, + morphoVaultMock.address, + ); + await setMinAmountTest({ vault: depositVaultWithMorpho, owner }, 10); + + await mintToken(stableCoins.usdc, regularAccounts[0], 100); + await approveBase18( + regularAccounts[0], + stableCoins.usdc, + depositVaultWithMorpho, + 100, + ); + await addPaymentTokenTest( + { vault: depositVaultWithMorpho, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + + await depositRequestWithMorphoTest( + { + depositVaultWithMorpho, + owner, + mTBILL, + mTokenToUsdDataFeed, + morphoVaultMock, + }, + stableCoins.usdc, + 100, + { + from: regularAccounts[0], + }, + ); + }); + + it('deposit request with morpho auto-invest, deposit reverts, fallback enabled: fallback to normal flow', async () => { + const { + owner, + depositVaultWithMorpho, + stableCoins, + mTBILL, + mTokenToUsdDataFeed, + regularAccounts, + dataFeed, + morphoVaultMock, + } = await loadFixture(defaultDeploy); + + await setMorphoDepositsEnabledTest( + { depositVaultWithMorpho, owner }, + true, + ); + await setAutoInvestFallbackEnabledMorphoTest( + { depositVaultWithMorpho, owner }, + true, + ); + await setMorphoVaultTest( + { depositVaultWithMorpho, owner }, + stableCoins.usdc.address, + morphoVaultMock.address, + ); + await setMinAmountTest({ vault: depositVaultWithMorpho, owner }, 10); + + await mintToken(stableCoins.usdc, regularAccounts[0], 100); + await approveBase18( + regularAccounts[0], + stableCoins.usdc, + depositVaultWithMorpho, + 100, + ); + await addPaymentTokenTest( + { vault: depositVaultWithMorpho, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + + await morphoVaultMock.setShouldRevertDeposit(true); + + await depositRequestWithMorphoTest( + { + depositVaultWithMorpho, + owner, + mTBILL, + mTokenToUsdDataFeed, + morphoVaultMock, + expectedMorphoDeposited: false, + }, + stableCoins.usdc, + 100, + { + from: regularAccounts[0], + }, + ); + }); + + it('should fail: deposit request with morpho auto-invest, deposit reverts, fallback disabled', async () => { + const { + owner, + depositVaultWithMorpho, + stableCoins, + mTBILL, + mTokenToUsdDataFeed, + regularAccounts, + dataFeed, + morphoVaultMock, + } = await loadFixture(defaultDeploy); + + await setMorphoDepositsEnabledTest( + { depositVaultWithMorpho, owner }, + true, + ); + await setMorphoVaultTest( + { depositVaultWithMorpho, owner }, + stableCoins.usdc.address, + morphoVaultMock.address, + ); + await setMinAmountTest({ vault: depositVaultWithMorpho, owner }, 10); + + await mintToken(stableCoins.usdc, regularAccounts[0], 100); + await approveBase18( + regularAccounts[0], + stableCoins.usdc, + depositVaultWithMorpho, + 100, + ); + await addPaymentTokenTest( + { vault: depositVaultWithMorpho, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + + await morphoVaultMock.setShouldRevertDeposit(true); + + await depositRequestWithMorphoTest( + { + depositVaultWithMorpho, + owner, + mTBILL, + mTokenToUsdDataFeed, + morphoVaultMock, + expectedMorphoDeposited: false, + }, + stableCoins.usdc, + 100, + { + from: regularAccounts[0], + revertMessage: 'DVM: auto-invest failed', + }, + ); + }); + }); + + describe('approveRequest()', () => { + it('should fail: call from address without DEPOSIT_VAULT_ADMIN_ROLE role', async () => { + const { + owner, + regularAccounts, + depositVaultWithMorpho, + stableCoins, + mTBILL, + mTokenToUsdDataFeed, + dataFeed, + } = await loadFixture(defaultDeploy); + + await mintToken(stableCoins.usdc, owner, 100); + await approveBase18(owner, stableCoins.usdc, depositVaultWithMorpho, 100); + await addPaymentTokenTest( + { vault: depositVaultWithMorpho, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + await setMinAmountTest({ vault: depositVaultWithMorpho, owner }, 10); + + const request = await depositRequestTest( + { + depositVault: depositVaultWithMorpho, + owner, + mTBILL, + mTokenToUsdDataFeed, + }, + stableCoins.usdc, + 100, + ); + + await approveRequestTest( + { + depositVault: depositVaultWithMorpho, + owner, + mTBILL, + mTokenToUsdDataFeed, + }, + request.requestId!, + request.rate!, + { + from: regularAccounts[0], + revertMessage: 'WMAC: hasnt role', + }, + ); + }); + + it('approve request: happy path', async () => { + const { + owner, + depositVaultWithMorpho, + stableCoins, + mTBILL, + mTokenToUsdDataFeed, + dataFeed, + } = await loadFixture(defaultDeploy); + + await mintToken(stableCoins.usdc, owner, 100); + await approveBase18(owner, stableCoins.usdc, depositVaultWithMorpho, 100); + await addPaymentTokenTest( + { vault: depositVaultWithMorpho, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + await setMinAmountTest({ vault: depositVaultWithMorpho, owner }, 10); + + const request = await depositRequestTest( + { + depositVault: depositVaultWithMorpho, + owner, + mTBILL, + mTokenToUsdDataFeed, + }, + stableCoins.usdc, + 100, + ); + + await approveRequestTest( + { + depositVault: depositVaultWithMorpho, + owner, + mTBILL, + mTokenToUsdDataFeed, + }, + request.requestId!, + request.rate!, + ); + }); + }); + + describe('safeApproveRequest()', () => { + it('should fail: call from address without DEPOSIT_VAULT_ADMIN_ROLE role', async () => { + const { + owner, + regularAccounts, + depositVaultWithMorpho, + stableCoins, + mTBILL, + mTokenToUsdDataFeed, + dataFeed, + } = await loadFixture(defaultDeploy); + + await mintToken(stableCoins.usdc, owner, 100); + await approveBase18(owner, stableCoins.usdc, depositVaultWithMorpho, 100); + await addPaymentTokenTest( + { vault: depositVaultWithMorpho, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + await setMinAmountTest({ vault: depositVaultWithMorpho, owner }, 10); + + const request = await depositRequestTest( + { + depositVault: depositVaultWithMorpho, + owner, + mTBILL, + mTokenToUsdDataFeed, + }, + stableCoins.usdc, + 100, + ); + + await safeApproveRequestTest( + { + depositVault: depositVaultWithMorpho, + owner, + mTBILL, + mTokenToUsdDataFeed, + }, + request.requestId!, + request.rate!, + { + from: regularAccounts[0], + revertMessage: 'WMAC: hasnt role', + }, + ); + }); + + it('safe approve request: happy path', async () => { + const { + owner, + depositVaultWithMorpho, + stableCoins, + mTBILL, + mTokenToUsdDataFeed, + dataFeed, + } = await loadFixture(defaultDeploy); + + await mintToken(stableCoins.usdc, owner, 100); + await approveBase18(owner, stableCoins.usdc, depositVaultWithMorpho, 100); + await addPaymentTokenTest( + { vault: depositVaultWithMorpho, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + await setMinAmountTest({ vault: depositVaultWithMorpho, owner }, 10); + + const request = await depositRequestTest( + { + depositVault: depositVaultWithMorpho, + owner, + mTBILL, + mTokenToUsdDataFeed, + }, + stableCoins.usdc, + 100, + ); + + await safeApproveRequestTest( + { + depositVault: depositVaultWithMorpho, + owner, + mTBILL, + mTokenToUsdDataFeed, + }, + request.requestId!, + request.rate!, + ); + }); + }); + + describe('safeBulkApproveRequest()', () => { + it('should fail: call from address without DEPOSIT_VAULT_ADMIN_ROLE role', async () => { + const { + owner, + regularAccounts, + depositVaultWithMorpho, + stableCoins, + mTBILL, + mTokenToUsdDataFeed, + dataFeed, + } = await loadFixture(defaultDeploy); + + await mintToken(stableCoins.usdc, owner, 100); + await approveBase18(owner, stableCoins.usdc, depositVaultWithMorpho, 100); + await addPaymentTokenTest( + { vault: depositVaultWithMorpho, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + await setMinAmountTest({ vault: depositVaultWithMorpho, owner }, 10); + + const request = await depositRequestTest( + { + depositVault: depositVaultWithMorpho, + owner, + mTBILL, + mTokenToUsdDataFeed, + }, + stableCoins.usdc, + 100, + ); + + await safeBulkApproveRequestTest( + { + depositVault: depositVaultWithMorpho, + owner, + mTBILL, + mTokenToUsdDataFeed, + }, + [{ id: request.requestId! }], + request.rate!, + { + from: regularAccounts[0], + revertMessage: 'WMAC: hasnt role', + }, + ); + }); + + it('safe bulk approve request: happy path', async () => { + const { + owner, + depositVaultWithMorpho, + stableCoins, + mTBILL, + mTokenToUsdDataFeed, + dataFeed, + } = await loadFixture(defaultDeploy); + + await mintToken(stableCoins.usdc, owner, 100); + await approveBase18(owner, stableCoins.usdc, depositVaultWithMorpho, 100); + await addPaymentTokenTest( + { vault: depositVaultWithMorpho, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + await setMinAmountTest({ vault: depositVaultWithMorpho, owner }, 10); + + const request = await depositRequestTest( + { + depositVault: depositVaultWithMorpho, + owner, + mTBILL, + mTokenToUsdDataFeed, + }, + stableCoins.usdc, + 100, + ); + + await safeBulkApproveRequestTest( + { + depositVault: depositVaultWithMorpho, + owner, + mTBILL, + mTokenToUsdDataFeed, + }, + [{ id: request.requestId! }], + request.rate!, + ); + }); + }); + + describe('rejectRequest()', () => { + it('should fail: call from address without DEPOSIT_VAULT_ADMIN_ROLE role', async () => { + const { + owner, + regularAccounts, + depositVaultWithMorpho, + stableCoins, + mTBILL, + mTokenToUsdDataFeed, + dataFeed, + } = await loadFixture(defaultDeploy); + + await mintToken(stableCoins.usdc, owner, 100); + await approveBase18(owner, stableCoins.usdc, depositVaultWithMorpho, 100); + await addPaymentTokenTest( + { vault: depositVaultWithMorpho, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + await setMinAmountTest({ vault: depositVaultWithMorpho, owner }, 10); + + const request = await depositRequestTest( + { + depositVault: depositVaultWithMorpho, + owner, + mTBILL, + mTokenToUsdDataFeed, + }, + stableCoins.usdc, + 100, + ); + + await rejectRequestTest( + { + depositVault: depositVaultWithMorpho, + owner, + mTBILL, + mTokenToUsdDataFeed, + }, + request.requestId!, + { + from: regularAccounts[0], + revertMessage: 'WMAC: hasnt role', + }, + ); + }); + + it('reject request: happy path', async () => { + const { + owner, + depositVaultWithMorpho, + stableCoins, + mTBILL, + mTokenToUsdDataFeed, + dataFeed, + } = await loadFixture(defaultDeploy); + + await mintToken(stableCoins.usdc, owner, 100); + await approveBase18(owner, stableCoins.usdc, depositVaultWithMorpho, 100); + await addPaymentTokenTest( + { vault: depositVaultWithMorpho, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + await setMinAmountTest({ vault: depositVaultWithMorpho, owner }, 10); + + const request = await depositRequestTest( + { + depositVault: depositVaultWithMorpho, + owner, + mTBILL, + mTokenToUsdDataFeed, + }, + stableCoins.usdc, + 100, + ); + + await rejectRequestTest( + { + depositVault: depositVaultWithMorpho, + owner, + mTBILL, + mTokenToUsdDataFeed, + }, + request.requestId!, + ); + }); + }); +}); diff --git a/test/unit/RedemptionVaultWithAave.test.ts b/test/unit/RedemptionVaultWithAave.test.ts new file mode 100644 index 00000000..72869bae --- /dev/null +++ b/test/unit/RedemptionVaultWithAave.test.ts @@ -0,0 +1,2703 @@ +import { loadFixture } from '@nomicfoundation/hardhat-network-helpers'; +import { expect } from 'chai'; +import { constants } from 'ethers'; +import { parseUnits } from 'ethers/lib/utils'; +import { ethers } from 'hardhat'; + +import { encodeFnSelector } from '../../helpers/utils'; +import { + ManageableVaultTester__factory, + RedemptionVaultWithAaveTest__factory, +} from '../../typechain-types'; +import { acErrors, blackList, greenList } from '../common/ac.helpers'; +import { + approveBase18, + mintToken, + pauseVaultFn, +} from '../common/common.helpers'; +import { setRoundData } from '../common/data-feed.helpers'; +import { defaultDeploy } from '../common/fixtures'; +import { + addPaymentTokenTest, + setInstantFeeTest, + setMinAmountTest, + setInstantDailyLimitTest, + addWaivedFeeAccountTest, + removeWaivedFeeAccountTest, + setVariabilityToleranceTest, + removePaymentTokenTest, + withdrawTest, + changeTokenFeeTest, + changeTokenAllowanceTest, +} from '../common/manageable-vault.helpers'; +import { + approveRedeemRequestTest, + redeemFiatRequestTest, + redeemInstantTest, + redeemRequestTest, + rejectRedeemRequestTest, + safeApproveRedeemRequestTest, + setFiatAdditionalFeeTest, + setMinFiatRedeemAmountTest, +} from '../common/redemption-vault.helpers'; +import { sanctionUser } from '../common/with-sanctions-list.helpers'; + +describe('RedemptionVaultWithAave', function () { + it('deployment', async () => { + const { + redemptionVaultWithAave, + aavePoolMock, + mTBILL, + tokensReceiver, + feeReceiver, + mTokenToUsdDataFeed, + stableCoins, + roles, + } = await loadFixture(defaultDeploy); + + expect(await redemptionVaultWithAave.mToken()).eq(mTBILL.address); + + expect(await redemptionVaultWithAave.ONE_HUNDRED_PERCENT()).eq('10000'); + + expect(await redemptionVaultWithAave.paused()).eq(false); + + expect(await redemptionVaultWithAave.tokensReceiver()).eq( + tokensReceiver.address, + ); + expect(await redemptionVaultWithAave.feeReceiver()).eq(feeReceiver.address); + + expect(await redemptionVaultWithAave.minAmount()).eq(1000); + expect(await redemptionVaultWithAave.minFiatRedeemAmount()).eq(1000); + + expect(await redemptionVaultWithAave.instantFee()).eq('100'); + + expect(await redemptionVaultWithAave.instantDailyLimit()).eq( + parseUnits('100000'), + ); + + expect(await redemptionVaultWithAave.mTokenDataFeed()).eq( + mTokenToUsdDataFeed.address, + ); + expect(await redemptionVaultWithAave.variationTolerance()).eq(1); + + expect(await redemptionVaultWithAave.vaultRole()).eq( + roles.tokenRoles.mTBILL.redemptionVaultAdmin, + ); + + expect(await redemptionVaultWithAave.MANUAL_FULLFILMENT_TOKEN()).eq( + ethers.constants.AddressZero, + ); + + expect( + await redemptionVaultWithAave.aavePools(stableCoins.usdc.address), + ).eq(aavePoolMock.address); + }); + + it('failing deployment', async () => { + const { + mTBILL, + tokensReceiver, + feeReceiver, + mTokenToUsdDataFeed, + accessControl, + mockedSanctionsList, + owner, + } = await loadFixture(defaultDeploy); + + const redemptionVaultWithAave = + await new RedemptionVaultWithAaveTest__factory(owner).deploy(); + + await expect( + redemptionVaultWithAave.initialize( + accessControl.address, + { + mToken: mTBILL.address, + mTokenDataFeed: mTokenToUsdDataFeed.address, + }, + { + feeReceiver: feeReceiver.address, + tokensReceiver: tokensReceiver.address, + }, + { + instantFee: 100, + instantDailyLimit: parseUnits('100000'), + }, + mockedSanctionsList.address, + 1, + parseUnits('100'), + { + fiatAdditionalFee: 10000, + fiatFlatFee: parseUnits('1'), + minFiatRedeemAmount: parseUnits('100'), + }, + constants.AddressZero, + ), + ).to.be.reverted; + }); + + describe('initialization', () => { + it('should fail: call initialize() when already initialized', async () => { + const { redemptionVaultWithAave } = await loadFixture(defaultDeploy); + + await expect( + redemptionVaultWithAave.initialize( + constants.AddressZero, + { + mToken: constants.AddressZero, + mTokenDataFeed: constants.AddressZero, + }, + { + feeReceiver: constants.AddressZero, + tokensReceiver: constants.AddressZero, + }, + { + instantFee: 0, + instantDailyLimit: 0, + }, + constants.AddressZero, + 0, + 0, + { + fiatAdditionalFee: 0, + fiatFlatFee: 0, + minFiatRedeemAmount: 0, + }, + constants.AddressZero, + ), + ).revertedWith('Initializable: contract is already initialized'); + }); + + it('should fail: call with initializing == false', async () => { + const { + owner, + accessControl, + mTBILL, + tokensReceiver, + feeReceiver, + mTokenToUsdDataFeed, + mockedSanctionsList, + } = await loadFixture(defaultDeploy); + + const vault = await new ManageableVaultTester__factory(owner).deploy(); + + await expect( + vault.initializeWithoutInitializer( + accessControl.address, + { + mToken: mTBILL.address, + mTokenDataFeed: mTokenToUsdDataFeed.address, + }, + { + feeReceiver: feeReceiver.address, + tokensReceiver: tokensReceiver.address, + }, + { + instantFee: 100, + instantDailyLimit: parseUnits('100000'), + }, + mockedSanctionsList.address, + 1, + 1000, + ), + ).revertedWith('Initializable: contract is not initializing'); + }); + }); + + describe('setAavePool()', () => { + it('should fail: call from address without vault admin role', async () => { + const { + redemptionVaultWithAave, + regularAccounts, + stableCoins, + aavePoolMock, + } = await loadFixture(defaultDeploy); + await expect( + redemptionVaultWithAave + .connect(regularAccounts[0]) + .setAavePool(stableCoins.usdc.address, aavePoolMock.address), + ).to.be.revertedWith('WMAC: hasnt role'); + }); + + it('should fail: zero address', async () => { + const { redemptionVaultWithAave, stableCoins } = await loadFixture( + defaultDeploy, + ); + await expect( + redemptionVaultWithAave.setAavePool( + stableCoins.usdc.address, + constants.AddressZero, + ), + ).to.be.revertedWith('zero address'); + }); + + it('should succeed and emit SetAavePool event', async () => { + const { redemptionVaultWithAave, owner, stableCoins, aavePoolMock } = + await loadFixture(defaultDeploy); + + await expect( + redemptionVaultWithAave.setAavePool( + stableCoins.usdc.address, + aavePoolMock.address, + ), + ) + .to.emit(redemptionVaultWithAave, 'SetAavePool') + .withArgs( + owner.address, + stableCoins.usdc.address, + aavePoolMock.address, + ); + + expect( + await redemptionVaultWithAave.aavePools(stableCoins.usdc.address), + ).eq(aavePoolMock.address); + }); + }); + + describe('removeAavePool()', () => { + it('should fail: call from address without vault admin role', async () => { + const { redemptionVaultWithAave, regularAccounts, stableCoins } = + await loadFixture(defaultDeploy); + await expect( + redemptionVaultWithAave + .connect(regularAccounts[0]) + .removeAavePool(stableCoins.usdc.address), + ).to.be.revertedWith('WMAC: hasnt role'); + }); + + it('should fail: pool not set', async () => { + const { redemptionVaultWithAave, stableCoins } = await loadFixture( + defaultDeploy, + ); + await expect( + redemptionVaultWithAave.removeAavePool(stableCoins.dai.address), + ).to.be.revertedWith('RVA: pool not set'); + }); + + it('should succeed and emit RemoveAavePool event', async () => { + const { redemptionVaultWithAave, owner, stableCoins } = await loadFixture( + defaultDeploy, + ); + + await expect( + redemptionVaultWithAave.removeAavePool(stableCoins.usdc.address), + ) + .to.emit(redemptionVaultWithAave, 'RemoveAavePool') + .withArgs(owner.address, stableCoins.usdc.address); + + expect( + await redemptionVaultWithAave.aavePools(stableCoins.usdc.address), + ).eq(constants.AddressZero); + }); + }); + + describe('setMinAmount()', () => { + it('should fail: call from address without vault admin role', async () => { + const { owner, redemptionVaultWithAave, regularAccounts } = + await loadFixture(defaultDeploy); + + await setMinAmountTest({ vault: redemptionVaultWithAave, owner }, 1.1, { + from: regularAccounts[0], + revertMessage: acErrors.WMAC_HASNT_ROLE, + }); + }); + + it('call from address with vault admin role', async () => { + const { owner, redemptionVaultWithAave } = await loadFixture( + defaultDeploy, + ); + await setMinAmountTest({ vault: redemptionVaultWithAave, owner }, 1.1); + }); + }); + + describe('setMinFiatRedeemAmount()', () => { + it('should fail: call from address without vault admin role', async () => { + const { owner, redemptionVaultWithAave, regularAccounts } = + await loadFixture(defaultDeploy); + + await setMinFiatRedeemAmountTest( + { redemptionVault: redemptionVaultWithAave, owner }, + 1.1, + { + from: regularAccounts[0], + revertMessage: acErrors.WMAC_HASNT_ROLE, + }, + ); + }); + + it('call from address with vault admin role', async () => { + const { owner, redemptionVaultWithAave } = await loadFixture( + defaultDeploy, + ); + await setMinFiatRedeemAmountTest( + { redemptionVault: redemptionVaultWithAave, owner }, + 1.1, + ); + }); + }); + + describe('setFiatAdditionalFee()', () => { + it('should fail: call from address without vault admin role', async () => { + const { owner, redemptionVaultWithAave, regularAccounts } = + await loadFixture(defaultDeploy); + + await setFiatAdditionalFeeTest( + { redemptionVault: redemptionVaultWithAave, owner }, + 100, + { + from: regularAccounts[0], + revertMessage: acErrors.WMAC_HASNT_ROLE, + }, + ); + }); + + it('call from address with vault admin role', async () => { + const { owner, redemptionVaultWithAave } = await loadFixture( + defaultDeploy, + ); + await setFiatAdditionalFeeTest( + { redemptionVault: redemptionVaultWithAave, owner }, + 100, + ); + }); + }); + + describe('setInstantDailyLimit()', () => { + it('should fail: call from address without vault admin role', async () => { + const { owner, redemptionVaultWithAave, regularAccounts } = + await loadFixture(defaultDeploy); + + await setInstantDailyLimitTest( + { vault: redemptionVaultWithAave, owner }, + parseUnits('1000'), + { + from: regularAccounts[0], + revertMessage: acErrors.WMAC_HASNT_ROLE, + }, + ); + }); + + it('should fail: try to set 0 limit', async () => { + const { owner, redemptionVaultWithAave } = await loadFixture( + defaultDeploy, + ); + + await setInstantDailyLimitTest( + { vault: redemptionVaultWithAave, owner }, + constants.Zero, + { + revertMessage: 'MV: limit zero', + }, + ); + }); + + it('call from address with vault admin role', async () => { + const { owner, redemptionVaultWithAave } = await loadFixture( + defaultDeploy, + ); + await setInstantDailyLimitTest( + { vault: redemptionVaultWithAave, owner }, + parseUnits('1000'), + ); + }); + }); + + describe('addPaymentToken()', () => { + it('should fail: call from address without vault admin role', async () => { + const { owner, redemptionVaultWithAave, regularAccounts } = + await loadFixture(defaultDeploy); + await addPaymentTokenTest( + { vault: redemptionVaultWithAave, owner }, + ethers.constants.AddressZero, + ethers.constants.AddressZero, + 0, + true, + constants.MaxUint256, + { revertMessage: acErrors.WMAC_HASNT_ROLE, from: regularAccounts[0] }, + ); + }); + + it('call from address with vault admin role', async () => { + const { owner, redemptionVaultWithAave, stableCoins, dataFeed } = + await loadFixture(defaultDeploy); + await addPaymentTokenTest( + { vault: redemptionVaultWithAave, owner }, + stableCoins.dai, + dataFeed.address, + 0, + true, + ); + }); + }); + + describe('removePaymentToken()', () => { + it('should fail: call from address without vault admin role', async () => { + const { owner, redemptionVaultWithAave, stableCoins, dataFeed } = + await loadFixture(defaultDeploy); + await addPaymentTokenTest( + { vault: redemptionVaultWithAave, owner }, + stableCoins.dai, + dataFeed.address, + 0, + true, + ); + await removePaymentTokenTest( + { vault: redemptionVaultWithAave, owner }, + stableCoins.dai, + { + from: (await ethers.getSigners())[10], + revertMessage: acErrors.WMAC_HASNT_ROLE, + }, + ); + }); + + it('call from address with vault admin role', async () => { + const { owner, redemptionVaultWithAave, stableCoins, dataFeed } = + await loadFixture(defaultDeploy); + await addPaymentTokenTest( + { vault: redemptionVaultWithAave, owner }, + stableCoins.dai, + dataFeed.address, + 0, + true, + ); + await removePaymentTokenTest( + { vault: redemptionVaultWithAave, owner }, + stableCoins.dai, + ); + }); + }); + + describe('addWaivedFeeAccount()', () => { + it('should fail: call from address without vault admin role', async () => { + const { owner, redemptionVaultWithAave, regularAccounts } = + await loadFixture(defaultDeploy); + await addWaivedFeeAccountTest( + { vault: redemptionVaultWithAave, owner }, + regularAccounts[0].address, + { + from: regularAccounts[0], + revertMessage: acErrors.WMAC_HASNT_ROLE, + }, + ); + }); + + it('call from address with vault admin role', async () => { + const { owner, redemptionVaultWithAave, regularAccounts } = + await loadFixture(defaultDeploy); + await addWaivedFeeAccountTest( + { vault: redemptionVaultWithAave, owner }, + regularAccounts[0].address, + ); + }); + }); + + describe('removeWaivedFeeAccount()', () => { + it('should fail: call from address without vault admin role', async () => { + const { owner, redemptionVaultWithAave, regularAccounts } = + await loadFixture(defaultDeploy); + await addWaivedFeeAccountTest( + { vault: redemptionVaultWithAave, owner }, + regularAccounts[0].address, + ); + await removeWaivedFeeAccountTest( + { vault: redemptionVaultWithAave, owner }, + regularAccounts[0].address, + { + from: regularAccounts[0], + revertMessage: acErrors.WMAC_HASNT_ROLE, + }, + ); + }); + + it('call from address with vault admin role', async () => { + const { owner, redemptionVaultWithAave, regularAccounts } = + await loadFixture(defaultDeploy); + await addWaivedFeeAccountTest( + { vault: redemptionVaultWithAave, owner }, + regularAccounts[0].address, + ); + await removeWaivedFeeAccountTest( + { vault: redemptionVaultWithAave, owner }, + regularAccounts[0].address, + ); + }); + }); + + describe('setFee()', () => { + it('should fail: call from address without vault admin role', async () => { + const { owner, redemptionVaultWithAave, regularAccounts } = + await loadFixture(defaultDeploy); + await setInstantFeeTest({ vault: redemptionVaultWithAave, owner }, 100, { + from: regularAccounts[0], + revertMessage: acErrors.WMAC_HASNT_ROLE, + }); + }); + + it('call from address with vault admin role', async () => { + const { owner, redemptionVaultWithAave } = await loadFixture( + defaultDeploy, + ); + await setInstantFeeTest({ vault: redemptionVaultWithAave, owner }, 100); + }); + }); + + describe('setVariabilityTolerance()', () => { + it('should fail: call from address without vault admin role', async () => { + const { owner, redemptionVaultWithAave, regularAccounts } = + await loadFixture(defaultDeploy); + await setVariabilityToleranceTest( + { vault: redemptionVaultWithAave, owner }, + 100, + { + from: regularAccounts[0], + revertMessage: acErrors.WMAC_HASNT_ROLE, + }, + ); + }); + + it('call from address with vault admin role', async () => { + const { owner, redemptionVaultWithAave } = await loadFixture( + defaultDeploy, + ); + await setVariabilityToleranceTest( + { vault: redemptionVaultWithAave, owner }, + 100, + ); + }); + }); + + describe('withdrawToken()', () => { + it('should fail: call from address without vault admin role', async () => { + const { owner, redemptionVaultWithAave, stableCoins, regularAccounts } = + await loadFixture(defaultDeploy); + await mintToken(stableCoins.dai, redemptionVaultWithAave, 1); + await withdrawTest( + { vault: redemptionVaultWithAave, owner }, + stableCoins.dai, + 1, + regularAccounts[0], + { + from: regularAccounts[0], + revertMessage: acErrors.WMAC_HASNT_ROLE, + }, + ); + }); + + it('call from address with vault admin role', async () => { + const { owner, redemptionVaultWithAave, stableCoins, regularAccounts } = + await loadFixture(defaultDeploy); + await mintToken(stableCoins.dai, redemptionVaultWithAave, 1); + await withdrawTest( + { vault: redemptionVaultWithAave, owner }, + stableCoins.dai, + 1, + regularAccounts[0], + ); + }); + }); + + describe('freeFromMinAmount()', async () => { + it('should fail: call from address without vault admin role', async () => { + const { redemptionVaultWithAave, regularAccounts } = await loadFixture( + defaultDeploy, + ); + await expect( + redemptionVaultWithAave + .connect(regularAccounts[0]) + .freeFromMinAmount(regularAccounts[1].address, true), + ).to.be.revertedWith('WMAC: hasnt role'); + }); + it('should not fail', async () => { + const { redemptionVaultWithAave, regularAccounts } = await loadFixture( + defaultDeploy, + ); + await expect( + redemptionVaultWithAave.freeFromMinAmount( + regularAccounts[0].address, + true, + ), + ).to.not.reverted; + + expect( + await redemptionVaultWithAave.isFreeFromMinAmount( + regularAccounts[0].address, + ), + ).to.eq(true); + }); + }); + + describe('changeTokenAllowance()', () => { + it('should fail: call from address without vault admin role', async () => { + const { owner, redemptionVaultWithAave, stableCoins, dataFeed } = + await loadFixture(defaultDeploy); + await addPaymentTokenTest( + { vault: redemptionVaultWithAave, owner }, + stableCoins.dai, + dataFeed.address, + 0, + true, + ); + await changeTokenAllowanceTest( + { vault: redemptionVaultWithAave, owner }, + stableCoins.dai.address, + 100, + { + from: (await ethers.getSigners())[10], + revertMessage: acErrors.WMAC_HASNT_ROLE, + }, + ); + }); + + it('call from address with vault admin role', async () => { + const { owner, redemptionVaultWithAave, stableCoins, dataFeed } = + await loadFixture(defaultDeploy); + await addPaymentTokenTest( + { vault: redemptionVaultWithAave, owner }, + stableCoins.dai, + dataFeed.address, + 0, + true, + ); + await changeTokenAllowanceTest( + { vault: redemptionVaultWithAave, owner }, + stableCoins.dai.address, + 100, + ); + }); + }); + + describe('changeTokenFee()', () => { + it('should fail: call from address without vault admin role', async () => { + const { owner, redemptionVaultWithAave, stableCoins, dataFeed } = + await loadFixture(defaultDeploy); + await addPaymentTokenTest( + { vault: redemptionVaultWithAave, owner }, + stableCoins.dai, + dataFeed.address, + 0, + true, + ); + await changeTokenFeeTest( + { vault: redemptionVaultWithAave, owner }, + stableCoins.dai.address, + 100, + { + from: (await ethers.getSigners())[10], + revertMessage: acErrors.WMAC_HASNT_ROLE, + }, + ); + }); + + it('call from address with vault admin role', async () => { + const { owner, redemptionVaultWithAave, stableCoins, dataFeed } = + await loadFixture(defaultDeploy); + await addPaymentTokenTest( + { vault: redemptionVaultWithAave, owner }, + stableCoins.dai, + dataFeed.address, + 0, + true, + ); + await changeTokenFeeTest( + { vault: redemptionVaultWithAave, owner }, + stableCoins.dai.address, + 100, + ); + }); + }); + + describe('checkAndRedeemAave()', () => { + it('should not withdraw from Aave when contract has enough balance', async () => { + const { redemptionVaultWithAave, stableCoins, aUSDC } = await loadFixture( + defaultDeploy, + ); + + const usdcAmount = parseUnits('1000', 8); + await stableCoins.usdc.mint(redemptionVaultWithAave.address, usdcAmount); + + const balanceBefore = await stableCoins.usdc.balanceOf( + redemptionVaultWithAave.address, + ); + const aTokenBefore = await aUSDC.balanceOf( + redemptionVaultWithAave.address, + ); + + await redemptionVaultWithAave.checkAndRedeemAave( + stableCoins.usdc.address, + parseUnits('500', 8), + ); + + const balanceAfter = await stableCoins.usdc.balanceOf( + redemptionVaultWithAave.address, + ); + const aTokenAfter = await aUSDC.balanceOf( + redemptionVaultWithAave.address, + ); + expect(balanceAfter).to.equal(balanceBefore); + expect(aTokenAfter).to.equal(aTokenBefore); + }); + + it('should withdraw missing amount from Aave', async () => { + const { redemptionVaultWithAave, stableCoins, aUSDC } = await loadFixture( + defaultDeploy, + ); + + // Vault has 500 USDC, needs 1000 + const initialUsdc = parseUnits('500', 8); + await stableCoins.usdc.mint(redemptionVaultWithAave.address, initialUsdc); + + // Vault has 600 aUSDC + const aTokenAmount = parseUnits('600', 8); + await aUSDC.mint(redemptionVaultWithAave.address, aTokenAmount); + + await redemptionVaultWithAave.checkAndRedeemAave( + stableCoins.usdc.address, + parseUnits('1000', 8), + ); + + // Vault should now have 1000 USDC (500 original + 500 withdrawn from Aave) + const usdcAfter = await stableCoins.usdc.balanceOf( + redemptionVaultWithAave.address, + ); + expect(usdcAfter).to.equal(parseUnits('1000', 8)); + + // aToken balance should decrease by 500 + const aTokenAfter = await aUSDC.balanceOf( + redemptionVaultWithAave.address, + ); + expect(aTokenAfter).to.equal(parseUnits('100', 8)); + }); + + it('should revert when no pool set for token', async () => { + const { redemptionVaultWithAave, stableCoins } = await loadFixture( + defaultDeploy, + ); + + await expect( + redemptionVaultWithAave.checkAndRedeemAave( + stableCoins.dai.address, + parseUnits('1000', 9), + ), + ).to.be.revertedWith('RVA: no pool for token'); + }); + + it('should revert when contract has insufficient aToken balance', async () => { + const { redemptionVaultWithAave, stableCoins, aUSDC } = await loadFixture( + defaultDeploy, + ); + + // Vault has 200 USDC, needs 1000 + await stableCoins.usdc.mint( + redemptionVaultWithAave.address, + parseUnits('200', 8), + ); + + // Vault has only 300 aUSDC (not enough for 800 missing) + await aUSDC.mint(redemptionVaultWithAave.address, parseUnits('300', 8)); + + await expect( + redemptionVaultWithAave.checkAndRedeemAave( + stableCoins.usdc.address, + parseUnits('1000', 8), + ), + ).to.be.revertedWith('RVA: insufficient aToken balance'); + }); + + it('should revert when Aave pool has insufficient underlying liquidity', async () => { + const { redemptionVaultWithAave, stableCoins, aUSDC, aavePoolMock } = + await loadFixture(defaultDeploy); + + // Vault needs to withdraw from Aave + await stableCoins.usdc.mint( + redemptionVaultWithAave.address, + parseUnits('200', 8), + ); + + // Vault has enough aTokens + await aUSDC.mint(redemptionVaultWithAave.address, parseUnits('1000', 8)); + + // Drain the pool's USDC + const poolBalance = await stableCoins.usdc.balanceOf( + aavePoolMock.address, + ); + await aavePoolMock.withdrawAdmin( + stableCoins.usdc.address, + ( + await ethers.getSigners() + )[10].address, + poolBalance, + ); + + await expect( + redemptionVaultWithAave.checkAndRedeemAave( + stableCoins.usdc.address, + parseUnits('1000', 8), + ), + ).to.be.revertedWith('AaveV3PoolMock: InsufficientLiquidity'); + }); + + it('should revert when Aave withdraws less than missing amount', async () => { + const { redemptionVaultWithAave, stableCoins, aUSDC, aavePoolMock } = + await loadFixture(defaultDeploy); + + // Vault has 200 USDC, needs 1000 + await stableCoins.usdc.mint( + redemptionVaultWithAave.address, + parseUnits('200', 8), + ); + + // Vault has enough aTokens to cover the gap + await aUSDC.mint(redemptionVaultWithAave.address, parseUnits('1000', 8)); + + // Simulate partial Aave withdrawal + await aavePoolMock.setWithdrawReturnBps(5000); + + await expect( + redemptionVaultWithAave.checkAndRedeemAave( + stableCoins.usdc.address, + parseUnits('1000', 8), + ), + ).to.be.revertedWith('RVA: withdrawn < needed'); + }); + }); + + describe('redeemInstant()', () => { + it('should fail: when there is no token in vault', async () => { + const { + owner, + redemptionVaultWithAave, + stableCoins, + mTBILL, + mTokenToUsdDataFeed, + } = await loadFixture(defaultDeploy); + + await redeemInstantTest( + { + redemptionVault: redemptionVaultWithAave, + owner, + mTBILL, + mTokenToUsdDataFeed, + }, + stableCoins.usdc, + 1, + { + revertMessage: 'MV: token not exists', + }, + ); + }); + + it('should fail: when trying to redeem 0 amount', async () => { + const { + owner, + redemptionVaultWithAave, + stableCoins, + mTBILL, + dataFeed, + mTokenToUsdDataFeed, + } = await loadFixture(defaultDeploy); + await addPaymentTokenTest( + { vault: redemptionVaultWithAave, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + await redeemInstantTest( + { + redemptionVault: redemptionVaultWithAave, + owner, + mTBILL, + mTokenToUsdDataFeed, + }, + stableCoins.usdc, + 0, + { + revertMessage: 'RV: invalid amount', + }, + ); + }); + + it('should fail: when function paused', async () => { + const { + owner, + redemptionVaultWithAave, + stableCoins, + mTBILL, + dataFeed, + mTokenToUsdDataFeed, + regularAccounts, + } = await loadFixture(defaultDeploy); + await mintToken(mTBILL, regularAccounts[0], 100); + await approveBase18( + regularAccounts[0], + stableCoins.dai, + redemptionVaultWithAave, + 100, + ); + await addPaymentTokenTest( + { vault: redemptionVaultWithAave, owner }, + stableCoins.dai, + dataFeed.address, + 0, + true, + ); + const selector = encodeFnSelector( + 'redeemInstant(address,uint256,uint256)', + ); + await pauseVaultFn(redemptionVaultWithAave, selector); + await redeemInstantTest( + { + redemptionVault: redemptionVaultWithAave, + owner, + mTBILL, + mTokenToUsdDataFeed, + }, + stableCoins.dai, + 100, + { + from: regularAccounts[0], + revertMessage: 'Pausable: fn paused', + }, + ); + }); + + it('should fail: call with insufficient allowance', async () => { + const { + owner, + redemptionVaultWithAave, + stableCoins, + mTBILL, + dataFeed, + mTokenToUsdDataFeed, + } = await loadFixture(defaultDeploy); + + await mintToken(mTBILL, owner, 100); + await addPaymentTokenTest( + { vault: redemptionVaultWithAave, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + await redeemInstantTest( + { + redemptionVault: redemptionVaultWithAave, + owner, + mTBILL, + mTokenToUsdDataFeed, + }, + stableCoins.usdc, + 100, + { + revertMessage: 'ERC20: insufficient allowance', + }, + ); + }); + + it('should fail: call with insufficient balance', async () => { + const { + owner, + redemptionVaultWithAave, + stableCoins, + mTBILL, + dataFeed, + mTokenToUsdDataFeed, + } = await loadFixture(defaultDeploy); + + await approveBase18(owner, mTBILL, redemptionVaultWithAave, 100); + await addPaymentTokenTest( + { vault: redemptionVaultWithAave, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + await redeemInstantTest( + { + redemptionVault: redemptionVaultWithAave, + owner, + mTBILL, + mTokenToUsdDataFeed, + }, + stableCoins.usdc, + 100, + { + revertMessage: 'ERC20: burn amount exceeds balance', + }, + ); + }); + + it('should fail: dataFeed rate 0', async () => { + const { + owner, + redemptionVaultWithAave, + stableCoins, + mTBILL, + dataFeed, + mockedAggregator, + mockedAggregatorMToken, + mTokenToUsdDataFeed, + } = await loadFixture(defaultDeploy); + + await approveBase18(owner, stableCoins.usdc, redemptionVaultWithAave, 10); + await addPaymentTokenTest( + { vault: redemptionVaultWithAave, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + await mintToken(mTBILL, owner, 100_000); + await setRoundData({ mockedAggregator }, 0); + await redeemInstantTest( + { + redemptionVault: redemptionVaultWithAave, + owner, + mTBILL, + mTokenToUsdDataFeed, + }, + stableCoins.usdc, + 1, + { + revertMessage: 'DF: feed is deprecated', + }, + ); + }); + + it('should fail: call for amount < minAmount', async () => { + const { + redemptionVaultWithAave, + mockedAggregator, + owner, + mTBILL, + stableCoins, + dataFeed, + mTokenToUsdDataFeed, + } = await loadFixture(defaultDeploy); + await addPaymentTokenTest( + { vault: redemptionVaultWithAave, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + await setRoundData({ mockedAggregator }, 1); + + await mintToken(mTBILL, owner, 100_000); + await approveBase18(owner, mTBILL, redemptionVaultWithAave, 100_000); + + await setMinAmountTest( + { vault: redemptionVaultWithAave, owner }, + 100_000, + ); + + await redeemInstantTest( + { + redemptionVault: redemptionVaultWithAave, + owner, + mTBILL, + mTokenToUsdDataFeed, + }, + stableCoins.usdc, + 99_999, + { + revertMessage: 'RV: amount < min', + }, + ); + }); + + it('should fail: if exceeds token allowance', async () => { + const { + owner, + redemptionVaultWithAave, + stableCoins, + mTBILL, + dataFeed, + mockedAggregator, + mTokenToUsdDataFeed, + } = await loadFixture(defaultDeploy); + + await addPaymentTokenTest( + { vault: redemptionVaultWithAave, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + await setRoundData({ mockedAggregator }, 4); + + await mintToken(mTBILL, owner, 100_000); + await changeTokenAllowanceTest( + { vault: redemptionVaultWithAave, owner }, + stableCoins.usdc.address, + 100, + ); + await approveBase18(owner, mTBILL, redemptionVaultWithAave, 100_000); + + await redeemInstantTest( + { + redemptionVault: redemptionVaultWithAave, + owner, + mTBILL, + mTokenToUsdDataFeed, + }, + stableCoins.usdc, + 99_999, + { + revertMessage: 'MV: exceed allowance', + }, + ); + }); + + it('should fail: if daily limit exceeded', async () => { + const { + owner, + redemptionVaultWithAave, + stableCoins, + mTBILL, + dataFeed, + mockedAggregator, + mTokenToUsdDataFeed, + } = await loadFixture(defaultDeploy); + + await addPaymentTokenTest( + { vault: redemptionVaultWithAave, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + await setRoundData({ mockedAggregator }, 4); + + await mintToken(mTBILL, owner, 100_000); + await setInstantDailyLimitTest( + { vault: redemptionVaultWithAave, owner }, + 1000, + ); + + await approveBase18(owner, mTBILL, redemptionVaultWithAave, 100_000); + + await redeemInstantTest( + { + redemptionVault: redemptionVaultWithAave, + owner, + mTBILL, + mTokenToUsdDataFeed, + }, + stableCoins.usdc, + 99_999, + { + revertMessage: 'MV: exceed limit', + }, + ); + }); + + it('should fail: if some fee = 100%', async () => { + const { + owner, + redemptionVaultWithAave, + stableCoins, + mTBILL, + dataFeed, + mTokenToUsdDataFeed, + } = await loadFixture(defaultDeploy); + + await mintToken(mTBILL, owner, 100); + await approveBase18(owner, mTBILL, redemptionVaultWithAave, 100); + await addPaymentTokenTest( + { vault: redemptionVaultWithAave, owner }, + stableCoins.usdc, + dataFeed.address, + 10000, + true, + ); + await redeemInstantTest( + { + redemptionVault: redemptionVaultWithAave, + owner, + mTBILL, + mTokenToUsdDataFeed, + }, + stableCoins.usdc, + 100, + { + revertMessage: 'RV: amountMTokenIn < fee', + }, + ); + }); + + it('should fail: greenlist enabled and user not in greenlist', async () => { + const { + owner, + redemptionVaultWithAave, + stableCoins, + mTBILL, + mTokenToUsdDataFeed, + } = await loadFixture(defaultDeploy); + + await redemptionVaultWithAave.setGreenlistEnable(true); + + await redeemInstantTest( + { + redemptionVault: redemptionVaultWithAave, + owner, + mTBILL, + mTokenToUsdDataFeed, + }, + stableCoins.dai, + 1, + { + revertMessage: 'WMAC: hasnt role', + }, + ); + }); + + it('should fail: user in blacklist', async () => { + const { + owner, + redemptionVaultWithAave, + stableCoins, + mTBILL, + mTokenToUsdDataFeed, + blackListableTester, + accessControl, + regularAccounts, + } = await loadFixture(defaultDeploy); + + await blackList( + { blacklistable: blackListableTester, accessControl, owner }, + regularAccounts[0], + ); + + await redeemInstantTest( + { + redemptionVault: redemptionVaultWithAave, + owner, + mTBILL, + mTokenToUsdDataFeed, + }, + stableCoins.dai, + 1, + { + from: regularAccounts[0], + revertMessage: acErrors.WMAC_HAS_ROLE, + }, + ); + }); + + it('should fail: user in sanctions list', async () => { + const { + owner, + redemptionVaultWithAave, + stableCoins, + mTBILL, + dataFeed, + mTokenToUsdDataFeed, + mockedSanctionsList, + regularAccounts, + } = await loadFixture(defaultDeploy); + + await sanctionUser( + { sanctionsList: mockedSanctionsList }, + regularAccounts[0], + ); + + await mintToken(mTBILL, regularAccounts[0], 100); + await approveBase18( + regularAccounts[0], + mTBILL, + redemptionVaultWithAave, + 100, + ); + await addPaymentTokenTest( + { vault: redemptionVaultWithAave, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + await redeemInstantTest( + { + redemptionVault: redemptionVaultWithAave, + owner, + mTBILL, + mTokenToUsdDataFeed, + }, + stableCoins.usdc, + 100, + { + from: regularAccounts[0], + revertMessage: 'WSL: sanctioned', + }, + ); + }); + + // ── Happy path tests ───────────────────────────────────────────────── + + it('redeem 100 mTBILL when vault has enough USDC (no Aave needed)', async () => { + const { + owner, + redemptionVaultWithAave, + stableCoins, + mTBILL, + mTokenToUsdDataFeed, + dataFeed, + aUSDC, + } = await loadFixture(defaultDeploy); + + await mintToken(stableCoins.usdc, redemptionVaultWithAave, 100000); + await mintToken(mTBILL, owner, 100); + await approveBase18(owner, mTBILL, redemptionVaultWithAave, 100); + await addPaymentTokenTest( + { vault: redemptionVaultWithAave, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + + const aTokenBefore = await aUSDC.balanceOf( + redemptionVaultWithAave.address, + ); + + await redeemInstantTest( + { + redemptionVault: redemptionVaultWithAave, + owner, + mTBILL, + mTokenToUsdDataFeed, + }, + stableCoins.usdc, + 100, + ); + + // aToken balance should not change + const aTokenAfter = await aUSDC.balanceOf( + redemptionVaultWithAave.address, + ); + expect(aTokenAfter).to.equal(aTokenBefore); + }); + + it('redeem 1000 mTBILL when vault has no USDC but has aTokens', async () => { + const { + owner, + mockedAggregator, + mockedAggregatorMToken, + redemptionVaultWithAave, + stableCoins, + mTBILL, + dataFeed, + mTokenToUsdDataFeed, + aUSDC, + } = await loadFixture(defaultDeploy); + + // Mint aTokens to vault (enough for redemption) + await aUSDC.mint(redemptionVaultWithAave.address, parseUnits('9900', 8)); + await mintToken(mTBILL, owner, 1000); + await approveBase18(owner, mTBILL, redemptionVaultWithAave, 1000); + await addPaymentTokenTest( + { vault: redemptionVaultWithAave, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + await setInstantFeeTest({ vault: redemptionVaultWithAave, owner }, 0); + await setRoundData({ mockedAggregator }, 1); + await setRoundData({ mockedAggregator: mockedAggregatorMToken }, 1); + + const aTokenBefore = await aUSDC.balanceOf( + redemptionVaultWithAave.address, + ); + + await redeemInstantTest( + { + redemptionVault: redemptionVaultWithAave, + owner, + mTBILL, + mTokenToUsdDataFeed, + }, + stableCoins.usdc, + 1000, + ); + + const aTokenAfter = await aUSDC.balanceOf( + redemptionVaultWithAave.address, + ); + + // aTokens should decrease + expect(aTokenAfter).to.be.lt(aTokenBefore); + }); + + it('redeem 1000 mTBILL when vault has 100 USDC and sufficient aTokens (partial Aave)', async () => { + const { + owner, + mockedAggregator, + mockedAggregatorMToken, + redemptionVaultWithAave, + stableCoins, + mTBILL, + dataFeed, + mTokenToUsdDataFeed, + aUSDC, + } = await loadFixture(defaultDeploy); + + // Vault has 100 USDC + 9900 aTokens + await mintToken(stableCoins.usdc, redemptionVaultWithAave, 100); + await aUSDC.mint(redemptionVaultWithAave.address, parseUnits('9900', 8)); + await mintToken(mTBILL, owner, 1000); + await approveBase18(owner, mTBILL, redemptionVaultWithAave, 1000); + await addPaymentTokenTest( + { vault: redemptionVaultWithAave, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + await setRoundData({ mockedAggregator }, 1); + await setRoundData({ mockedAggregator: mockedAggregatorMToken }, 1); + + await redeemInstantTest( + { + redemptionVault: redemptionVaultWithAave, + owner, + mTBILL, + mTokenToUsdDataFeed, + }, + stableCoins.usdc, + 1000, + ); + }); + + it('redeem 1000 mTBILL with different prices (stable 1.03$, mToken 5$) and partial Aave', async () => { + const { + owner, + mockedAggregator, + mockedAggregatorMToken, + redemptionVaultWithAave, + stableCoins, + mTBILL, + dataFeed, + mTokenToUsdDataFeed, + aUSDC, + } = await loadFixture(defaultDeploy); + + await mintToken(stableCoins.usdc, redemptionVaultWithAave, 100); + await aUSDC.mint(redemptionVaultWithAave.address, parseUnits('15000', 8)); + await mintToken(mTBILL, owner, 1000); + await approveBase18(owner, mTBILL, redemptionVaultWithAave, 1000); + await addPaymentTokenTest( + { vault: redemptionVaultWithAave, owner }, + stableCoins.usdc, + dataFeed.address, + 100, + true, + ); + await setRoundData({ mockedAggregator }, 1.03); + await setRoundData({ mockedAggregator: mockedAggregatorMToken }, 5); + await redemptionVaultWithAave.freeFromMinAmount(owner.address, true); + + await redeemInstantTest( + { + redemptionVault: redemptionVaultWithAave, + owner, + mTBILL, + mTokenToUsdDataFeed, + }, + stableCoins.usdc, + 1000, + ); + }); + + it('redeem 1000 mTBILL with waived fee and Aave withdrawal', async () => { + const { + owner, + mockedAggregator, + mockedAggregatorMToken, + redemptionVaultWithAave, + stableCoins, + mTBILL, + dataFeed, + mTokenToUsdDataFeed, + aUSDC, + } = await loadFixture(defaultDeploy); + + await mintToken(stableCoins.usdc, redemptionVaultWithAave, 100); + await aUSDC.mint(redemptionVaultWithAave.address, parseUnits('15000', 8)); + await mintToken(mTBILL, owner, 1000); + await approveBase18(owner, mTBILL, redemptionVaultWithAave, 1000); + await addPaymentTokenTest( + { vault: redemptionVaultWithAave, owner }, + stableCoins.usdc, + dataFeed.address, + 100, + true, + ); + await setRoundData({ mockedAggregator }, 1.03); + await setRoundData({ mockedAggregator: mockedAggregatorMToken }, 5); + + await addWaivedFeeAccountTest( + { vault: redemptionVaultWithAave, owner }, + owner.address, + ); + + await redeemInstantTest( + { + redemptionVault: redemptionVaultWithAave, + owner, + mTBILL, + mTokenToUsdDataFeed, + waivedFee: true, + }, + stableCoins.usdc, + 1000, + ); + }); + + it('should fail: insufficient aToken balance during redeemInstant', async () => { + const { + owner, + mockedAggregator, + mockedAggregatorMToken, + redemptionVaultWithAave, + stableCoins, + mTBILL, + dataFeed, + mTokenToUsdDataFeed, + aUSDC, + } = await loadFixture(defaultDeploy); + + // Vault has no USDC and only 10 aTokens (not enough for 1000 mTBILL redemption) + await aUSDC.mint(redemptionVaultWithAave.address, parseUnits('10', 8)); + await mintToken(mTBILL, owner, 1000); + await approveBase18(owner, mTBILL, redemptionVaultWithAave, 1000); + await addPaymentTokenTest( + { vault: redemptionVaultWithAave, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + await setInstantFeeTest({ vault: redemptionVaultWithAave, owner }, 0); + await setRoundData({ mockedAggregator }, 1); + await setRoundData({ mockedAggregator: mockedAggregatorMToken }, 1); + + await expect( + redemptionVaultWithAave['redeemInstant(address,uint256,uint256)']( + stableCoins.usdc.address, + parseUnits('1000'), + 0, + ), + ).to.be.revertedWith('RVA: insufficient aToken balance'); + }); + + it('should fail: Aave pool has insufficient liquidity during redeemInstant', async () => { + const { + owner, + mockedAggregator, + mockedAggregatorMToken, + redemptionVaultWithAave, + stableCoins, + mTBILL, + dataFeed, + mTokenToUsdDataFeed, + aUSDC, + aavePoolMock, + } = await loadFixture(defaultDeploy); + + // Vault has aTokens but pool has no liquidity + await aUSDC.mint(redemptionVaultWithAave.address, parseUnits('10000', 8)); + await mintToken(mTBILL, owner, 100000); + await approveBase18(owner, mTBILL, redemptionVaultWithAave, 100000); + await addPaymentTokenTest( + { vault: redemptionVaultWithAave, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + await setInstantFeeTest({ vault: redemptionVaultWithAave, owner }, 0); + await setRoundData({ mockedAggregator }, 1); + await setRoundData({ mockedAggregator: mockedAggregatorMToken }, 1); + + // Drain the pool + const poolBalance = await stableCoins.usdc.balanceOf( + aavePoolMock.address, + ); + await aavePoolMock.withdrawAdmin( + stableCoins.usdc.address, + ( + await ethers.getSigners() + )[10].address, + poolBalance, + ); + + await expect( + redemptionVaultWithAave['redeemInstant(address,uint256,uint256)']( + stableCoins.usdc.address, + parseUnits('1000'), + 0, + ), + ).to.be.revertedWith('AaveV3PoolMock: InsufficientLiquidity'); + }); + + it('should fail: short Aave withdrawal during redeemInstant', async () => { + const { + owner, + mockedAggregator, + mockedAggregatorMToken, + redemptionVaultWithAave, + stableCoins, + mTBILL, + dataFeed, + aUSDC, + aavePoolMock, + } = await loadFixture(defaultDeploy); + + await aUSDC.mint(redemptionVaultWithAave.address, parseUnits('10000', 8)); + await mintToken(mTBILL, owner, 100000); + await approveBase18(owner, mTBILL, redemptionVaultWithAave, 100000); + await addPaymentTokenTest( + { vault: redemptionVaultWithAave, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + await setInstantFeeTest({ vault: redemptionVaultWithAave, owner }, 0); + await setRoundData({ mockedAggregator }, 1); + await setRoundData({ mockedAggregator: mockedAggregatorMToken }, 1); + await aavePoolMock.setWithdrawReturnBps(5000); + + await expect( + redemptionVaultWithAave['redeemInstant(address,uint256,uint256)']( + stableCoins.usdc.address, + parseUnits('1000'), + 0, + ), + ).to.be.revertedWith('RVA: withdrawn < needed'); + }); + + // ── Custom recipient tests ─────────────────────────────────────────── + + it('redeem 100 mTBILL (custom recipient overload)', async () => { + const { + owner, + redemptionVaultWithAave, + stableCoins, + mTBILL, + mTokenToUsdDataFeed, + regularAccounts, + dataFeed, + customRecipient, + } = await loadFixture(defaultDeploy); + + await mintToken(stableCoins.usdc, redemptionVaultWithAave, 100000); + await mintToken(mTBILL, regularAccounts[0], 100); + await approveBase18( + regularAccounts[0], + mTBILL, + redemptionVaultWithAave, + 100, + ); + await addPaymentTokenTest( + { vault: redemptionVaultWithAave, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + + await redeemInstantTest( + { + redemptionVault: redemptionVaultWithAave, + owner, + mTBILL, + mTokenToUsdDataFeed, + customRecipient, + }, + stableCoins.usdc, + 100, + { + from: regularAccounts[0], + }, + ); + }); + + it('redeem 100 mTBILL when other fn overload is paused (custom recipient overload)', async () => { + const { + owner, + redemptionVaultWithAave, + stableCoins, + mTBILL, + mTokenToUsdDataFeed, + regularAccounts, + dataFeed, + customRecipient, + } = await loadFixture(defaultDeploy); + + await pauseVaultFn( + redemptionVaultWithAave, + encodeFnSelector('redeemInstant(address,uint256,uint256)'), + ); + await mintToken(stableCoins.usdc, redemptionVaultWithAave, 100000); + await mintToken(mTBILL, regularAccounts[0], 100); + await approveBase18( + regularAccounts[0], + mTBILL, + redemptionVaultWithAave, + 100, + ); + await addPaymentTokenTest( + { vault: redemptionVaultWithAave, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + + await redeemInstantTest( + { + redemptionVault: redemptionVaultWithAave, + owner, + mTBILL, + mTokenToUsdDataFeed, + customRecipient, + }, + stableCoins.usdc, + 100, + { + from: regularAccounts[0], + }, + ); + }); + + it('redeem 100 mTBILL when other fn overload is paused', async () => { + const { + owner, + redemptionVaultWithAave, + stableCoins, + mTBILL, + mTokenToUsdDataFeed, + regularAccounts, + dataFeed, + } = await loadFixture(defaultDeploy); + + await pauseVaultFn( + redemptionVaultWithAave, + encodeFnSelector('redeemInstant(address,uint256,uint256,address)'), + ); + await mintToken(stableCoins.usdc, redemptionVaultWithAave, 100000); + await mintToken(mTBILL, regularAccounts[0], 100); + await approveBase18( + regularAccounts[0], + mTBILL, + redemptionVaultWithAave, + 100, + ); + await addPaymentTokenTest( + { vault: redemptionVaultWithAave, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + + await redeemInstantTest( + { + redemptionVault: redemptionVaultWithAave, + owner, + mTBILL, + mTokenToUsdDataFeed, + }, + stableCoins.usdc, + 100, + { + from: regularAccounts[0], + }, + ); + }); + + it('should fail: when function with custom recipient is paused', async () => { + const { + owner, + redemptionVaultWithAave, + stableCoins, + mTBILL, + dataFeed, + mTokenToUsdDataFeed, + regularAccounts, + customRecipient, + } = await loadFixture(defaultDeploy); + await mintToken(mTBILL, regularAccounts[0], 100); + await approveBase18( + regularAccounts[0], + stableCoins.dai, + redemptionVaultWithAave, + 100, + ); + await addPaymentTokenTest( + { vault: redemptionVaultWithAave, owner }, + stableCoins.dai, + dataFeed.address, + 0, + true, + ); + const selector = encodeFnSelector( + 'redeemInstant(address,uint256,uint256,address)', + ); + await pauseVaultFn(redemptionVaultWithAave, selector); + await redeemInstantTest( + { + redemptionVault: redemptionVaultWithAave, + owner, + mTBILL, + mTokenToUsdDataFeed, + customRecipient, + }, + stableCoins.dai, + 100, + { + from: regularAccounts[0], + revertMessage: 'Pausable: fn paused', + }, + ); + }); + + it('should fail: greenlist enabled and recipient not in greenlist (custom recipient overload)', async () => { + const { + owner, + redemptionVaultWithAave, + stableCoins, + mTBILL, + mTokenToUsdDataFeed, + greenListableTester, + accessControl, + customRecipient, + } = await loadFixture(defaultDeploy); + + await redemptionVaultWithAave.setGreenlistEnable(true); + + await greenList( + { greenlistable: greenListableTester, accessControl, owner }, + owner, + ); + + await redeemInstantTest( + { + redemptionVault: redemptionVaultWithAave, + owner, + mTBILL, + mTokenToUsdDataFeed, + customRecipient, + }, + stableCoins.dai, + 1, + { + revertMessage: 'WMAC: hasnt role', + }, + ); + }); + + it('should fail: recipient in blacklist (custom recipient overload)', async () => { + const { + owner, + redemptionVaultWithAave, + stableCoins, + mTBILL, + mTokenToUsdDataFeed, + blackListableTester, + accessControl, + regularAccounts, + customRecipient, + } = await loadFixture(defaultDeploy); + + await blackList( + { blacklistable: blackListableTester, accessControl, owner }, + customRecipient, + ); + + await redeemInstantTest( + { + redemptionVault: redemptionVaultWithAave, + owner, + mTBILL, + mTokenToUsdDataFeed, + customRecipient, + }, + stableCoins.dai, + 1, + { + from: regularAccounts[0], + revertMessage: acErrors.WMAC_HAS_ROLE, + }, + ); + }); + + it('should fail: recipient in sanctions list (custom recipient overload)', async () => { + const { + owner, + redemptionVaultWithAave, + stableCoins, + mTBILL, + mTokenToUsdDataFeed, + regularAccounts, + mockedSanctionsList, + customRecipient, + } = await loadFixture(defaultDeploy); + + await sanctionUser( + { sanctionsList: mockedSanctionsList }, + customRecipient, + ); + + await redeemInstantTest( + { + redemptionVault: redemptionVaultWithAave, + owner, + mTBILL, + mTokenToUsdDataFeed, + customRecipient, + }, + stableCoins.dai, + 1, + { + from: regularAccounts[0], + revertMessage: 'WSL: sanctioned', + }, + ); + }); + }); + + describe('redeemRequest()', () => { + it('should fail: when there is no token in vault', async () => { + const { + owner, + redemptionVaultWithAave, + stableCoins, + mTBILL, + mTokenToUsdDataFeed, + } = await loadFixture(defaultDeploy); + + await redeemRequestTest( + { + redemptionVault: redemptionVaultWithAave, + owner, + mTBILL, + mTokenToUsdDataFeed, + }, + stableCoins.dai, + 1, + { + revertMessage: 'MV: token not exists', + }, + ); + }); + + it('should fail: when trying to redeem 0 amount', async () => { + const { + owner, + redemptionVaultWithAave, + stableCoins, + mTBILL, + dataFeed, + mTokenToUsdDataFeed, + } = await loadFixture(defaultDeploy); + await addPaymentTokenTest( + { vault: redemptionVaultWithAave, owner }, + stableCoins.dai, + dataFeed.address, + 0, + true, + ); + await redeemRequestTest( + { + redemptionVault: redemptionVaultWithAave, + owner, + mTBILL, + mTokenToUsdDataFeed, + }, + stableCoins.dai, + 0, + { + revertMessage: 'RV: invalid amount', + }, + ); + }); + + it('should fail: when function paused', async () => { + const { + owner, + redemptionVaultWithAave, + stableCoins, + mTBILL, + dataFeed, + mTokenToUsdDataFeed, + regularAccounts, + } = await loadFixture(defaultDeploy); + await mintToken(mTBILL, regularAccounts[0], 100); + await approveBase18( + regularAccounts[0], + stableCoins.dai, + redemptionVaultWithAave, + 100, + ); + await addPaymentTokenTest( + { vault: redemptionVaultWithAave, owner }, + stableCoins.dai, + dataFeed.address, + 0, + true, + ); + const selector = encodeFnSelector('redeemRequest(address,uint256)'); + await pauseVaultFn(redemptionVaultWithAave, selector); + await redeemRequestTest( + { + redemptionVault: redemptionVaultWithAave, + owner, + mTBILL, + mTokenToUsdDataFeed, + }, + stableCoins.dai, + 100, + { + from: regularAccounts[0], + revertMessage: 'Pausable: fn paused', + }, + ); + }); + + it('should fail: call with insufficient balance', async () => { + const { + owner, + redemptionVaultWithAave, + stableCoins, + mTBILL, + dataFeed, + mTokenToUsdDataFeed, + } = await loadFixture(defaultDeploy); + + await approveBase18(owner, mTBILL, redemptionVaultWithAave, 100); + await addPaymentTokenTest( + { vault: redemptionVaultWithAave, owner }, + stableCoins.dai, + dataFeed.address, + 0, + true, + ); + await redeemRequestTest( + { + redemptionVault: redemptionVaultWithAave, + owner, + mTBILL, + mTokenToUsdDataFeed, + }, + stableCoins.dai, + 100, + { + revertMessage: 'ERC20: transfer amount exceeds balance', + }, + ); + }); + + it('redeem request: happy path', async () => { + const { + owner, + redemptionVaultWithAave, + stableCoins, + mTBILL, + dataFeed, + mTokenToUsdDataFeed, + } = await loadFixture(defaultDeploy); + + await mintToken(mTBILL, owner, 100); + await approveBase18(owner, mTBILL, redemptionVaultWithAave, 100); + await addPaymentTokenTest( + { vault: redemptionVaultWithAave, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + await redeemRequestTest( + { + redemptionVault: redemptionVaultWithAave, + owner, + mTBILL, + mTokenToUsdDataFeed, + }, + stableCoins.usdc, + 100, + ); + }); + }); + + describe('redeemFiatRequest()', () => { + it('should fail: when function paused', async () => { + const { + owner, + redemptionVaultWithAave, + mTBILL, + mTokenToUsdDataFeed, + regularAccounts, + } = await loadFixture(defaultDeploy); + await mintToken(mTBILL, regularAccounts[0], 100000); + await approveBase18( + regularAccounts[0], + mTBILL, + redemptionVaultWithAave, + 100000, + ); + const selector = encodeFnSelector('redeemFiatRequest(uint256)'); + await pauseVaultFn(redemptionVaultWithAave, selector); + await redeemFiatRequestTest( + { + redemptionVault: redemptionVaultWithAave, + owner, + mTBILL, + mTokenToUsdDataFeed, + }, + 100000, + { + from: regularAccounts[0], + revertMessage: 'Pausable: fn paused', + }, + ); + }); + + it('redeem fiat request: happy path', async () => { + const { owner, redemptionVaultWithAave, mTBILL, mTokenToUsdDataFeed } = + await loadFixture(defaultDeploy); + + await mintToken(mTBILL, owner, 100000); + await approveBase18(owner, mTBILL, redemptionVaultWithAave, 100000); + await redeemFiatRequestTest( + { + redemptionVault: redemptionVaultWithAave, + owner, + mTBILL, + mTokenToUsdDataFeed, + }, + 100000, + ); + }); + }); + + describe('approveRequest()', async () => { + it('should fail: call from address without vault admin role', async () => { + const { + redemptionVaultWithAave: redemptionVault, + regularAccounts, + mTokenToUsdDataFeed, + mTBILL, + } = await loadFixture(defaultDeploy); + await approveRedeemRequestTest( + { + redemptionVault, + owner: regularAccounts[1], + mTBILL, + mTokenToUsdDataFeed, + }, + 1, + parseUnits('1'), + { + revertMessage: 'WMAC: hasnt role', + }, + ); + }); + + it('should fail: request by id not exist', async () => { + const { + owner, + redemptionVaultWithAave: redemptionVault, + stableCoins, + mTBILL, + dataFeed, + mTokenToUsdDataFeed, + } = await loadFixture(defaultDeploy); + await addPaymentTokenTest( + { vault: redemptionVault, owner }, + stableCoins.dai, + dataFeed.address, + 0, + true, + ); + await approveRedeemRequestTest( + { redemptionVault, owner, mTBILL, mTokenToUsdDataFeed }, + 1, + parseUnits('1'), + { + revertMessage: 'RV: request not exist', + }, + ); + }); + + it('should fail: request already processed', async () => { + const { + owner, + mockedAggregator, + mockedAggregatorMToken, + redemptionVaultWithAave: redemptionVault, + stableCoins, + mTBILL, + dataFeed, + mTokenToUsdDataFeed, + requestRedeemer, + } = await loadFixture(defaultDeploy); + + await mintToken(stableCoins.dai, requestRedeemer, 100000); + await approveBase18( + requestRedeemer, + stableCoins.dai, + redemptionVault, + 100000, + ); + + await mintToken(mTBILL, owner, 100); + await approveBase18(owner, mTBILL, redemptionVault, 100); + await addPaymentTokenTest( + { vault: redemptionVault, owner }, + stableCoins.dai, + dataFeed.address, + 0, + true, + ); + await setRoundData({ mockedAggregator }, 1.03); + await setRoundData({ mockedAggregator: mockedAggregatorMToken }, 5); + + await redeemRequestTest( + { redemptionVault, owner, mTBILL, mTokenToUsdDataFeed }, + stableCoins.dai, + 100, + ); + const requestId = 0; + + await approveRedeemRequestTest( + { redemptionVault, owner, mTBILL, mTokenToUsdDataFeed }, + +requestId, + parseUnits('1'), + ); + await approveRedeemRequestTest( + { redemptionVault, owner, mTBILL, mTokenToUsdDataFeed }, + +requestId, + parseUnits('1'), + { revertMessage: 'RV: request not pending' }, + ); + }); + + it('approve request from vaut admin account', async () => { + const { + owner, + mockedAggregator, + mockedAggregatorMToken, + redemptionVaultWithAave: redemptionVault, + stableCoins, + mTBILL, + dataFeed, + mTokenToUsdDataFeed, + requestRedeemer, + } = await loadFixture(defaultDeploy); + + await mintToken(stableCoins.dai, requestRedeemer, 100000); + await approveBase18( + requestRedeemer, + stableCoins.dai, + redemptionVault, + 100000, + ); + + await mintToken(mTBILL, owner, 100); + await approveBase18(owner, mTBILL, redemptionVault, 100); + await addPaymentTokenTest( + { vault: redemptionVault, owner }, + stableCoins.dai, + dataFeed.address, + 0, + true, + ); + await setRoundData({ mockedAggregator }, 1.03); + await setRoundData({ mockedAggregator: mockedAggregatorMToken }, 5); + + await redeemRequestTest( + { redemptionVault, owner, mTBILL, mTokenToUsdDataFeed }, + stableCoins.dai, + 100, + ); + const requestId = 0; + + await approveRedeemRequestTest( + { redemptionVault, owner, mTBILL, mTokenToUsdDataFeed }, + +requestId, + parseUnits('1'), + ); + }); + }); + + describe('approveRequest() with fiat', async () => { + it('approve request from vaut admin account', async () => { + const { + owner, + mockedAggregator, + mockedAggregatorMToken, + redemptionVaultWithAave: redemptionVault, + mTBILL, + mTokenToUsdDataFeed, + greenListableTester, + accessControl, + } = await loadFixture(defaultDeploy); + + await greenList( + { greenlistable: greenListableTester, accessControl, owner }, + owner, + ); + + await mintToken(mTBILL, owner, 100); + await approveBase18(owner, mTBILL, redemptionVault, 100); + await setRoundData({ mockedAggregator }, 1.03); + await setRoundData({ mockedAggregator: mockedAggregatorMToken }, 5); + + await redeemFiatRequestTest( + { redemptionVault, owner, mTBILL, mTokenToUsdDataFeed }, + 100, + ); + const requestId = 0; + await changeTokenAllowanceTest( + { vault: redemptionVault, owner }, + constants.AddressZero, + parseUnits('100'), + ); + + await approveRedeemRequestTest( + { redemptionVault, owner, mTBILL, mTokenToUsdDataFeed }, + +requestId, + parseUnits('1'), + ); + }); + }); + + describe('safeApproveRequest()', async () => { + it('should fail: call from address without vault admin role', async () => { + const { + redemptionVaultWithAave: redemptionVault, + regularAccounts, + mTokenToUsdDataFeed, + mTBILL, + } = await loadFixture(defaultDeploy); + await safeApproveRedeemRequestTest( + { + redemptionVault, + owner: regularAccounts[1], + mTBILL, + mTokenToUsdDataFeed, + }, + 1, + parseUnits('1'), + { + revertMessage: 'WMAC: hasnt role', + }, + ); + }); + + it('should fail: request by id not exist', async () => { + const { + owner, + redemptionVaultWithAave: redemptionVault, + stableCoins, + mTBILL, + dataFeed, + mTokenToUsdDataFeed, + } = await loadFixture(defaultDeploy); + await addPaymentTokenTest( + { vault: redemptionVault, owner }, + stableCoins.dai, + dataFeed.address, + 0, + true, + ); + await safeApproveRedeemRequestTest( + { redemptionVault, owner, mTBILL, mTokenToUsdDataFeed }, + 1, + parseUnits('1'), + { + revertMessage: 'RV: request not exist', + }, + ); + }); + + it('should fail: if new rate greater then variabilityTolerance', async () => { + const { + owner, + mockedAggregator, + mockedAggregatorMToken, + redemptionVaultWithAave: redemptionVault, + stableCoins, + mTBILL, + dataFeed, + mTokenToUsdDataFeed, + requestRedeemer, + } = await loadFixture(defaultDeploy); + + await mintToken(stableCoins.dai, requestRedeemer, 100000); + await approveBase18( + requestRedeemer, + stableCoins.dai, + redemptionVault, + 100000, + ); + await mintToken(mTBILL, owner, 100); + await approveBase18(owner, mTBILL, redemptionVault, 100); + await addPaymentTokenTest( + { vault: redemptionVault, owner }, + stableCoins.dai, + dataFeed.address, + 0, + true, + ); + await setRoundData({ mockedAggregator }, 1.001); + await setRoundData({ mockedAggregator: mockedAggregatorMToken }, 5); + + await redeemRequestTest( + { redemptionVault, owner, mTBILL, mTokenToUsdDataFeed }, + stableCoins.dai, + 100, + ); + const requestId = 0; + + await safeApproveRedeemRequestTest( + { redemptionVault, owner, mTBILL, mTokenToUsdDataFeed }, + +requestId, + parseUnits('6'), + { revertMessage: 'MV: exceed price diviation' }, + ); + }); + + it('should fail: request already processed', async () => { + const { + owner, + mockedAggregator, + mockedAggregatorMToken, + redemptionVaultWithAave: redemptionVault, + stableCoins, + mTBILL, + dataFeed, + mTokenToUsdDataFeed, + requestRedeemer, + } = await loadFixture(defaultDeploy); + + await mintToken(stableCoins.dai, requestRedeemer, 100000); + await approveBase18( + requestRedeemer, + stableCoins.dai, + redemptionVault, + 100000, + ); + await mintToken(mTBILL, owner, 100); + await approveBase18(owner, mTBILL, redemptionVault, 100); + await addPaymentTokenTest( + { vault: redemptionVault, owner }, + stableCoins.dai, + dataFeed.address, + 0, + true, + ); + await setRoundData({ mockedAggregator }, 1.001); + await setRoundData({ mockedAggregator: mockedAggregatorMToken }, 5); + + await redeemRequestTest( + { redemptionVault, owner, mTBILL, mTokenToUsdDataFeed }, + stableCoins.dai, + 100, + ); + const requestId = 0; + + await safeApproveRedeemRequestTest( + { redemptionVault, owner, mTBILL, mTokenToUsdDataFeed }, + +requestId, + parseUnits('5.000001'), + ); + await safeApproveRedeemRequestTest( + { redemptionVault, owner, mTBILL, mTokenToUsdDataFeed }, + +requestId, + parseUnits('5.00001'), + { revertMessage: 'RV: request not pending' }, + ); + }); + + it('safe approve request from vaut admin account', async () => { + const { + owner, + mockedAggregator, + mockedAggregatorMToken, + redemptionVaultWithAave: redemptionVault, + stableCoins, + mTBILL, + mTokenToUsdDataFeed, + dataFeed, + requestRedeemer, + } = await loadFixture(defaultDeploy); + + await mintToken(stableCoins.dai, requestRedeemer, 100000); + await approveBase18( + requestRedeemer, + stableCoins.dai, + redemptionVault, + 100000, + ); + await mintToken(mTBILL, owner, 100); + await approveBase18(owner, mTBILL, redemptionVault, 100); + await addPaymentTokenTest( + { vault: redemptionVault, owner }, + stableCoins.dai, + dataFeed.address, + 0, + true, + ); + + await setRoundData({ mockedAggregator }, 1.03); + await setRoundData({ mockedAggregator: mockedAggregatorMToken }, 5); + + await redeemRequestTest( + { redemptionVault, owner, mTBILL, mTokenToUsdDataFeed }, + stableCoins.dai, + 100, + ); + const requestId = 0; + + await safeApproveRedeemRequestTest( + { redemptionVault, owner, mTBILL, mTokenToUsdDataFeed }, + +requestId, + parseUnits('5.000001'), + ); + }); + }); + + describe('rejectRequest()', async () => { + it('should fail: call from address without vault admin role', async () => { + const { + redemptionVaultWithAave: redemptionVault, + regularAccounts, + mTokenToUsdDataFeed, + mTBILL, + } = await loadFixture(defaultDeploy); + await rejectRedeemRequestTest( + { + redemptionVault, + owner: regularAccounts[1], + mTBILL, + mTokenToUsdDataFeed, + }, + 1, + { + revertMessage: 'WMAC: hasnt role', + }, + ); + }); + + it('should fail: request by id not exist', async () => { + const { + owner, + redemptionVaultWithAave: redemptionVault, + stableCoins, + mTBILL, + dataFeed, + mTokenToUsdDataFeed, + } = await loadFixture(defaultDeploy); + await addPaymentTokenTest( + { vault: redemptionVault, owner }, + stableCoins.dai, + dataFeed.address, + 0, + true, + ); + await rejectRedeemRequestTest( + { redemptionVault, owner, mTBILL, mTokenToUsdDataFeed }, + 1, + { + revertMessage: 'RV: request not exist', + }, + ); + }); + + it('should fail: request already processed', async () => { + const { + owner, + mockedAggregator, + mockedAggregatorMToken, + redemptionVaultWithAave: redemptionVault, + stableCoins, + mTBILL, + dataFeed, + mTokenToUsdDataFeed, + } = await loadFixture(defaultDeploy); + + await mintToken(stableCoins.dai, redemptionVault, 100000); + await mintToken(mTBILL, owner, 100); + await approveBase18(owner, mTBILL, redemptionVault, 100); + await addPaymentTokenTest( + { vault: redemptionVault, owner }, + stableCoins.dai, + dataFeed.address, + 0, + true, + ); + await setRoundData({ mockedAggregator }, 1.001); + await setRoundData({ mockedAggregator: mockedAggregatorMToken }, 5); + + await redeemRequestTest( + { redemptionVault, owner, mTBILL, mTokenToUsdDataFeed }, + stableCoins.dai, + 100, + ); + const requestId = 0; + + await rejectRedeemRequestTest( + { redemptionVault, owner, mTBILL, mTokenToUsdDataFeed }, + +requestId, + ); + await rejectRedeemRequestTest( + { redemptionVault, owner, mTBILL, mTokenToUsdDataFeed }, + +requestId, + { revertMessage: 'RV: request not pending' }, + ); + }); + + it('reject request from vaut admin account', async () => { + const { + owner, + mockedAggregator, + mockedAggregatorMToken, + redemptionVaultWithAave: redemptionVault, + stableCoins, + mTBILL, + mTokenToUsdDataFeed, + dataFeed, + } = await loadFixture(defaultDeploy); + + await mintToken(stableCoins.dai, redemptionVault, 100000); + await mintToken(mTBILL, owner, 100); + await approveBase18(owner, mTBILL, redemptionVault, 100); + await addPaymentTokenTest( + { vault: redemptionVault, owner }, + stableCoins.dai, + dataFeed.address, + 0, + true, + ); + + await setRoundData({ mockedAggregator }, 1.03); + await setRoundData({ mockedAggregator: mockedAggregatorMToken }, 5); + + await redeemRequestTest( + { redemptionVault, owner, mTBILL, mTokenToUsdDataFeed }, + stableCoins.dai, + 100, + ); + const requestId = 0; + + await rejectRedeemRequestTest( + { redemptionVault, owner, mTBILL, mTokenToUsdDataFeed }, + +requestId, + ); + }); + }); +}); diff --git a/test/unit/RedemptionVaultWithMToken.test.ts b/test/unit/RedemptionVaultWithMToken.test.ts new file mode 100644 index 00000000..0210ccb1 --- /dev/null +++ b/test/unit/RedemptionVaultWithMToken.test.ts @@ -0,0 +1,5062 @@ +import { loadFixture } from '@nomicfoundation/hardhat-network-helpers'; +import { expect } from 'chai'; +import { BigNumber, constants } from 'ethers'; +import { parseUnits } from 'ethers/lib/utils'; +import { ethers } from 'hardhat'; + +import { encodeFnSelector } from '../../helpers/utils'; +import { + ManageableVaultTester__factory, + RedemptionVaultWithMTokenTest__factory, +} from '../../typechain-types'; +import { acErrors, blackList, greenList } from '../common/ac.helpers'; +import { + approveBase18, + mintToken, + pauseVaultFn, +} from '../common/common.helpers'; +import { setRoundData } from '../common/data-feed.helpers'; +import { defaultDeploy } from '../common/fixtures'; +import { + addPaymentTokenTest, + setInstantFeeTest, + setMinAmountTest, + setInstantDailyLimitTest, + addWaivedFeeAccountTest, + removeWaivedFeeAccountTest, + setVariabilityToleranceTest, + removePaymentTokenTest, + withdrawTest, + changeTokenFeeTest, + changeTokenAllowanceTest, +} from '../common/manageable-vault.helpers'; +import { redeemInstantWithMTokenTest } from '../common/redemption-vault-mtoken.helpers'; +import { + approveRedeemRequestTest, + redeemFiatRequestTest, + redeemRequestTest, + rejectRedeemRequestTest, + safeApproveRedeemRequestTest, + setFiatAdditionalFeeTest, + setMinFiatRedeemAmountTest, +} from '../common/redemption-vault.helpers'; +import { sanctionUser } from '../common/with-sanctions-list.helpers'; + +describe('RedemptionVaultWithMToken', function () { + it('deployment', async () => { + const { + redemptionVaultWithMToken, + redemptionVault, + mFONE, + tokensReceiver, + feeReceiver, + mFoneToUsdDataFeed, + roles, + } = await loadFixture(defaultDeploy); + + expect(await redemptionVaultWithMToken.mToken()).eq(mFONE.address); + + expect(await redemptionVaultWithMToken.ONE_HUNDRED_PERCENT()).eq('10000'); + + expect(await redemptionVaultWithMToken.paused()).eq(false); + + expect(await redemptionVaultWithMToken.tokensReceiver()).eq( + tokensReceiver.address, + ); + expect(await redemptionVaultWithMToken.feeReceiver()).eq( + feeReceiver.address, + ); + + expect(await redemptionVaultWithMToken.minAmount()).eq(1000); + expect(await redemptionVaultWithMToken.minFiatRedeemAmount()).eq(1000); + + expect(await redemptionVaultWithMToken.instantFee()).eq('100'); + + expect(await redemptionVaultWithMToken.instantDailyLimit()).eq( + parseUnits('100000'), + ); + + expect(await redemptionVaultWithMToken.mTokenDataFeed()).eq( + mFoneToUsdDataFeed.address, + ); + expect(await redemptionVaultWithMToken.variationTolerance()).eq(1); + + expect(await redemptionVaultWithMToken.vaultRole()).eq( + roles.tokenRoles.mTBILL.redemptionVaultAdmin, + ); + + expect(await redemptionVaultWithMToken.MANUAL_FULLFILMENT_TOKEN()).eq( + ethers.constants.AddressZero, + ); + + expect(await redemptionVaultWithMToken.redemptionVault()).eq( + redemptionVault.address, + ); + }); + + it('failing deployment', async () => { + const { + mFONE, + tokensReceiver, + feeReceiver, + mFoneToUsdDataFeed, + accessControl, + mockedSanctionsList, + owner, + } = await loadFixture(defaultDeploy); + + const redemptionVaultWithMToken = + await new RedemptionVaultWithMTokenTest__factory(owner).deploy(); + + await expect( + redemptionVaultWithMToken[ + 'initialize(address,(address,address),(address,address),(uint256,uint256),address,uint256,uint256,(uint256,uint256,uint256),address)' + ]( + accessControl.address, + { + mToken: mFONE.address, + mTokenDataFeed: mFoneToUsdDataFeed.address, + }, + { + feeReceiver: feeReceiver.address, + tokensReceiver: tokensReceiver.address, + }, + { + instantFee: 100, + instantDailyLimit: parseUnits('100000'), + }, + mockedSanctionsList.address, + 1, + parseUnits('100'), + { + fiatAdditionalFee: 10000, + fiatFlatFee: parseUnits('1'), + minFiatRedeemAmount: parseUnits('100'), + }, + constants.AddressZero, + ), + ).to.be.reverted; + }); + + describe('initialization', () => { + it('should fail: call initialize() when already initialized', async () => { + const { redemptionVaultWithMToken } = await loadFixture(defaultDeploy); + + await expect( + redemptionVaultWithMToken[ + 'initialize(address,(address,address),(address,address),(uint256,uint256),address,uint256,uint256,(uint256,uint256,uint256),address,address)' + ]( + constants.AddressZero, + { + mToken: constants.AddressZero, + mTokenDataFeed: constants.AddressZero, + }, + { + feeReceiver: constants.AddressZero, + tokensReceiver: constants.AddressZero, + }, + { + instantFee: 0, + instantDailyLimit: 0, + }, + constants.AddressZero, + 0, + 0, + { + fiatAdditionalFee: 0, + fiatFlatFee: 0, + minFiatRedeemAmount: 0, + }, + constants.AddressZero, + constants.AddressZero, + ), + ).revertedWith('Initializable: contract is already initialized'); + }); + + it('should fail: call with initializing == false', async () => { + const { + owner, + accessControl, + mFONE, + tokensReceiver, + feeReceiver, + mFoneToUsdDataFeed, + mockedSanctionsList, + } = await loadFixture(defaultDeploy); + + const vault = await new ManageableVaultTester__factory(owner).deploy(); + + await expect( + vault.initializeWithoutInitializer( + accessControl.address, + { + mToken: mFONE.address, + mTokenDataFeed: mFoneToUsdDataFeed.address, + }, + { + feeReceiver: feeReceiver.address, + tokensReceiver: tokensReceiver.address, + }, + { + instantFee: 100, + instantDailyLimit: parseUnits('100000'), + }, + mockedSanctionsList.address, + 1, + 1000, + ), + ).revertedWith('Initializable: contract is not initializing'); + }); + + it('should fail: when redemptionVault address zero', async () => { + const { + owner, + accessControl, + mFONE, + tokensReceiver, + feeReceiver, + mFoneToUsdDataFeed, + mockedSanctionsList, + requestRedeemer, + } = await loadFixture(defaultDeploy); + + const redemptionVaultWithMToken = + await new RedemptionVaultWithMTokenTest__factory(owner).deploy(); + + await expect( + redemptionVaultWithMToken[ + 'initialize(address,(address,address),(address,address),(uint256,uint256),address,uint256,uint256,(uint256,uint256,uint256),address,address)' + ]( + accessControl.address, + { + mToken: mFONE.address, + mTokenDataFeed: mFoneToUsdDataFeed.address, + }, + { + feeReceiver: feeReceiver.address, + tokensReceiver: tokensReceiver.address, + }, + { + instantFee: 100, + instantDailyLimit: parseUnits('100000'), + }, + mockedSanctionsList.address, + 1, + 1000, + { + fiatAdditionalFee: 100, + fiatFlatFee: parseUnits('1'), + minFiatRedeemAmount: 1000, + }, + requestRedeemer.address, + constants.AddressZero, + ), + ).revertedWith('zero address'); + }); + }); + + describe('setRedemptionVault()', () => { + it('should fail: call from address without vault admin role', async () => { + const { redemptionVaultWithMToken, regularAccounts } = await loadFixture( + defaultDeploy, + ); + await expect( + redemptionVaultWithMToken + .connect(regularAccounts[0]) + .setRedemptionVault(regularAccounts[1].address), + ).to.be.revertedWith('WMAC: hasnt role'); + }); + + it('should fail: zero address', async () => { + const { redemptionVaultWithMToken } = await loadFixture(defaultDeploy); + await expect( + redemptionVaultWithMToken.setRedemptionVault(constants.AddressZero), + ).to.be.revertedWith('zero address'); + }); + + it('should fail: same address', async () => { + const { redemptionVaultWithMToken, redemptionVault } = await loadFixture( + defaultDeploy, + ); + await expect( + redemptionVaultWithMToken.setRedemptionVault(redemptionVault.address), + ).to.be.revertedWith('RVMT: already set'); + }); + + it('should succeed and emit SetRedemptionVault event', async () => { + const { redemptionVaultWithMToken, owner, regularAccounts } = + await loadFixture(defaultDeploy); + + const newVault = regularAccounts[0].address; + + await expect(redemptionVaultWithMToken.setRedemptionVault(newVault)) + .to.emit(redemptionVaultWithMToken, 'SetRedemptionVault') + .withArgs(owner.address, newVault); + + expect(await redemptionVaultWithMToken.redemptionVault()).eq(newVault); + }); + }); + + describe('setMinAmount()', () => { + it('should fail: call from address without vault admin role', async () => { + const { owner, redemptionVaultWithMToken, regularAccounts } = + await loadFixture(defaultDeploy); + await setMinAmountTest( + { vault: redemptionVaultWithMToken, owner }, + 10000, + { revertMessage: acErrors.WMAC_HASNT_ROLE, from: regularAccounts[0] }, + ); + }); + it('call from address with vault admin role', async () => { + const { owner, redemptionVaultWithMToken } = await loadFixture( + defaultDeploy, + ); + await setMinAmountTest( + { vault: redemptionVaultWithMToken, owner }, + 10000, + ); + }); + }); + + describe('setMinFiatRedeemAmount()', () => { + it('should fail: call from address without vault admin role', async () => { + const { owner, redemptionVaultWithMToken, regularAccounts } = + await loadFixture(defaultDeploy); + await setMinFiatRedeemAmountTest( + { redemptionVault: redemptionVaultWithMToken, owner }, + 10000, + { revertMessage: acErrors.WMAC_HASNT_ROLE, from: regularAccounts[0] }, + ); + }); + it('call from address with vault admin role', async () => { + const { owner, redemptionVaultWithMToken } = await loadFixture( + defaultDeploy, + ); + await setMinFiatRedeemAmountTest( + { redemptionVault: redemptionVaultWithMToken, owner }, + 10000, + ); + }); + }); + + describe('setFiatAdditionalFee()', () => { + it('should fail: call from address without vault admin role', async () => { + const { owner, redemptionVaultWithMToken, regularAccounts } = + await loadFixture(defaultDeploy); + await setFiatAdditionalFeeTest( + { redemptionVault: redemptionVaultWithMToken, owner }, + 100, + { revertMessage: acErrors.WMAC_HASNT_ROLE, from: regularAccounts[0] }, + ); + }); + it('call from address with vault admin role', async () => { + const { owner, redemptionVaultWithMToken } = await loadFixture( + defaultDeploy, + ); + await setFiatAdditionalFeeTest( + { redemptionVault: redemptionVaultWithMToken, owner }, + 100, + ); + }); + }); + + describe('setInstantDailyLimit()', () => { + it('should fail: call from address without vault admin role', async () => { + const { owner, redemptionVaultWithMToken, regularAccounts } = + await loadFixture(defaultDeploy); + await setInstantDailyLimitTest( + { vault: redemptionVaultWithMToken, owner }, + parseUnits('1000'), + { revertMessage: acErrors.WMAC_HASNT_ROLE, from: regularAccounts[0] }, + ); + }); + it('call from address with vault admin role', async () => { + const { owner, redemptionVaultWithMToken } = await loadFixture( + defaultDeploy, + ); + await setInstantDailyLimitTest( + { vault: redemptionVaultWithMToken, owner }, + parseUnits('1000'), + ); + }); + it('should fail: when limit is zero', async () => { + const { owner, redemptionVaultWithMToken } = await loadFixture( + defaultDeploy, + ); + await setInstantDailyLimitTest( + { vault: redemptionVaultWithMToken, owner }, + constants.Zero, + { revertMessage: 'MV: limit zero' }, + ); + }); + }); + + describe('addPaymentToken()', () => { + it('should fail: call from address without vault admin role', async () => { + const { + owner, + redemptionVaultWithMToken, + stableCoins, + regularAccounts, + dataFeed, + } = await loadFixture(defaultDeploy); + await addPaymentTokenTest( + { vault: redemptionVaultWithMToken, owner }, + stableCoins.dai, + dataFeed.address, + 0, + true, + undefined, + { revertMessage: acErrors.WMAC_HASNT_ROLE, from: regularAccounts[0] }, + ); + }); + it('call from address with vault admin role', async () => { + const { owner, redemptionVaultWithMToken, stableCoins, dataFeed } = + await loadFixture(defaultDeploy); + await addPaymentTokenTest( + { vault: redemptionVaultWithMToken, owner }, + stableCoins.dai, + dataFeed.address, + 0, + true, + ); + }); + }); + + describe('removePaymentToken()', () => { + it('should fail: call from address without vault admin role', async () => { + const { + owner, + redemptionVaultWithMToken, + stableCoins, + regularAccounts, + dataFeed, + } = await loadFixture(defaultDeploy); + await addPaymentTokenTest( + { vault: redemptionVaultWithMToken, owner }, + stableCoins.dai, + dataFeed.address, + 0, + true, + ); + await removePaymentTokenTest( + { vault: redemptionVaultWithMToken, owner }, + stableCoins.dai, + { revertMessage: acErrors.WMAC_HASNT_ROLE, from: regularAccounts[0] }, + ); + }); + it('call from address with vault admin role', async () => { + const { owner, redemptionVaultWithMToken, stableCoins, dataFeed } = + await loadFixture(defaultDeploy); + await addPaymentTokenTest( + { vault: redemptionVaultWithMToken, owner }, + stableCoins.dai, + dataFeed.address, + 0, + true, + ); + await removePaymentTokenTest( + { vault: redemptionVaultWithMToken, owner }, + stableCoins.dai, + ); + }); + }); + + describe('addWaivedFeeAccount()', () => { + it('should fail: call from address without vault admin role', async () => { + const { owner, redemptionVaultWithMToken, regularAccounts } = + await loadFixture(defaultDeploy); + await addWaivedFeeAccountTest( + { vault: redemptionVaultWithMToken, owner }, + regularAccounts[0].address, + { revertMessage: acErrors.WMAC_HASNT_ROLE, from: regularAccounts[0] }, + ); + }); + it('call from address with vault admin role', async () => { + const { owner, redemptionVaultWithMToken, regularAccounts } = + await loadFixture(defaultDeploy); + await addWaivedFeeAccountTest( + { vault: redemptionVaultWithMToken, owner }, + regularAccounts[0].address, + ); + }); + }); + + describe('removeWaivedFeeAccount()', () => { + it('should fail: call from address without vault admin role', async () => { + const { owner, redemptionVaultWithMToken, regularAccounts } = + await loadFixture(defaultDeploy); + await addWaivedFeeAccountTest( + { vault: redemptionVaultWithMToken, owner }, + regularAccounts[0].address, + ); + await removeWaivedFeeAccountTest( + { vault: redemptionVaultWithMToken, owner }, + regularAccounts[0].address, + { revertMessage: acErrors.WMAC_HASNT_ROLE, from: regularAccounts[0] }, + ); + }); + it('call from address with vault admin role', async () => { + const { owner, redemptionVaultWithMToken, regularAccounts } = + await loadFixture(defaultDeploy); + await addWaivedFeeAccountTest( + { vault: redemptionVaultWithMToken, owner }, + regularAccounts[0].address, + ); + await removeWaivedFeeAccountTest( + { vault: redemptionVaultWithMToken, owner }, + regularAccounts[0].address, + ); + }); + }); + + describe('setFee()', () => { + it('should fail: call from address without vault admin role', async () => { + const { owner, redemptionVaultWithMToken, regularAccounts } = + await loadFixture(defaultDeploy); + await setInstantFeeTest( + { vault: redemptionVaultWithMToken, owner }, + 100, + { revertMessage: acErrors.WMAC_HASNT_ROLE, from: regularAccounts[0] }, + ); + }); + it('call from address with vault admin role', async () => { + const { owner, redemptionVaultWithMToken } = await loadFixture( + defaultDeploy, + ); + await setInstantFeeTest({ vault: redemptionVaultWithMToken, owner }, 100); + }); + }); + + describe('setVariabilityTolerance()', () => { + it('should fail: call from address without vault admin role', async () => { + const { owner, redemptionVaultWithMToken, regularAccounts } = + await loadFixture(defaultDeploy); + await setVariabilityToleranceTest( + { vault: redemptionVaultWithMToken, owner }, + 100, + { revertMessage: acErrors.WMAC_HASNT_ROLE, from: regularAccounts[0] }, + ); + }); + it('call from address with vault admin role', async () => { + const { owner, redemptionVaultWithMToken } = await loadFixture( + defaultDeploy, + ); + await setVariabilityToleranceTest( + { vault: redemptionVaultWithMToken, owner }, + 100, + ); + }); + }); + + describe('withdrawToken()', () => { + it('should fail: call from address without vault admin role', async () => { + const { owner, redemptionVaultWithMToken, stableCoins, regularAccounts } = + await loadFixture(defaultDeploy); + await mintToken(stableCoins.dai, redemptionVaultWithMToken, 1); + await withdrawTest( + { vault: redemptionVaultWithMToken, owner }, + stableCoins.dai, + 1, + owner, + { revertMessage: acErrors.WMAC_HASNT_ROLE, from: regularAccounts[0] }, + ); + }); + it('call from address with vault admin role', async () => { + const { owner, redemptionVaultWithMToken, stableCoins } = + await loadFixture(defaultDeploy); + await mintToken(stableCoins.dai, redemptionVaultWithMToken, 100); + await withdrawTest( + { vault: redemptionVaultWithMToken, owner }, + stableCoins.dai, + 100, + owner, + ); + }); + }); + + describe('freeFromMinAmount()', async () => { + it('should fail: call from address without vault admin role', async () => { + const { redemptionVaultWithMToken, regularAccounts } = await loadFixture( + defaultDeploy, + ); + await expect( + redemptionVaultWithMToken + .connect(regularAccounts[0]) + .freeFromMinAmount(regularAccounts[1].address, true), + ).to.be.revertedWith('WMAC: hasnt role'); + }); + + it('should not fail', async () => { + const { redemptionVaultWithMToken, regularAccounts } = await loadFixture( + defaultDeploy, + ); + await expect( + redemptionVaultWithMToken.freeFromMinAmount( + regularAccounts[0].address, + true, + ), + ).to.not.reverted; + + expect( + await redemptionVaultWithMToken.isFreeFromMinAmount( + regularAccounts[0].address, + ), + ).to.eq(true); + }); + }); + + describe('changeTokenFee()', () => { + it('should fail: call from address without vault admin role', async () => { + const { + owner, + redemptionVaultWithMToken, + stableCoins, + regularAccounts, + dataFeed, + } = await loadFixture(defaultDeploy); + await addPaymentTokenTest( + { vault: redemptionVaultWithMToken, owner }, + stableCoins.dai, + dataFeed.address, + 0, + true, + ); + await changeTokenFeeTest( + { vault: redemptionVaultWithMToken, owner }, + stableCoins.dai.address, + 100, + { revertMessage: acErrors.WMAC_HASNT_ROLE, from: regularAccounts[0] }, + ); + }); + it('call from address with vault admin role', async () => { + const { owner, redemptionVaultWithMToken, stableCoins, dataFeed } = + await loadFixture(defaultDeploy); + await addPaymentTokenTest( + { vault: redemptionVaultWithMToken, owner }, + stableCoins.dai, + dataFeed.address, + 0, + true, + ); + await changeTokenFeeTest( + { vault: redemptionVaultWithMToken, owner }, + stableCoins.dai.address, + 100, + ); + }); + }); + + describe('changeTokenAllowance()', () => { + it('should fail: call from address without vault admin role', async () => { + const { + owner, + redemptionVaultWithMToken, + stableCoins, + regularAccounts, + dataFeed, + } = await loadFixture(defaultDeploy); + await addPaymentTokenTest( + { vault: redemptionVaultWithMToken, owner }, + stableCoins.dai, + dataFeed.address, + 0, + true, + ); + await changeTokenAllowanceTest( + { vault: redemptionVaultWithMToken, owner }, + stableCoins.dai.address, + 100, + { revertMessage: acErrors.WMAC_HASNT_ROLE, from: regularAccounts[0] }, + ); + }); + it('call from address with vault admin role', async () => { + const { owner, redemptionVaultWithMToken, stableCoins, dataFeed } = + await loadFixture(defaultDeploy); + await addPaymentTokenTest( + { vault: redemptionVaultWithMToken, owner }, + stableCoins.dai, + dataFeed.address, + 0, + true, + ); + await changeTokenAllowanceTest( + { vault: redemptionVaultWithMToken, owner }, + stableCoins.dai.address, + 100, + ); + }); + }); + + describe('checkAndRedeemMToken()', () => { + it('should not redeem mTBILL when vault has sufficient tokenOut balance', async () => { + const { + redemptionVaultWithMToken, + stableCoins, + mTBILL, + owner, + dataFeed, + redemptionVault, + } = await loadFixture(defaultDeploy); + + await addPaymentTokenTest( + { vault: redemptionVaultWithMToken, owner }, + stableCoins.dai, + dataFeed.address, + 0, + true, + ); + await addPaymentTokenTest( + { vault: redemptionVault, owner }, + stableCoins.dai, + dataFeed.address, + 0, + true, + ); + + await mintToken(stableCoins.dai, redemptionVaultWithMToken, 1000); + await mintToken(mTBILL, redemptionVaultWithMToken, 1000); + + const mTbillBefore = await mTBILL.balanceOf( + redemptionVaultWithMToken.address, + ); + + await redemptionVaultWithMToken.checkAndRedeemMToken( + stableCoins.dai.address, + parseUnits('500', 9), + parseUnits('1'), + ); + + const mTbillAfter = await mTBILL.balanceOf( + redemptionVaultWithMToken.address, + ); + expect(mTbillAfter).to.equal(mTbillBefore); + }); + + it('should redeem missing amount via mToken RV', async () => { + const { + redemptionVaultWithMToken, + stableCoins, + mTBILL, + owner, + dataFeed, + redemptionVault, + } = await loadFixture(defaultDeploy); + + await addPaymentTokenTest( + { vault: redemptionVaultWithMToken, owner }, + stableCoins.dai, + dataFeed.address, + 0, + true, + ); + await addPaymentTokenTest( + { vault: redemptionVault, owner }, + stableCoins.dai, + dataFeed.address, + 0, + true, + ); + + await mintToken(stableCoins.dai, redemptionVaultWithMToken, 500); + await mintToken(mTBILL, redemptionVaultWithMToken, 10000); + await mintToken(stableCoins.dai, redemptionVault, 1_000_000); + + const mTbillBefore = await mTBILL.balanceOf( + redemptionVaultWithMToken.address, + ); + + await redemptionVaultWithMToken.checkAndRedeemMToken( + stableCoins.dai.address, + parseUnits('1000', 9), + parseUnits('1'), + ); + + const mTbillAfter = await mTBILL.balanceOf( + redemptionVaultWithMToken.address, + ); + expect(mTbillAfter).to.be.lt(mTbillBefore); + + const daiAfter = await stableCoins.dai.balanceOf( + redemptionVaultWithMToken.address, + ); + expect(daiAfter).to.be.gte(parseUnits('1000', 9)); + }); + + it('should revert when insufficient mTBILL balance', async () => { + const { + redemptionVaultWithMToken, + stableCoins, + owner, + dataFeed, + redemptionVault, + } = await loadFixture(defaultDeploy); + + await addPaymentTokenTest( + { vault: redemptionVaultWithMToken, owner }, + stableCoins.dai, + dataFeed.address, + 0, + true, + ); + await addPaymentTokenTest( + { vault: redemptionVault, owner }, + stableCoins.dai, + dataFeed.address, + 0, + true, + ); + + await expect( + redemptionVaultWithMToken.checkAndRedeemMToken( + stableCoins.dai.address, + parseUnits('1000', 9), + parseUnits('1'), + ), + ).to.be.revertedWith('RVMT: balance < needed'); + }); + }); + + describe('redeemInstant()', () => { + it('should fail: when there is no token in vault', async () => { + const { + owner, + redemptionVaultWithMToken, + stableCoins, + mTBILL, + mFONE, + mTokenToUsdDataFeed, + mFoneToUsdDataFeed, + } = await loadFixture(defaultDeploy); + + await redeemInstantWithMTokenTest( + { + redemptionVaultWithMToken, + owner, + mTBILL, + mFONE, + mFoneToUsdDataFeed, + mTokenToUsdDataFeed, + }, + stableCoins.dai, + 1, + { + revertMessage: 'MV: token not exists', + }, + ); + }); + + it('should fail: when trying to redeem 0 amount', async () => { + const { + owner, + redemptionVaultWithMToken, + stableCoins, + mTBILL, + mFONE, + mTokenToUsdDataFeed, + mFoneToUsdDataFeed, + dataFeed, + } = await loadFixture(defaultDeploy); + + await addPaymentTokenTest( + { vault: redemptionVaultWithMToken, owner }, + stableCoins.dai, + dataFeed.address, + 0, + true, + ); + + await redeemInstantWithMTokenTest( + { + redemptionVaultWithMToken, + owner, + mTBILL, + mFONE, + mFoneToUsdDataFeed, + mTokenToUsdDataFeed, + }, + stableCoins.dai, + 0, + { + revertMessage: 'RV: invalid amount', + }, + ); + }); + + it('should fail: call is paused', async () => { + const { + owner, + redemptionVaultWithMToken, + stableCoins, + mTBILL, + mFONE, + mTokenToUsdDataFeed, + mFoneToUsdDataFeed, + dataFeed, + regularAccounts, + } = await loadFixture(defaultDeploy); + + await addPaymentTokenTest( + { vault: redemptionVaultWithMToken, owner }, + stableCoins.dai, + dataFeed.address, + 0, + true, + ); + const selector = encodeFnSelector( + 'redeemInstant(address,uint256,uint256)', + ); + await pauseVaultFn(redemptionVaultWithMToken, selector); + await redeemInstantWithMTokenTest( + { + redemptionVaultWithMToken, + owner, + mTBILL, + mFONE, + mFoneToUsdDataFeed, + mTokenToUsdDataFeed, + }, + stableCoins.dai, + 1, + { + from: regularAccounts[0], + revertMessage: 'Pausable: fn paused', + }, + ); + }); + + it('should fail: when user has no mFONE allowance', async () => { + const { + owner, + redemptionVaultWithMToken, + stableCoins, + mTBILL, + mFONE, + mTokenToUsdDataFeed, + mFoneToUsdDataFeed, + dataFeed, + } = await loadFixture(defaultDeploy); + + await addPaymentTokenTest( + { vault: redemptionVaultWithMToken, owner }, + stableCoins.dai, + dataFeed.address, + 0, + true, + ); + await mintToken(mFONE, owner, 100_000); + + await redeemInstantWithMTokenTest( + { + redemptionVaultWithMToken, + owner, + mTBILL, + mFONE, + mFoneToUsdDataFeed, + mTokenToUsdDataFeed, + }, + stableCoins.dai, + 100, + { + revertMessage: 'ERC20: insufficient allowance', + }, + ); + }); + + it('should fail: when user has no mFONE balance', async () => { + const { + owner, + redemptionVaultWithMToken, + stableCoins, + mTBILL, + mFONE, + mTokenToUsdDataFeed, + mFoneToUsdDataFeed, + dataFeed, + } = await loadFixture(defaultDeploy); + + await addPaymentTokenTest( + { vault: redemptionVaultWithMToken, owner }, + stableCoins.dai, + dataFeed.address, + 0, + true, + ); + await approveBase18(owner, mFONE, redemptionVaultWithMToken, 100_000); + + await redeemInstantWithMTokenTest( + { + redemptionVaultWithMToken, + owner, + mTBILL, + mFONE, + mFoneToUsdDataFeed, + mTokenToUsdDataFeed, + }, + stableCoins.dai, + 100, + { + revertMessage: 'ERC20: burn amount exceeds balance', + }, + ); + }); + + it('should fail: when data feed rate is 0', async () => { + const { + owner, + redemptionVaultWithMToken, + stableCoins, + mTBILL, + mFONE, + mTokenToUsdDataFeed, + mFoneToUsdDataFeed, + dataFeed, + mockedAggregatorMFone, + } = await loadFixture(defaultDeploy); + + await addPaymentTokenTest( + { vault: redemptionVaultWithMToken, owner }, + stableCoins.dai, + dataFeed.address, + 0, + true, + ); + await mintToken(mFONE, owner, 100_000); + await approveBase18(owner, mFONE, redemptionVaultWithMToken, 100_000); + await setRoundData({ mockedAggregator: mockedAggregatorMFone }, 0); + + await redeemInstantWithMTokenTest( + { + redemptionVaultWithMToken, + owner, + mTBILL, + mFONE, + mFoneToUsdDataFeed, + mTokenToUsdDataFeed, + }, + stableCoins.dai, + 100, + { + revertMessage: 'DF: feed is deprecated', + }, + ); + }); + + it('should fail: when amount < minAmount', async () => { + const { + owner, + redemptionVaultWithMToken, + stableCoins, + mTBILL, + mFONE, + mTokenToUsdDataFeed, + mFoneToUsdDataFeed, + dataFeed, + } = await loadFixture(defaultDeploy); + + await addPaymentTokenTest( + { vault: redemptionVaultWithMToken, owner }, + stableCoins.dai, + dataFeed.address, + 0, + true, + ); + await mintToken(mFONE, owner, 100_000); + await approveBase18(owner, mFONE, redemptionVaultWithMToken, 100_000); + + await setMinAmountTest( + { vault: redemptionVaultWithMToken, owner }, + 100_000, + ); + + await redeemInstantWithMTokenTest( + { + redemptionVaultWithMToken, + owner, + mTBILL, + mFONE, + mFoneToUsdDataFeed, + mTokenToUsdDataFeed, + }, + stableCoins.dai, + 999, + { + revertMessage: 'RV: amount < min', + }, + ); + }); + + it('should fail: when token allowance exceeded', async () => { + const { + owner, + redemptionVaultWithMToken, + stableCoins, + mTBILL, + mFONE, + mTokenToUsdDataFeed, + mFoneToUsdDataFeed, + dataFeed, + mockedAggregator, + } = await loadFixture(defaultDeploy); + + await addPaymentTokenTest( + { vault: redemptionVaultWithMToken, owner }, + stableCoins.dai, + dataFeed.address, + 0, + true, + ); + await mintToken(mFONE, owner, 100_000); + await mintToken(stableCoins.dai, redemptionVaultWithMToken, 1_000_000); + await approveBase18(owner, mFONE, redemptionVaultWithMToken, 100_000); + await changeTokenAllowanceTest( + { vault: redemptionVaultWithMToken, owner }, + stableCoins.dai.address, + 100, + ); + await setRoundData({ mockedAggregator }, 4); + + await redeemInstantWithMTokenTest( + { + redemptionVaultWithMToken, + owner, + mTBILL, + mFONE, + mFoneToUsdDataFeed, + mTokenToUsdDataFeed, + }, + stableCoins.dai, + 99_999, + { + revertMessage: 'MV: exceed allowance', + }, + ); + }); + + it('should fail: when daily limit exceeded', async () => { + const { + owner, + redemptionVaultWithMToken, + stableCoins, + mTBILL, + mFONE, + mTokenToUsdDataFeed, + mFoneToUsdDataFeed, + dataFeed, + mockedAggregator, + } = await loadFixture(defaultDeploy); + + await addPaymentTokenTest( + { vault: redemptionVaultWithMToken, owner }, + stableCoins.dai, + dataFeed.address, + 0, + true, + ); + await mintToken(mFONE, owner, 100_000); + await mintToken(stableCoins.dai, redemptionVaultWithMToken, 1_000_000); + await approveBase18(owner, mFONE, redemptionVaultWithMToken, 100_000); + await setInstantDailyLimitTest( + { vault: redemptionVaultWithMToken, owner }, + parseUnits('1000'), + ); + await setRoundData({ mockedAggregator }, 4); + + await redeemInstantWithMTokenTest( + { + redemptionVaultWithMToken, + owner, + mTBILL, + mFONE, + mFoneToUsdDataFeed, + mTokenToUsdDataFeed, + }, + stableCoins.dai, + 99_999, + { + revertMessage: 'MV: exceed limit', + }, + ); + }); + + it('should fail: when fee is 100%', async () => { + const { + owner, + redemptionVaultWithMToken, + stableCoins, + mTBILL, + mFONE, + mTokenToUsdDataFeed, + mFoneToUsdDataFeed, + dataFeed, + } = await loadFixture(defaultDeploy); + + await addPaymentTokenTest( + { vault: redemptionVaultWithMToken, owner }, + stableCoins.dai, + dataFeed.address, + 0, + true, + ); + await mintToken(mFONE, owner, 100_000); + await approveBase18(owner, mFONE, redemptionVaultWithMToken, 100_000); + await setInstantFeeTest( + { vault: redemptionVaultWithMToken, owner }, + 10000, + ); + + await redeemInstantWithMTokenTest( + { + redemptionVaultWithMToken, + owner, + mTBILL, + mFONE, + mFoneToUsdDataFeed, + mTokenToUsdDataFeed, + }, + stableCoins.dai, + 100, + { + revertMessage: 'RV: amountMTokenIn < fee', + }, + ); + }); + + it('should fail: greenlist enabled and user not in greenlist', async () => { + const { + owner, + redemptionVaultWithMToken, + stableCoins, + mTBILL, + mFONE, + mTokenToUsdDataFeed, + mFoneToUsdDataFeed, + } = await loadFixture(defaultDeploy); + + await redemptionVaultWithMToken.setGreenlistEnable(true); + + await redeemInstantWithMTokenTest( + { + redemptionVaultWithMToken, + owner, + mTBILL, + mFONE, + mFoneToUsdDataFeed, + mTokenToUsdDataFeed, + }, + stableCoins.dai, + 1, + { + revertMessage: 'WMAC: hasnt role', + }, + ); + }); + + it('should fail: user is blacklisted', async () => { + const { + owner, + redemptionVaultWithMToken, + stableCoins, + mTBILL, + mFONE, + mTokenToUsdDataFeed, + mFoneToUsdDataFeed, + blackListableTester, + accessControl, + regularAccounts, + } = await loadFixture(defaultDeploy); + + await blackList( + { blacklistable: blackListableTester, accessControl, owner }, + regularAccounts[0], + ); + + await redeemInstantWithMTokenTest( + { + redemptionVaultWithMToken, + owner, + mTBILL, + mFONE, + mFoneToUsdDataFeed, + mTokenToUsdDataFeed, + }, + stableCoins.dai, + 1, + { + from: regularAccounts[0], + revertMessage: acErrors.WMAC_HAS_ROLE, + }, + ); + }); + + it('should fail: user is sanctioned', async () => { + const { + owner, + redemptionVaultWithMToken, + stableCoins, + mTBILL, + mFONE, + mTokenToUsdDataFeed, + mFoneToUsdDataFeed, + mockedSanctionsList, + regularAccounts, + } = await loadFixture(defaultDeploy); + + await sanctionUser( + { sanctionsList: mockedSanctionsList }, + regularAccounts[0], + ); + + await redeemInstantWithMTokenTest( + { + redemptionVaultWithMToken, + owner, + mTBILL, + mFONE, + mFoneToUsdDataFeed, + mTokenToUsdDataFeed, + }, + stableCoins.dai, + 1, + { + from: regularAccounts[0], + revertMessage: 'WSL: sanctioned', + }, + ); + }); + + it('should fail: user try to instant redeem fiat', async () => { + const { + owner, + redemptionVaultWithMToken, + mTBILL, + mFONE, + mTokenToUsdDataFeed, + mFoneToUsdDataFeed, + } = await loadFixture(defaultDeploy); + + await mintToken(mFONE, owner, 100_000); + await mintToken(mTBILL, redemptionVaultWithMToken, 100_000); + await approveBase18(owner, mFONE, redemptionVaultWithMToken, 100_000); + + await redeemInstantWithMTokenTest( + { + redemptionVaultWithMToken, + owner, + mTBILL, + mFONE, + mFoneToUsdDataFeed, + mTokenToUsdDataFeed, + }, + await redemptionVaultWithMToken.MANUAL_FULLFILMENT_TOKEN(), + 99_999, + { + revertMessage: 'MV: token not exists', + }, + ); + }); + + it('should fail: when inner vault fee is not waived', async () => { + const { + owner, + redemptionVaultWithMToken, + stableCoins, + mTBILL, + mFONE, + mTokenToUsdDataFeed, + mFoneToUsdDataFeed, + dataFeed, + redemptionVault, + } = await loadFixture(defaultDeploy); + + await addPaymentTokenTest( + { vault: redemptionVaultWithMToken, owner }, + stableCoins.dai, + dataFeed.address, + 0, + true, + ); + await addPaymentTokenTest( + { vault: redemptionVault, owner }, + stableCoins.dai, + dataFeed.address, + 0, + true, + ); + + // Remove the waived fee — inner vault will charge fee on this contract + await redemptionVault.removeWaivedFeeAccount( + redemptionVaultWithMToken.address, + ); + + await mintToken(mFONE, owner, 100_000); + await mintToken(mTBILL, redemptionVaultWithMToken, 100_000); + await mintToken(stableCoins.dai, redemptionVault, 1_000_000); + await approveBase18(owner, mFONE, redemptionVaultWithMToken, 100_000); + + // No DAI on vault — forces mTBILL redemption path where inner vault fee causes revert + await redeemInstantWithMTokenTest( + { + redemptionVaultWithMToken, + owner, + mTBILL, + mFONE, + mFoneToUsdDataFeed, + mTokenToUsdDataFeed, + }, + stableCoins.dai, + 100, + { + revertMessage: 'RVMT: fees not waived on target', + }, + ); + }); + + it('should fail: vault has no mTBILL and no DAI', async () => { + const { + owner, + redemptionVaultWithMToken, + stableCoins, + mTBILL, + mFONE, + mTokenToUsdDataFeed, + mFoneToUsdDataFeed, + dataFeed, + redemptionVault, + } = await loadFixture(defaultDeploy); + + await addPaymentTokenTest( + { vault: redemptionVaultWithMToken, owner }, + stableCoins.dai, + dataFeed.address, + 0, + true, + ); + await addPaymentTokenTest( + { vault: redemptionVault, owner }, + stableCoins.dai, + dataFeed.address, + 0, + true, + ); + + await mintToken(mFONE, owner, 100_000); + await approveBase18(owner, mFONE, redemptionVaultWithMToken, 100_000); + + await redeemInstantWithMTokenTest( + { + redemptionVaultWithMToken, + owner, + mTBILL, + mFONE, + mFoneToUsdDataFeed, + mTokenToUsdDataFeed, + }, + stableCoins.dai, + 100, + { + revertMessage: 'RVMT: balance < needed', + }, + ); + }); + + it('redeem 100 mFONE, when vault has enough DAI and all fees are 0', async () => { + const { + owner, + redemptionVaultWithMToken, + stableCoins, + mTBILL, + mFONE, + mTokenToUsdDataFeed, + mFoneToUsdDataFeed, + dataFeed, + mockedAggregator, + } = await loadFixture(defaultDeploy); + + await addPaymentTokenTest( + { vault: redemptionVaultWithMToken, owner }, + stableCoins.dai, + dataFeed.address, + 0, + true, + ); + + await setRoundData({ mockedAggregator }, 1); + + await mintToken(mFONE, owner, 100_000); + await mintToken(stableCoins.dai, redemptionVaultWithMToken, 1_000_000); + await mintToken(mTBILL, redemptionVaultWithMToken, 100_000); + await approveBase18(owner, mFONE, redemptionVaultWithMToken, 100_000); + + await setInstantFeeTest({ vault: redemptionVaultWithMToken, owner }, 0); + + await redeemInstantWithMTokenTest( + { + redemptionVaultWithMToken, + owner, + mTBILL, + mFONE, + mFoneToUsdDataFeed, + mTokenToUsdDataFeed, + }, + stableCoins.dai, + 100, + ); + }); + + it('redeem 100 mFONE, when vault has enough DAI (with fees)', async () => { + const { + owner, + redemptionVaultWithMToken, + stableCoins, + mTBILL, + mFONE, + mTokenToUsdDataFeed, + mFoneToUsdDataFeed, + dataFeed, + mockedAggregator, + } = await loadFixture(defaultDeploy); + + await addPaymentTokenTest( + { vault: redemptionVaultWithMToken, owner }, + stableCoins.dai, + dataFeed.address, + 0, + true, + ); + + await setRoundData({ mockedAggregator }, 1); + + await mintToken(mFONE, owner, 100_000); + await mintToken(stableCoins.dai, redemptionVaultWithMToken, 1_000_000); + await mintToken(mTBILL, redemptionVaultWithMToken, 100_000); + await approveBase18(owner, mFONE, redemptionVaultWithMToken, 100_000); + + await redeemInstantWithMTokenTest( + { + redemptionVaultWithMToken, + owner, + mTBILL, + mFONE, + mFoneToUsdDataFeed, + mTokenToUsdDataFeed, + }, + stableCoins.dai, + 100, + ); + }); + + it('redeem 100 mFONE, vault has no DAI => triggers mTBILL redemption', async () => { + const { + owner, + redemptionVaultWithMToken, + stableCoins, + mTBILL, + mFONE, + mTokenToUsdDataFeed, + mFoneToUsdDataFeed, + dataFeed, + redemptionVault, + } = await loadFixture(defaultDeploy); + + await addPaymentTokenTest( + { vault: redemptionVaultWithMToken, owner }, + stableCoins.dai, + dataFeed.address, + 0, + true, + ); + await addPaymentTokenTest( + { vault: redemptionVault, owner }, + stableCoins.dai, + dataFeed.address, + 0, + true, + ); + + await mintToken(mFONE, owner, 100_000); + await mintToken(mTBILL, redemptionVaultWithMToken, 100_000); + await mintToken(stableCoins.dai, redemptionVault, 1_000_000); + await approveBase18(owner, mFONE, redemptionVaultWithMToken, 100_000); + + await redeemInstantWithMTokenTest( + { + redemptionVaultWithMToken, + owner, + mTBILL, + mFONE, + mFoneToUsdDataFeed, + mTokenToUsdDataFeed, + useMTokenSleeve: true, + }, + stableCoins.dai, + 100, + ); + }); + + it('redeem 100 mFONE, vault has partial DAI => triggers partial mTBILL redemption', async () => { + const { + owner, + redemptionVaultWithMToken, + stableCoins, + mTBILL, + mFONE, + mTokenToUsdDataFeed, + mFoneToUsdDataFeed, + dataFeed, + redemptionVault, + } = await loadFixture(defaultDeploy); + + await addPaymentTokenTest( + { vault: redemptionVaultWithMToken, owner }, + stableCoins.dai, + dataFeed.address, + 0, + true, + ); + await addPaymentTokenTest( + { vault: redemptionVault, owner }, + stableCoins.dai, + dataFeed.address, + 0, + true, + ); + + await mintToken(mFONE, owner, 100_000); + await mintToken(mTBILL, redemptionVaultWithMToken, 100_000); + await mintToken(stableCoins.dai, redemptionVaultWithMToken, 10); + await mintToken(stableCoins.dai, redemptionVault, 1_000_000); + await approveBase18(owner, mFONE, redemptionVaultWithMToken, 100_000); + + await redeemInstantWithMTokenTest( + { + redemptionVaultWithMToken, + owner, + mTBILL, + mFONE, + mFoneToUsdDataFeed, + mTokenToUsdDataFeed, + useMTokenSleeve: true, + }, + stableCoins.dai, + 100, + ); + }); + + it('redeem 100 mFONE with divergent rates (mFONE=$5, mTBILL=$2) => triggers mTBILL redemption', async () => { + const { + owner, + redemptionVaultWithMToken, + stableCoins, + mTBILL, + mFONE, + mTokenToUsdDataFeed, + mFoneToUsdDataFeed, + dataFeed, + redemptionVault, + mockedAggregatorMFone, + } = await loadFixture(defaultDeploy); + + await addPaymentTokenTest( + { vault: redemptionVaultWithMToken, owner }, + stableCoins.dai, + dataFeed.address, + 0, + true, + ); + await addPaymentTokenTest( + { vault: redemptionVault, owner }, + stableCoins.dai, + dataFeed.address, + 0, + true, + ); + + await setRoundData({ mockedAggregator: mockedAggregatorMFone }, 5); + + await mintToken(mFONE, owner, 100_000); + await mintToken(mTBILL, redemptionVaultWithMToken, 100_000); + await mintToken(stableCoins.dai, redemptionVault, 1_000_000); + await approveBase18(owner, mFONE, redemptionVaultWithMToken, 100_000); + + await redeemInstantWithMTokenTest( + { + redemptionVaultWithMToken, + owner, + mTBILL, + mFONE, + mFoneToUsdDataFeed, + mTokenToUsdDataFeed, + useMTokenSleeve: true, + }, + stableCoins.dai, + 100, + ); + }); + + it('redeem with waived fee', async () => { + const { + owner, + redemptionVaultWithMToken, + stableCoins, + mTBILL, + mFONE, + mTokenToUsdDataFeed, + mFoneToUsdDataFeed, + dataFeed, + mockedAggregator, + } = await loadFixture(defaultDeploy); + + await addPaymentTokenTest( + { vault: redemptionVaultWithMToken, owner }, + stableCoins.dai, + dataFeed.address, + 0, + true, + ); + await addWaivedFeeAccountTest( + { vault: redemptionVaultWithMToken, owner }, + owner.address, + ); + + await setRoundData({ mockedAggregator }, 1); + + await mintToken(mFONE, owner, 100_000); + await mintToken(stableCoins.dai, redemptionVaultWithMToken, 1_000_000); + await mintToken(mTBILL, redemptionVaultWithMToken, 100_000); + await approveBase18(owner, mFONE, redemptionVaultWithMToken, 100_000); + + await redeemInstantWithMTokenTest( + { + redemptionVaultWithMToken, + owner, + mTBILL, + mFONE, + mFoneToUsdDataFeed, + mTokenToUsdDataFeed, + waivedFee: true, + }, + stableCoins.dai, + 100, + ); + }); + it('redeem 100 mFONE (custom recipient overload)', async () => { + const { + owner, + redemptionVaultWithMToken, + stableCoins, + mTokenToUsdDataFeed, + regularAccounts, + dataFeed, + customRecipient, + mFoneToUsdDataFeed, + mFONE, + mTBILL, + } = await loadFixture(defaultDeploy); + + await mintToken(stableCoins.dai, redemptionVaultWithMToken, 100000); + await mintToken(mTBILL, redemptionVaultWithMToken, 100000); + await mintToken(mFONE, regularAccounts[0], 100); + await approveBase18( + regularAccounts[0], + mFONE, + redemptionVaultWithMToken, + 100, + ); + await addPaymentTokenTest( + { vault: redemptionVaultWithMToken, owner }, + stableCoins.dai, + dataFeed.address, + 0, + true, + ); + + await redeemInstantWithMTokenTest( + { + redemptionVaultWithMToken, + owner, + mTBILL, + mTokenToUsdDataFeed, + customRecipient, + mFoneToUsdDataFeed, + mFONE, + }, + stableCoins.dai, + 100, + { + from: regularAccounts[0], + }, + ); + }); + + it('redeem 100 mFONE when other fn overload is paused (custom recipient overload)', async () => { + const { + owner, + redemptionVaultWithMToken, + stableCoins, + mTBILL, + mTokenToUsdDataFeed, + regularAccounts, + dataFeed, + customRecipient, + mFoneToUsdDataFeed, + mFONE, + } = await loadFixture(defaultDeploy); + + await pauseVaultFn( + redemptionVaultWithMToken, + encodeFnSelector('redeemInstant(address,uint256,uint256)'), + ); + await mintToken(stableCoins.dai, redemptionVaultWithMToken, 100000); + await mintToken(mTBILL, redemptionVaultWithMToken, 100000); + await mintToken(mFONE, regularAccounts[0], 100); + await approveBase18( + regularAccounts[0], + mFONE, + redemptionVaultWithMToken, + 100, + ); + await addPaymentTokenTest( + { vault: redemptionVaultWithMToken, owner }, + stableCoins.dai, + dataFeed.address, + 0, + true, + ); + + await redeemInstantWithMTokenTest( + { + redemptionVaultWithMToken, + owner, + mTBILL, + mTokenToUsdDataFeed, + customRecipient, + mFoneToUsdDataFeed, + mFONE, + }, + stableCoins.dai, + 100, + { + from: regularAccounts[0], + }, + ); + }); + + it('redeem 100 mFONE when other fn overload is paused', async () => { + const { + owner, + redemptionVaultWithMToken, + stableCoins, + mTBILL, + mTokenToUsdDataFeed, + regularAccounts, + dataFeed, + mFoneToUsdDataFeed, + mFONE, + } = await loadFixture(defaultDeploy); + + await pauseVaultFn( + redemptionVaultWithMToken, + encodeFnSelector('redeemInstant(address,uint256,uint256,address)'), + ); + await mintToken(stableCoins.dai, redemptionVaultWithMToken, 100000); + await mintToken(mTBILL, redemptionVaultWithMToken, 100000); + await mintToken(mFONE, regularAccounts[0], 100); + await approveBase18( + regularAccounts[0], + mFONE, + redemptionVaultWithMToken, + 100, + ); + await addPaymentTokenTest( + { vault: redemptionVaultWithMToken, owner }, + stableCoins.dai, + dataFeed.address, + 0, + true, + ); + + await redeemInstantWithMTokenTest( + { + redemptionVaultWithMToken, + owner, + mTBILL, + mTokenToUsdDataFeed, + mFoneToUsdDataFeed, + mFONE, + }, + stableCoins.dai, + 100, + { + from: regularAccounts[0], + }, + ); + }); + + it('should fail: when function with custom recipient is paused', async () => { + const { + owner, + redemptionVaultWithMToken, + stableCoins, + mTBILL, + dataFeed, + mTokenToUsdDataFeed, + regularAccounts, + customRecipient, + mFoneToUsdDataFeed, + mFONE, + } = await loadFixture(defaultDeploy); + await mintToken(mFONE, regularAccounts[0], 100); + await approveBase18( + regularAccounts[0], + stableCoins.dai, + redemptionVaultWithMToken, + 100, + ); + await addPaymentTokenTest( + { vault: redemptionVaultWithMToken, owner }, + stableCoins.dai, + dataFeed.address, + 0, + true, + ); + const selector = encodeFnSelector( + 'redeemInstant(address,uint256,uint256,address)', + ); + await pauseVaultFn(redemptionVaultWithMToken, selector); + await redeemInstantWithMTokenTest( + { + redemptionVaultWithMToken, + owner, + mTBILL, + mTokenToUsdDataFeed, + customRecipient, + mFoneToUsdDataFeed, + mFONE, + }, + stableCoins.dai, + 100, + { + from: regularAccounts[0], + revertMessage: 'Pausable: fn paused', + }, + ); + }); + + it('should fail: greenlist enabled and recipient not in greenlist (custom recipient overload)', async () => { + const { + owner, + redemptionVaultWithMToken, + stableCoins, + mTBILL, + mTokenToUsdDataFeed, + greenListableTester, + accessControl, + customRecipient, + mFoneToUsdDataFeed, + mFONE, + } = await loadFixture(defaultDeploy); + + await redemptionVaultWithMToken.setGreenlistEnable(true); + + await greenList( + { greenlistable: greenListableTester, accessControl, owner }, + owner, + ); + + await redeemInstantWithMTokenTest( + { + redemptionVaultWithMToken, + owner, + mTBILL, + mTokenToUsdDataFeed, + customRecipient, + mFoneToUsdDataFeed, + mFONE, + }, + stableCoins.dai, + 1, + { + revertMessage: 'WMAC: hasnt role', + }, + ); + }); + + it('should fail: recipient in blacklist (custom recipient overload)', async () => { + const { + owner, + redemptionVaultWithMToken, + stableCoins, + mTBILL, + mTokenToUsdDataFeed, + blackListableTester, + accessControl, + regularAccounts, + customRecipient, + mFoneToUsdDataFeed, + mFONE, + } = await loadFixture(defaultDeploy); + + await blackList( + { blacklistable: blackListableTester, accessControl, owner }, + customRecipient, + ); + + await redeemInstantWithMTokenTest( + { + redemptionVaultWithMToken, + owner, + mTBILL, + mTokenToUsdDataFeed, + customRecipient, + mFoneToUsdDataFeed, + mFONE, + }, + stableCoins.dai, + 1, + { + from: regularAccounts[0], + revertMessage: acErrors.WMAC_HAS_ROLE, + }, + ); + }); + + it('should fail: recipient in sanctions list (custom recipient overload)', async () => { + const { + owner, + redemptionVaultWithMToken, + stableCoins, + mTBILL, + mTokenToUsdDataFeed, + regularAccounts, + mockedSanctionsList, + customRecipient, + mFoneToUsdDataFeed, + mFONE, + } = await loadFixture(defaultDeploy); + + await sanctionUser( + { sanctionsList: mockedSanctionsList }, + customRecipient, + ); + + await redeemInstantWithMTokenTest( + { + redemptionVaultWithMToken, + owner, + mTBILL, + mTokenToUsdDataFeed, + customRecipient, + mFoneToUsdDataFeed, + mFONE, + }, + stableCoins.dai, + 1, + { + from: regularAccounts[0], + revertMessage: 'WSL: sanctioned', + }, + ); + }); + }); + + describe('redeemRequest()', () => { + it('should fail: when there is no token in vault', async () => { + const { + owner, + redemptionVaultWithMToken, + stableCoins, + mFONE, + mFoneToUsdDataFeed, + } = await loadFixture(defaultDeploy); + + await redeemRequestTest( + { + redemptionVault: redemptionVaultWithMToken, + owner, + mTBILL: mFONE, + mTokenToUsdDataFeed: mFoneToUsdDataFeed, + }, + stableCoins.dai, + 1, + { + revertMessage: 'MV: token not exists', + }, + ); + }); + + it('should fail: when trying to redeem 0 amount', async () => { + const { + owner, + redemptionVaultWithMToken, + stableCoins, + mFONE, + mFoneToUsdDataFeed, + dataFeed, + } = await loadFixture(defaultDeploy); + + await addPaymentTokenTest( + { vault: redemptionVaultWithMToken, owner }, + stableCoins.dai, + dataFeed.address, + 0, + true, + ); + + await redeemRequestTest( + { + redemptionVault: redemptionVaultWithMToken, + owner, + mTBILL: mFONE, + mTokenToUsdDataFeed: mFoneToUsdDataFeed, + }, + stableCoins.dai, + 0, + { + revertMessage: 'RV: invalid amount', + }, + ); + }); + + it('should fail: call is paused', async () => { + const { + owner, + redemptionVaultWithMToken, + stableCoins, + mFONE, + mFoneToUsdDataFeed, + dataFeed, + } = await loadFixture(defaultDeploy); + + await addPaymentTokenTest( + { vault: redemptionVaultWithMToken, owner }, + stableCoins.dai, + dataFeed.address, + 0, + true, + ); + await pauseVaultFn( + redemptionVaultWithMToken, + encodeFnSelector('redeemRequest(address,uint256)'), + ); + + await redeemRequestTest( + { + redemptionVault: redemptionVaultWithMToken, + owner, + mTBILL: mFONE, + mTokenToUsdDataFeed: mFoneToUsdDataFeed, + }, + stableCoins.dai, + 1, + { + revertMessage: 'Pausable: fn paused', + }, + ); + }); + + it('should fail: when user has no mFONE balance', async () => { + const { + owner, + redemptionVaultWithMToken, + stableCoins, + mFONE, + mFoneToUsdDataFeed, + dataFeed, + } = await loadFixture(defaultDeploy); + + await addPaymentTokenTest( + { vault: redemptionVaultWithMToken, owner }, + stableCoins.dai, + dataFeed.address, + 0, + true, + ); + await approveBase18(owner, mFONE, redemptionVaultWithMToken, 100_000); + + await redeemRequestTest( + { + redemptionVault: redemptionVaultWithMToken, + owner, + mTBILL: mFONE, + mTokenToUsdDataFeed: mFoneToUsdDataFeed, + }, + stableCoins.dai, + 100, + { + revertMessage: 'ERC20: transfer amount exceeds balance', + }, + ); + }); + + it('redeem request 100 mFONE', async () => { + const { + owner, + redemptionVaultWithMToken, + stableCoins, + mFONE, + mFoneToUsdDataFeed, + dataFeed, + } = await loadFixture(defaultDeploy); + + await addPaymentTokenTest( + { vault: redemptionVaultWithMToken, owner }, + stableCoins.dai, + dataFeed.address, + 0, + true, + ); + await mintToken(mFONE, owner, 100_000); + await approveBase18(owner, mFONE, redemptionVaultWithMToken, 100_000); + + await redeemRequestTest( + { + redemptionVault: redemptionVaultWithMToken, + owner, + mTBILL: mFONE, + mTokenToUsdDataFeed: mFoneToUsdDataFeed, + }, + stableCoins.dai, + 100, + ); + }); + }); + + describe('redeemFiatRequest()', () => { + it('should fail: call is paused', async () => { + const { owner, redemptionVaultWithMToken, mFONE, mFoneToUsdDataFeed } = + await loadFixture(defaultDeploy); + + await pauseVaultFn( + redemptionVaultWithMToken, + encodeFnSelector('redeemFiatRequest(uint256)'), + ); + + await redeemFiatRequestTest( + { + redemptionVault: redemptionVaultWithMToken, + owner, + mTBILL: mFONE, + mTokenToUsdDataFeed: mFoneToUsdDataFeed, + }, + 100, + { + revertMessage: 'Pausable: fn paused', + }, + ); + }); + + it('redeem fiat request 100 mFONE', async () => { + const { owner, redemptionVaultWithMToken, mFONE, mFoneToUsdDataFeed } = + await loadFixture(defaultDeploy); + + await mintToken(mFONE, owner, 100_000); + await approveBase18(owner, mFONE, redemptionVaultWithMToken, 100_000); + + await redeemFiatRequestTest( + { + redemptionVault: redemptionVaultWithMToken, + owner, + mTBILL: mFONE, + mTokenToUsdDataFeed: mFoneToUsdDataFeed, + }, + 100, + ); + }); + }); + + describe('approveRequest()', async () => { + it('should fail: call from address without vault admin role', async () => { + const { + redemptionVaultWithMToken: redemptionVault, + regularAccounts, + mFoneToUsdDataFeed, + mFONE, + } = await loadFixture(defaultDeploy); + await approveRedeemRequestTest( + { + redemptionVault, + owner: regularAccounts[1], + mTBILL: mFONE, + mTokenToUsdDataFeed: mFoneToUsdDataFeed, + }, + 1, + parseUnits('1'), + { + revertMessage: 'WMAC: hasnt role', + }, + ); + }); + + it('should fail: request by id not exist', async () => { + const { + owner, + redemptionVaultWithMToken: redemptionVault, + stableCoins, + mFONE, + mFoneToUsdDataFeed, + dataFeed, + } = await loadFixture(defaultDeploy); + await addPaymentTokenTest( + { vault: redemptionVault, owner }, + stableCoins.dai, + dataFeed.address, + 0, + true, + ); + await approveRedeemRequestTest( + { + redemptionVault, + owner, + mTBILL: mFONE, + mTokenToUsdDataFeed: mFoneToUsdDataFeed, + }, + 1, + parseUnits('1'), + { + revertMessage: 'RV: request not exist', + }, + ); + }); + + it('should fail: request already processed', async () => { + const { + owner, + mockedAggregator, + mockedAggregatorMFone, + redemptionVaultWithMToken: redemptionVault, + stableCoins, + mFONE, + mFoneToUsdDataFeed, + dataFeed, + requestRedeemer, + } = await loadFixture(defaultDeploy); + + await mintToken(stableCoins.dai, requestRedeemer, 100000); + await approveBase18( + requestRedeemer, + stableCoins.dai, + redemptionVault, + 100000, + ); + + await mintToken(mFONE, owner, 100); + await approveBase18(owner, mFONE, redemptionVault, 100); + await addPaymentTokenTest( + { vault: redemptionVault, owner }, + stableCoins.dai, + dataFeed.address, + 0, + true, + ); + await setRoundData({ mockedAggregator }, 1.03); + await setRoundData({ mockedAggregator: mockedAggregatorMFone }, 5); + + await redeemRequestTest( + { + redemptionVault, + owner, + mTBILL: mFONE, + mTokenToUsdDataFeed: mFoneToUsdDataFeed, + }, + stableCoins.dai, + 100, + ); + const requestId = 0; + + await approveRedeemRequestTest( + { + redemptionVault, + owner, + mTBILL: mFONE, + mTokenToUsdDataFeed: mFoneToUsdDataFeed, + }, + +requestId, + parseUnits('1'), + ); + await approveRedeemRequestTest( + { + redemptionVault, + owner, + mTBILL: mFONE, + mTokenToUsdDataFeed: mFoneToUsdDataFeed, + }, + +requestId, + parseUnits('1'), + { revertMessage: 'RV: request not pending' }, + ); + }); + + it('approve request from vaut admin account', async () => { + const { + owner, + mockedAggregator, + mockedAggregatorMFone, + redemptionVaultWithMToken: redemptionVault, + stableCoins, + mFONE, + mFoneToUsdDataFeed, + dataFeed, + requestRedeemer, + } = await loadFixture(defaultDeploy); + + await mintToken(stableCoins.dai, requestRedeemer, 100000); + await approveBase18( + requestRedeemer, + stableCoins.dai, + redemptionVault, + 100000, + ); + + await mintToken(mFONE, owner, 100); + await approveBase18(owner, mFONE, redemptionVault, 100); + await addPaymentTokenTest( + { vault: redemptionVault, owner }, + stableCoins.dai, + dataFeed.address, + 0, + true, + ); + await setRoundData({ mockedAggregator }, 1.03); + await setRoundData({ mockedAggregator: mockedAggregatorMFone }, 5); + + await redeemRequestTest( + { + redemptionVault, + owner, + mTBILL: mFONE, + mTokenToUsdDataFeed: mFoneToUsdDataFeed, + }, + stableCoins.dai, + 100, + ); + const requestId = 0; + + await approveRedeemRequestTest( + { + redemptionVault, + owner, + mTBILL: mFONE, + mTokenToUsdDataFeed: mFoneToUsdDataFeed, + }, + +requestId, + parseUnits('1'), + ); + }); + }); + + describe('approveRequest() with fiat', async () => { + it('approve request from vaut admin account', async () => { + const { + owner, + mockedAggregator, + mockedAggregatorMFone, + redemptionVaultWithMToken: redemptionVault, + mFONE, + mFoneToUsdDataFeed, + greenListableTester, + accessControl, + } = await loadFixture(defaultDeploy); + + await greenList( + { greenlistable: greenListableTester, accessControl, owner }, + owner, + ); + + await mintToken(mFONE, owner, 100); + await approveBase18(owner, mFONE, redemptionVault, 100); + await setRoundData({ mockedAggregator }, 1.03); + await setRoundData({ mockedAggregator: mockedAggregatorMFone }, 5); + + await redeemFiatRequestTest( + { + redemptionVault, + owner, + mTBILL: mFONE, + mTokenToUsdDataFeed: mFoneToUsdDataFeed, + }, + 100, + ); + const requestId = 0; + await changeTokenAllowanceTest( + { vault: redemptionVault, owner }, + constants.AddressZero, + parseUnits('100'), + ); + + await approveRedeemRequestTest( + { + redemptionVault, + owner, + mTBILL: mFONE, + mTokenToUsdDataFeed: mFoneToUsdDataFeed, + }, + +requestId, + parseUnits('1'), + ); + }); + }); + + describe('safeApproveRequest()', async () => { + it('should fail: call from address without vault admin role', async () => { + const { + redemptionVaultWithMToken: redemptionVault, + regularAccounts, + mFoneToUsdDataFeed, + mFONE, + } = await loadFixture(defaultDeploy); + await safeApproveRedeemRequestTest( + { + redemptionVault, + owner: regularAccounts[1], + mTBILL: mFONE, + mTokenToUsdDataFeed: mFoneToUsdDataFeed, + }, + 1, + parseUnits('1'), + { + revertMessage: 'WMAC: hasnt role', + }, + ); + }); + + it('should fail: request by id not exist', async () => { + const { + owner, + redemptionVaultWithMToken: redemptionVault, + stableCoins, + mFONE, + mFoneToUsdDataFeed, + dataFeed, + } = await loadFixture(defaultDeploy); + await addPaymentTokenTest( + { vault: redemptionVault, owner }, + stableCoins.dai, + dataFeed.address, + 0, + true, + ); + await safeApproveRedeemRequestTest( + { + redemptionVault, + owner, + mTBILL: mFONE, + mTokenToUsdDataFeed: mFoneToUsdDataFeed, + }, + 1, + parseUnits('1'), + { + revertMessage: 'RV: request not exist', + }, + ); + }); + + it('should fail: if new rate greater then variabilityTolerance', async () => { + const { + owner, + mockedAggregator, + mockedAggregatorMFone, + redemptionVaultWithMToken: redemptionVault, + stableCoins, + mFONE, + mFoneToUsdDataFeed, + dataFeed, + requestRedeemer, + } = await loadFixture(defaultDeploy); + + await mintToken(stableCoins.dai, requestRedeemer, 100000); + await approveBase18( + requestRedeemer, + stableCoins.dai, + redemptionVault, + 100000, + ); + await mintToken(mFONE, owner, 100); + await approveBase18(owner, mFONE, redemptionVault, 100); + await addPaymentTokenTest( + { vault: redemptionVault, owner }, + stableCoins.dai, + dataFeed.address, + 0, + true, + ); + await setRoundData({ mockedAggregator }, 1.001); + await setRoundData({ mockedAggregator: mockedAggregatorMFone }, 5); + + await redeemRequestTest( + { + redemptionVault, + owner, + mTBILL: mFONE, + mTokenToUsdDataFeed: mFoneToUsdDataFeed, + }, + stableCoins.dai, + 100, + ); + const requestId = 0; + + await safeApproveRedeemRequestTest( + { + redemptionVault, + owner, + mTBILL: mFONE, + mTokenToUsdDataFeed: mFoneToUsdDataFeed, + }, + +requestId, + parseUnits('6'), + { revertMessage: 'MV: exceed price diviation' }, + ); + }); + + it('should fail: request already processed', async () => { + const { + owner, + mockedAggregator, + mockedAggregatorMFone, + redemptionVaultWithMToken: redemptionVault, + stableCoins, + mFONE, + mFoneToUsdDataFeed, + dataFeed, + requestRedeemer, + } = await loadFixture(defaultDeploy); + + await mintToken(stableCoins.dai, requestRedeemer, 100000); + await approveBase18( + requestRedeemer, + stableCoins.dai, + redemptionVault, + 100000, + ); + await mintToken(mFONE, owner, 100); + await approveBase18(owner, mFONE, redemptionVault, 100); + await addPaymentTokenTest( + { vault: redemptionVault, owner }, + stableCoins.dai, + dataFeed.address, + 0, + true, + ); + await setRoundData({ mockedAggregator }, 1.001); + await setRoundData({ mockedAggregator: mockedAggregatorMFone }, 5); + + await redeemRequestTest( + { + redemptionVault, + owner, + mTBILL: mFONE, + mTokenToUsdDataFeed: mFoneToUsdDataFeed, + }, + stableCoins.dai, + 100, + ); + const requestId = 0; + + await safeApproveRedeemRequestTest( + { + redemptionVault, + owner, + mTBILL: mFONE, + mTokenToUsdDataFeed: mFoneToUsdDataFeed, + }, + +requestId, + parseUnits('5.000001'), + ); + await safeApproveRedeemRequestTest( + { + redemptionVault, + owner, + mTBILL: mFONE, + mTokenToUsdDataFeed: mFoneToUsdDataFeed, + }, + +requestId, + parseUnits('5.00001'), + { revertMessage: 'RV: request not pending' }, + ); + }); + + it('safe approve request from vaut admin account', async () => { + const { + owner, + mockedAggregator, + mockedAggregatorMFone, + redemptionVaultWithMToken: redemptionVault, + stableCoins, + mFONE, + mFoneToUsdDataFeed, + dataFeed, + requestRedeemer, + } = await loadFixture(defaultDeploy); + + await mintToken(stableCoins.dai, requestRedeemer, 100000); + await approveBase18( + requestRedeemer, + stableCoins.dai, + redemptionVault, + 100000, + ); + await mintToken(mFONE, owner, 100); + await approveBase18(owner, mFONE, redemptionVault, 100); + await addPaymentTokenTest( + { vault: redemptionVault, owner }, + stableCoins.dai, + dataFeed.address, + 0, + true, + ); + + await setRoundData({ mockedAggregator }, 1.03); + await setRoundData({ mockedAggregator: mockedAggregatorMFone }, 5); + + await redeemRequestTest( + { + redemptionVault, + owner, + mTBILL: mFONE, + mTokenToUsdDataFeed: mFoneToUsdDataFeed, + }, + stableCoins.dai, + 100, + ); + const requestId = 0; + + await safeApproveRedeemRequestTest( + { + redemptionVault, + owner, + mTBILL: mFONE, + mTokenToUsdDataFeed: mFoneToUsdDataFeed, + }, + +requestId, + parseUnits('5.000001'), + ); + }); + }); + + describe('rejectRequest()', async () => { + it('should fail: call from address without vault admin role', async () => { + const { + redemptionVaultWithMToken: redemptionVault, + regularAccounts, + mFoneToUsdDataFeed, + mFONE, + } = await loadFixture(defaultDeploy); + await rejectRedeemRequestTest( + { + redemptionVault, + owner: regularAccounts[1], + mTBILL: mFONE, + mTokenToUsdDataFeed: mFoneToUsdDataFeed, + }, + 1, + { + revertMessage: 'WMAC: hasnt role', + }, + ); + }); + + it('should fail: request by id not exist', async () => { + const { + owner, + redemptionVaultWithMToken: redemptionVault, + stableCoins, + mFONE, + mFoneToUsdDataFeed, + dataFeed, + } = await loadFixture(defaultDeploy); + await addPaymentTokenTest( + { vault: redemptionVault, owner }, + stableCoins.dai, + dataFeed.address, + 0, + true, + ); + await rejectRedeemRequestTest( + { + redemptionVault, + owner, + mTBILL: mFONE, + mTokenToUsdDataFeed: mFoneToUsdDataFeed, + }, + 1, + { + revertMessage: 'RV: request not exist', + }, + ); + }); + + it('should fail: request already processed', async () => { + const { + owner, + mockedAggregator, + mockedAggregatorMFone, + redemptionVaultWithMToken: redemptionVault, + stableCoins, + mFONE, + mFoneToUsdDataFeed, + dataFeed, + } = await loadFixture(defaultDeploy); + + await mintToken(stableCoins.dai, redemptionVault, 100000); + await mintToken(mFONE, owner, 100); + await approveBase18(owner, mFONE, redemptionVault, 100); + await addPaymentTokenTest( + { vault: redemptionVault, owner }, + stableCoins.dai, + dataFeed.address, + 0, + true, + ); + await setRoundData({ mockedAggregator }, 1.001); + await setRoundData({ mockedAggregator: mockedAggregatorMFone }, 5); + + await redeemRequestTest( + { + redemptionVault, + owner, + mTBILL: mFONE, + mTokenToUsdDataFeed: mFoneToUsdDataFeed, + }, + stableCoins.dai, + 100, + ); + const requestId = 0; + + await rejectRedeemRequestTest( + { + redemptionVault, + owner, + mTBILL: mFONE, + mTokenToUsdDataFeed: mFoneToUsdDataFeed, + }, + +requestId, + ); + await rejectRedeemRequestTest( + { + redemptionVault, + owner, + mTBILL: mFONE, + mTokenToUsdDataFeed: mFoneToUsdDataFeed, + }, + +requestId, + { revertMessage: 'RV: request not pending' }, + ); + }); + + it('reject request from vaut admin account', async () => { + const { + owner, + mockedAggregator, + mockedAggregatorMFone, + redemptionVaultWithMToken: redemptionVault, + stableCoins, + mFONE, + mFoneToUsdDataFeed, + dataFeed, + } = await loadFixture(defaultDeploy); + + await mintToken(stableCoins.dai, redemptionVault, 100000); + await mintToken(mFONE, owner, 100); + await approveBase18(owner, mFONE, redemptionVault, 100); + await addPaymentTokenTest( + { vault: redemptionVault, owner }, + stableCoins.dai, + dataFeed.address, + 0, + true, + ); + + await setRoundData({ mockedAggregator }, 1.03); + await setRoundData({ mockedAggregator: mockedAggregatorMFone }, 5); + + await redeemRequestTest( + { + redemptionVault, + owner, + mTBILL: mFONE, + mTokenToUsdDataFeed: mFoneToUsdDataFeed, + }, + stableCoins.dai, + 100, + ); + const requestId = 0; + + await rejectRedeemRequestTest( + { + redemptionVault, + owner, + mTBILL: mFONE, + mTokenToUsdDataFeed: mFoneToUsdDataFeed, + }, + +requestId, + ); + }); + }); + + describe('ceiling division math correctness', () => { + const TOKEN_DECIMALS: Record = { + usdc6: 6, + usdc: 8, + dai: 9, + usdt: 18, + }; + + /** + * Mirrors the Solidity math in RedemptionVaultWithMToken._redeemInstant: + * amountTokenOutWithoutFee = truncate( + * (amountMTokenWithoutFee * mTokenRate / tokenOutRate), tokenDecimals + * ) + * Returns the expected user output in NATIVE decimals. + */ + function computeExpectedTokenOut( + amountMTokenIn: BigNumber, + mTokenRate: BigNumber, + tokenOutRate: BigNumber, + tokenDecimals: number, + feePercent: number, + isWaived: boolean, + ): BigNumber { + const fee = isWaived + ? BigNumber.from(0) + : amountMTokenIn.mul(feePercent).div(10000); + const amountWithoutFee = amountMTokenIn.sub(fee); + const base18Out = amountWithoutFee.mul(mTokenRate).div(tokenOutRate); + const scale = BigNumber.from(10).pow(18 - tokenDecimals); + const truncated = base18Out.div(scale); + return truncated; + } + + /** + * Lean helper: configures both outer + inner vault for a given tokenOut, + * sets rates, mints tokens, and returns everything needed to call redeemInstant. + * The outer vault has ZERO tokenOut to force the inner-vault redemption path. + */ + async function setupCeilDivTest(opts: { + fixture: Awaited>; + tokenKey: 'usdc6' | 'usdc' | 'dai' | 'usdt'; + mTbillRate: number; + isStable: boolean; + tokenOutRate?: number; + redeemAmount?: number; + }) { + const { + redemptionVaultWithMToken, + redemptionVault, + owner, + mTBILL, + mFONE, + mFoneToUsdDataFeed, + mTokenToUsdDataFeed, + mockedAggregatorMToken, + mockedAggregatorMFone, + mockedAggregator, + dataFeed, + stableCoins, + } = opts.fixture; + + const token = stableCoins[opts.tokenKey]; + + await addPaymentTokenTest( + { vault: redemptionVaultWithMToken, owner }, + token, + dataFeed.address, + 0, + opts.isStable, + ); + await addPaymentTokenTest( + { vault: redemptionVault, owner }, + token, + dataFeed.address, + 0, + opts.isStable, + ); + + await setRoundData( + { mockedAggregator: mockedAggregatorMToken }, + opts.mTbillRate, + ); + if (opts.tokenOutRate !== undefined) { + await setRoundData({ mockedAggregator }, opts.tokenOutRate); + } + + await setInstantFeeTest({ vault: redemptionVaultWithMToken, owner }, 0); + + const amount = opts.redeemAmount ?? 100; + + await mintToken(mFONE, owner, amount * 100); + await mintToken(mTBILL, redemptionVaultWithMToken, amount * 100); + await mintToken(token, redemptionVault, amount * 10000); + await approveBase18( + owner, + mFONE, + redemptionVaultWithMToken, + amount * 100, + ); + + const mFoneRate = await mFoneToUsdDataFeed.getDataInBase18(); + const tokenOutRate = opts.isStable + ? parseUnits('1') + : await dataFeed.getDataInBase18(); + + return { + token, + amount, + redemptionVaultWithMToken, + redemptionVault, + owner, + mTBILL, + mFONE, + mFoneToUsdDataFeed, + mTokenToUsdDataFeed, + mockedAggregatorMFone, + mockedAggregator, + mFoneRate, + tokenOutRate, + }; + } + + describe('with base RedemptionVault as inner vault', () => { + const tokenVariants: Array<{ + key: 'usdc6' | 'usdc' | 'dai' | 'usdt'; + label: string; + }> = [ + { key: 'usdc6', label: '6-dec' }, + { key: 'usdc', label: '8-dec' }, + { key: 'dai', label: '9-dec' }, + { key: 'usdt', label: '18-dec' }, + ]; + + for (const { key, label } of tokenVariants) { + describe(`tokenOut ${label} (${key})`, () => { + it('succeeds with exact division (mTBILL=5, stable)', async () => { + const fixture = await loadFixture(defaultDeploy); + const { + token, + amount, + redemptionVaultWithMToken, + owner, + mTBILL, + mFoneRate, + tokenOutRate, + } = await setupCeilDivTest({ + fixture, + tokenKey: key, + mTbillRate: 5, + isStable: true, + }); + + const mTbillBefore = await mTBILL.balanceOf( + redemptionVaultWithMToken.address, + ); + const userTokenBefore = await token.balanceOf(owner.address); + + await redemptionVaultWithMToken + .connect(owner) + ['redeemInstant(address,uint256,uint256)']( + token.address, + parseUnits(amount.toString()), + 0, + ); + + expect( + await mTBILL.balanceOf(redemptionVaultWithMToken.address), + ).to.be.lt(mTbillBefore); + + const expected = computeExpectedTokenOut( + parseUnits(amount.toString()), + mFoneRate, + tokenOutRate, + TOKEN_DECIMALS[key], + 0, + true, + ); + const userTokenAfter = await token.balanceOf(owner.address); + expect(userTokenAfter.sub(userTokenBefore)).to.equal(expected); + }); + + it('succeeds with remainder-producing rate (mTBILL=1.05, stable)', async () => { + const fixture = await loadFixture(defaultDeploy); + const { + token, + amount, + redemptionVaultWithMToken, + owner, + mTBILL, + mFoneRate, + tokenOutRate, + } = await setupCeilDivTest({ + fixture, + tokenKey: key, + mTbillRate: 1.05, + isStable: true, + }); + + const mTbillBefore = await mTBILL.balanceOf( + redemptionVaultWithMToken.address, + ); + const userTokenBefore = await token.balanceOf(owner.address); + + await redemptionVaultWithMToken + .connect(owner) + ['redeemInstant(address,uint256,uint256)']( + token.address, + parseUnits(amount.toString()), + 0, + ); + + expect( + await mTBILL.balanceOf(redemptionVaultWithMToken.address), + ).to.be.lt(mTbillBefore); + + const expected = computeExpectedTokenOut( + parseUnits(amount.toString()), + mFoneRate, + tokenOutRate, + TOKEN_DECIMALS[key], + 0, + true, + ); + const userTokenAfter = await token.balanceOf(owner.address); + expect(userTokenAfter.sub(userTokenBefore)).to.equal(expected); + }); + + it('succeeds with high mTBILL rate (mTBILL=100, stable)', async () => { + const fixture = await loadFixture(defaultDeploy); + const { + token, + amount, + redemptionVaultWithMToken, + owner, + mTBILL, + mFoneRate, + tokenOutRate, + } = await setupCeilDivTest({ + fixture, + tokenKey: key, + mTbillRate: 100, + isStable: true, + }); + + const mTbillBefore = await mTBILL.balanceOf( + redemptionVaultWithMToken.address, + ); + const userTokenBefore = await token.balanceOf(owner.address); + + await redemptionVaultWithMToken + .connect(owner) + ['redeemInstant(address,uint256,uint256)']( + token.address, + parseUnits(amount.toString()), + 0, + ); + + expect( + await mTBILL.balanceOf(redemptionVaultWithMToken.address), + ).to.be.lt(mTbillBefore); + + const expected = computeExpectedTokenOut( + parseUnits(amount.toString()), + mFoneRate, + tokenOutRate, + TOKEN_DECIMALS[key], + 0, + true, + ); + const userTokenAfter = await token.balanceOf(owner.address); + expect(userTokenAfter.sub(userTokenBefore)).to.equal(expected); + }); + + it('succeeds with low mTBILL rate (mTBILL=0.5, stable)', async () => { + const fixture = await loadFixture(defaultDeploy); + const { + token, + amount, + redemptionVaultWithMToken, + owner, + mTBILL, + mFoneRate, + tokenOutRate, + } = await setupCeilDivTest({ + fixture, + tokenKey: key, + mTbillRate: 0.5, + isStable: true, + }); + + const mTbillBefore = await mTBILL.balanceOf( + redemptionVaultWithMToken.address, + ); + const userTokenBefore = await token.balanceOf(owner.address); + + await redemptionVaultWithMToken + .connect(owner) + ['redeemInstant(address,uint256,uint256)']( + token.address, + parseUnits(amount.toString()), + 0, + ); + + expect( + await mTBILL.balanceOf(redemptionVaultWithMToken.address), + ).to.be.lt(mTbillBefore); + + const expected = computeExpectedTokenOut( + parseUnits(amount.toString()), + mFoneRate, + tokenOutRate, + TOKEN_DECIMALS[key], + 0, + true, + ); + const userTokenAfter = await token.balanceOf(owner.address); + expect(userTokenAfter.sub(userTokenBefore)).to.equal(expected); + }); + + it('succeeds with near-boundary rate (mTBILL=1.00000003, stable)', async () => { + const fixture = await loadFixture(defaultDeploy); + const { + token, + redemptionVaultWithMToken, + owner, + mTBILL, + mFoneRate, + tokenOutRate, + } = await setupCeilDivTest({ + fixture, + tokenKey: key, + mTbillRate: 1.00000003, + isStable: true, + redeemAmount: 10000, + }); + + const mTbillBefore = await mTBILL.balanceOf( + redemptionVaultWithMToken.address, + ); + const userTokenBefore = await token.balanceOf(owner.address); + + await redemptionVaultWithMToken + .connect(owner) + ['redeemInstant(address,uint256,uint256)']( + token.address, + parseUnits('10000'), + 0, + ); + + expect( + await mTBILL.balanceOf(redemptionVaultWithMToken.address), + ).to.be.lt(mTbillBefore); + + const expected = computeExpectedTokenOut( + parseUnits('10000'), + mFoneRate, + tokenOutRate, + TOKEN_DECIMALS[key], + 0, + true, + ); + const userTokenAfter = await token.balanceOf(owner.address); + expect(userTokenAfter.sub(userTokenBefore)).to.equal(expected); + }); + + it('succeeds with non-stable tokenOut (mTBILL=1.05, tokenOut=1.03)', async () => { + const fixture = await loadFixture(defaultDeploy); + const { + token, + amount, + redemptionVaultWithMToken, + owner, + mTBILL, + mFoneRate, + tokenOutRate, + } = await setupCeilDivTest({ + fixture, + tokenKey: key, + mTbillRate: 1.05, + isStable: false, + tokenOutRate: 1.03, + }); + + const mTbillBefore = await mTBILL.balanceOf( + redemptionVaultWithMToken.address, + ); + const userTokenBefore = await token.balanceOf(owner.address); + + await redemptionVaultWithMToken + .connect(owner) + ['redeemInstant(address,uint256,uint256)']( + token.address, + parseUnits(amount.toString()), + 0, + ); + + expect( + await mTBILL.balanceOf(redemptionVaultWithMToken.address), + ).to.be.lt(mTbillBefore); + + const expected = computeExpectedTokenOut( + parseUnits(amount.toString()), + mFoneRate, + tokenOutRate, + TOKEN_DECIMALS[key], + 0, + true, + ); + const userTokenAfter = await token.balanceOf(owner.address); + expect(userTokenAfter.sub(userTokenBefore)).to.equal(expected); + }); + + it('succeeds with small redeem (1 mFONE, mTBILL=1.05, stable)', async () => { + const fixture = await loadFixture(defaultDeploy); + const { + token, + redemptionVaultWithMToken, + owner, + mTBILL, + mFoneRate, + tokenOutRate, + } = await setupCeilDivTest({ + fixture, + tokenKey: key, + mTbillRate: 1.05, + isStable: true, + redeemAmount: 1, + }); + + const mTbillBefore = await mTBILL.balanceOf( + redemptionVaultWithMToken.address, + ); + const userTokenBefore = await token.balanceOf(owner.address); + + await setMinAmountTest( + { vault: redemptionVaultWithMToken, owner }, + 0, + ); + + await redemptionVaultWithMToken + .connect(owner) + ['redeemInstant(address,uint256,uint256)']( + token.address, + parseUnits('1'), + 0, + ); + + expect( + await mTBILL.balanceOf(redemptionVaultWithMToken.address), + ).to.be.lt(mTbillBefore); + + const expected = computeExpectedTokenOut( + parseUnits('1'), + mFoneRate, + tokenOutRate, + TOKEN_DECIMALS[key], + 0, + true, + ); + const userTokenAfter = await token.balanceOf(owner.address); + expect(userTokenAfter.sub(userTokenBefore)).to.equal(expected); + }); + + it('succeeds with large redeem near daily limit (mTBILL=1.05, stable)', async () => { + const fixture = await loadFixture(defaultDeploy); + const { + token, + redemptionVaultWithMToken, + owner, + mTBILL, + mFoneRate, + tokenOutRate, + } = await setupCeilDivTest({ + fixture, + tokenKey: key, + mTbillRate: 1.05, + isStable: true, + redeemAmount: 50000, + }); + + const mTbillBefore = await mTBILL.balanceOf( + redemptionVaultWithMToken.address, + ); + const userTokenBefore = await token.balanceOf(owner.address); + + await redemptionVaultWithMToken + .connect(owner) + ['redeemInstant(address,uint256,uint256)']( + token.address, + parseUnits('50000'), + 0, + ); + + expect( + await mTBILL.balanceOf(redemptionVaultWithMToken.address), + ).to.be.lt(mTbillBefore); + + const expected = computeExpectedTokenOut( + parseUnits('50000'), + mFoneRate, + tokenOutRate, + TOKEN_DECIMALS[key], + 0, + true, + ); + const userTokenAfter = await token.balanceOf(owner.address); + expect(userTokenAfter.sub(userTokenBefore)).to.equal(expected); + }); + }); + } + }); + + describe('with RedemptionVaultWithAave as inner vault', () => { + for (const { key, label } of [ + { key: 'usdc' as const, label: '8-dec' }, + { key: 'usdc6' as const, label: '6-dec' }, + ]) { + describe(`tokenOut ${label} (${key})`, () => { + async function setupAaveInnerVault( + fixture: Awaited>, + tokenKey: 'usdc' | 'usdc6', + ) { + const { + redemptionVaultWithMToken, + redemptionVaultWithAave, + aavePoolMock, + aUSDC, + owner, + mTBILL, + mFONE, + mFoneToUsdDataFeed, + mTokenToUsdDataFeed, + mockedAggregatorMToken, + mockedAggregator, + dataFeed, + stableCoins, + } = fixture; + + const token = stableCoins[tokenKey]; + + await redemptionVaultWithMToken.setRedemptionVault( + redemptionVaultWithAave.address, + ); + + await addPaymentTokenTest( + { vault: redemptionVaultWithMToken, owner }, + token, + dataFeed.address, + 0, + true, + ); + await addPaymentTokenTest( + { vault: redemptionVaultWithAave, owner }, + token, + dataFeed.address, + 0, + true, + ); + + await redemptionVaultWithAave.addWaivedFeeAccount( + redemptionVaultWithMToken.address, + ); + + if (tokenKey === 'usdc6') { + const { ERC20Mock__factory } = await import( + '../../typechain-types' + ); + const aUsdc6 = await new ERC20Mock__factory(owner).deploy(6); + await aavePoolMock.setReserveAToken( + token.address, + aUsdc6.address, + ); + await token.mint(aavePoolMock.address, parseUnits('1000000', 6)); + await aUsdc6.mint( + redemptionVaultWithAave.address, + parseUnits('1000000', 6), + ); + await redemptionVaultWithAave.setAavePool( + token.address, + aavePoolMock.address, + ); + } else { + await token.mint(aavePoolMock.address, parseUnits('1000000')); + await aUSDC.mint( + redemptionVaultWithAave.address, + parseUnits('1000000'), + ); + } + + await setInstantFeeTest( + { vault: redemptionVaultWithMToken, owner }, + 0, + ); + + return { + token, + redemptionVaultWithMToken, + owner, + mTBILL, + mFONE, + mFoneToUsdDataFeed, + mTokenToUsdDataFeed, + mockedAggregatorMToken, + }; + } + + it('succeeds with remainder-producing rate (mTBILL=1.05, stable)', async () => { + const fixture = await loadFixture(defaultDeploy); + const { + token, + redemptionVaultWithMToken, + owner, + mTBILL, + mFONE, + mockedAggregatorMToken, + } = await setupAaveInnerVault(fixture, key); + + await setRoundData( + { mockedAggregator: mockedAggregatorMToken }, + 1.05, + ); + await mintToken(mFONE, owner, 10000); + await mintToken(mTBILL, redemptionVaultWithMToken, 10000); + await approveBase18(owner, mFONE, redemptionVaultWithMToken, 10000); + + const mTbillBefore = await mTBILL.balanceOf( + redemptionVaultWithMToken.address, + ); + + await redemptionVaultWithMToken + .connect(owner) + ['redeemInstant(address,uint256,uint256)']( + token.address, + parseUnits('100'), + 0, + ); + + expect( + await mTBILL.balanceOf(redemptionVaultWithMToken.address), + ).to.be.lt(mTbillBefore); + }); + + it('succeeds with high mTBILL rate (mTBILL=100, stable)', async () => { + const fixture = await loadFixture(defaultDeploy); + const { + token, + redemptionVaultWithMToken, + owner, + mTBILL, + mFONE, + mockedAggregatorMToken, + } = await setupAaveInnerVault(fixture, key); + + await setRoundData( + { mockedAggregator: mockedAggregatorMToken }, + 100, + ); + await mintToken(mFONE, owner, 10000); + await mintToken(mTBILL, redemptionVaultWithMToken, 10000); + await approveBase18(owner, mFONE, redemptionVaultWithMToken, 10000); + + const mTbillBefore = await mTBILL.balanceOf( + redemptionVaultWithMToken.address, + ); + + await redemptionVaultWithMToken + .connect(owner) + ['redeemInstant(address,uint256,uint256)']( + token.address, + parseUnits('100'), + 0, + ); + + expect( + await mTBILL.balanceOf(redemptionVaultWithMToken.address), + ).to.be.lt(mTbillBefore); + }); + + it('succeeds with near-boundary rate (mTBILL=1.00000003, stable)', async () => { + const fixture = await loadFixture(defaultDeploy); + const { + token, + redemptionVaultWithMToken, + owner, + mTBILL, + mFONE, + mockedAggregatorMToken, + } = await setupAaveInnerVault(fixture, key); + + await setRoundData( + { mockedAggregator: mockedAggregatorMToken }, + 1.00000003, + ); + await mintToken(mFONE, owner, 100000); + await mintToken(mTBILL, redemptionVaultWithMToken, 100000); + await approveBase18( + owner, + mFONE, + redemptionVaultWithMToken, + 100000, + ); + + const mTbillBefore = await mTBILL.balanceOf( + redemptionVaultWithMToken.address, + ); + + await redemptionVaultWithMToken + .connect(owner) + ['redeemInstant(address,uint256,uint256)']( + token.address, + parseUnits('10000'), + 0, + ); + + expect( + await mTBILL.balanceOf(redemptionVaultWithMToken.address), + ).to.be.lt(mTbillBefore); + }); + }); + } + }); + + describe('with RedemptionVaultWithMorpho as inner vault', () => { + for (const { key, label } of [ + { key: 'usdc' as const, label: '8-dec' }, + { key: 'usdc6' as const, label: '6-dec' }, + ]) { + describe(`tokenOut ${label} (${key})`, () => { + async function setupMorphoInnerVault( + fixture: Awaited>, + tokenKey: 'usdc' | 'usdc6', + ) { + const { + redemptionVaultWithMToken, + redemptionVaultWithMorpho, + morphoVaultMock, + owner, + mTBILL, + mFONE, + mFoneToUsdDataFeed, + mTokenToUsdDataFeed, + mockedAggregatorMToken, + mockedAggregator, + dataFeed, + stableCoins, + } = fixture; + + const token = stableCoins[tokenKey]; + + await redemptionVaultWithMToken.setRedemptionVault( + redemptionVaultWithMorpho.address, + ); + + await addPaymentTokenTest( + { vault: redemptionVaultWithMToken, owner }, + token, + dataFeed.address, + 0, + true, + ); + await addPaymentTokenTest( + { vault: redemptionVaultWithMorpho, owner }, + token, + dataFeed.address, + 0, + true, + ); + + await redemptionVaultWithMorpho.addWaivedFeeAccount( + redemptionVaultWithMToken.address, + ); + + if (tokenKey === 'usdc6') { + const { MorphoVaultMock__factory } = await import( + '../../typechain-types' + ); + const morphoUsdc6 = await new MorphoVaultMock__factory( + owner, + ).deploy(token.address); + await token.mint(morphoUsdc6.address, parseUnits('1000000', 6)); + await morphoUsdc6.mint( + redemptionVaultWithMorpho.address, + parseUnits('1000000', 6), + ); + await redemptionVaultWithMorpho.setMorphoVault( + token.address, + morphoUsdc6.address, + ); + } else { + await token.mint(morphoVaultMock.address, parseUnits('1000000')); + await morphoVaultMock.mint( + redemptionVaultWithMorpho.address, + parseUnits('1000000'), + ); + } + + await setInstantFeeTest( + { vault: redemptionVaultWithMToken, owner }, + 0, + ); + + return { + token, + redemptionVaultWithMToken, + owner, + mTBILL, + mFONE, + mFoneToUsdDataFeed, + mTokenToUsdDataFeed, + mockedAggregatorMToken, + }; + } + + it('succeeds with remainder-producing rate (mTBILL=1.05, stable)', async () => { + const fixture = await loadFixture(defaultDeploy); + const { + token, + redemptionVaultWithMToken, + owner, + mTBILL, + mFONE, + mockedAggregatorMToken, + } = await setupMorphoInnerVault(fixture, key); + + await setRoundData( + { mockedAggregator: mockedAggregatorMToken }, + 1.05, + ); + await mintToken(mFONE, owner, 10000); + await mintToken(mTBILL, redemptionVaultWithMToken, 10000); + await approveBase18(owner, mFONE, redemptionVaultWithMToken, 10000); + + const mTbillBefore = await mTBILL.balanceOf( + redemptionVaultWithMToken.address, + ); + + await redemptionVaultWithMToken + .connect(owner) + ['redeemInstant(address,uint256,uint256)']( + token.address, + parseUnits('100'), + 0, + ); + + expect( + await mTBILL.balanceOf(redemptionVaultWithMToken.address), + ).to.be.lt(mTbillBefore); + }); + + it('succeeds with high mTBILL rate (mTBILL=100, stable)', async () => { + const fixture = await loadFixture(defaultDeploy); + const { + token, + redemptionVaultWithMToken, + owner, + mTBILL, + mFONE, + mockedAggregatorMToken, + } = await setupMorphoInnerVault(fixture, key); + + await setRoundData( + { mockedAggregator: mockedAggregatorMToken }, + 100, + ); + await mintToken(mFONE, owner, 10000); + await mintToken(mTBILL, redemptionVaultWithMToken, 10000); + await approveBase18(owner, mFONE, redemptionVaultWithMToken, 10000); + + const mTbillBefore = await mTBILL.balanceOf( + redemptionVaultWithMToken.address, + ); + + await redemptionVaultWithMToken + .connect(owner) + ['redeemInstant(address,uint256,uint256)']( + token.address, + parseUnits('100'), + 0, + ); + + expect( + await mTBILL.balanceOf(redemptionVaultWithMToken.address), + ).to.be.lt(mTbillBefore); + }); + + it('succeeds with near-boundary rate (mTBILL=1.00000003, stable)', async () => { + const fixture = await loadFixture(defaultDeploy); + const { + token, + redemptionVaultWithMToken, + owner, + mTBILL, + mFONE, + mockedAggregatorMToken, + } = await setupMorphoInnerVault(fixture, key); + + await setRoundData( + { mockedAggregator: mockedAggregatorMToken }, + 1.00000003, + ); + await mintToken(mFONE, owner, 100000); + await mintToken(mTBILL, redemptionVaultWithMToken, 100000); + await approveBase18( + owner, + mFONE, + redemptionVaultWithMToken, + 100000, + ); + + const mTbillBefore = await mTBILL.balanceOf( + redemptionVaultWithMToken.address, + ); + + await redemptionVaultWithMToken + .connect(owner) + ['redeemInstant(address,uint256,uint256)']( + token.address, + parseUnits('10000'), + 0, + ); + + expect( + await mTBILL.balanceOf(redemptionVaultWithMToken.address), + ).to.be.lt(mTbillBefore); + }); + }); + } + }); + + describe('with RedemptionVaultWithUSTB as inner vault', () => { + describe('tokenOut 8-dec (usdc — USTB only supports its configured USDC)', () => { + async function setupUstbInnerVault( + fixture: Awaited>, + ) { + const { + redemptionVaultWithMToken, + redemptionVaultWithUSTB, + ustbToken, + ustbRedemption, + owner, + mTBILL, + mFONE, + mFoneToUsdDataFeed, + mTokenToUsdDataFeed, + mockedAggregatorMToken, + mockedAggregator, + dataFeed, + stableCoins, + } = fixture; + + const token = stableCoins.usdc; + + await redemptionVaultWithMToken.setRedemptionVault( + redemptionVaultWithUSTB.address, + ); + + await addPaymentTokenTest( + { vault: redemptionVaultWithMToken, owner }, + token, + dataFeed.address, + 0, + true, + ); + await addPaymentTokenTest( + { vault: redemptionVaultWithUSTB, owner }, + token, + dataFeed.address, + 0, + true, + ); + + await redemptionVaultWithUSTB.addWaivedFeeAccount( + redemptionVaultWithMToken.address, + ); + + await ustbToken.mint( + redemptionVaultWithUSTB.address, + parseUnits('1000000', 6), + ); + await token.mint(ustbRedemption.address, parseUnits('1000000')); + + await setInstantFeeTest( + { vault: redemptionVaultWithMToken, owner }, + 0, + ); + + return { + token, + redemptionVaultWithMToken, + owner, + mTBILL, + mFONE, + mFoneToUsdDataFeed, + mTokenToUsdDataFeed, + mockedAggregatorMToken, + }; + } + + it('succeeds with remainder-producing rate (mTBILL=1.05, stable)', async () => { + const fixture = await loadFixture(defaultDeploy); + const { + token, + redemptionVaultWithMToken, + owner, + mTBILL, + mFONE, + mockedAggregatorMToken, + } = await setupUstbInnerVault(fixture); + + await setRoundData( + { mockedAggregator: mockedAggregatorMToken }, + 1.05, + ); + await mintToken(mFONE, owner, 10000); + await mintToken(mTBILL, redemptionVaultWithMToken, 10000); + await approveBase18(owner, mFONE, redemptionVaultWithMToken, 10000); + + const mTbillBefore = await mTBILL.balanceOf( + redemptionVaultWithMToken.address, + ); + + await redemptionVaultWithMToken + .connect(owner) + ['redeemInstant(address,uint256,uint256)']( + token.address, + parseUnits('100'), + 0, + ); + + expect( + await mTBILL.balanceOf(redemptionVaultWithMToken.address), + ).to.be.lt(mTbillBefore); + }); + + it('succeeds with high mTBILL rate (mTBILL=100, stable)', async () => { + const fixture = await loadFixture(defaultDeploy); + const { + token, + redemptionVaultWithMToken, + owner, + mTBILL, + mFONE, + mockedAggregatorMToken, + } = await setupUstbInnerVault(fixture); + + await setRoundData({ mockedAggregator: mockedAggregatorMToken }, 100); + await mintToken(mFONE, owner, 10000); + await mintToken(mTBILL, redemptionVaultWithMToken, 10000); + await approveBase18(owner, mFONE, redemptionVaultWithMToken, 10000); + + const mTbillBefore = await mTBILL.balanceOf( + redemptionVaultWithMToken.address, + ); + + await redemptionVaultWithMToken + .connect(owner) + ['redeemInstant(address,uint256,uint256)']( + token.address, + parseUnits('100'), + 0, + ); + + expect( + await mTBILL.balanceOf(redemptionVaultWithMToken.address), + ).to.be.lt(mTbillBefore); + }); + + it('succeeds with near-boundary rate (mTBILL=1.00000003, stable)', async () => { + const fixture = await loadFixture(defaultDeploy); + const { + token, + redemptionVaultWithMToken, + owner, + mTBILL, + mFONE, + mockedAggregatorMToken, + } = await setupUstbInnerVault(fixture); + + await setRoundData( + { mockedAggregator: mockedAggregatorMToken }, + 1.00000003, + ); + await mintToken(mFONE, owner, 100000); + await mintToken(mTBILL, redemptionVaultWithMToken, 100000); + await approveBase18(owner, mFONE, redemptionVaultWithMToken, 100000); + + const mTbillBefore = await mTBILL.balanceOf( + redemptionVaultWithMToken.address, + ); + + await redemptionVaultWithMToken + .connect(owner) + ['redeemInstant(address,uint256,uint256)']( + token.address, + parseUnits('10000'), + 0, + ); + + expect( + await mTBILL.balanceOf(redemptionVaultWithMToken.address), + ).to.be.lt(mTbillBefore); + }); + }); + }); + + describe('with RedemptionVaultWithSwapper as inner vault - direct path', () => { + async function setupSwapperDirectPath( + fixture: Awaited>, + tokenKey: 'usdc6' | 'usdc' | 'dai' | 'usdt', + ) { + const { + redemptionVaultWithMToken, + redemptionVaultWithSwapper, + owner, + mBASIS, + mFONE, + mFoneToUsdDataFeed, + mBasisToUsdDataFeed, + mockedAggregatorMBasis, + mockedAggregator, + dataFeed, + stableCoins, + } = fixture; + + const token = stableCoins[tokenKey]; + + await redemptionVaultWithMToken.setRedemptionVault( + redemptionVaultWithSwapper.address, + ); + + await addPaymentTokenTest( + { vault: redemptionVaultWithMToken, owner }, + token, + dataFeed.address, + 0, + true, + ); + await addPaymentTokenTest( + { vault: redemptionVaultWithSwapper, owner }, + token, + dataFeed.address, + 0, + true, + ); + + await redemptionVaultWithSwapper.addWaivedFeeAccount( + redemptionVaultWithMToken.address, + ); + + await setInstantFeeTest({ vault: redemptionVaultWithMToken, owner }, 0); + + await mintToken(token, redemptionVaultWithSwapper, 1_000_000); + + return { + token, + redemptionVaultWithMToken, + redemptionVaultWithSwapper, + owner, + mBASIS, + mFONE, + mFoneToUsdDataFeed, + mBasisToUsdDataFeed, + mockedAggregatorMBasis, + mockedAggregator, + }; + } + + for (const { key, label } of [ + { key: 'usdc6' as const, label: '6-dec' }, + { key: 'usdc' as const, label: '8-dec' }, + { key: 'dai' as const, label: '9-dec' }, + { key: 'usdt' as const, label: '18-dec' }, + ]) { + describe(`tokenOut ${label} (${key})`, () => { + it('succeeds with exact division (mBasis=5, stable)', async () => { + const fixture = await loadFixture(defaultDeploy); + const { + token, + redemptionVaultWithMToken, + owner, + mBASIS, + mFONE, + mockedAggregatorMBasis, + } = await setupSwapperDirectPath(fixture, key); + + await setRoundData({ mockedAggregator: mockedAggregatorMBasis }, 5); + await mintToken(mFONE, owner, 10000); + await mintToken(mBASIS, redemptionVaultWithMToken, 10000); + await approveBase18(owner, mFONE, redemptionVaultWithMToken, 10000); + + const mBasisBefore = await mBASIS.balanceOf( + redemptionVaultWithMToken.address, + ); + + await redemptionVaultWithMToken + .connect(owner) + ['redeemInstant(address,uint256,uint256)']( + token.address, + parseUnits('100'), + 0, + ); + + expect( + await mBASIS.balanceOf(redemptionVaultWithMToken.address), + ).to.be.lt(mBasisBefore); + }); + + it('succeeds with remainder-producing rate (mBasis=1.05, stable)', async () => { + const fixture = await loadFixture(defaultDeploy); + const { + token, + redemptionVaultWithMToken, + owner, + mBASIS, + mFONE, + mockedAggregatorMBasis, + } = await setupSwapperDirectPath(fixture, key); + + await setRoundData( + { mockedAggregator: mockedAggregatorMBasis }, + 1.05, + ); + await mintToken(mFONE, owner, 10000); + await mintToken(mBASIS, redemptionVaultWithMToken, 10000); + await approveBase18(owner, mFONE, redemptionVaultWithMToken, 10000); + + const mBasisBefore = await mBASIS.balanceOf( + redemptionVaultWithMToken.address, + ); + const userTokenBefore = await token.balanceOf(owner.address); + + await redemptionVaultWithMToken + .connect(owner) + ['redeemInstant(address,uint256,uint256)']( + token.address, + parseUnits('100'), + 0, + ); + + expect( + await mBASIS.balanceOf(redemptionVaultWithMToken.address), + ).to.be.lt(mBasisBefore); + expect(await token.balanceOf(owner.address)).to.be.gt( + userTokenBefore, + ); + }); + + it('succeeds with high rate (mBasis=100, stable)', async () => { + const fixture = await loadFixture(defaultDeploy); + const { + token, + redemptionVaultWithMToken, + owner, + mBASIS, + mFONE, + mockedAggregatorMBasis, + } = await setupSwapperDirectPath(fixture, key); + + await setRoundData( + { mockedAggregator: mockedAggregatorMBasis }, + 100, + ); + await mintToken(mFONE, owner, 10000); + await mintToken(mBASIS, redemptionVaultWithMToken, 10000); + await approveBase18(owner, mFONE, redemptionVaultWithMToken, 10000); + + const mBasisBefore = await mBASIS.balanceOf( + redemptionVaultWithMToken.address, + ); + + await redemptionVaultWithMToken + .connect(owner) + ['redeemInstant(address,uint256,uint256)']( + token.address, + parseUnits('100'), + 0, + ); + + expect( + await mBASIS.balanceOf(redemptionVaultWithMToken.address), + ).to.be.lt(mBasisBefore); + }); + + it('succeeds with near-boundary rate (mBasis=1.00000003, stable)', async () => { + const fixture = await loadFixture(defaultDeploy); + const { + token, + redemptionVaultWithMToken, + owner, + mBASIS, + mFONE, + mockedAggregatorMBasis, + } = await setupSwapperDirectPath(fixture, key); + + await setRoundData( + { mockedAggregator: mockedAggregatorMBasis }, + 1.00000003, + ); + await mintToken(mFONE, owner, 100000); + await mintToken(mBASIS, redemptionVaultWithMToken, 100000); + await approveBase18( + owner, + mFONE, + redemptionVaultWithMToken, + 100000, + ); + + const mBasisBefore = await mBASIS.balanceOf( + redemptionVaultWithMToken.address, + ); + + await redemptionVaultWithMToken + .connect(owner) + ['redeemInstant(address,uint256,uint256)']( + token.address, + parseUnits('10000'), + 0, + ); + + expect( + await mBASIS.balanceOf(redemptionVaultWithMToken.address), + ).to.be.lt(mBasisBefore); + }); + + it('succeeds with non-stable tokenOut (mBasis=1.05, tokenOut=1.03)', async () => { + const fixture = await loadFixture(defaultDeploy); + const { + token, + redemptionVaultWithMToken, + owner, + mBASIS, + mFONE, + mockedAggregatorMBasis, + mockedAggregator, + } = await setupSwapperDirectPath(fixture, key); + + await removePaymentTokenTest( + { vault: redemptionVaultWithMToken, owner }, + token, + ); + await addPaymentTokenTest( + { vault: redemptionVaultWithMToken, owner }, + token, + fixture.dataFeed.address, + 0, + false, + ); + await removePaymentTokenTest( + { vault: fixture.redemptionVaultWithSwapper, owner }, + token, + ); + await addPaymentTokenTest( + { vault: fixture.redemptionVaultWithSwapper, owner }, + token, + fixture.dataFeed.address, + 0, + false, + ); + + await setRoundData( + { mockedAggregator: mockedAggregatorMBasis }, + 1.05, + ); + await setRoundData({ mockedAggregator }, 1.03); + await mintToken(mFONE, owner, 10000); + await mintToken(mBASIS, redemptionVaultWithMToken, 10000); + await approveBase18(owner, mFONE, redemptionVaultWithMToken, 10000); + + const mBasisBefore = await mBASIS.balanceOf( + redemptionVaultWithMToken.address, + ); + + await redemptionVaultWithMToken + .connect(owner) + ['redeemInstant(address,uint256,uint256)']( + token.address, + parseUnits('100'), + 0, + ); + + expect( + await mBASIS.balanceOf(redemptionVaultWithMToken.address), + ).to.be.lt(mBasisBefore); + }); + }); + } + }); + + describe('with RedemptionVaultWithSwapper as inner vault - swap path', () => { + async function setupSwapperSwapPath( + fixture: Awaited>, + tokenKey: 'usdc6' | 'usdc' | 'dai' | 'usdt', + ) { + const { + redemptionVaultWithMToken, + redemptionVaultWithSwapper, + redemptionVault, + owner, + mTBILL, + mBASIS, + mFONE, + mFoneToUsdDataFeed, + mBasisToUsdDataFeed, + mockedAggregatorMBasis, + mockedAggregatorMToken, + mockedAggregator, + dataFeed, + stableCoins, + liquidityProvider, + } = fixture; + + const token = stableCoins[tokenKey]; + + await redemptionVaultWithMToken.setRedemptionVault( + redemptionVaultWithSwapper.address, + ); + + await addPaymentTokenTest( + { vault: redemptionVaultWithMToken, owner }, + token, + dataFeed.address, + 0, + true, + ); + await addPaymentTokenTest( + { vault: redemptionVaultWithSwapper, owner }, + token, + dataFeed.address, + 0, + true, + ); + await addPaymentTokenTest( + { vault: redemptionVault, owner }, + token, + dataFeed.address, + 0, + true, + ); + + await redemptionVaultWithSwapper.addWaivedFeeAccount( + redemptionVaultWithMToken.address, + ); + + await setInstantFeeTest({ vault: redemptionVaultWithMToken, owner }, 0); + + await mintToken(mTBILL, liquidityProvider, 1_000_000); + await approveBase18( + liquidityProvider, + mTBILL, + redemptionVaultWithSwapper, + 1_000_000, + ); + await mintToken(token, redemptionVault, 1_000_000); + + return { + token, + redemptionVaultWithMToken, + redemptionVaultWithSwapper, + redemptionVault, + owner, + mTBILL, + mBASIS, + mFONE, + mFoneToUsdDataFeed, + mBasisToUsdDataFeed, + mockedAggregatorMBasis, + mockedAggregatorMToken, + mockedAggregator, + liquidityProvider, + }; + } + + for (const { key, label } of [ + { key: 'usdc6' as const, label: '6-dec' }, + { key: 'usdc' as const, label: '8-dec' }, + { key: 'dai' as const, label: '9-dec' }, + { key: 'usdt' as const, label: '18-dec' }, + ]) { + describe(`tokenOut ${label} (${key})`, () => { + it('succeeds with equal mBasis/mTBILL rates (mBasis=5, mTBILL=5, stable)', async () => { + const fixture = await loadFixture(defaultDeploy); + const { + token, + redemptionVaultWithMToken, + owner, + mBASIS, + mFONE, + mockedAggregatorMBasis, + mockedAggregatorMToken, + } = await setupSwapperSwapPath(fixture, key); + + await setRoundData({ mockedAggregator: mockedAggregatorMBasis }, 5); + await setRoundData({ mockedAggregator: mockedAggregatorMToken }, 5); + await mintToken(mFONE, owner, 10000); + await mintToken(mBASIS, redemptionVaultWithMToken, 10000); + await approveBase18(owner, mFONE, redemptionVaultWithMToken, 10000); + + const mBasisBefore = await mBASIS.balanceOf( + redemptionVaultWithMToken.address, + ); + + await redemptionVaultWithMToken + .connect(owner) + ['redeemInstant(address,uint256,uint256)']( + token.address, + parseUnits('100'), + 0, + ); + + expect( + await mBASIS.balanceOf(redemptionVaultWithMToken.address), + ).to.be.lt(mBasisBefore); + }); + + it('succeeds with clean-division rates (mBasis=6, mTBILL=3, stable)', async () => { + const fixture = await loadFixture(defaultDeploy); + const { + token, + redemptionVaultWithMToken, + owner, + mBASIS, + mFONE, + mockedAggregatorMBasis, + mockedAggregatorMToken, + } = await setupSwapperSwapPath(fixture, key); + + await setRoundData({ mockedAggregator: mockedAggregatorMBasis }, 6); + await setRoundData({ mockedAggregator: mockedAggregatorMToken }, 3); + await mintToken(mFONE, owner, 10000); + await mintToken(mBASIS, redemptionVaultWithMToken, 10000); + await approveBase18(owner, mFONE, redemptionVaultWithMToken, 10000); + + const mBasisBefore = await mBASIS.balanceOf( + redemptionVaultWithMToken.address, + ); + + await redemptionVaultWithMToken + .connect(owner) + ['redeemInstant(address,uint256,uint256)']( + token.address, + parseUnits('100'), + 0, + ); + + expect( + await mBASIS.balanceOf(redemptionVaultWithMToken.address), + ).to.be.lt(mBasisBefore); + }); + + it('succeeds with remainder-producing swap (mBasis=1.05, mTBILL=1.03, stable)', async () => { + const fixture = await loadFixture(defaultDeploy); + const { + token, + redemptionVaultWithMToken, + owner, + mBASIS, + mFONE, + mockedAggregatorMBasis, + mockedAggregatorMToken, + } = await setupSwapperSwapPath(fixture, key); + + await setRoundData( + { mockedAggregator: mockedAggregatorMBasis }, + 1.05, + ); + await setRoundData( + { mockedAggregator: mockedAggregatorMToken }, + 1.03, + ); + await mintToken(mFONE, owner, 10000); + await mintToken(mBASIS, redemptionVaultWithMToken, 10000); + await approveBase18(owner, mFONE, redemptionVaultWithMToken, 10000); + + const mBasisBefore = await mBASIS.balanceOf( + redemptionVaultWithMToken.address, + ); + + await redemptionVaultWithMToken + .connect(owner) + ['redeemInstant(address,uint256,uint256)']( + token.address, + parseUnits('100'), + 0, + ); + + expect( + await mBASIS.balanceOf(redemptionVaultWithMToken.address), + ).to.be.lt(mBasisBefore); + }); + + it('succeeds with high divergent rates (mBasis=100, mTBILL=7, stable)', async () => { + const fixture = await loadFixture(defaultDeploy); + const { + token, + redemptionVaultWithMToken, + owner, + mBASIS, + mFONE, + mockedAggregatorMBasis, + mockedAggregatorMToken, + } = await setupSwapperSwapPath(fixture, key); + + await setRoundData( + { mockedAggregator: mockedAggregatorMBasis }, + 100, + ); + await setRoundData({ mockedAggregator: mockedAggregatorMToken }, 7); + await mintToken(mFONE, owner, 10000); + await mintToken(mBASIS, redemptionVaultWithMToken, 10000); + await approveBase18(owner, mFONE, redemptionVaultWithMToken, 10000); + + const mBasisBefore = await mBASIS.balanceOf( + redemptionVaultWithMToken.address, + ); + + await redemptionVaultWithMToken + .connect(owner) + ['redeemInstant(address,uint256,uint256)']( + token.address, + parseUnits('100'), + 0, + ); + + expect( + await mBASIS.balanceOf(redemptionVaultWithMToken.address), + ).to.be.lt(mBasisBefore); + }); + + it('succeeds with near-boundary rates (mBasis=1.00000003, mTBILL=1.00000007, stable)', async () => { + const fixture = await loadFixture(defaultDeploy); + const { + token, + redemptionVaultWithMToken, + owner, + mBASIS, + mFONE, + mockedAggregatorMBasis, + mockedAggregatorMToken, + } = await setupSwapperSwapPath(fixture, key); + + await setRoundData( + { mockedAggregator: mockedAggregatorMBasis }, + 1.00000003, + ); + await setRoundData( + { mockedAggregator: mockedAggregatorMToken }, + 1.00000007, + ); + await mintToken(mFONE, owner, 100000); + await mintToken(mBASIS, redemptionVaultWithMToken, 100000); + await approveBase18( + owner, + mFONE, + redemptionVaultWithMToken, + 100000, + ); + + const mBasisBefore = await mBASIS.balanceOf( + redemptionVaultWithMToken.address, + ); + + await redemptionVaultWithMToken + .connect(owner) + ['redeemInstant(address,uint256,uint256)']( + token.address, + parseUnits('10000'), + 0, + ); + + expect( + await mBASIS.balanceOf(redemptionVaultWithMToken.address), + ).to.be.lt(mBasisBefore); + }); + }); + } + }); + + describe('with outer vault fee enabled', () => { + it('succeeds with 1% fee, usdc6 (6-dec), mTBILL=1.05, stable', async () => { + const fixture = await loadFixture(defaultDeploy); + const { token, amount, redemptionVaultWithMToken, owner, mTBILL } = + await setupCeilDivTest({ + fixture, + tokenKey: 'usdc6', + mTbillRate: 1.05, + isStable: true, + }); + + await setInstantFeeTest( + { vault: redemptionVaultWithMToken, owner }, + 100, + ); + + const mTbillBefore = await mTBILL.balanceOf( + redemptionVaultWithMToken.address, + ); + const userTokenBefore = await token.balanceOf(owner.address); + + await redemptionVaultWithMToken + .connect(owner) + ['redeemInstant(address,uint256,uint256)']( + token.address, + parseUnits(amount.toString()), + 0, + ); + + expect( + await mTBILL.balanceOf(redemptionVaultWithMToken.address), + ).to.be.lt(mTbillBefore); + expect(await token.balanceOf(owner.address)).to.be.gt(userTokenBefore); + }); + + it('succeeds with 1% fee, usdc (8-dec), mTBILL=1.05, stable', async () => { + const fixture = await loadFixture(defaultDeploy); + const { token, amount, redemptionVaultWithMToken, owner, mTBILL } = + await setupCeilDivTest({ + fixture, + tokenKey: 'usdc', + mTbillRate: 1.05, + isStable: true, + }); + + await setInstantFeeTest( + { vault: redemptionVaultWithMToken, owner }, + 100, + ); + + const mTbillBefore = await mTBILL.balanceOf( + redemptionVaultWithMToken.address, + ); + const userTokenBefore = await token.balanceOf(owner.address); + + await redemptionVaultWithMToken + .connect(owner) + ['redeemInstant(address,uint256,uint256)']( + token.address, + parseUnits(amount.toString()), + 0, + ); + + expect( + await mTBILL.balanceOf(redemptionVaultWithMToken.address), + ).to.be.lt(mTbillBefore); + expect(await token.balanceOf(owner.address)).to.be.gt(userTokenBefore); + }); + + it('succeeds with 1% fee, usdt (18-dec), mTBILL=1.05, stable', async () => { + const fixture = await loadFixture(defaultDeploy); + const { token, amount, redemptionVaultWithMToken, owner, mTBILL } = + await setupCeilDivTest({ + fixture, + tokenKey: 'usdt', + mTbillRate: 1.05, + isStable: true, + }); + + await setInstantFeeTest( + { vault: redemptionVaultWithMToken, owner }, + 100, + ); + + const mTbillBefore = await mTBILL.balanceOf( + redemptionVaultWithMToken.address, + ); + const userTokenBefore = await token.balanceOf(owner.address); + + await redemptionVaultWithMToken + .connect(owner) + ['redeemInstant(address,uint256,uint256)']( + token.address, + parseUnits(amount.toString()), + 0, + ); + + expect( + await mTBILL.balanceOf(redemptionVaultWithMToken.address), + ).to.be.lt(mTbillBefore); + expect(await token.balanceOf(owner.address)).to.be.gt(userTokenBefore); + }); + + it('succeeds with 1% fee, usdc6 (6-dec), mTBILL=100, stable', async () => { + const fixture = await loadFixture(defaultDeploy); + const { token, amount, redemptionVaultWithMToken, owner, mTBILL } = + await setupCeilDivTest({ + fixture, + tokenKey: 'usdc6', + mTbillRate: 100, + isStable: true, + }); + + await setInstantFeeTest( + { vault: redemptionVaultWithMToken, owner }, + 100, + ); + + const mTbillBefore = await mTBILL.balanceOf( + redemptionVaultWithMToken.address, + ); + + await redemptionVaultWithMToken + .connect(owner) + ['redeemInstant(address,uint256,uint256)']( + token.address, + parseUnits(amount.toString()), + 0, + ); + + expect( + await mTBILL.balanceOf(redemptionVaultWithMToken.address), + ).to.be.lt(mTbillBefore); + }); + + it('succeeds with 1% fee, usdc (8-dec), non-stable mTBILL=1.05 tokenOut=1.03', async () => { + const fixture = await loadFixture(defaultDeploy); + const { token, amount, redemptionVaultWithMToken, owner, mTBILL } = + await setupCeilDivTest({ + fixture, + tokenKey: 'usdc', + mTbillRate: 1.05, + isStable: false, + tokenOutRate: 1.03, + }); + + await setInstantFeeTest( + { vault: redemptionVaultWithMToken, owner }, + 100, + ); + + const mTbillBefore = await mTBILL.balanceOf( + redemptionVaultWithMToken.address, + ); + const userTokenBefore = await token.balanceOf(owner.address); + + await redemptionVaultWithMToken + .connect(owner) + ['redeemInstant(address,uint256,uint256)']( + token.address, + parseUnits(amount.toString()), + 0, + ); + + expect( + await mTBILL.balanceOf(redemptionVaultWithMToken.address), + ).to.be.lt(mTbillBefore); + expect(await token.balanceOf(owner.address)).to.be.gt(userTokenBefore); + }); + + it('succeeds with 1% fee, usdc6 (6-dec), near-boundary mTBILL=1.00000003', async () => { + const fixture = await loadFixture(defaultDeploy); + const { token, redemptionVaultWithMToken, owner, mTBILL } = + await setupCeilDivTest({ + fixture, + tokenKey: 'usdc6', + mTbillRate: 1.00000003, + isStable: true, + redeemAmount: 10000, + }); + + await setInstantFeeTest( + { vault: redemptionVaultWithMToken, owner }, + 100, + ); + + const mTbillBefore = await mTBILL.balanceOf( + redemptionVaultWithMToken.address, + ); + + await redemptionVaultWithMToken + .connect(owner) + ['redeemInstant(address,uint256,uint256)']( + token.address, + parseUnits('10000'), + 0, + ); + + expect( + await mTBILL.balanceOf(redemptionVaultWithMToken.address), + ).to.be.lt(mTbillBefore); + }); + }); + + describe('minReceiveAmount assertions', () => { + it('succeeds with exact minReceiveAmount (6-dec, mTBILL=1.05)', async () => { + const fixture = await loadFixture(defaultDeploy); + const { + token, + amount, + redemptionVaultWithMToken, + owner, + mFoneRate, + tokenOutRate, + } = await setupCeilDivTest({ + fixture, + tokenKey: 'usdc6', + mTbillRate: 1.05, + isStable: true, + }); + + const expectedNative = computeExpectedTokenOut( + parseUnits(amount.toString()), + mFoneRate, + tokenOutRate, + TOKEN_DECIMALS['usdc6'], + 0, + true, + ); + const expectedBase18 = expectedNative.mul( + BigNumber.from(10).pow(18 - TOKEN_DECIMALS['usdc6']), + ); + + const userTokenBefore = await token.balanceOf(owner.address); + + await redemptionVaultWithMToken + .connect(owner) + ['redeemInstant(address,uint256,uint256)']( + token.address, + parseUnits(amount.toString()), + expectedBase18, + ); + + const userTokenAfter = await token.balanceOf(owner.address); + expect(userTokenAfter.sub(userTokenBefore)).to.equal(expectedNative); + }); + + it('reverts when minReceiveAmount exceeds actual (6-dec, mTBILL=1.05)', async () => { + const fixture = await loadFixture(defaultDeploy); + const { + token, + amount, + redemptionVaultWithMToken, + owner, + mFoneRate, + tokenOutRate, + } = await setupCeilDivTest({ + fixture, + tokenKey: 'usdc6', + mTbillRate: 1.05, + isStable: true, + }); + + const expectedNative = computeExpectedTokenOut( + parseUnits(amount.toString()), + mFoneRate, + tokenOutRate, + TOKEN_DECIMALS['usdc6'], + 0, + true, + ); + const tooHigh = expectedNative + .mul(BigNumber.from(10).pow(18 - TOKEN_DECIMALS['usdc6'])) + .add(1); + + await expect( + redemptionVaultWithMToken + .connect(owner) + ['redeemInstant(address,uint256,uint256)']( + token.address, + parseUnits(amount.toString()), + tooHigh, + ), + ).to.be.revertedWith('RVMT: minReceiveAmount > actual'); + }); + }); + + describe('partial outer vault balance', () => { + it('succeeds with 30% tokenOut present in outer vault (6-dec, mTBILL=1.05)', async () => { + const fixture = await loadFixture(defaultDeploy); + const { + token, + amount, + redemptionVaultWithMToken, + owner, + mTBILL, + mFoneRate, + tokenOutRate, + } = await setupCeilDivTest({ + fixture, + tokenKey: 'usdc6', + mTbillRate: 1.05, + isStable: true, + }); + + const expectedNative = computeExpectedTokenOut( + parseUnits(amount.toString()), + mFoneRate, + tokenOutRate, + TOKEN_DECIMALS['usdc6'], + 0, + true, + ); + const thirtyPercent = expectedNative.mul(30).div(100); + await token.mint(redemptionVaultWithMToken.address, thirtyPercent); + + const mTbillBefore = await mTBILL.balanceOf( + redemptionVaultWithMToken.address, + ); + const userTokenBefore = await token.balanceOf(owner.address); + + await redemptionVaultWithMToken + .connect(owner) + ['redeemInstant(address,uint256,uint256)']( + token.address, + parseUnits(amount.toString()), + 0, + ); + + expect( + await mTBILL.balanceOf(redemptionVaultWithMToken.address), + ).to.be.lt(mTbillBefore); + + const userTokenAfter = await token.balanceOf(owner.address); + expect(userTokenAfter.sub(userTokenBefore)).to.equal(expectedNative); + }); + + it('succeeds with 99% tokenOut present in outer vault (6-dec, mTBILL=1.05)', async () => { + const fixture = await loadFixture(defaultDeploy); + const { + token, + amount, + redemptionVaultWithMToken, + owner, + mTBILL, + mFoneRate, + tokenOutRate, + } = await setupCeilDivTest({ + fixture, + tokenKey: 'usdc6', + mTbillRate: 1.05, + isStable: true, + }); + + const expectedNative = computeExpectedTokenOut( + parseUnits(amount.toString()), + mFoneRate, + tokenOutRate, + TOKEN_DECIMALS['usdc6'], + 0, + true, + ); + const ninetyNinePercent = expectedNative.mul(99).div(100); + await token.mint(redemptionVaultWithMToken.address, ninetyNinePercent); + + const mTbillBefore = await mTBILL.balanceOf( + redemptionVaultWithMToken.address, + ); + const userTokenBefore = await token.balanceOf(owner.address); + + await redemptionVaultWithMToken + .connect(owner) + ['redeemInstant(address,uint256,uint256)']( + token.address, + parseUnits(amount.toString()), + 0, + ); + + expect( + await mTBILL.balanceOf(redemptionVaultWithMToken.address), + ).to.be.lt(mTbillBefore); + + const userTokenAfter = await token.balanceOf(owner.address); + expect(userTokenAfter.sub(userTokenBefore)).to.equal(expectedNative); + }); + }); + + describe('mFONE rate variation', () => { + it('succeeds with high mFONE rate (mFONE=3.5, mTBILL=1.05, 6-dec)', async () => { + const fixture = await loadFixture(defaultDeploy); + const { + token, + amount, + redemptionVaultWithMToken, + owner, + mTBILL, + mFoneToUsdDataFeed, + mockedAggregatorMFone, + tokenOutRate, + } = await setupCeilDivTest({ + fixture, + tokenKey: 'usdc6', + mTbillRate: 1.05, + isStable: true, + }); + + await setRoundData({ mockedAggregator: mockedAggregatorMFone }, 3.5); + const mFoneRate = await mFoneToUsdDataFeed.getDataInBase18(); + + const mTbillBefore = await mTBILL.balanceOf( + redemptionVaultWithMToken.address, + ); + const userTokenBefore = await token.balanceOf(owner.address); + + await redemptionVaultWithMToken + .connect(owner) + ['redeemInstant(address,uint256,uint256)']( + token.address, + parseUnits(amount.toString()), + 0, + ); + + expect( + await mTBILL.balanceOf(redemptionVaultWithMToken.address), + ).to.be.lt(mTbillBefore); + + const expected = computeExpectedTokenOut( + parseUnits(amount.toString()), + mFoneRate, + tokenOutRate, + TOKEN_DECIMALS['usdc6'], + 0, + true, + ); + const userTokenAfter = await token.balanceOf(owner.address); + expect(userTokenAfter.sub(userTokenBefore)).to.equal(expected); + }); + + it('succeeds with low mFONE rate (mFONE=0.5, mTBILL=1.05, 6-dec)', async () => { + const fixture = await loadFixture(defaultDeploy); + const { + token, + amount, + redemptionVaultWithMToken, + owner, + mTBILL, + mFoneToUsdDataFeed, + mockedAggregatorMFone, + tokenOutRate, + } = await setupCeilDivTest({ + fixture, + tokenKey: 'usdc6', + mTbillRate: 1.05, + isStable: true, + }); + + await setRoundData({ mockedAggregator: mockedAggregatorMFone }, 0.5); + const mFoneRate = await mFoneToUsdDataFeed.getDataInBase18(); + + const mTbillBefore = await mTBILL.balanceOf( + redemptionVaultWithMToken.address, + ); + const userTokenBefore = await token.balanceOf(owner.address); + + await redemptionVaultWithMToken + .connect(owner) + ['redeemInstant(address,uint256,uint256)']( + token.address, + parseUnits(amount.toString()), + 0, + ); + + expect( + await mTBILL.balanceOf(redemptionVaultWithMToken.address), + ).to.be.lt(mTbillBefore); + + const expected = computeExpectedTokenOut( + parseUnits(amount.toString()), + mFoneRate, + tokenOutRate, + TOKEN_DECIMALS['usdc6'], + 0, + true, + ); + const userTokenAfter = await token.balanceOf(owner.address); + expect(userTokenAfter.sub(userTokenBefore)).to.equal(expected); + }); + }); + }); +}); diff --git a/test/unit/RedemptionVaultWithMorpho.test.ts b/test/unit/RedemptionVaultWithMorpho.test.ts new file mode 100644 index 00000000..1517a731 --- /dev/null +++ b/test/unit/RedemptionVaultWithMorpho.test.ts @@ -0,0 +1,2774 @@ +import { loadFixture } from '@nomicfoundation/hardhat-network-helpers'; +import { expect } from 'chai'; +import { constants } from 'ethers'; +import { parseUnits } from 'ethers/lib/utils'; +import { ethers } from 'hardhat'; + +import { encodeFnSelector } from '../../helpers/utils'; +import { + ManageableVaultTester__factory, + RedemptionVaultWithMorphoTest__factory, +} from '../../typechain-types'; +import { acErrors, blackList, greenList } from '../common/ac.helpers'; +import { + approveBase18, + mintToken, + pauseVaultFn, +} from '../common/common.helpers'; +import { setRoundData } from '../common/data-feed.helpers'; +import { defaultDeploy } from '../common/fixtures'; +import { + addPaymentTokenTest, + setInstantFeeTest, + setMinAmountTest, + setInstantDailyLimitTest, + addWaivedFeeAccountTest, + removeWaivedFeeAccountTest, + setVariabilityToleranceTest, + removePaymentTokenTest, + withdrawTest, + changeTokenFeeTest, + changeTokenAllowanceTest, +} from '../common/manageable-vault.helpers'; +import { + setMorphoVaultTest, + removeMorphoVaultTest, +} from '../common/redemption-vault-morpho.helpers'; +import { + approveRedeemRequestTest, + redeemFiatRequestTest, + redeemInstantTest, + redeemRequestTest, + rejectRedeemRequestTest, + safeApproveRedeemRequestTest, + setFiatAdditionalFeeTest, + setMinFiatRedeemAmountTest, +} from '../common/redemption-vault.helpers'; +import { sanctionUser } from '../common/with-sanctions-list.helpers'; + +describe('RedemptionVaultWithMorpho', function () { + it('deployment', async () => { + const { + redemptionVaultWithMorpho, + morphoVaultMock, + stableCoins, + mTBILL, + tokensReceiver, + feeReceiver, + mTokenToUsdDataFeed, + roles, + } = await loadFixture(defaultDeploy); + + expect(await redemptionVaultWithMorpho.mToken()).eq(mTBILL.address); + + expect(await redemptionVaultWithMorpho.ONE_HUNDRED_PERCENT()).eq('10000'); + + expect(await redemptionVaultWithMorpho.paused()).eq(false); + + expect(await redemptionVaultWithMorpho.tokensReceiver()).eq( + tokensReceiver.address, + ); + expect(await redemptionVaultWithMorpho.feeReceiver()).eq( + feeReceiver.address, + ); + + expect(await redemptionVaultWithMorpho.minAmount()).eq(1000); + expect(await redemptionVaultWithMorpho.minFiatRedeemAmount()).eq(1000); + + expect(await redemptionVaultWithMorpho.instantFee()).eq('100'); + + expect(await redemptionVaultWithMorpho.instantDailyLimit()).eq( + parseUnits('100000'), + ); + + expect(await redemptionVaultWithMorpho.mTokenDataFeed()).eq( + mTokenToUsdDataFeed.address, + ); + expect(await redemptionVaultWithMorpho.variationTolerance()).eq(1); + + expect(await redemptionVaultWithMorpho.vaultRole()).eq( + roles.tokenRoles.mTBILL.redemptionVaultAdmin, + ); + + expect(await redemptionVaultWithMorpho.MANUAL_FULLFILMENT_TOKEN()).eq( + ethers.constants.AddressZero, + ); + + expect( + await redemptionVaultWithMorpho.morphoVaults(stableCoins.usdc.address), + ).eq(morphoVaultMock.address); + }); + + it('failing deployment', async () => { + const { + mTBILL, + tokensReceiver, + feeReceiver, + mTokenToUsdDataFeed, + accessControl, + mockedSanctionsList, + owner, + } = await loadFixture(defaultDeploy); + + const redemptionVaultWithMorpho = + await new RedemptionVaultWithMorphoTest__factory(owner).deploy(); + + await expect( + redemptionVaultWithMorpho.initialize( + accessControl.address, + { + mToken: mTBILL.address, + mTokenDataFeed: mTokenToUsdDataFeed.address, + }, + { + feeReceiver: feeReceiver.address, + tokensReceiver: tokensReceiver.address, + }, + { + instantFee: 100, + instantDailyLimit: parseUnits('100000'), + }, + mockedSanctionsList.address, + 1, + parseUnits('100'), + { + fiatAdditionalFee: 10000, + fiatFlatFee: parseUnits('1'), + minFiatRedeemAmount: parseUnits('100'), + }, + constants.AddressZero, + ), + ).to.be.reverted; + }); + + describe('initialization', () => { + it('should fail: call initialize() when already initialized', async () => { + const { redemptionVaultWithMorpho } = await loadFixture(defaultDeploy); + + await expect( + redemptionVaultWithMorpho.initialize( + constants.AddressZero, + { + mToken: constants.AddressZero, + mTokenDataFeed: constants.AddressZero, + }, + { + feeReceiver: constants.AddressZero, + tokensReceiver: constants.AddressZero, + }, + { + instantFee: 0, + instantDailyLimit: 0, + }, + constants.AddressZero, + 0, + 0, + { + fiatAdditionalFee: 0, + fiatFlatFee: 0, + minFiatRedeemAmount: 0, + }, + constants.AddressZero, + ), + ).revertedWith('Initializable: contract is already initialized'); + }); + + it('should fail: call with initializing == false', async () => { + const { + owner, + accessControl, + mTBILL, + tokensReceiver, + feeReceiver, + mTokenToUsdDataFeed, + mockedSanctionsList, + } = await loadFixture(defaultDeploy); + + const vault = await new ManageableVaultTester__factory(owner).deploy(); + + await expect( + vault.initializeWithoutInitializer( + accessControl.address, + { + mToken: mTBILL.address, + mTokenDataFeed: mTokenToUsdDataFeed.address, + }, + { + feeReceiver: feeReceiver.address, + tokensReceiver: tokensReceiver.address, + }, + { + instantFee: 100, + instantDailyLimit: parseUnits('100000'), + }, + mockedSanctionsList.address, + 1, + 1000, + ), + ).revertedWith('Initializable: contract is not initializing'); + }); + }); + + describe('setMorphoVault()', () => { + it('should fail: call from address without vault admin role', async () => { + const { + redemptionVaultWithMorpho, + owner, + regularAccounts, + stableCoins, + morphoVaultMock, + } = await loadFixture(defaultDeploy); + + await setMorphoVaultTest( + { redemptionVault: redemptionVaultWithMorpho, owner }, + stableCoins.usdc.address, + morphoVaultMock.address, + { + from: regularAccounts[0], + revertMessage: 'WMAC: hasnt role', + }, + ); + }); + + it('should fail: zero token address', async () => { + const { redemptionVaultWithMorpho, owner, morphoVaultMock } = + await loadFixture(defaultDeploy); + + await setMorphoVaultTest( + { redemptionVault: redemptionVaultWithMorpho, owner }, + constants.AddressZero, + morphoVaultMock.address, + { + revertMessage: 'zero address', + }, + ); + }); + + it('should fail: zero vault address', async () => { + const { redemptionVaultWithMorpho, owner, stableCoins } = + await loadFixture(defaultDeploy); + + await setMorphoVaultTest( + { redemptionVault: redemptionVaultWithMorpho, owner }, + stableCoins.usdc.address, + constants.AddressZero, + { + revertMessage: 'zero address', + }, + ); + }); + + it('should fail: asset mismatch', async () => { + const { redemptionVaultWithMorpho, owner, stableCoins, morphoVaultMock } = + await loadFixture(defaultDeploy); + + await setMorphoVaultTest( + { redemptionVault: redemptionVaultWithMorpho, owner }, + stableCoins.dai.address, + morphoVaultMock.address, + { + revertMessage: 'RVM: asset mismatch', + }, + ); + }); + + it('call from address with vault admin role', async () => { + const { redemptionVaultWithMorpho, owner, stableCoins, morphoVaultMock } = + await loadFixture(defaultDeploy); + + await setMorphoVaultTest( + { redemptionVault: redemptionVaultWithMorpho, owner }, + stableCoins.usdc.address, + morphoVaultMock.address, + ); + }); + }); + + describe('removeMorphoVault()', () => { + it('should fail: call from address without vault admin role', async () => { + const { redemptionVaultWithMorpho, owner, regularAccounts, stableCoins } = + await loadFixture(defaultDeploy); + + await removeMorphoVaultTest( + { redemptionVault: redemptionVaultWithMorpho, owner }, + stableCoins.usdc.address, + { + from: regularAccounts[0], + revertMessage: 'WMAC: hasnt role', + }, + ); + }); + + it('should fail: vault not set', async () => { + const { redemptionVaultWithMorpho, owner, stableCoins } = + await loadFixture(defaultDeploy); + + await removeMorphoVaultTest( + { redemptionVault: redemptionVaultWithMorpho, owner }, + stableCoins.dai.address, + { + revertMessage: 'RVM: vault not set', + }, + ); + }); + + it('call from address with vault admin role', async () => { + const { redemptionVaultWithMorpho, owner, stableCoins } = + await loadFixture(defaultDeploy); + + await removeMorphoVaultTest( + { redemptionVault: redemptionVaultWithMorpho, owner }, + stableCoins.usdc.address, + ); + }); + }); + + describe('setMinAmount()', () => { + it('should fail: call from address without vault admin role', async () => { + const { redemptionVaultWithMorpho, regularAccounts } = await loadFixture( + defaultDeploy, + ); + await setMinAmountTest( + { vault: redemptionVaultWithMorpho, owner: regularAccounts[0] }, + 1, + { revertMessage: 'WMAC: hasnt role', from: regularAccounts[0] }, + ); + }); + + it('call from address with vault admin role', async () => { + const { owner, redemptionVaultWithMorpho } = await loadFixture( + defaultDeploy, + ); + await setMinAmountTest({ vault: redemptionVaultWithMorpho, owner }, 1); + }); + }); + + describe('setMinFiatRedeemAmount()', () => { + it('should fail: call from address without vault admin role', async () => { + const { redemptionVaultWithMorpho, regularAccounts } = await loadFixture( + defaultDeploy, + ); + await setMinFiatRedeemAmountTest( + { + redemptionVault: redemptionVaultWithMorpho, + owner: regularAccounts[0], + }, + 1, + { revertMessage: 'WMAC: hasnt role', from: regularAccounts[0] }, + ); + }); + + it('call from address with vault admin role', async () => { + const { owner, redemptionVaultWithMorpho } = await loadFixture( + defaultDeploy, + ); + await setMinFiatRedeemAmountTest( + { redemptionVault: redemptionVaultWithMorpho, owner }, + 1, + ); + }); + }); + + describe('setFiatAdditionalFee()', () => { + it('should fail: call from address without vault admin role', async () => { + const { redemptionVaultWithMorpho, regularAccounts } = await loadFixture( + defaultDeploy, + ); + await setFiatAdditionalFeeTest( + { + redemptionVault: redemptionVaultWithMorpho, + owner: regularAccounts[0], + }, + 1, + { revertMessage: 'WMAC: hasnt role', from: regularAccounts[0] }, + ); + }); + + it('call from address with vault admin role', async () => { + const { owner, redemptionVaultWithMorpho } = await loadFixture( + defaultDeploy, + ); + await setFiatAdditionalFeeTest( + { redemptionVault: redemptionVaultWithMorpho, owner }, + 1, + ); + }); + }); + + describe('setInstantDailyLimit()', () => { + it('should fail: call from address without vault admin role', async () => { + const { redemptionVaultWithMorpho, regularAccounts } = await loadFixture( + defaultDeploy, + ); + await setInstantDailyLimitTest( + { vault: redemptionVaultWithMorpho, owner: regularAccounts[0] }, + 1, + { revertMessage: 'WMAC: hasnt role', from: regularAccounts[0] }, + ); + }); + + it('should fail: try to set 0 limit', async () => { + const { owner, redemptionVaultWithMorpho } = await loadFixture( + defaultDeploy, + ); + await setInstantDailyLimitTest( + { vault: redemptionVaultWithMorpho, owner }, + constants.Zero, + { revertMessage: 'MV: limit zero' }, + ); + }); + + it('call from address with vault admin role', async () => { + const { owner, redemptionVaultWithMorpho } = await loadFixture( + defaultDeploy, + ); + await setInstantDailyLimitTest( + { vault: redemptionVaultWithMorpho, owner }, + 1, + ); + }); + }); + + describe('addPaymentToken()', () => { + it('should fail: call from address without vault admin role', async () => { + const { + redemptionVaultWithMorpho, + regularAccounts, + dataFeed, + stableCoins, + } = await loadFixture(defaultDeploy); + await addPaymentTokenTest( + { vault: redemptionVaultWithMorpho, owner: regularAccounts[0] }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + constants.MaxUint256, + { revertMessage: 'WMAC: hasnt role', from: regularAccounts[0] }, + ); + }); + + it('call from address with vault admin role', async () => { + const { owner, redemptionVaultWithMorpho, stableCoins, dataFeed } = + await loadFixture(defaultDeploy); + await addPaymentTokenTest( + { vault: redemptionVaultWithMorpho, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + }); + }); + + describe('removePaymentToken()', () => { + it('should fail: call from address without vault admin role', async () => { + const { + owner, + redemptionVaultWithMorpho, + regularAccounts, + stableCoins, + dataFeed, + } = await loadFixture(defaultDeploy); + await addPaymentTokenTest( + { vault: redemptionVaultWithMorpho, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + await removePaymentTokenTest( + { vault: redemptionVaultWithMorpho, owner: regularAccounts[0] }, + stableCoins.usdc, + { revertMessage: 'WMAC: hasnt role', from: regularAccounts[0] }, + ); + }); + + it('call from address with vault admin role', async () => { + const { owner, redemptionVaultWithMorpho, stableCoins, dataFeed } = + await loadFixture(defaultDeploy); + await addPaymentTokenTest( + { vault: redemptionVaultWithMorpho, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + await removePaymentTokenTest( + { vault: redemptionVaultWithMorpho, owner }, + stableCoins.usdc, + ); + }); + }); + + describe('addWaivedFeeAccount()', () => { + it('should fail: call from address without vault admin role', async () => { + const { redemptionVaultWithMorpho, regularAccounts } = await loadFixture( + defaultDeploy, + ); + await addWaivedFeeAccountTest( + { vault: redemptionVaultWithMorpho, owner: regularAccounts[0] }, + regularAccounts[1].address, + { revertMessage: 'WMAC: hasnt role', from: regularAccounts[0] }, + ); + }); + + it('call from address with vault admin role', async () => { + const { owner, redemptionVaultWithMorpho, regularAccounts } = + await loadFixture(defaultDeploy); + await addWaivedFeeAccountTest( + { vault: redemptionVaultWithMorpho, owner }, + regularAccounts[0].address, + ); + }); + }); + + describe('removeWaivedFeeAccount()', () => { + it('should fail: call from address without vault admin role', async () => { + const { owner, redemptionVaultWithMorpho, regularAccounts } = + await loadFixture(defaultDeploy); + await addWaivedFeeAccountTest( + { vault: redemptionVaultWithMorpho, owner }, + regularAccounts[0].address, + ); + await removeWaivedFeeAccountTest( + { vault: redemptionVaultWithMorpho, owner: regularAccounts[0] }, + regularAccounts[0].address, + { revertMessage: 'WMAC: hasnt role', from: regularAccounts[0] }, + ); + }); + + it('call from address with vault admin role', async () => { + const { owner, redemptionVaultWithMorpho, regularAccounts } = + await loadFixture(defaultDeploy); + await addWaivedFeeAccountTest( + { vault: redemptionVaultWithMorpho, owner }, + regularAccounts[0].address, + ); + await removeWaivedFeeAccountTest( + { vault: redemptionVaultWithMorpho, owner }, + regularAccounts[0].address, + ); + }); + }); + + describe('setFee()', () => { + it('should fail: call from address without vault admin role', async () => { + const { redemptionVaultWithMorpho, regularAccounts } = await loadFixture( + defaultDeploy, + ); + await setInstantFeeTest( + { vault: redemptionVaultWithMorpho, owner: regularAccounts[0] }, + 1, + { revertMessage: 'WMAC: hasnt role', from: regularAccounts[0] }, + ); + }); + + it('call from address with vault admin role', async () => { + const { owner, redemptionVaultWithMorpho } = await loadFixture( + defaultDeploy, + ); + await setInstantFeeTest({ vault: redemptionVaultWithMorpho, owner }, 1); + }); + }); + + describe('setVariabilityTolerance()', () => { + it('should fail: call from address without vault admin role', async () => { + const { redemptionVaultWithMorpho, regularAccounts } = await loadFixture( + defaultDeploy, + ); + await setVariabilityToleranceTest( + { vault: redemptionVaultWithMorpho, owner: regularAccounts[0] }, + 1, + { revertMessage: 'WMAC: hasnt role', from: regularAccounts[0] }, + ); + }); + + it('call from address with vault admin role', async () => { + const { owner, redemptionVaultWithMorpho } = await loadFixture( + defaultDeploy, + ); + await setVariabilityToleranceTest( + { vault: redemptionVaultWithMorpho, owner }, + 1, + ); + }); + }); + + describe('withdrawToken()', () => { + it('should fail: call from address without vault admin role', async () => { + const { redemptionVaultWithMorpho, stableCoins, regularAccounts } = + await loadFixture(defaultDeploy); + await withdrawTest( + { vault: redemptionVaultWithMorpho, owner: regularAccounts[0] }, + stableCoins.usdc, + 1, + regularAccounts[0], + { revertMessage: 'WMAC: hasnt role', from: regularAccounts[0] }, + ); + }); + + it('call from address with vault admin role', async () => { + const { owner, redemptionVaultWithMorpho, stableCoins } = + await loadFixture(defaultDeploy); + await mintToken(stableCoins.usdc, redemptionVaultWithMorpho, 1); + await withdrawTest( + { vault: redemptionVaultWithMorpho, owner }, + stableCoins.usdc, + 1, + owner, + ); + }); + }); + + describe('freeFromMinAmount()', async () => { + it('should fail: call from address without vault admin role', async () => { + const { redemptionVaultWithMorpho, regularAccounts } = await loadFixture( + defaultDeploy, + ); + await expect( + redemptionVaultWithMorpho + .connect(regularAccounts[0]) + .freeFromMinAmount(regularAccounts[0].address, true), + ).revertedWith('WMAC: hasnt role'); + }); + + it('should not fail', async () => { + const { owner, redemptionVaultWithMorpho } = await loadFixture( + defaultDeploy, + ); + + expect( + await redemptionVaultWithMorpho.isFreeFromMinAmount(owner.address), + ).eq(false); + await redemptionVaultWithMorpho.freeFromMinAmount(owner.address, true); + expect( + await redemptionVaultWithMorpho.isFreeFromMinAmount(owner.address), + ).eq(true); + }); + }); + + describe('changeTokenAllowance()', () => { + it('should fail: call from address without vault admin role', async () => { + const { + owner, + redemptionVaultWithMorpho, + regularAccounts, + stableCoins, + dataFeed, + } = await loadFixture(defaultDeploy); + await addPaymentTokenTest( + { vault: redemptionVaultWithMorpho, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + await changeTokenAllowanceTest( + { vault: redemptionVaultWithMorpho, owner: regularAccounts[0] }, + stableCoins.usdc.address, + 100, + { revertMessage: 'WMAC: hasnt role', from: regularAccounts[0] }, + ); + }); + + it('call from address with vault admin role', async () => { + const { owner, redemptionVaultWithMorpho, stableCoins, dataFeed } = + await loadFixture(defaultDeploy); + await addPaymentTokenTest( + { vault: redemptionVaultWithMorpho, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + await changeTokenAllowanceTest( + { vault: redemptionVaultWithMorpho, owner }, + stableCoins.usdc.address, + 100, + ); + }); + }); + + describe('changeTokenFee()', () => { + it('should fail: call from address without vault admin role', async () => { + const { + owner, + redemptionVaultWithMorpho, + regularAccounts, + stableCoins, + dataFeed, + } = await loadFixture(defaultDeploy); + await addPaymentTokenTest( + { vault: redemptionVaultWithMorpho, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + await changeTokenFeeTest( + { vault: redemptionVaultWithMorpho, owner: regularAccounts[0] }, + stableCoins.usdc.address, + 100, + { revertMessage: 'WMAC: hasnt role', from: regularAccounts[0] }, + ); + }); + + it('call from address with vault admin role', async () => { + const { owner, redemptionVaultWithMorpho, stableCoins, dataFeed } = + await loadFixture(defaultDeploy); + await addPaymentTokenTest( + { vault: redemptionVaultWithMorpho, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + await changeTokenFeeTest( + { vault: redemptionVaultWithMorpho, owner }, + stableCoins.usdc.address, + 100, + ); + }); + }); + + describe('checkAndRedeemMorpho()', () => { + it('should not withdraw from Morpho when contract has enough balance', async () => { + const { redemptionVaultWithMorpho, stableCoins, morphoVaultMock } = + await loadFixture(defaultDeploy); + + const usdcAmount = parseUnits('1000', 8); + await stableCoins.usdc.mint( + redemptionVaultWithMorpho.address, + usdcAmount, + ); + + const balanceBefore = await stableCoins.usdc.balanceOf( + redemptionVaultWithMorpho.address, + ); + const sharesBefore = await morphoVaultMock.balanceOf( + redemptionVaultWithMorpho.address, + ); + + await redemptionVaultWithMorpho.checkAndRedeemMorpho( + stableCoins.usdc.address, + parseUnits('500', 8), + ); + + const balanceAfter = await stableCoins.usdc.balanceOf( + redemptionVaultWithMorpho.address, + ); + const sharesAfter = await morphoVaultMock.balanceOf( + redemptionVaultWithMorpho.address, + ); + expect(balanceAfter).to.equal(balanceBefore); + expect(sharesAfter).to.equal(sharesBefore); + }); + + it('should withdraw missing amount from Morpho', async () => { + const { redemptionVaultWithMorpho, stableCoins, morphoVaultMock } = + await loadFixture(defaultDeploy); + + // Vault has 500 USDC, needs 1000 + const initialUsdc = parseUnits('500', 8); + await stableCoins.usdc.mint( + redemptionVaultWithMorpho.address, + initialUsdc, + ); + + // Vault has 600 Morpho shares (1:1 exchange rate by default) + const sharesAmount = parseUnits('600', 8); + await morphoVaultMock.mint( + redemptionVaultWithMorpho.address, + sharesAmount, + ); + + await redemptionVaultWithMorpho.checkAndRedeemMorpho( + stableCoins.usdc.address, + parseUnits('1000', 8), + ); + + // Vault should now have 1000 USDC (500 original + 500 withdrawn from Morpho) + const usdcAfter = await stableCoins.usdc.balanceOf( + redemptionVaultWithMorpho.address, + ); + expect(usdcAfter).to.equal(parseUnits('1000', 8)); + + // Share balance should decrease by 500 (1:1 rate) + const sharesAfter = await morphoVaultMock.balanceOf( + redemptionVaultWithMorpho.address, + ); + expect(sharesAfter).to.equal(parseUnits('100', 8)); + }); + + it('should revert when token not vault asset', async () => { + const { redemptionVaultWithMorpho, stableCoins } = await loadFixture( + defaultDeploy, + ); + + // DAI is not the Morpho vault's underlying asset (USDC is) + await expect( + redemptionVaultWithMorpho.checkAndRedeemMorpho( + stableCoins.dai.address, + parseUnits('1000', 9), + ), + ).to.be.revertedWith('RVM: no vault for token'); + }); + + it('should revert when contract has insufficient shares', async () => { + const { redemptionVaultWithMorpho, stableCoins, morphoVaultMock } = + await loadFixture(defaultDeploy); + + // Vault has 200 USDC, needs 1000 + await stableCoins.usdc.mint( + redemptionVaultWithMorpho.address, + parseUnits('200', 8), + ); + + // Vault has only 300 shares (not enough for 800 missing at 1:1 rate) + await morphoVaultMock.mint( + redemptionVaultWithMorpho.address, + parseUnits('300', 8), + ); + + await expect( + redemptionVaultWithMorpho.checkAndRedeemMorpho( + stableCoins.usdc.address, + parseUnits('1000', 8), + ), + ).to.be.revertedWith('RVM: insufficient shares'); + }); + + it('should revert when Morpho vault has insufficient underlying liquidity', async () => { + const { redemptionVaultWithMorpho, stableCoins, morphoVaultMock } = + await loadFixture(defaultDeploy); + + // Vault needs to withdraw from Morpho + await stableCoins.usdc.mint( + redemptionVaultWithMorpho.address, + parseUnits('200', 8), + ); + + // Vault has enough shares + await morphoVaultMock.mint( + redemptionVaultWithMorpho.address, + parseUnits('1000', 8), + ); + + // Drain the mock's USDC + const mockBalance = await stableCoins.usdc.balanceOf( + morphoVaultMock.address, + ); + await morphoVaultMock.withdrawAdmin( + stableCoins.usdc.address, + ( + await ethers.getSigners() + )[10].address, + mockBalance, + ); + + await expect( + redemptionVaultWithMorpho.checkAndRedeemMorpho( + stableCoins.usdc.address, + parseUnits('1000', 8), + ), + ).to.be.revertedWith('MorphoVaultMock: InsufficientLiquidity'); + }); + + it('should withdraw correctly with non-1:1 exchange rate (shares worth more)', async () => { + const { redemptionVaultWithMorpho, stableCoins, morphoVaultMock } = + await loadFixture(defaultDeploy); + + // Set exchange rate: 1 share = 1.05 underlying (5% interest accrued) + await morphoVaultMock.setExchangeRate(parseUnits('1.05', 18)); + + // Vault has 200 USDC, needs 1000 → missing 800 + await stableCoins.usdc.mint( + redemptionVaultWithMorpho.address, + parseUnits('200', 8), + ); + + // At 1.05 rate, 800 assets needs ceil(800 / 1.05) ≈ 762 shares + // Mint 800 shares (more than enough) + await morphoVaultMock.mint( + redemptionVaultWithMorpho.address, + parseUnits('800', 8), + ); + + const sharesBefore = await morphoVaultMock.balanceOf( + redemptionVaultWithMorpho.address, + ); + + await redemptionVaultWithMorpho.checkAndRedeemMorpho( + stableCoins.usdc.address, + parseUnits('1000', 8), + ); + + const usdcAfter = await stableCoins.usdc.balanceOf( + redemptionVaultWithMorpho.address, + ); + expect(usdcAfter).to.equal(parseUnits('1000', 8)); + + const sharesAfter = await morphoVaultMock.balanceOf( + redemptionVaultWithMorpho.address, + ); + // Shares burned should be less than 800 because each share is worth 1.05 + expect(sharesAfter).to.be.gt(0); + const sharesBurned = sharesBefore.sub(sharesAfter); + expect(sharesBurned).to.be.lt(parseUnits('800', 8)); + }); + + it('should revert with insufficient shares at non-1:1 exchange rate', async () => { + const { redemptionVaultWithMorpho, stableCoins, morphoVaultMock } = + await loadFixture(defaultDeploy); + + // Set exchange rate: 1 share = 0.95 underlying (loss scenario) + await morphoVaultMock.setExchangeRate(parseUnits('0.95', 18)); + + // Vault has 200 USDC, needs 1000 → missing 800 + await stableCoins.usdc.mint( + redemptionVaultWithMorpho.address, + parseUnits('200', 8), + ); + + // At 0.95 rate, 800 assets needs ceil(800 / 0.95) ≈ 843 shares + // Mint only 800 shares (not enough at this rate) + await morphoVaultMock.mint( + redemptionVaultWithMorpho.address, + parseUnits('800', 8), + ); + + await expect( + redemptionVaultWithMorpho.checkAndRedeemMorpho( + stableCoins.usdc.address, + parseUnits('1000', 8), + ), + ).to.be.revertedWith('RVM: insufficient shares'); + }); + }); + + describe('redeemInstant()', () => { + it('should fail: when there is no token in vault', async () => { + const { + owner, + redemptionVaultWithMorpho, + stableCoins, + mTBILL, + mTokenToUsdDataFeed, + } = await loadFixture(defaultDeploy); + + await redeemInstantTest( + { + redemptionVault: redemptionVaultWithMorpho, + owner, + mTBILL, + mTokenToUsdDataFeed, + }, + stableCoins.usdc, + 1, + { + revertMessage: 'MV: token not exists', + }, + ); + }); + + it('should fail: when trying to redeem 0 amount', async () => { + const { + owner, + redemptionVaultWithMorpho, + stableCoins, + mTBILL, + dataFeed, + mTokenToUsdDataFeed, + } = await loadFixture(defaultDeploy); + await addPaymentTokenTest( + { vault: redemptionVaultWithMorpho, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + await redeemInstantTest( + { + redemptionVault: redemptionVaultWithMorpho, + owner, + mTBILL, + mTokenToUsdDataFeed, + }, + stableCoins.usdc, + 0, + { + revertMessage: 'RV: invalid amount', + }, + ); + }); + + it('should fail: when function paused', async () => { + const { + owner, + redemptionVaultWithMorpho, + stableCoins, + mTBILL, + dataFeed, + mTokenToUsdDataFeed, + regularAccounts, + } = await loadFixture(defaultDeploy); + await mintToken(mTBILL, regularAccounts[0], 100); + await approveBase18( + regularAccounts[0], + stableCoins.dai, + redemptionVaultWithMorpho, + 100, + ); + await addPaymentTokenTest( + { vault: redemptionVaultWithMorpho, owner }, + stableCoins.dai, + dataFeed.address, + 0, + true, + ); + const selector = encodeFnSelector( + 'redeemInstant(address,uint256,uint256)', + ); + await pauseVaultFn(redemptionVaultWithMorpho, selector); + await redeemInstantTest( + { + redemptionVault: redemptionVaultWithMorpho, + owner, + mTBILL, + mTokenToUsdDataFeed, + }, + stableCoins.dai, + 100, + { + from: regularAccounts[0], + revertMessage: 'Pausable: fn paused', + }, + ); + }); + + it('should fail: call with insufficient allowance', async () => { + const { + owner, + redemptionVaultWithMorpho, + stableCoins, + mTBILL, + dataFeed, + mTokenToUsdDataFeed, + } = await loadFixture(defaultDeploy); + + await mintToken(mTBILL, owner, 100); + await addPaymentTokenTest( + { vault: redemptionVaultWithMorpho, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + await redeemInstantTest( + { + redemptionVault: redemptionVaultWithMorpho, + owner, + mTBILL, + mTokenToUsdDataFeed, + }, + stableCoins.usdc, + 100, + { + revertMessage: 'ERC20: insufficient allowance', + }, + ); + }); + + it('should fail: call with insufficient balance', async () => { + const { + owner, + redemptionVaultWithMorpho, + stableCoins, + mTBILL, + dataFeed, + mTokenToUsdDataFeed, + } = await loadFixture(defaultDeploy); + + await approveBase18(owner, mTBILL, redemptionVaultWithMorpho, 100); + await addPaymentTokenTest( + { vault: redemptionVaultWithMorpho, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + await redeemInstantTest( + { + redemptionVault: redemptionVaultWithMorpho, + owner, + mTBILL, + mTokenToUsdDataFeed, + }, + stableCoins.usdc, + 100, + { + revertMessage: 'ERC20: burn amount exceeds balance', + }, + ); + }); + + it('should fail: dataFeed rate 0', async () => { + const { + owner, + redemptionVaultWithMorpho, + stableCoins, + mTBILL, + dataFeed, + mockedAggregator, + mockedAggregatorMToken, + mTokenToUsdDataFeed, + } = await loadFixture(defaultDeploy); + + await approveBase18( + owner, + stableCoins.usdc, + redemptionVaultWithMorpho, + 10, + ); + await addPaymentTokenTest( + { vault: redemptionVaultWithMorpho, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + await mintToken(mTBILL, owner, 100_000); + await setRoundData({ mockedAggregator }, 0); + await redeemInstantTest( + { + redemptionVault: redemptionVaultWithMorpho, + owner, + mTBILL, + mTokenToUsdDataFeed, + }, + stableCoins.usdc, + 1, + { + revertMessage: 'DF: feed is deprecated', + }, + ); + }); + + it('should fail: call for amount < minAmount', async () => { + const { + redemptionVaultWithMorpho, + mockedAggregator, + owner, + mTBILL, + stableCoins, + dataFeed, + mTokenToUsdDataFeed, + } = await loadFixture(defaultDeploy); + await addPaymentTokenTest( + { vault: redemptionVaultWithMorpho, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + await setRoundData({ mockedAggregator }, 1); + + await mintToken(mTBILL, owner, 100_000); + await approveBase18(owner, mTBILL, redemptionVaultWithMorpho, 100_000); + + await setMinAmountTest( + { vault: redemptionVaultWithMorpho, owner }, + 100_000, + ); + + await redeemInstantTest( + { + redemptionVault: redemptionVaultWithMorpho, + owner, + mTBILL, + mTokenToUsdDataFeed, + }, + stableCoins.usdc, + 99_999, + { + revertMessage: 'RV: amount < min', + }, + ); + }); + + it('should fail: if exceeds token allowance', async () => { + const { + owner, + redemptionVaultWithMorpho, + stableCoins, + mTBILL, + dataFeed, + mockedAggregator, + mTokenToUsdDataFeed, + } = await loadFixture(defaultDeploy); + + await addPaymentTokenTest( + { vault: redemptionVaultWithMorpho, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + await setRoundData({ mockedAggregator }, 4); + + await mintToken(mTBILL, owner, 100_000); + await changeTokenAllowanceTest( + { vault: redemptionVaultWithMorpho, owner }, + stableCoins.usdc.address, + 100, + ); + await approveBase18(owner, mTBILL, redemptionVaultWithMorpho, 100_000); + + await redeemInstantTest( + { + redemptionVault: redemptionVaultWithMorpho, + owner, + mTBILL, + mTokenToUsdDataFeed, + }, + stableCoins.usdc, + 99_999, + { + revertMessage: 'MV: exceed allowance', + }, + ); + }); + + it('should fail: if daily limit exceeded', async () => { + const { + owner, + redemptionVaultWithMorpho, + stableCoins, + mTBILL, + dataFeed, + mockedAggregator, + mTokenToUsdDataFeed, + } = await loadFixture(defaultDeploy); + + await addPaymentTokenTest( + { vault: redemptionVaultWithMorpho, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + await setRoundData({ mockedAggregator }, 4); + + await mintToken(mTBILL, owner, 100_000); + await setInstantDailyLimitTest( + { vault: redemptionVaultWithMorpho, owner }, + 1000, + ); + + await approveBase18(owner, mTBILL, redemptionVaultWithMorpho, 100_000); + + await redeemInstantTest( + { + redemptionVault: redemptionVaultWithMorpho, + owner, + mTBILL, + mTokenToUsdDataFeed, + }, + stableCoins.usdc, + 99_999, + { + revertMessage: 'MV: exceed limit', + }, + ); + }); + + it('should fail: if some fee = 100%', async () => { + const { + owner, + redemptionVaultWithMorpho, + stableCoins, + mTBILL, + dataFeed, + mTokenToUsdDataFeed, + } = await loadFixture(defaultDeploy); + + await mintToken(mTBILL, owner, 100); + await approveBase18(owner, mTBILL, redemptionVaultWithMorpho, 100); + await addPaymentTokenTest( + { vault: redemptionVaultWithMorpho, owner }, + stableCoins.usdc, + dataFeed.address, + 10000, + true, + ); + await redeemInstantTest( + { + redemptionVault: redemptionVaultWithMorpho, + owner, + mTBILL, + mTokenToUsdDataFeed, + }, + stableCoins.usdc, + 100, + { + revertMessage: 'RV: amountMTokenIn < fee', + }, + ); + }); + + it('should fail: greenlist enabled and user not in greenlist', async () => { + const { + owner, + redemptionVaultWithMorpho, + stableCoins, + mTBILL, + mTokenToUsdDataFeed, + } = await loadFixture(defaultDeploy); + + await redemptionVaultWithMorpho.setGreenlistEnable(true); + + await redeemInstantTest( + { + redemptionVault: redemptionVaultWithMorpho, + owner, + mTBILL, + mTokenToUsdDataFeed, + }, + stableCoins.dai, + 1, + { + revertMessage: 'WMAC: hasnt role', + }, + ); + }); + + it('should fail: user in blacklist', async () => { + const { + owner, + redemptionVaultWithMorpho, + stableCoins, + mTBILL, + mTokenToUsdDataFeed, + blackListableTester, + accessControl, + regularAccounts, + } = await loadFixture(defaultDeploy); + + await blackList( + { blacklistable: blackListableTester, accessControl, owner }, + regularAccounts[0], + ); + + await redeemInstantTest( + { + redemptionVault: redemptionVaultWithMorpho, + owner, + mTBILL, + mTokenToUsdDataFeed, + }, + stableCoins.dai, + 1, + { + from: regularAccounts[0], + revertMessage: acErrors.WMAC_HAS_ROLE, + }, + ); + }); + + it('should fail: user in sanctions list', async () => { + const { + owner, + redemptionVaultWithMorpho, + stableCoins, + mTBILL, + dataFeed, + mTokenToUsdDataFeed, + mockedSanctionsList, + regularAccounts, + } = await loadFixture(defaultDeploy); + + await sanctionUser( + { sanctionsList: mockedSanctionsList }, + regularAccounts[0], + ); + + await mintToken(mTBILL, regularAccounts[0], 100); + await approveBase18( + regularAccounts[0], + mTBILL, + redemptionVaultWithMorpho, + 100, + ); + await addPaymentTokenTest( + { vault: redemptionVaultWithMorpho, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + await redeemInstantTest( + { + redemptionVault: redemptionVaultWithMorpho, + owner, + mTBILL, + mTokenToUsdDataFeed, + }, + stableCoins.usdc, + 100, + { + from: regularAccounts[0], + revertMessage: 'WSL: sanctioned', + }, + ); + }); + + // ── Happy path tests ───────────────────────────────────────────────── + + it('redeem 100 mTBILL when vault has enough USDC (no Morpho needed)', async () => { + const { + owner, + redemptionVaultWithMorpho, + stableCoins, + mTBILL, + mTokenToUsdDataFeed, + dataFeed, + morphoVaultMock, + } = await loadFixture(defaultDeploy); + + await mintToken(stableCoins.usdc, redemptionVaultWithMorpho, 100000); + await mintToken(mTBILL, owner, 100); + await approveBase18(owner, mTBILL, redemptionVaultWithMorpho, 100); + await addPaymentTokenTest( + { vault: redemptionVaultWithMorpho, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + + const sharesBefore = await morphoVaultMock.balanceOf( + redemptionVaultWithMorpho.address, + ); + + await redeemInstantTest( + { + redemptionVault: redemptionVaultWithMorpho, + owner, + mTBILL, + mTokenToUsdDataFeed, + }, + stableCoins.usdc, + 100, + ); + + // Share balance should not change + const sharesAfter = await morphoVaultMock.balanceOf( + redemptionVaultWithMorpho.address, + ); + expect(sharesAfter).to.equal(sharesBefore); + }); + + it('redeem 1000 mTBILL when vault has no USDC but has Morpho shares', async () => { + const { + owner, + mockedAggregator, + mockedAggregatorMToken, + redemptionVaultWithMorpho, + stableCoins, + mTBILL, + dataFeed, + mTokenToUsdDataFeed, + morphoVaultMock, + } = await loadFixture(defaultDeploy); + + // Mint shares to vault (enough for redemption at 1:1 rate) + await morphoVaultMock.mint( + redemptionVaultWithMorpho.address, + parseUnits('9900', 8), + ); + await mintToken(mTBILL, owner, 1000); + await approveBase18(owner, mTBILL, redemptionVaultWithMorpho, 1000); + await addPaymentTokenTest( + { vault: redemptionVaultWithMorpho, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + await setInstantFeeTest({ vault: redemptionVaultWithMorpho, owner }, 0); + await setRoundData({ mockedAggregator }, 1); + await setRoundData({ mockedAggregator: mockedAggregatorMToken }, 1); + + const sharesBefore = await morphoVaultMock.balanceOf( + redemptionVaultWithMorpho.address, + ); + + await redeemInstantTest( + { + redemptionVault: redemptionVaultWithMorpho, + owner, + mTBILL, + mTokenToUsdDataFeed, + }, + stableCoins.usdc, + 1000, + ); + + const sharesAfter = await morphoVaultMock.balanceOf( + redemptionVaultWithMorpho.address, + ); + + // Shares should decrease + expect(sharesAfter).to.be.lt(sharesBefore); + }); + + it('redeem 1000 mTBILL when vault has 100 USDC and sufficient shares (partial Morpho)', async () => { + const { + owner, + mockedAggregator, + mockedAggregatorMToken, + redemptionVaultWithMorpho, + stableCoins, + mTBILL, + dataFeed, + mTokenToUsdDataFeed, + morphoVaultMock, + } = await loadFixture(defaultDeploy); + + // Vault has 100 USDC + shares + await mintToken(stableCoins.usdc, redemptionVaultWithMorpho, 100); + await morphoVaultMock.mint( + redemptionVaultWithMorpho.address, + parseUnits('9900', 8), + ); + await mintToken(mTBILL, owner, 1000); + await approveBase18(owner, mTBILL, redemptionVaultWithMorpho, 1000); + await addPaymentTokenTest( + { vault: redemptionVaultWithMorpho, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + await setRoundData({ mockedAggregator }, 1); + await setRoundData({ mockedAggregator: mockedAggregatorMToken }, 1); + + await redeemInstantTest( + { + redemptionVault: redemptionVaultWithMorpho, + owner, + mTBILL, + mTokenToUsdDataFeed, + }, + stableCoins.usdc, + 1000, + ); + }); + + it('redeem 1000 mTBILL with different prices (stable 1.03$, mToken 5$) and partial Morpho', async () => { + const { + owner, + mockedAggregator, + mockedAggregatorMToken, + redemptionVaultWithMorpho, + stableCoins, + mTBILL, + dataFeed, + mTokenToUsdDataFeed, + morphoVaultMock, + } = await loadFixture(defaultDeploy); + + await mintToken(stableCoins.usdc, redemptionVaultWithMorpho, 100); + await morphoVaultMock.mint( + redemptionVaultWithMorpho.address, + parseUnits('15000', 8), + ); + await mintToken(mTBILL, owner, 1000); + await approveBase18(owner, mTBILL, redemptionVaultWithMorpho, 1000); + await addPaymentTokenTest( + { vault: redemptionVaultWithMorpho, owner }, + stableCoins.usdc, + dataFeed.address, + 100, + true, + ); + await setRoundData({ mockedAggregator }, 1.03); + await setRoundData({ mockedAggregator: mockedAggregatorMToken }, 5); + await redemptionVaultWithMorpho.freeFromMinAmount(owner.address, true); + + await redeemInstantTest( + { + redemptionVault: redemptionVaultWithMorpho, + owner, + mTBILL, + mTokenToUsdDataFeed, + }, + stableCoins.usdc, + 1000, + ); + }); + + it('redeem 1000 mTBILL with waived fee and Morpho withdrawal', async () => { + const { + owner, + mockedAggregator, + mockedAggregatorMToken, + redemptionVaultWithMorpho, + stableCoins, + mTBILL, + dataFeed, + mTokenToUsdDataFeed, + morphoVaultMock, + } = await loadFixture(defaultDeploy); + + await mintToken(stableCoins.usdc, redemptionVaultWithMorpho, 100); + await morphoVaultMock.mint( + redemptionVaultWithMorpho.address, + parseUnits('15000', 8), + ); + await mintToken(mTBILL, owner, 1000); + await approveBase18(owner, mTBILL, redemptionVaultWithMorpho, 1000); + await addPaymentTokenTest( + { vault: redemptionVaultWithMorpho, owner }, + stableCoins.usdc, + dataFeed.address, + 100, + true, + ); + await setRoundData({ mockedAggregator }, 1.03); + await setRoundData({ mockedAggregator: mockedAggregatorMToken }, 5); + + await addWaivedFeeAccountTest( + { vault: redemptionVaultWithMorpho, owner }, + owner.address, + ); + + await redeemInstantTest( + { + redemptionVault: redemptionVaultWithMorpho, + owner, + mTBILL, + mTokenToUsdDataFeed, + waivedFee: true, + }, + stableCoins.usdc, + 1000, + ); + }); + + it('should fail: insufficient shares during redeemInstant', async () => { + const { + owner, + mockedAggregator, + mockedAggregatorMToken, + redemptionVaultWithMorpho, + stableCoins, + mTBILL, + dataFeed, + mTokenToUsdDataFeed, + morphoVaultMock, + } = await loadFixture(defaultDeploy); + + // Vault has no USDC and only 10 shares (not enough for 1000 mTBILL redemption) + await morphoVaultMock.mint( + redemptionVaultWithMorpho.address, + parseUnits('10', 8), + ); + await mintToken(mTBILL, owner, 1000); + await approveBase18(owner, mTBILL, redemptionVaultWithMorpho, 1000); + await addPaymentTokenTest( + { vault: redemptionVaultWithMorpho, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + await setInstantFeeTest({ vault: redemptionVaultWithMorpho, owner }, 0); + await setRoundData({ mockedAggregator }, 1); + await setRoundData({ mockedAggregator: mockedAggregatorMToken }, 1); + + await expect( + redemptionVaultWithMorpho['redeemInstant(address,uint256,uint256)']( + stableCoins.usdc.address, + parseUnits('1000'), + 0, + ), + ).to.be.revertedWith('RVM: insufficient shares'); + }); + + it('should fail: Morpho vault has insufficient liquidity during redeemInstant', async () => { + const { + owner, + mockedAggregator, + mockedAggregatorMToken, + redemptionVaultWithMorpho, + stableCoins, + mTBILL, + dataFeed, + mTokenToUsdDataFeed, + morphoVaultMock, + } = await loadFixture(defaultDeploy); + + // Vault has shares but mock has no liquidity + await morphoVaultMock.mint( + redemptionVaultWithMorpho.address, + parseUnits('10000', 8), + ); + await mintToken(mTBILL, owner, 100000); + await approveBase18(owner, mTBILL, redemptionVaultWithMorpho, 100000); + await addPaymentTokenTest( + { vault: redemptionVaultWithMorpho, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + await setInstantFeeTest({ vault: redemptionVaultWithMorpho, owner }, 0); + await setRoundData({ mockedAggregator }, 1); + await setRoundData({ mockedAggregator: mockedAggregatorMToken }, 1); + + // Drain the mock + const mockBalance = await stableCoins.usdc.balanceOf( + morphoVaultMock.address, + ); + await morphoVaultMock.withdrawAdmin( + stableCoins.usdc.address, + ( + await ethers.getSigners() + )[10].address, + mockBalance, + ); + + await expect( + redemptionVaultWithMorpho['redeemInstant(address,uint256,uint256)']( + stableCoins.usdc.address, + parseUnits('1000'), + 0, + ), + ).to.be.revertedWith('MorphoVaultMock: InsufficientLiquidity'); + }); + + // ── Custom recipient tests ─────────────────────────────────────────── + + it('redeem 100 mTBILL (custom recipient overload)', async () => { + const { + owner, + redemptionVaultWithMorpho, + stableCoins, + mTBILL, + mTokenToUsdDataFeed, + regularAccounts, + dataFeed, + customRecipient, + } = await loadFixture(defaultDeploy); + + await mintToken(stableCoins.usdc, redemptionVaultWithMorpho, 100000); + await mintToken(mTBILL, regularAccounts[0], 100); + await approveBase18( + regularAccounts[0], + mTBILL, + redemptionVaultWithMorpho, + 100, + ); + await addPaymentTokenTest( + { vault: redemptionVaultWithMorpho, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + + await redeemInstantTest( + { + redemptionVault: redemptionVaultWithMorpho, + owner, + mTBILL, + mTokenToUsdDataFeed, + customRecipient, + }, + stableCoins.usdc, + 100, + { + from: regularAccounts[0], + }, + ); + }); + + it('redeem 100 mTBILL when other fn overload is paused (custom recipient overload)', async () => { + const { + owner, + redemptionVaultWithMorpho, + stableCoins, + mTBILL, + mTokenToUsdDataFeed, + regularAccounts, + dataFeed, + customRecipient, + } = await loadFixture(defaultDeploy); + + await pauseVaultFn( + redemptionVaultWithMorpho, + encodeFnSelector('redeemInstant(address,uint256,uint256)'), + ); + await mintToken(stableCoins.usdc, redemptionVaultWithMorpho, 100000); + await mintToken(mTBILL, regularAccounts[0], 100); + await approveBase18( + regularAccounts[0], + mTBILL, + redemptionVaultWithMorpho, + 100, + ); + await addPaymentTokenTest( + { vault: redemptionVaultWithMorpho, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + + await redeemInstantTest( + { + redemptionVault: redemptionVaultWithMorpho, + owner, + mTBILL, + mTokenToUsdDataFeed, + customRecipient, + }, + stableCoins.usdc, + 100, + { + from: regularAccounts[0], + }, + ); + }); + + it('redeem 100 mTBILL when other fn overload is paused', async () => { + const { + owner, + redemptionVaultWithMorpho, + stableCoins, + mTBILL, + mTokenToUsdDataFeed, + regularAccounts, + dataFeed, + } = await loadFixture(defaultDeploy); + + await pauseVaultFn( + redemptionVaultWithMorpho, + encodeFnSelector('redeemInstant(address,uint256,uint256,address)'), + ); + await mintToken(stableCoins.usdc, redemptionVaultWithMorpho, 100000); + await mintToken(mTBILL, regularAccounts[0], 100); + await approveBase18( + regularAccounts[0], + mTBILL, + redemptionVaultWithMorpho, + 100, + ); + await addPaymentTokenTest( + { vault: redemptionVaultWithMorpho, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + + await redeemInstantTest( + { + redemptionVault: redemptionVaultWithMorpho, + owner, + mTBILL, + mTokenToUsdDataFeed, + }, + stableCoins.usdc, + 100, + { + from: regularAccounts[0], + }, + ); + }); + + it('should fail: when function with custom recipient is paused', async () => { + const { + owner, + redemptionVaultWithMorpho, + stableCoins, + mTBILL, + dataFeed, + mTokenToUsdDataFeed, + regularAccounts, + customRecipient, + } = await loadFixture(defaultDeploy); + await mintToken(mTBILL, regularAccounts[0], 100); + await approveBase18( + regularAccounts[0], + stableCoins.dai, + redemptionVaultWithMorpho, + 100, + ); + await addPaymentTokenTest( + { vault: redemptionVaultWithMorpho, owner }, + stableCoins.dai, + dataFeed.address, + 0, + true, + ); + const selector = encodeFnSelector( + 'redeemInstant(address,uint256,uint256,address)', + ); + await pauseVaultFn(redemptionVaultWithMorpho, selector); + await redeemInstantTest( + { + redemptionVault: redemptionVaultWithMorpho, + owner, + mTBILL, + mTokenToUsdDataFeed, + customRecipient, + }, + stableCoins.dai, + 100, + { + from: regularAccounts[0], + revertMessage: 'Pausable: fn paused', + }, + ); + }); + + it('should fail: greenlist enabled and recipient not in greenlist (custom recipient overload)', async () => { + const { + owner, + redemptionVaultWithMorpho, + stableCoins, + mTBILL, + mTokenToUsdDataFeed, + greenListableTester, + accessControl, + customRecipient, + } = await loadFixture(defaultDeploy); + + await redemptionVaultWithMorpho.setGreenlistEnable(true); + + await greenList( + { greenlistable: greenListableTester, accessControl, owner }, + owner, + ); + + await redeemInstantTest( + { + redemptionVault: redemptionVaultWithMorpho, + owner, + mTBILL, + mTokenToUsdDataFeed, + customRecipient, + }, + stableCoins.dai, + 1, + { + revertMessage: 'WMAC: hasnt role', + }, + ); + }); + + it('should fail: recipient in blacklist (custom recipient overload)', async () => { + const { + owner, + redemptionVaultWithMorpho, + stableCoins, + mTBILL, + mTokenToUsdDataFeed, + blackListableTester, + accessControl, + regularAccounts, + customRecipient, + } = await loadFixture(defaultDeploy); + + await blackList( + { blacklistable: blackListableTester, accessControl, owner }, + customRecipient, + ); + + await redeemInstantTest( + { + redemptionVault: redemptionVaultWithMorpho, + owner, + mTBILL, + mTokenToUsdDataFeed, + customRecipient, + }, + stableCoins.dai, + 1, + { + from: regularAccounts[0], + revertMessage: acErrors.WMAC_HAS_ROLE, + }, + ); + }); + + it('should fail: recipient in sanctions list (custom recipient overload)', async () => { + const { + owner, + redemptionVaultWithMorpho, + stableCoins, + mTBILL, + mTokenToUsdDataFeed, + regularAccounts, + mockedSanctionsList, + customRecipient, + } = await loadFixture(defaultDeploy); + + await sanctionUser( + { sanctionsList: mockedSanctionsList }, + customRecipient, + ); + + await redeemInstantTest( + { + redemptionVault: redemptionVaultWithMorpho, + owner, + mTBILL, + mTokenToUsdDataFeed, + customRecipient, + }, + stableCoins.dai, + 1, + { + from: regularAccounts[0], + revertMessage: 'WSL: sanctioned', + }, + ); + }); + }); + + describe('redeemRequest()', () => { + it('should fail: when there is no token in vault', async () => { + const { + owner, + redemptionVaultWithMorpho, + stableCoins, + mTBILL, + mTokenToUsdDataFeed, + } = await loadFixture(defaultDeploy); + + await redeemRequestTest( + { + redemptionVault: redemptionVaultWithMorpho, + owner, + mTBILL, + mTokenToUsdDataFeed, + }, + stableCoins.dai, + 1, + { + revertMessage: 'MV: token not exists', + }, + ); + }); + + it('should fail: when trying to redeem 0 amount', async () => { + const { + owner, + redemptionVaultWithMorpho, + stableCoins, + mTBILL, + dataFeed, + mTokenToUsdDataFeed, + } = await loadFixture(defaultDeploy); + await addPaymentTokenTest( + { vault: redemptionVaultWithMorpho, owner }, + stableCoins.dai, + dataFeed.address, + 0, + true, + ); + await redeemRequestTest( + { + redemptionVault: redemptionVaultWithMorpho, + owner, + mTBILL, + mTokenToUsdDataFeed, + }, + stableCoins.dai, + 0, + { + revertMessage: 'RV: invalid amount', + }, + ); + }); + + it('should fail: when function paused', async () => { + const { + owner, + redemptionVaultWithMorpho, + stableCoins, + mTBILL, + dataFeed, + mTokenToUsdDataFeed, + regularAccounts, + } = await loadFixture(defaultDeploy); + await mintToken(mTBILL, regularAccounts[0], 100); + await approveBase18( + regularAccounts[0], + stableCoins.dai, + redemptionVaultWithMorpho, + 100, + ); + await addPaymentTokenTest( + { vault: redemptionVaultWithMorpho, owner }, + stableCoins.dai, + dataFeed.address, + 0, + true, + ); + const selector = encodeFnSelector('redeemRequest(address,uint256)'); + await pauseVaultFn(redemptionVaultWithMorpho, selector); + await redeemRequestTest( + { + redemptionVault: redemptionVaultWithMorpho, + owner, + mTBILL, + mTokenToUsdDataFeed, + }, + stableCoins.dai, + 100, + { + from: regularAccounts[0], + revertMessage: 'Pausable: fn paused', + }, + ); + }); + + it('should fail: call with insufficient balance', async () => { + const { + owner, + redemptionVaultWithMorpho, + stableCoins, + mTBILL, + dataFeed, + mTokenToUsdDataFeed, + } = await loadFixture(defaultDeploy); + + await approveBase18(owner, mTBILL, redemptionVaultWithMorpho, 100); + await addPaymentTokenTest( + { vault: redemptionVaultWithMorpho, owner }, + stableCoins.dai, + dataFeed.address, + 0, + true, + ); + await redeemRequestTest( + { + redemptionVault: redemptionVaultWithMorpho, + owner, + mTBILL, + mTokenToUsdDataFeed, + }, + stableCoins.dai, + 100, + { + revertMessage: 'ERC20: transfer amount exceeds balance', + }, + ); + }); + + it('redeem request: happy path', async () => { + const { + owner, + redemptionVaultWithMorpho, + stableCoins, + mTBILL, + dataFeed, + mTokenToUsdDataFeed, + } = await loadFixture(defaultDeploy); + + await mintToken(mTBILL, owner, 100); + await approveBase18(owner, mTBILL, redemptionVaultWithMorpho, 100); + await addPaymentTokenTest( + { vault: redemptionVaultWithMorpho, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + await redeemRequestTest( + { + redemptionVault: redemptionVaultWithMorpho, + owner, + mTBILL, + mTokenToUsdDataFeed, + }, + stableCoins.usdc, + 100, + ); + }); + }); + + describe('redeemFiatRequest()', () => { + it('should fail: when function paused', async () => { + const { + owner, + redemptionVaultWithMorpho, + mTBILL, + mTokenToUsdDataFeed, + regularAccounts, + } = await loadFixture(defaultDeploy); + await mintToken(mTBILL, regularAccounts[0], 100000); + await approveBase18( + regularAccounts[0], + mTBILL, + redemptionVaultWithMorpho, + 100000, + ); + const selector = encodeFnSelector('redeemFiatRequest(uint256)'); + await pauseVaultFn(redemptionVaultWithMorpho, selector); + await redeemFiatRequestTest( + { + redemptionVault: redemptionVaultWithMorpho, + owner, + mTBILL, + mTokenToUsdDataFeed, + }, + 100000, + { + from: regularAccounts[0], + revertMessage: 'Pausable: fn paused', + }, + ); + }); + + it('redeem fiat request: happy path', async () => { + const { owner, redemptionVaultWithMorpho, mTBILL, mTokenToUsdDataFeed } = + await loadFixture(defaultDeploy); + + await mintToken(mTBILL, owner, 100000); + await approveBase18(owner, mTBILL, redemptionVaultWithMorpho, 100000); + await redeemFiatRequestTest( + { + redemptionVault: redemptionVaultWithMorpho, + owner, + mTBILL, + mTokenToUsdDataFeed, + }, + 100000, + ); + }); + }); + + describe('approveRequest()', async () => { + it('should fail: call from address without vault admin role', async () => { + const { + redemptionVaultWithMorpho: redemptionVault, + regularAccounts, + mTokenToUsdDataFeed, + mTBILL, + } = await loadFixture(defaultDeploy); + await approveRedeemRequestTest( + { + redemptionVault, + owner: regularAccounts[1], + mTBILL, + mTokenToUsdDataFeed, + }, + 1, + parseUnits('1'), + { + revertMessage: 'WMAC: hasnt role', + }, + ); + }); + + it('should fail: request by id not exist', async () => { + const { + owner, + redemptionVaultWithMorpho: redemptionVault, + stableCoins, + mTBILL, + dataFeed, + mTokenToUsdDataFeed, + } = await loadFixture(defaultDeploy); + await addPaymentTokenTest( + { vault: redemptionVault, owner }, + stableCoins.dai, + dataFeed.address, + 0, + true, + ); + await approveRedeemRequestTest( + { redemptionVault, owner, mTBILL, mTokenToUsdDataFeed }, + 1, + parseUnits('1'), + { + revertMessage: 'RV: request not exist', + }, + ); + }); + + it('should fail: request already processed', async () => { + const { + owner, + mockedAggregator, + mockedAggregatorMToken, + redemptionVaultWithMorpho: redemptionVault, + stableCoins, + mTBILL, + dataFeed, + mTokenToUsdDataFeed, + requestRedeemer, + } = await loadFixture(defaultDeploy); + + await mintToken(stableCoins.dai, requestRedeemer, 100000); + await approveBase18( + requestRedeemer, + stableCoins.dai, + redemptionVault, + 100000, + ); + + await mintToken(mTBILL, owner, 100); + await approveBase18(owner, mTBILL, redemptionVault, 100); + await addPaymentTokenTest( + { vault: redemptionVault, owner }, + stableCoins.dai, + dataFeed.address, + 0, + true, + ); + await setRoundData({ mockedAggregator }, 1.03); + await setRoundData({ mockedAggregator: mockedAggregatorMToken }, 5); + + await redeemRequestTest( + { redemptionVault, owner, mTBILL, mTokenToUsdDataFeed }, + stableCoins.dai, + 100, + ); + const requestId = 0; + + await approveRedeemRequestTest( + { redemptionVault, owner, mTBILL, mTokenToUsdDataFeed }, + +requestId, + parseUnits('1'), + ); + await approveRedeemRequestTest( + { redemptionVault, owner, mTBILL, mTokenToUsdDataFeed }, + +requestId, + parseUnits('1'), + { revertMessage: 'RV: request not pending' }, + ); + }); + + it('approve request from vaut admin account', async () => { + const { + owner, + mockedAggregator, + mockedAggregatorMToken, + redemptionVaultWithMorpho: redemptionVault, + stableCoins, + mTBILL, + dataFeed, + mTokenToUsdDataFeed, + requestRedeemer, + } = await loadFixture(defaultDeploy); + + await mintToken(stableCoins.dai, requestRedeemer, 100000); + await approveBase18( + requestRedeemer, + stableCoins.dai, + redemptionVault, + 100000, + ); + + await mintToken(mTBILL, owner, 100); + await approveBase18(owner, mTBILL, redemptionVault, 100); + await addPaymentTokenTest( + { vault: redemptionVault, owner }, + stableCoins.dai, + dataFeed.address, + 0, + true, + ); + await setRoundData({ mockedAggregator }, 1.03); + await setRoundData({ mockedAggregator: mockedAggregatorMToken }, 5); + + await redeemRequestTest( + { redemptionVault, owner, mTBILL, mTokenToUsdDataFeed }, + stableCoins.dai, + 100, + ); + const requestId = 0; + + await approveRedeemRequestTest( + { redemptionVault, owner, mTBILL, mTokenToUsdDataFeed }, + +requestId, + parseUnits('1'), + ); + }); + }); + + describe('approveRequest() with fiat', async () => { + it('approve request from vaut admin account', async () => { + const { + owner, + mockedAggregator, + mockedAggregatorMToken, + redemptionVaultWithMorpho: redemptionVault, + mTBILL, + mTokenToUsdDataFeed, + greenListableTester, + accessControl, + } = await loadFixture(defaultDeploy); + + await greenList( + { greenlistable: greenListableTester, accessControl, owner }, + owner, + ); + + await mintToken(mTBILL, owner, 100); + await approveBase18(owner, mTBILL, redemptionVault, 100); + await setRoundData({ mockedAggregator }, 1.03); + await setRoundData({ mockedAggregator: mockedAggregatorMToken }, 5); + + await redeemFiatRequestTest( + { redemptionVault, owner, mTBILL, mTokenToUsdDataFeed }, + 100, + ); + const requestId = 0; + await changeTokenAllowanceTest( + { vault: redemptionVault, owner }, + constants.AddressZero, + parseUnits('100'), + ); + + await approveRedeemRequestTest( + { redemptionVault, owner, mTBILL, mTokenToUsdDataFeed }, + +requestId, + parseUnits('1'), + ); + }); + }); + + describe('safeApproveRequest()', async () => { + it('should fail: call from address without vault admin role', async () => { + const { + redemptionVaultWithMorpho: redemptionVault, + regularAccounts, + mTokenToUsdDataFeed, + mTBILL, + } = await loadFixture(defaultDeploy); + await safeApproveRedeemRequestTest( + { + redemptionVault, + owner: regularAccounts[1], + mTBILL, + mTokenToUsdDataFeed, + }, + 1, + parseUnits('1'), + { + revertMessage: 'WMAC: hasnt role', + }, + ); + }); + + it('should fail: request by id not exist', async () => { + const { + owner, + redemptionVaultWithMorpho: redemptionVault, + stableCoins, + mTBILL, + dataFeed, + mTokenToUsdDataFeed, + } = await loadFixture(defaultDeploy); + await addPaymentTokenTest( + { vault: redemptionVault, owner }, + stableCoins.dai, + dataFeed.address, + 0, + true, + ); + await safeApproveRedeemRequestTest( + { redemptionVault, owner, mTBILL, mTokenToUsdDataFeed }, + 1, + parseUnits('1'), + { + revertMessage: 'RV: request not exist', + }, + ); + }); + + it('should fail: if new rate greater then variabilityTolerance', async () => { + const { + owner, + mockedAggregator, + mockedAggregatorMToken, + redemptionVaultWithMorpho: redemptionVault, + stableCoins, + mTBILL, + dataFeed, + mTokenToUsdDataFeed, + requestRedeemer, + } = await loadFixture(defaultDeploy); + + await mintToken(stableCoins.dai, requestRedeemer, 100000); + await approveBase18( + requestRedeemer, + stableCoins.dai, + redemptionVault, + 100000, + ); + await mintToken(mTBILL, owner, 100); + await approveBase18(owner, mTBILL, redemptionVault, 100); + await addPaymentTokenTest( + { vault: redemptionVault, owner }, + stableCoins.dai, + dataFeed.address, + 0, + true, + ); + await setRoundData({ mockedAggregator }, 1.001); + await setRoundData({ mockedAggregator: mockedAggregatorMToken }, 5); + + await redeemRequestTest( + { redemptionVault, owner, mTBILL, mTokenToUsdDataFeed }, + stableCoins.dai, + 100, + ); + const requestId = 0; + + await safeApproveRedeemRequestTest( + { redemptionVault, owner, mTBILL, mTokenToUsdDataFeed }, + +requestId, + parseUnits('6'), + { revertMessage: 'MV: exceed price diviation' }, + ); + }); + + it('should fail: request already processed', async () => { + const { + owner, + mockedAggregator, + mockedAggregatorMToken, + redemptionVaultWithMorpho: redemptionVault, + stableCoins, + mTBILL, + dataFeed, + mTokenToUsdDataFeed, + requestRedeemer, + } = await loadFixture(defaultDeploy); + + await mintToken(stableCoins.dai, requestRedeemer, 100000); + await approveBase18( + requestRedeemer, + stableCoins.dai, + redemptionVault, + 100000, + ); + await mintToken(mTBILL, owner, 100); + await approveBase18(owner, mTBILL, redemptionVault, 100); + await addPaymentTokenTest( + { vault: redemptionVault, owner }, + stableCoins.dai, + dataFeed.address, + 0, + true, + ); + await setRoundData({ mockedAggregator }, 1.001); + await setRoundData({ mockedAggregator: mockedAggregatorMToken }, 5); + + await redeemRequestTest( + { redemptionVault, owner, mTBILL, mTokenToUsdDataFeed }, + stableCoins.dai, + 100, + ); + const requestId = 0; + + await safeApproveRedeemRequestTest( + { redemptionVault, owner, mTBILL, mTokenToUsdDataFeed }, + +requestId, + parseUnits('5.000001'), + ); + await safeApproveRedeemRequestTest( + { redemptionVault, owner, mTBILL, mTokenToUsdDataFeed }, + +requestId, + parseUnits('5.00001'), + { revertMessage: 'RV: request not pending' }, + ); + }); + + it('safe approve request from vaut admin account', async () => { + const { + owner, + mockedAggregator, + mockedAggregatorMToken, + redemptionVaultWithMorpho: redemptionVault, + stableCoins, + mTBILL, + mTokenToUsdDataFeed, + dataFeed, + requestRedeemer, + } = await loadFixture(defaultDeploy); + + await mintToken(stableCoins.dai, requestRedeemer, 100000); + await approveBase18( + requestRedeemer, + stableCoins.dai, + redemptionVault, + 100000, + ); + await mintToken(mTBILL, owner, 100); + await approveBase18(owner, mTBILL, redemptionVault, 100); + await addPaymentTokenTest( + { vault: redemptionVault, owner }, + stableCoins.dai, + dataFeed.address, + 0, + true, + ); + + await setRoundData({ mockedAggregator }, 1.03); + await setRoundData({ mockedAggregator: mockedAggregatorMToken }, 5); + + await redeemRequestTest( + { redemptionVault, owner, mTBILL, mTokenToUsdDataFeed }, + stableCoins.dai, + 100, + ); + const requestId = 0; + + await safeApproveRedeemRequestTest( + { redemptionVault, owner, mTBILL, mTokenToUsdDataFeed }, + +requestId, + parseUnits('5.000001'), + ); + }); + }); + + describe('rejectRequest()', async () => { + it('should fail: call from address without vault admin role', async () => { + const { + redemptionVaultWithMorpho: redemptionVault, + regularAccounts, + mTokenToUsdDataFeed, + mTBILL, + } = await loadFixture(defaultDeploy); + await rejectRedeemRequestTest( + { + redemptionVault, + owner: regularAccounts[1], + mTBILL, + mTokenToUsdDataFeed, + }, + 1, + { + revertMessage: 'WMAC: hasnt role', + }, + ); + }); + + it('should fail: request by id not exist', async () => { + const { + owner, + redemptionVaultWithMorpho: redemptionVault, + stableCoins, + mTBILL, + dataFeed, + mTokenToUsdDataFeed, + } = await loadFixture(defaultDeploy); + await addPaymentTokenTest( + { vault: redemptionVault, owner }, + stableCoins.dai, + dataFeed.address, + 0, + true, + ); + await rejectRedeemRequestTest( + { redemptionVault, owner, mTBILL, mTokenToUsdDataFeed }, + 1, + { + revertMessage: 'RV: request not exist', + }, + ); + }); + + it('should fail: request already processed', async () => { + const { + owner, + mockedAggregator, + mockedAggregatorMToken, + redemptionVaultWithMorpho: redemptionVault, + stableCoins, + mTBILL, + dataFeed, + mTokenToUsdDataFeed, + } = await loadFixture(defaultDeploy); + + await mintToken(stableCoins.dai, redemptionVault, 100000); + await mintToken(mTBILL, owner, 100); + await approveBase18(owner, mTBILL, redemptionVault, 100); + await addPaymentTokenTest( + { vault: redemptionVault, owner }, + stableCoins.dai, + dataFeed.address, + 0, + true, + ); + await setRoundData({ mockedAggregator }, 1.001); + await setRoundData({ mockedAggregator: mockedAggregatorMToken }, 5); + + await redeemRequestTest( + { redemptionVault, owner, mTBILL, mTokenToUsdDataFeed }, + stableCoins.dai, + 100, + ); + const requestId = 0; + + await rejectRedeemRequestTest( + { redemptionVault, owner, mTBILL, mTokenToUsdDataFeed }, + +requestId, + ); + await rejectRedeemRequestTest( + { redemptionVault, owner, mTBILL, mTokenToUsdDataFeed }, + +requestId, + { revertMessage: 'RV: request not pending' }, + ); + }); + + it('reject request from vaut admin account', async () => { + const { + owner, + mockedAggregator, + mockedAggregatorMToken, + redemptionVaultWithMorpho: redemptionVault, + stableCoins, + mTBILL, + mTokenToUsdDataFeed, + dataFeed, + } = await loadFixture(defaultDeploy); + + await mintToken(stableCoins.dai, redemptionVault, 100000); + await mintToken(mTBILL, owner, 100); + await approveBase18(owner, mTBILL, redemptionVault, 100); + await addPaymentTokenTest( + { vault: redemptionVault, owner }, + stableCoins.dai, + dataFeed.address, + 0, + true, + ); + + await setRoundData({ mockedAggregator }, 1.03); + await setRoundData({ mockedAggregator: mockedAggregatorMToken }, 5); + + await redeemRequestTest( + { redemptionVault, owner, mTBILL, mTokenToUsdDataFeed }, + stableCoins.dai, + 100, + ); + const requestId = 0; + + await rejectRedeemRequestTest( + { redemptionVault, owner, mTBILL, mTokenToUsdDataFeed }, + +requestId, + ); + }); + }); +});